From 3609681a5e9182ad84dd332aa0e2768ee99b130e Mon Sep 17 00:00:00 2001 From: widal001 Date: Thu, 19 Feb 2026 11:29:04 -0500 Subject: [PATCH 1/9] feat(ts-sdk): Supports passing schema to client methods Allows API client users to pass a custom zod schema to resource methods like `Client.opportunities.get()` this allows users to parse API responses that include custom fields by passing in a schema generated from `withCustomFields()` --- .../__tests__/client/opportunities.spec.ts | 120 ++++++++++++++++++ lib/ts-sdk/src/client/opportunities.ts | 101 +++++++++++---- 2 files changed, 198 insertions(+), 23 deletions(-) diff --git a/lib/ts-sdk/__tests__/client/opportunities.spec.ts b/lib/ts-sdk/__tests__/client/opportunities.spec.ts index 8a21b105..62060c12 100644 --- a/lib/ts-sdk/__tests__/client/opportunities.spec.ts +++ b/lib/ts-sdk/__tests__/client/opportunities.spec.ts @@ -1,6 +1,23 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { z } from "zod"; import { http, HttpResponse, setupServer, createPaginatedHandler } from "../utils/mock-fetch"; import { Client, Auth } from "../../src/client"; +import { OpportunityBaseSchema } from "../../src/schemas"; +import { withCustomFields } from "../../src/extensions"; +import { CustomFieldType } from "../../src/constants"; + +// ============================================================================= +// Custom schema for testing withCustomFields support +// ============================================================================= + +const OpportunityWithLegacyIdSchema = withCustomFields(OpportunityBaseSchema, [ + { + key: "legacyId", + fieldType: CustomFieldType.integer, + valueSchema: z.number().int(), + description: "Maps to the opportunity_id in the legacy system", + }, +] as const); // ============================================================================= // Mock API Handlers @@ -16,6 +33,23 @@ const createMockOpportunity = (id: string, title: string, statusValue: string) = lastModifiedAt: "2024-06-01T14:22:00Z", }); +const createMockOpportunityWithCustomFields = ( + id: string, + title: string, + statusValue: string, + legacyIdValue: number +) => ({ + ...createMockOpportunity(id, title, statusValue), + customFields: { + legacyId: { + name: "legacyId", + fieldType: "integer", + value: legacyIdValue, + description: "Maps to the opportunity_id in the legacy system", + }, + }, +}); + // Valid UUIDs for testing const OPP_UUID_1 = "550e8400-e29b-41d4-a716-446655440001"; const OPP_UUID_2 = "550e8400-e29b-41d4-a716-446655440002"; @@ -114,6 +148,30 @@ describe("Opportunities", () => { await expect(client.opportunities.get(OPP_UUID_1)).rejects.toThrow("500"); }); + + it("parses response with a custom schema", async () => { + server.use( + http.get("/common-grants/opportunities/:id", ({ params }) => { + return HttpResponse.json({ + status: 200, + message: "Success", + data: createMockOpportunityWithCustomFields( + params.id as string, + "Custom Fields Grant", + "open", + 42 + ), + }); + }) + ); + + const opp = await client.opportunities.get(OPP_UUID_1, OpportunityWithLegacyIdSchema); + + expect(opp.id).toBe(OPP_UUID_1); + expect(opp.title).toBe("Custom Fields Grant"); + expect(opp.customFields?.legacyId?.value).toBe(42); + expect(opp.customFields?.legacyId?.fieldType).toBe("integer"); + }); }); // ============================================================================= @@ -221,6 +279,33 @@ describe("Opportunities", () => { expect(requestCount).toBe(3); expect(result.items).toHaveLength(5); }); + + it("parses items with a custom schema", async () => { + server.use( + http.get("/common-grants/opportunities", () => { + return HttpResponse.json({ + status: 200, + message: "Success", + items: [ + createMockOpportunityWithCustomFields(OPP_UUID_1, "Grant A", "open", 100), + createMockOpportunityWithCustomFields(OPP_UUID_2, "Grant B", "forecasted", 200), + ], + paginationInfo: { + page: 1, + pageSize: 25, + totalItems: 2, + totalPages: 1, + }, + }); + }) + ); + + const result = await client.opportunities.list({ page: 1 }, OpportunityWithLegacyIdSchema); + + expect(result.items).toHaveLength(2); + expect(result.items[0].customFields?.legacyId?.value).toBe(100); + expect(result.items[1].customFields?.legacyId?.value).toBe(200); + }); }); // ============================================================================= @@ -368,6 +453,41 @@ describe("Opportunities", () => { await expect(client.opportunities.search({ query: "test" })).rejects.toThrow("500"); }); + it("parses items with a custom schema", async () => { + server.use( + http.post("/common-grants/opportunities/search", () => { + return HttpResponse.json({ + status: 200, + message: "Success", + items: [createMockOpportunityWithCustomFields(OPP_UUID_1, "Custom Grant", "open", 555)], + paginationInfo: { + page: 1, + pageSize: 25, + totalItems: 1, + totalPages: 1, + }, + sortInfo: { + sortBy: "lastModifiedAt", + sortOrder: "desc", + }, + filterInfo: { + filters: {}, + }, + }); + }) + ); + + const result = await client.opportunities.search( + { query: "custom" }, + OpportunityWithLegacyIdSchema + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0].title).toBe("Custom Grant"); + expect(result.items[0].customFields?.legacyId?.value).toBe(555); + expect(result.items[0].customFields?.legacyId?.fieldType).toBe("integer"); + }); + // ========================================================================= // Single page (explicit page parameter) // ========================================================================= diff --git a/lib/ts-sdk/src/client/opportunities.ts b/lib/ts-sdk/src/client/opportunities.ts index fe72fcf9..b4eb0dcb 100644 --- a/lib/ts-sdk/src/client/opportunities.ts +++ b/lib/ts-sdk/src/client/opportunities.ts @@ -2,14 +2,15 @@ * Opportunities resource namespace for the CommonGrants API. */ +import { z } from "zod"; import type { Client, FetchManyOptions } from "./client"; import type { OpportunityBase, - OpportunitiesListResponse, - OpportunitiesFilteredResponse, OppStatusOptions, OppFilters, OppSearchRequest, + Paginated, + Filtered, } from "../types"; import { OkSchema, @@ -20,10 +21,22 @@ import { } from "../schemas"; import { ArrayOperator } from "../constants"; -// Response schemas with validation -const OpportunityResponseSchema = OkSchema(OpportunityBaseSchema); -const OpportunitiesListResponseSchema = PaginatedSchema(OpportunityBaseSchema); -const OpportunitiesFilteredResponseSchema = FilteredSchema(OpportunityBaseSchema, OppFiltersSchema); +// ============================================================================= +// Schema type constraint +// ============================================================================= + +/** + * Constrains the schema parameter to any Zod schema whose `.parse()` output + * is at least an `OpportunityBase`. + * + * We intentionally constrain on the OUTPUT type (`OpportunityBase`) rather than + * the concrete schema type (`typeof OpportunityBaseSchema`). This is because + * `withCustomFields()` returns a schema with a different internal Zod type tree + * (e.g. a typed `ZodOptional` for `customFields` instead of `ZodNullable`), + * even though its parsed output is still a superset of `OpportunityBase`. Constraining + * on the output type accepts both the base schema and any extended variant. + */ +type OppSchema = z.ZodType; // ============================================================================= // Search types @@ -67,16 +80,29 @@ export class Opportunities { * Get a specific opportunity by ID. * * @param id - The opportunity ID + * @param schema - Zod schema to parse and type the response. Defaults to `OpportunityBaseSchema`. + * Pass a schema from `withCustomFields()` for typed custom field access. * @returns The opportunity data * @throws {Error} If the request fails * * @example * ```ts + * // Default usage * const opp = await client.opportunities.get("123e4567-e89b-12d3-a456-426614174000"); * console.log(opp.title); + * + * // With a custom-fields schema for typed access + * const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ + * { key: "legacyId", fieldType: "integer", valueSchema: z.number().int() }, + * ] as const); + * const typed = await client.opportunities.get(id, OpportunitySchema); + * console.log(typed.customFields?.legacyId?.value); // typed as number * ``` */ - async get(id: string): Promise { + async get( + id: string, + schema: S = OpportunityBaseSchema as unknown as S + ): Promise> { const response = await this.client.get(`${this.basePath}/${id}`); if (!response.ok) { @@ -84,9 +110,9 @@ export class Opportunities { } const json = await response.json(); - const result = OpportunityResponseSchema.parse(json); + const result = OkSchema(schema).parse(json); - return result.data; + return result.data as z.infer; } // ############################################################################ @@ -98,6 +124,8 @@ export class Opportunities { * * @param options - Pagination options. If `page` is specified, fetches only that page. * Otherwise, auto-paginates to fetch all items. + * @param schema - Zod schema to parse and type each item. Defaults to `OpportunityBaseSchema`. + * Pass a schema from `withCustomFields()` for typed custom field access. * @returns Paginated list of opportunities * @throws {Error} If the request fails * @@ -111,9 +139,15 @@ export class Opportunities { * * // Get a specific page (disables auto-pagination) * const page2 = await client.opportunities.list({ page: 2, pageSize: 10 }); + * + * // With a custom-fields schema + * const typed = await client.opportunities.list(undefined, OpportunitySchema); * ``` */ - async list(options?: FetchManyOptions): Promise { + async list( + options?: FetchManyOptions, + schema: S = OpportunityBaseSchema as unknown as S + ): Promise>> { // If page is specified, fetch only that page if (options?.page !== undefined) { const params: Record = { page: options.page }; @@ -129,13 +163,13 @@ export class Opportunities { } const json = await response.json(); - return OpportunitiesListResponseSchema.parse(json); + return PaginatedSchema(schema).parse(json) as Paginated>; } // Auto-paginate by default - return this.client.fetchMany(this.basePath, { + return this.client.fetchMany(this.basePath, { ...options, - parseItem: item => OpportunityBaseSchema.parse(item), + parseItem: (item: unknown) => schema.parse(item) as z.infer, }); } @@ -149,6 +183,8 @@ export class Opportunities { * Supports auto-pagination by default. If `page` is specified, only fetches that page. * * @param options - Search options including query text, status filters, and pagination + * @param schema - Zod schema to parse and type each item. Defaults to `OpportunityBaseSchema`. + * Pass a schema from `withCustomFields()` for typed custom field access. * @returns Filtered list of opportunities * @throws {Error} If the request fails * @@ -179,36 +215,54 @@ export class Opportunities { * maxItems: 100, * pageSize: 25, * }); + * + * // With a custom-fields schema + * const typed = await client.opportunities.search({ query: "test" }, OpportunitySchema); * ``` */ - async search(options?: SearchOptions): Promise { + async search( + options?: SearchOptions, + schema: S = OpportunityBaseSchema as unknown as S + ): Promise, OppFilters>> { // Build the base search body (without pagination) const searchBody = this.buildSearchBody(options); // If page is specified, fetch only that page if (options?.page !== undefined) { - return this.fetchSearchPage(searchBody, options.page, options.pageSize, options.signal); + return this.fetchSearchPage( + searchBody, + options.page, + options.pageSize, + options.signal, + schema + ); } // Auto-paginate by default using fetchMany with POST method - const result = await this.client.fetchMany(`${this.basePath}/search`, { + const result = await this.client.fetchMany(this.basePath + "/search", { method: "POST", body: searchBody as Record, pageSize: options?.pageSize, maxItems: options?.maxItems, signal: options?.signal, - parseItem: item => OpportunityBaseSchema.parse(item), + parseItem: (item: unknown) => schema.parse(item) as z.infer, }); // Fetch first page to get sortInfo and filterInfo metadata - const firstPageResponse = await this.fetchSearchPage(searchBody, 1, options?.pageSize); + const firstPageResponse = await this.fetchSearchPage( + searchBody, + 1, + options?.pageSize, + undefined, + schema + ); // Merge the aggregated items with metadata from first page return { ...result, sortInfo: firstPageResponse.sortInfo, filterInfo: firstPageResponse.filterInfo, - }; + } as Filtered, OppFilters>; } // ############################################################################ @@ -239,12 +293,13 @@ export class Opportunities { } /** Fetches a single search page */ - private async fetchSearchPage( + private async fetchSearchPage( searchBody: OppSearchRequest, page: number, pageSize?: number, - signal?: AbortSignal - ): Promise { + signal?: AbortSignal, + schema: S = OpportunityBaseSchema as unknown as S + ): Promise, OppFilters>> { const requestBody: OppSearchRequest = { ...searchBody, pagination: { @@ -260,6 +315,6 @@ export class Opportunities { } const json = await response.json(); - return OpportunitiesFilteredResponseSchema.parse(json); + return FilteredSchema(schema, OppFiltersSchema).parse(json) as Filtered, OppFilters>; } } From d63d93cb69b52b0ba66576824c7e9da4c1027628 Mon Sep 17 00:00:00 2001 From: widal001 Date: Thu, 19 Feb 2026 11:46:45 -0500 Subject: [PATCH 2/9] refactor(ts-sdk): Fixes code smells in API client - fetchMany() preserves metadata from first response - search() avoids an extra round trip to get response metadata --- lib/ts-sdk/__tests__/client/client.spec.ts | 33 +++++++++++++++++++ .../__tests__/client/opportunities.spec.ts | 8 ++--- lib/ts-sdk/src/client/client.ts | 25 +++++++++----- lib/ts-sdk/src/client/opportunities.ts | 20 +++-------- 4 files changed, 57 insertions(+), 29 deletions(-) diff --git a/lib/ts-sdk/__tests__/client/client.spec.ts b/lib/ts-sdk/__tests__/client/client.spec.ts index 1dc2051b..633a1c48 100644 --- a/lib/ts-sdk/__tests__/client/client.spec.ts +++ b/lib/ts-sdk/__tests__/client/client.spec.ts @@ -404,6 +404,39 @@ describe("Client", () => { expect(result.items[2].parsed).toBe(true); }); + it("preserves response metadata from the first page", async () => { + server.use( + http.get("/test-items", ({ url }) => { + const urlObj = new URL(url); + const page = parseInt(urlObj.searchParams.get("page") || "1"); + const pageSize = parseInt(urlObj.searchParams.get("pageSize") || "5"); + const allItems = generateMockItems(3); + const start = (page - 1) * pageSize; + const pageItems = allItems.slice(start, start + pageSize); + + return HttpResponse.json({ + status: 206, + message: "Partial Content", + items: pageItems, + paginationInfo: { + page, + pageSize, + totalItems: 3, + totalPages: 1, + }, + extraField: "preserved", + }); + }) + ); + + const result = await defaultClient.fetchMany("/test-items", { pageSize: 10 }); + + expect(result.status).toBe(206); + expect(result.message).toBe("Partial Content"); + expect((result as Record).extraField).toBe("preserved"); + expect(result.items).toHaveLength(3); + }); + it("throws when parseItem throws (validation failure)", async () => { server.use( http.get( diff --git a/lib/ts-sdk/__tests__/client/opportunities.spec.ts b/lib/ts-sdk/__tests__/client/opportunities.spec.ts index 62060c12..e9d9086b 100644 --- a/lib/ts-sdk/__tests__/client/opportunities.spec.ts +++ b/lib/ts-sdk/__tests__/client/opportunities.spec.ts @@ -587,8 +587,8 @@ describe("Opportunities", () => { pageSize: 2, }); - // Should make 4 requests: 3 for fetchMany pagination + 1 for metadata - expect(requestCount).toBe(4); + // Should make 3 requests (pages 1, 2, 3) — metadata comes from page 1 + expect(requestCount).toBe(3); // Should return all 5 items aggregated expect(result.items).toHaveLength(5); @@ -642,8 +642,8 @@ describe("Opportunities", () => { maxItems: 5, }); - // Should stop after collecting 5 items (3 pages for fetchMany + 1 for metadata = 4 requests) - expect(requestCount).toBe(4); + // Should stop after collecting 5 items (3 pages: 2 + 2 + 1) + expect(requestCount).toBe(3); expect(result.items).toHaveLength(5); }); }); diff --git a/lib/ts-sdk/src/client/client.ts b/lib/ts-sdk/src/client/client.ts index 7d48f640..4a4f8f33 100644 --- a/lib/ts-sdk/src/client/client.ts +++ b/lib/ts-sdk/src/client/client.ts @@ -218,7 +218,7 @@ export class Client { let currentPage = options?.page ?? 1; const allItems: T[] = []; - let totalItems: number | undefined; + let firstPageJson: Paginated | undefined; let totalPages: number | undefined; while (allItems.length < maxItems) { @@ -249,6 +249,12 @@ export class Client { const json = (await response.json()) as Paginated; + // Preserve the first page's full response envelope (status, message, + // paginationInfo, and any extra fields like sortInfo/filterInfo) + if (!firstPageJson) { + firstPageJson = json; + } + const { items: rawItems, paginationInfo } = json; // Parse/validate items if parseItem function is provided @@ -256,9 +262,7 @@ export class Client { ? rawItems.map(item => options.parseItem!(item)) : (rawItems as T[]); - // Store pagination metadata from first response - if (totalItems === undefined) { - totalItems = paginationInfo.totalItems ?? undefined; + if (totalPages === undefined) { totalPages = paginationInfo.totalPages ?? undefined; } @@ -280,17 +284,20 @@ export class Client { currentPage++; } + // Merge aggregated items into the first page's real response envelope + if (!firstPageJson) { + throw new Error("No response received from paginated endpoint"); + } + return { - status: 200, - message: "Success", + ...firstPageJson, items: allItems, paginationInfo: { + ...firstPageJson.paginationInfo, page: 1, pageSize: allItems.length, - totalItems, - totalPages, }, - }; + } as Paginated; } // ============================================================================= diff --git a/lib/ts-sdk/src/client/opportunities.ts b/lib/ts-sdk/src/client/opportunities.ts index b4eb0dcb..25637c79 100644 --- a/lib/ts-sdk/src/client/opportunities.ts +++ b/lib/ts-sdk/src/client/opportunities.ts @@ -238,7 +238,9 @@ export class Opportunities { ); } - // Auto-paginate by default using fetchMany with POST method + // Auto-paginate using fetchMany with POST method. + // fetchMany preserves the first page's full response envelope, so + // sortInfo and filterInfo pass through without an extra request. const result = await this.client.fetchMany(this.basePath + "/search", { method: "POST", body: searchBody as Record, @@ -248,21 +250,7 @@ export class Opportunities { parseItem: (item: unknown) => schema.parse(item) as z.infer, }); - // Fetch first page to get sortInfo and filterInfo metadata - const firstPageResponse = await this.fetchSearchPage( - searchBody, - 1, - options?.pageSize, - undefined, - schema - ); - - // Merge the aggregated items with metadata from first page - return { - ...result, - sortInfo: firstPageResponse.sortInfo, - filterInfo: firstPageResponse.filterInfo, - } as Filtered, OppFilters>; + return result as Filtered, OppFilters>; } // ############################################################################ From 1f6a89ab98c52c70bcd3b63d5a551cdfef1fe13c Mon Sep 17 00:00:00 2001 From: widal001 Date: Thu, 19 Feb 2026 12:14:13 -0500 Subject: [PATCH 3/9] refactor(ts-sdk): Input for withCustomFields() Previously accepted CustomFieldSpec[] and used `CustomFieldSpec.key` to build the resulting customFields object. Now we expect Record which more closely matches the final customFields object added to a schema --- .../__tests__/client/opportunities.spec.ts | 7 +- lib/ts-sdk/__tests__/extensions/index.spec.ts | 51 +++---- .../extensions/with-custom-fields.spec.ts | 127 ++++++++---------- lib/ts-sdk/src/extensions/types.ts | 50 +++---- .../src/extensions/with-custom-fields.ts | 34 ++--- 5 files changed, 127 insertions(+), 142 deletions(-) diff --git a/lib/ts-sdk/__tests__/client/opportunities.spec.ts b/lib/ts-sdk/__tests__/client/opportunities.spec.ts index e9d9086b..d5f06587 100644 --- a/lib/ts-sdk/__tests__/client/opportunities.spec.ts +++ b/lib/ts-sdk/__tests__/client/opportunities.spec.ts @@ -10,14 +10,13 @@ import { CustomFieldType } from "../../src/constants"; // Custom schema for testing withCustomFields support // ============================================================================= -const OpportunityWithLegacyIdSchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "legacyId", +const OpportunityWithLegacyIdSchema = withCustomFields(OpportunityBaseSchema, { + legacyId: { fieldType: CustomFieldType.integer, valueSchema: z.number().int(), description: "Maps to the opportunity_id in the legacy system", }, -] as const); +} as const); // ============================================================================= // Mock API Handlers diff --git a/lib/ts-sdk/__tests__/extensions/index.spec.ts b/lib/ts-sdk/__tests__/extensions/index.spec.ts index e02b8d16..2c513b35 100644 --- a/lib/ts-sdk/__tests__/extensions/index.spec.ts +++ b/lib/ts-sdk/__tests__/extensions/index.spec.ts @@ -28,25 +28,22 @@ const MetadataValueSchema = z.object({ describe("withCustomFields + getCustomFieldValue integration", () => { it("should work together to create typed schemas and extract values", () => { // Step 1: Create an extended schema with typed custom fields - const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "legacyId", + const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + legacyId: { fieldType: CustomFieldType.object, valueSchema: LegacyIdValueSchema, description: "Maps to the opportunity_id in the legacy system", }, - { - key: "tags", + tags: { fieldType: CustomFieldType.array, valueSchema: TagsValueSchema, description: "Tags for categorizing the opportunity", }, - { - key: "category", + category: { fieldType: CustomFieldType.string, description: "Grant category", }, - ] as const); + } as const); // Step 2: Parse data using the extended schema const opportunityData = { @@ -100,13 +97,12 @@ describe("withCustomFields + getCustomFieldValue integration", () => { }); it("should handle missing custom fields gracefully", () => { - const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "legacyId", + const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + legacyId: { fieldType: CustomFieldType.object, valueSchema: LegacyIdValueSchema, }, - ] as const); + } as const); const opportunityData = { id: "573525f2-8e15-4405-83fb-e6523511d893", @@ -126,13 +122,12 @@ describe("withCustomFields + getCustomFieldValue integration", () => { }); it("should reject invalid custom field values during parsing", () => { - const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "legacyId", + const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + legacyId: { fieldType: CustomFieldType.object, valueSchema: LegacyIdValueSchema, }, - ] as const); + } as const); const opportunityData = { id: "573525f2-8e15-4405-83fb-e6523511d893", @@ -176,16 +171,14 @@ describe("withCustomFields + getCustomFieldValue integration", () => { }); it("should work with default value schemas (no valueSchema provided)", () => { - const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "category", + const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + category: { fieldType: CustomFieldType.string, // No valueSchema - uses default z.string() }, - { - key: "priority", + priority: { fieldType: CustomFieldType.integer, // No valueSchema - uses default z.number().int() }, - ] as const); + } as const); const opportunityData = { id: "573525f2-8e15-4405-83fb-e6523511d893", @@ -219,13 +212,12 @@ describe("withCustomFields + getCustomFieldValue integration", () => { }); it("should handle complex nested custom fields", () => { - const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "metadata", + const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + metadata: { fieldType: CustomFieldType.object, valueSchema: MetadataValueSchema, }, - ] as const); + } as const); const opportunityData = { id: "573525f2-8e15-4405-83fb-e6523511d893", @@ -253,13 +245,12 @@ describe("withCustomFields + getCustomFieldValue integration", () => { }); it("should maintain type safety throughout the workflow", () => { - const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "legacyId", + const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + legacyId: { fieldType: CustomFieldType.object, valueSchema: LegacyIdValueSchema, }, - ] as const); + } as const); const opportunityData = { id: "573525f2-8e15-4405-83fb-e6523511d893", diff --git a/lib/ts-sdk/__tests__/extensions/with-custom-fields.spec.ts b/lib/ts-sdk/__tests__/extensions/with-custom-fields.spec.ts index e616a1ff..48d924ed 100644 --- a/lib/ts-sdk/__tests__/extensions/with-custom-fields.spec.ts +++ b/lib/ts-sdk/__tests__/extensions/with-custom-fields.spec.ts @@ -34,13 +34,12 @@ describe("withCustomFields", () => { describe("basic functionality", () => { it("should extend a schema with typed custom fields", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { - key: "category", + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + category: { fieldType: CustomFieldType.string, description: "Test category field", }, - ] as const); + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -59,14 +58,13 @@ describe("withCustomFields", () => { }); it("should work with OpportunityBaseSchema", () => { - const ExtendedOppSchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "legacyId", + const ExtendedOppSchema = withCustomFields(OpportunityBaseSchema, { + legacyId: { fieldType: CustomFieldType.object, valueSchema: LegacyIdValueSchema, description: "Maps to the opportunity_id in the legacy system", }, - ] as const); + } as const); const validOpp = { id: "573525f2-8e15-4405-83fb-e6523511d893", @@ -96,9 +94,9 @@ describe("withCustomFields", () => { describe("default value schemas", () => { it("should use default string schema when valueSchema not provided", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "stringField", fieldType: CustomFieldType.string }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + stringField: { fieldType: CustomFieldType.string }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -116,9 +114,9 @@ describe("withCustomFields", () => { }); it("should use default number schema when valueSchema not provided", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "numberField", fieldType: CustomFieldType.number }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + numberField: { fieldType: CustomFieldType.number }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -136,9 +134,9 @@ describe("withCustomFields", () => { }); it("should use default integer schema when valueSchema not provided", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "integerField", fieldType: CustomFieldType.integer }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + integerField: { fieldType: CustomFieldType.integer }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -171,9 +169,9 @@ describe("withCustomFields", () => { }); it("should use default boolean schema when valueSchema not provided", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "boolField", fieldType: CustomFieldType.boolean }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + boolField: { fieldType: CustomFieldType.boolean }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -191,9 +189,9 @@ describe("withCustomFields", () => { }); it("should use default object schema when valueSchema not provided", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "objectField", fieldType: CustomFieldType.object }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + objectField: { fieldType: CustomFieldType.object }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -214,9 +212,9 @@ describe("withCustomFields", () => { }); it("should use default array schema when valueSchema not provided", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "arrayField", fieldType: CustomFieldType.array }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + arrayField: { fieldType: CustomFieldType.array }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -240,13 +238,12 @@ describe("withCustomFields", () => { describe("custom value schemas", () => { it("should use provided valueSchema for validation", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { - key: "legacyId", + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + legacyId: { fieldType: "object", valueSchema: LegacyIdValueSchema, }, - ] as const); + } as const); // Valid value const result = ExtendedSchema.parse({ @@ -281,13 +278,12 @@ describe("withCustomFields", () => { }); it("should validate array value schema", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { - key: "tags", + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + tags: { fieldType: CustomFieldType.array, valueSchema: TagsValueSchema, }, - ] as const); + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -326,9 +322,9 @@ describe("withCustomFields", () => { describe("passthrough for unregistered fields", () => { it("should allow unregistered custom fields to pass through", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "registered", fieldType: CustomFieldType.string }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + registered: { fieldType: CustomFieldType.string }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -358,8 +354,8 @@ describe("withCustomFields", () => { // ############################################################################ describe("edge cases", () => { - it("should handle empty specs array", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [] as const); + it("should handle empty specs object", () => { + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, {} as const); const result = ExtendedSchema.parse({ id: "123", @@ -377,9 +373,9 @@ describe("withCustomFields", () => { }); it("should handle missing customFields property", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "category", fieldType: CustomFieldType.string }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + category: { fieldType: CustomFieldType.string }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -390,9 +386,9 @@ describe("withCustomFields", () => { }); it("should handle null customFields", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "category", fieldType: CustomFieldType.string }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + category: { fieldType: CustomFieldType.string }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -404,9 +400,9 @@ describe("withCustomFields", () => { }); it("should validate fieldType literal on registered fields", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "stringField", fieldType: CustomFieldType.string }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + stringField: { fieldType: CustomFieldType.string }, + } as const); // Should fail if fieldType doesn't match expect(() => @@ -431,24 +427,23 @@ describe("withCustomFields", () => { }); expect(() => - withCustomFields(SimpleSchemaWithoutCustomFields, [ - { key: "category", fieldType: CustomFieldType.string }, - ] as const) + withCustomFields(SimpleSchemaWithoutCustomFields, { + category: { fieldType: CustomFieldType.string }, + } as const) ).toThrow("Cannot register custom fields on a schema that doesn't support them"); }); }); describe("multiple custom fields", () => { it("should handle multiple custom field specs", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "category", fieldType: CustomFieldType.string }, - { key: "priority", fieldType: CustomFieldType.integer }, - { - key: "metadata", + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + category: { fieldType: CustomFieldType.string }, + priority: { fieldType: CustomFieldType.integer }, + metadata: { fieldType: CustomFieldType.object, valueSchema: z.object({ version: z.number() }), }, - ] as const); + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -480,17 +475,13 @@ describe("withCustomFields", () => { describe("using CustomFieldType constant", () => { it("should work with CustomFieldType enum constant", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { - key: "legacyId", + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + legacyId: { fieldType: CustomFieldType.object, valueSchema: LegacyIdValueSchema, }, - { - key: "category", - fieldType: CustomFieldType.string, - }, - ] as const); + category: { fieldType: CustomFieldType.string }, + } as const); const result = ExtendedSchema.parse({ id: "123", @@ -514,9 +505,9 @@ describe("withCustomFields", () => { }); it("should work with string literal fieldType", () => { - const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, [ - { key: "category", fieldType: "string" }, - ] as const); + const ExtendedSchema = withCustomFields(SimpleSchemaWithCustomFields, { + category: { fieldType: "string" }, + } as const); const result = ExtendedSchema.parse({ id: "123", diff --git a/lib/ts-sdk/src/extensions/types.ts b/lib/ts-sdk/src/extensions/types.ts index 9e8b190a..fe0b8d4b 100644 --- a/lib/ts-sdk/src/extensions/types.ts +++ b/lib/ts-sdk/src/extensions/types.ts @@ -13,15 +13,17 @@ import type { CustomField, CustomFieldType } from "../types"; /** * Specification for a custom field to be registered on a schema. + * The key in the Record passed to withCustomFields() is the field key in customFields. + * CustomField.name defaults to spec.name when provided, otherwise to the record key. */ export interface CustomFieldSpec { - /** The key used in the customFields record */ - key: string; + /** Optional display name; used as the default for CustomField.name when provided, otherwise the record key is used */ + name?: string; /** The JSON schema type for the field */ fieldType: CustomFieldType; /** Optional Zod schema to validate the value property. Defaults based on fieldType */ valueSchema?: z.ZodTypeAny; - /** Optional description of the custom field */ + /** Optional description; used as the default for CustomField.description when present */ description?: string; } @@ -33,20 +35,19 @@ export interface CustomFieldSpec { * WHY THESE UTILITIES EXIST: * * The `withCustomFields()` function builds Zod schemas dynamically at runtime by - * looping over a `specs` array. However, TypeScript's type system operates at - * compile time and cannot "unroll" runtime loops to infer types. + * iterating over `Object.entries(specs)`. However, TypeScript's type system + * operates at compile time and cannot "unroll" runtime loops to infer types. * * When we do: * const schemas = {}; - * for (const spec of specs) { schemas[spec.key] = ... } + * for (const [name, spec] of Object.entries(specs)) { schemas[name] = ... } * * TypeScript only sees `Record`, losing all specific * key-value type information. * * These type utilities bridge that gap by operating at the TYPE level instead - * of the VALUE level. They use TypeScript's mapped types to iterate over the - * `specs` array type at compile time, reconstructing what the inferred type - * should be. + * of the VALUE level. They use TypeScript's mapped types over the Record's + * keys at compile time, reconstructing what the inferred type should be. */ /** @@ -111,7 +112,6 @@ type InferValueType = T["valueSchema"] extends z.ZodT * @example * ```typescript * type Field = TypedCustomField<{ - * key: "legacyId", * fieldType: "object", * valueSchema: z.object({ system: z.string(), id: z.number() }) * }>; @@ -133,26 +133,26 @@ type TypedCustomField = { }; /** - * Builds the complete `customFields` object type from an array of specs. + * Builds the complete `customFields` object type from a Record of specs. * * This is the core type transformation that makes `withCustomFields()` work. * It does two things: * - * 1. **Mapped type iteration**: `[K in TSpecs[number] as K["key"]]` iterates - * over each spec in the array at the TYPE level, extracting the `key` from - * each spec and creating a typed property for it. + * 1. **Mapped type iteration**: `[K in keyof TSpecs]` iterates over each key + * in the specs Record at the TYPE level, creating a typed property for it + * using the spec value type at TSpecs[K]. * * 2. **Passthrough for unknown fields**: `& Record` ensures - * that unregistered custom fields (not in the specs array) can still pass + * that unregistered custom fields (not in the specs Record) can still pass * through validation, but they'll be typed as the base `CustomField` type * (with `value: unknown`). * * @example * ```typescript - * type Fields = TypedCustomFields<[ - * { key: "legacyId", fieldType: "object", valueSchema: ... }, - * { key: "category", fieldType: "string" } - * ]>; + * type Fields = TypedCustomFields<{ + * legacyId: { fieldType: "object", valueSchema: ... }, + * category: { fieldType: "string" } + * }>; * // Fields = { * // legacyId?: { fieldType: "object", value: { system: string; id: number }, ... }; * // category?: { fieldType: "string", value: string, ... }; @@ -164,8 +164,8 @@ type TypedCustomField = { * - `fields.category?.value` → typed as `string` ✅ * - `fields.unknownField?.value` → typed as `unknown` (passthrough) */ -type TypedCustomFields = { - [K in TSpecs[number] as K["key"]]?: TypedCustomField; +type TypedCustomFields> = { + [K in keyof TSpecs]?: TypedCustomField; } & Record; // ############################################################################ @@ -188,9 +188,9 @@ type TypedCustomFields = { * * @example * ```typescript - * const Schema = withCustomFields(OpportunityBaseSchema, [ - * { key: "legacyId", fieldType: "object", valueSchema: ... } - * ] as const); + * const Schema = withCustomFields(OpportunityBaseSchema, { + * legacyId: { fieldType: "object", valueSchema: ... } + * } as const); * * type Opportunity = z.infer; * // Opportunity.customFields?.legacyId?.value.id → typed as number ✅ @@ -198,7 +198,7 @@ type TypedCustomFields = { */ export type WithCustomFieldsResult< TSchema extends z.AnyZodObject, - TSpecs extends readonly CustomFieldSpec[], + TSpecs extends Record, > = z.ZodObject< Omit & { customFields: z.ZodOptional>>; diff --git a/lib/ts-sdk/src/extensions/with-custom-fields.ts b/lib/ts-sdk/src/extensions/with-custom-fields.ts index 61780663..dee16362 100644 --- a/lib/ts-sdk/src/extensions/with-custom-fields.ts +++ b/lib/ts-sdk/src/extensions/with-custom-fields.ts @@ -44,15 +44,17 @@ function getValueSchema(spec: CustomFieldSpec): z.ZodTypeAny { /** * Extends a schema with typed custom fields. * - * This function takes a base schema (like OpportunityBaseSchema) and an array - * of custom field specifications, returning a new schema where the customFields - * property is typed according to the specs. + * This function takes a base schema (like OpportunityBaseSchema) and a Record + * of custom field specifications keyed by field name, returning a new schema + * where the customFields property is typed according to the specs. The record + * key is used as the default for each CustomField's `name`; spec.description + * is used as the default for CustomField.description when present. * * Unregistered custom fields will still pass through validation but won't have * typed access. * * @param baseSchema - The base Zod object schema to extend - * @param specs - Array of custom field specifications + * @param specs - Record of custom field specifications (key = field name) * @returns A new schema with typed customFields * * @example @@ -62,19 +64,17 @@ function getValueSchema(spec: CustomFieldSpec): z.ZodTypeAny { * id: z.number().int(), * }); * - * const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ - * { - * key: "legacyId", + * const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + * legacyId: { * fieldType: "object", * valueSchema: LegacyIdValueSchema, * description: "Maps to the opportunity_id in the legacy system", * }, - * { - * key: "category", + * category: { * fieldType: "string", * description: "Grant category", * }, - * ] as const); + * } as const); * * type Opportunity = z.infer; * // opp.customFields?.legacyId?.value.id → typed as number @@ -83,7 +83,7 @@ function getValueSchema(spec: CustomFieldSpec): z.ZodTypeAny { */ export function withCustomFields< TSchema extends z.AnyZodObject, - const TSpecs extends readonly CustomFieldSpec[], + const TSpecs extends Record, >(baseSchema: TSchema, specs: TSpecs): WithCustomFieldsResult { // Validate that the base schema has a customFields property const schemaShape = baseSchema.shape; @@ -94,14 +94,18 @@ export function withCustomFields< ); } - // Build typed schema for each spec + // Build typed schema for each spec; record key is the field name const typedFieldSchemas: Record = {}; - for (const spec of specs) { - // Extend CustomFieldSchema, overriding only fieldType and value - typedFieldSchemas[spec.key] = CustomFieldSchema.extend({ + for (const [key, spec] of Object.entries(specs)) { + typedFieldSchemas[key] = CustomFieldSchema.extend({ fieldType: z.literal(spec.fieldType), value: getValueSchema(spec), + name: z.string().default(spec.name ?? key), + description: + spec.description !== undefined + ? z.string().nullish().default(spec.description) + : z.string().nullish(), }).optional(); } From 436a06169a0032573b789746478cf9936d3c4fa4 Mon Sep 17 00:00:00 2001 From: widal001 Date: Thu, 19 Feb 2026 12:27:17 -0500 Subject: [PATCH 4/9] refactor(ts-sdk): Update and expand examples --- lib/ts-sdk/examples/README.md | 43 +++- lib/ts-sdk/examples/custom-fields.ts | 16 +- .../get-opportunity-with-custom-fields.ts | 95 +++++++++ lib/ts-sdk/examples/mock-api-server.ts | 195 ++++++++++++++++++ lib/ts-sdk/package.json | 4 +- 5 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 lib/ts-sdk/examples/get-opportunity-with-custom-fields.ts create mode 100644 lib/ts-sdk/examples/mock-api-server.ts diff --git a/lib/ts-sdk/examples/README.md b/lib/ts-sdk/examples/README.md index 736c058a..945291e6 100644 --- a/lib/ts-sdk/examples/README.md +++ b/lib/ts-sdk/examples/README.md @@ -4,15 +4,24 @@ This folder contains example scripts demonstrating how to use the CommonGrants T ## Prerequisites -You can run these examples against either a locally hosted API or a remote API. +You can run these examples against a mock API (no backend required), the California Grants FastAPI example, or a remote API. -### Option A: Local API +### Option A: Mock API (easiest, no Python/FastAPI) -By default the examples will run against a local API at `http://localhost:8000`. You can follow the instructions below to start a local API server using the California Grants example API. +From the `lib/ts-sdk` directory, start the built-in mock server in one terminal: -**1. Start the Example API** +```bash +pnpm install +pnpm example:server +``` + +Then in another terminal run any example. The mock server listens on `http://localhost:8000` and serves list, get, and search with sample data including custom fields. -From the repository root, run: +### Option B: California Grants FastAPI API + +By default the examples use `http://localhost:8000`. To use the California Grants example API instead of the mock server: + +From the repository root: ```bash cd examples/ca-opportunity-example @@ -20,23 +29,19 @@ make install make dev ``` -This starts a local API server at `http://localhost:8000`. - > [!NOTE] > The commands above require both Python and Poetry to be installed. > For more details, see the [California grants example API README](../../../examples/ca-opportunity-example/README.md). -**2. Install SDK Dependencies** - From the `lib/ts-sdk` directory: ```bash pnpm install ``` -### Option B: Remote API +### Option C: Remote API -To connect to a remote CommonGrants-compatible API, set the following environment variables: +To connect to a remote CommonGrants-compatible API instead of localhost, set the following environment variables: ```bash export CG_BASE_URL="https://your-api-endpoint.com" @@ -65,6 +70,10 @@ pnpm example:search # Demonstrate custom fields usage pnpm example:custom-fields + +# Parse custom fields (mock response, or fetch by ID from API) +pnpm example:get-custom-fields +pnpm example:get-custom-fields ``` ## Examples @@ -128,6 +137,18 @@ Demonstrates how to extend schemas with typed custom fields and extract their va pnpm example:custom-fields ``` +### Get Opportunity with Custom Fields + +Parses a mock API response (no server) or fetches an opportunity from the API using a schema with typed custom fields. + +```bash +# Parse inline mock response (no API required) +pnpm example:get-custom-fields + +# Fetch from API with typed custom fields +pnpm example:get-custom-fields +``` + **Output Example:** ``` diff --git a/lib/ts-sdk/examples/custom-fields.ts b/lib/ts-sdk/examples/custom-fields.ts index 65a08603..c06c26fc 100644 --- a/lib/ts-sdk/examples/custom-fields.ts +++ b/lib/ts-sdk/examples/custom-fields.ts @@ -29,31 +29,27 @@ const MetadataValueSchema = z.object({ }); // Create an extended schema with typed custom fields -const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "legacyId", +const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + legacyId: { fieldType: CustomFieldType.object, valueSchema: LegacyIdValueSchema, description: "Maps to the opportunity_id in the legacy system", }, - { - key: "tags", + tags: { fieldType: CustomFieldType.array, valueSchema: TagsValueSchema, description: "Tags for categorizing the opportunity", }, - { - key: "category", + category: { fieldType: CustomFieldType.string, description: "Grant category", }, - { - key: "metadata", + metadata: { fieldType: CustomFieldType.object, valueSchema: MetadataValueSchema, description: "Import metadata", }, -] as const); +} as const); // Sample opportunity data with custom fields const opportunityData = { diff --git a/lib/ts-sdk/examples/get-opportunity-with-custom-fields.ts b/lib/ts-sdk/examples/get-opportunity-with-custom-fields.ts new file mode 100644 index 00000000..49321a2c --- /dev/null +++ b/lib/ts-sdk/examples/get-opportunity-with-custom-fields.ts @@ -0,0 +1,95 @@ +/** + * Example: parse custom fields from API responses. + * + * Two modes: + * 1. Parse inline mock (no API): pnpm example:get-custom-fields + * 2. Fetch from API: pnpm example:get-custom-fields + */ + +import { z } from "zod"; +import { OpportunityBaseSchema, OkSchema } from "../src/schemas"; +import { CustomFieldType } from "../src/constants"; +import { withCustomFields } from "../src/extensions"; +import { Client, Auth } from "../src/client"; + +// Extended schema with one typed custom field (e.g. from your API) +const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + legacyId: { + fieldType: CustomFieldType.integer, + valueSchema: z.number().int(), + description: "Legacy system opportunity ID", + }, +} as const); + +const ResponseSchema = OkSchema(OpportunitySchema); + +// Mock API response shape (same as GET /common-grants/opportunities/:id) +const MOCK_OPPORTUNITY_RESPONSE = { + status: 200, + message: "Success", + data: { + id: "573525f2-8e15-4405-83fb-e6523511d893", + title: "STEM Education Grant Program", + description: "A grant program focused on improving STEM education.", + status: { value: "open" }, + createdAt: "2025-01-01T00:00:00Z", + lastModifiedAt: "2025-01-15T00:00:00Z", + customFields: { + legacyId: { + name: "legacyId", + fieldType: "integer", + value: 12345, + description: "Legacy system opportunity ID", + }, + }, + }, +}; + +function parseMockResponse() { + const parsed = ResponseSchema.parse(MOCK_OPPORTUNITY_RESPONSE); + return parsed.data; +} + +async function fetchFromApi(oppId: string) { + const baseUrl = process.env.CG_BASE_URL ?? "http://localhost:8000"; + const apiKey = process.env.CG_API_KEY ?? ""; + + const client = new Client({ + baseUrl, + auth: Auth.apiKey(apiKey), + timeout: 5000, + }); + + return client.opportunities.get(oppId, OpportunitySchema); +} + +function printOpportunity(opp: z.infer) { + console.log("Opportunity:"); + console.log(` id: ${opp.id}`); + console.log(` title: ${opp.title}`); + console.log(` status: ${opp.status.value}`); + if (opp.customFields?.legacyId != null) { + console.log( + ` customFields.legacyId.value: ${opp.customFields.legacyId.value} (typed as number)` + ); + } else { + console.log(" customFields.legacyId: (not present)"); + } +} + +async function main() { + const oppId = process.argv[2]; + + if (!oppId) { + console.log("Parsing mock API response (no server required)...\n"); + const opp = parseMockResponse(); + printOpportunity(opp); + return; + } + + console.log(`Fetching opportunity ${oppId} with custom schema...\n`); + const opp = await fetchFromApi(oppId); + printOpportunity(opp); +} + +main().catch(console.error); diff --git a/lib/ts-sdk/examples/mock-api-server.ts b/lib/ts-sdk/examples/mock-api-server.ts new file mode 100644 index 00000000..61968a39 --- /dev/null +++ b/lib/ts-sdk/examples/mock-api-server.ts @@ -0,0 +1,195 @@ +/** + * Minimal mock CommonGrants API server for running examples without the FastAPI template. + * + * Serves GET/POST routes that match the SDK client. Start with: + * pnpm example:server + * + * Then run any example (get, list, search, get-custom-fields) against http://localhost:8000. + */ + +import { createServer } from "http"; + +const PORT = parseInt(process.env.PORT ?? "8000", 10); + +// ============================================================================= +// Mock Opportunities +// ============================================================================= + +const MOCK_OPPORTUNITIES = [ + { + id: "573525f2-8e15-4405-83fb-e6523511d893", + title: "STEM Education Grant Program", + description: "A grant program focused on improving STEM education.", + status: { value: "open" }, + createdAt: "2025-01-01T00:00:00Z", + lastModifiedAt: "2025-01-15T00:00:00Z", + customFields: { + legacyId: { + name: "legacyId", + fieldType: "integer", + value: 12345, + description: "Legacy system opportunity ID", + }, + }, + }, + { + id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + title: "Community Development Grant", + description: "Funding for community development projects.", + status: { value: "open" }, + createdAt: "2025-01-02T00:00:00Z", + lastModifiedAt: "2025-01-16T00:00:00Z", + customFields: { + legacyId: { + name: "legacyId", + fieldType: "integer", + value: 12346, + description: "Legacy system opportunity ID", + }, + }, + }, + { + id: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + title: "Education Initiative", + description: "Support for education initiatives.", + status: { value: "forecasted" }, + createdAt: "2025-01-03T00:00:00Z", + lastModifiedAt: "2025-01-17T00:00:00Z", + customFields: { + legacyId: { + name: "legacyId", + fieldType: "integer", + value: 12347, + description: "Legacy system opportunity ID", + }, + }, + }, +]; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +function send(res: import("http").ServerResponse, status: number, body: unknown) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function parseQuery(url: string): Record { + const i = url.indexOf("?"); + if (i === -1) return {}; + const out: Record = {}; + for (const part of url.slice(i + 1).split("&")) { + const [k, v] = part.split("=").map(decodeURIComponent); + if (k && v !== undefined) out[k] = v; + } + return out; +} + +// ============================================================================= +// Mock API Server +// ============================================================================= +const server = createServer((req, res) => { + const url = req.url ?? ""; + const method = req.method ?? "GET"; + const path = url.split("?")[0]; + + // ============================================================================= + // GET /common-grants/opportunities + // ============================================================================= + + if (method === "GET" && path === "/common-grants/opportunities") { + const q = parseQuery(url); + const page = Math.max(1, parseInt(q.page ?? "1", 10)); + const pageSize = Math.min(100, Math.max(1, parseInt(q.pageSize ?? "25", 10))); + const start = (page - 1) * pageSize; + const items = MOCK_OPPORTUNITIES.slice(start, start + pageSize); + send(res, 200, { + status: 200, + message: "Success", + items, + paginationInfo: { + page, + pageSize, + totalItems: MOCK_OPPORTUNITIES.length, + totalPages: Math.ceil(MOCK_OPPORTUNITIES.length / pageSize), + }, + }); + return; + } + + // ============================================================================= + // GET /common-grants/opportunities/:id + // ============================================================================= + + if (method === "GET" && path.startsWith("/common-grants/opportunities/")) { + const id = path.slice("/common-grants/opportunities/".length); + const opp = MOCK_OPPORTUNITIES.find(o => o.id === id) ?? { + id, + title: "Mock Opportunity", + description: "Mock opportunity for examples.", + status: { value: "open" }, + createdAt: "2025-01-01T00:00:00Z", + lastModifiedAt: "2025-01-01T00:00:00Z", + }; + send(res, 200, { + status: 200, + message: "Success", + data: opp, + }); + return; + } + + // ============================================================================= + // POST /common-grants/opportunities/search + // ============================================================================= + + if (method === "POST" && path === "/common-grants/opportunities/search") { + let body = ""; + req.on("data", chunk => { + body += chunk; + }); + req.on("end", () => { + let items = [...MOCK_OPPORTUNITIES]; + try { + const parsed = body ? (JSON.parse(body) as { search?: string }) : {}; + if (parsed.search && typeof parsed.search === "string") { + const q = parsed.search.toLowerCase(); + items = items.filter(o => o.title.toLowerCase().includes(q)); + } + } catch { + // ignore + } + send(res, 200, { + status: 200, + message: "Success", + items, + paginationInfo: { + page: 1, + pageSize: items.length, + totalItems: items.length, + totalPages: 1, + }, + sortInfo: { sortBy: "lastModifiedAt", sortOrder: "desc" }, + filterInfo: { filters: {} }, + }); + }); + return; + } + + send(res, 404, { status: 404, message: "Not found" }); +}); + +// ============================================================================= +// Start Server +// ============================================================================= +server.listen(PORT, () => { + console.log(`Mock CommonGrants API listening on http://localhost:${PORT}`); + console.log(""); + console.log("Run examples in another terminal:"); + console.log(" pnpm example:list"); + console.log(" pnpm example:get 573525f2-8e15-4405-83fb-e6523511d893"); + console.log(" pnpm example:search education"); + console.log(" pnpm example:get-custom-fields 573525f2-8e15-4405-83fb-e6523511d893"); + console.log(""); +}); diff --git a/lib/ts-sdk/package.json b/lib/ts-sdk/package.json index 232272de..533e2e2c 100644 --- a/lib/ts-sdk/package.json +++ b/lib/ts-sdk/package.json @@ -72,7 +72,9 @@ "example:list": "tsx examples/list-opportunities.ts", "example:get": "tsx examples/get-opportunity.ts", "example:search": "tsx examples/search-opportunities.ts", - "example:custom-fields": "tsx examples/custom-fields.ts" + "example:custom-fields": "tsx examples/custom-fields.ts", + "example:get-custom-fields": "tsx examples/get-opportunity-with-custom-fields.ts", + "example:server": "tsx examples/mock-api-server.ts" }, "dependencies": { "zod": "^3.25.76" From ac602033fabc160cd7beafcb3bf2900a0d334dd6 Mon Sep 17 00:00:00 2001 From: widal001 Date: Thu, 19 Feb 2026 12:32:22 -0500 Subject: [PATCH 5/9] docs(ts-sdk): Update READMEs - Fixes `withCustomFields()` examples in TS SDK README - Adds TS SDK to root-level README --- README.md | 1 + lib/ts-sdk/README.md | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 19ae85d5..60a1b571 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ CommonGrants is an open standard for sharing data about funding opportunities, a - [Libraries](lib): The code for the CommonGrants public packages and libraries: - [@common-grants/core](lib/core): The TypeSpec library with the CommonGrants specification. - [@common-grants/cli](lib/cli): The command-line tool for working with the CommonGrants protocol. + - [@common-grants/sdk](lib/ts-sdk): The TypeScript SDK to streamline adoption of CommonGrants in TypeScript applications. - [python-sdk](lib/python-sdk): The Python SDK to streamline adoption of CommonGrants in Python applications. - [Templates](templates): Templates with boilerplate code for implementing the CommonGrants protocol. - [Examples](examples): Examples implementations of the CommonGrants protocol. diff --git a/lib/ts-sdk/README.md b/lib/ts-sdk/README.md index a0988673..a41cbacf 100644 --- a/lib/ts-sdk/README.md +++ b/lib/ts-sdk/README.md @@ -314,20 +314,18 @@ const LegacyIdValueSchema = z.object({ id: z.number().int(), }); -// Extend the base schema with typed custom fields -const OpportunitySchema = withCustomFields(OpportunityBaseSchema, [ - { - key: "legacyId", +// Extend the base schema with typed custom fields (record: key = field key) +const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { + legacyId: { fieldType: CustomFieldType.object, valueSchema: LegacyIdValueSchema, description: "Maps to the opportunity_id in the legacy system", }, - { - key: "category", + category: { fieldType: CustomFieldType.string, description: "Grant category", }, -] as const); +}); // Parse data with custom fields const opportunity = OpportunitySchema.parse({ @@ -357,6 +355,8 @@ type Opportunity = z.infer; // opportunity.customFields?.category?.value → typed as string ``` +Each spec can include optional `name` (used as the default for `CustomField.name`; otherwise the record key is used) and optional `description`. + #### Extracting Custom Field Values Use `getCustomFieldValue()` to safely extract and parse custom field values: From 7989c7b14c34e0435ca83771f4664b9c6e18750a Mon Sep 17 00:00:00 2001 From: widal001 Date: Thu, 19 Feb 2026 17:02:34 -0500 Subject: [PATCH 6/9] test(ts-sdk): Make test names more descriptive --- lib/ts-sdk/__tests__/client/opportunities.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ts-sdk/__tests__/client/opportunities.spec.ts b/lib/ts-sdk/__tests__/client/opportunities.spec.ts index d5f06587..384ba27c 100644 --- a/lib/ts-sdk/__tests__/client/opportunities.spec.ts +++ b/lib/ts-sdk/__tests__/client/opportunities.spec.ts @@ -148,7 +148,7 @@ describe("Opportunities", () => { await expect(client.opportunities.get(OPP_UUID_1)).rejects.toThrow("500"); }); - it("parses response with a custom schema", async () => { + it("parses get result with a custom schema", async () => { server.use( http.get("/common-grants/opportunities/:id", ({ params }) => { return HttpResponse.json({ @@ -279,7 +279,7 @@ describe("Opportunities", () => { expect(result.items).toHaveLength(5); }); - it("parses items with a custom schema", async () => { + it("parses list results with a custom schema", async () => { server.use( http.get("/common-grants/opportunities", () => { return HttpResponse.json({ @@ -452,7 +452,7 @@ describe("Opportunities", () => { await expect(client.opportunities.search({ query: "test" })).rejects.toThrow("500"); }); - it("parses items with a custom schema", async () => { + it("parses search results with a custom schema", async () => { server.use( http.post("/common-grants/opportunities/search", () => { return HttpResponse.json({ From 9e12251216a3bbf17ae565204edbfb1143789f6c Mon Sep 17 00:00:00 2001 From: widal001 Date: Thu, 19 Feb 2026 17:43:40 -0500 Subject: [PATCH 7/9] refactor(ts-sdk): Client.fetchMany() Makes the function easier to reason about by moving some logic to a private fetchOnePage() method and moving the first page request out of the while loop --- lib/ts-sdk/src/client/client.ts | 136 ++++++++++++++++---------------- 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/lib/ts-sdk/src/client/client.ts b/lib/ts-sdk/src/client/client.ts index 4a4f8f33..d38f6017 100644 --- a/lib/ts-sdk/src/client/client.ts +++ b/lib/ts-sdk/src/client/client.ts @@ -212,83 +212,30 @@ export class Client { * ``` */ async fetchMany(path: string, options?: FetchManyOptions): Promise> { + // Set defaults. const pageSize = options?.pageSize ?? this.config.pageSize; const maxItems = options?.maxItems ?? this.config.maxItems; const method = options?.method ?? "GET"; - let currentPage = options?.page ?? 1; - - const allItems: T[] = []; - let firstPageJson: Paginated | undefined; - let totalPages: number | undefined; - - while (allItems.length < maxItems) { - let response: Response; - - if (method === "POST") { - // For POST requests, pagination goes in the request body - const requestBody = { - ...options?.body, - pagination: { - page: currentPage, - pageSize, - }, - }; - - response = await this.post(path, requestBody, { signal: options?.signal }); - } else { - // For GET requests, pagination goes in query params - response = await this.get(path, { - params: { page: currentPage, pageSize }, - signal: options?.signal, - }); - } - - if (!response.ok) { - throw new Error(`Failed to fetch ${path}: ${response.status} ${response.statusText}`); - } - - const json = (await response.json()) as Paginated; - - // Preserve the first page's full response envelope (status, message, - // paginationInfo, and any extra fields like sortInfo/filterInfo) - if (!firstPageJson) { - firstPageJson = json; - } + const startPage = options?.page ?? 1; - const { items: rawItems, paginationInfo } = json; + // Fetch first page so we always have firstPageJson. + const firstResult = await this.fetchOnePage(path, method, startPage, pageSize, options); + const firstPageJson = firstResult.json; + const allItems: T[] = [...firstResult.items.slice(0, maxItems)]; - // Parse/validate items if parseItem function is provided - const items: T[] = options?.parseItem - ? rawItems.map(item => options.parseItem!(item)) - : (rawItems as T[]); + // Fetch remaining pages, up to maxItems. + let currentPage = startPage + 1; + while (allItems.length < maxItems && !firstResult.isLastPage) { + const result = await this.fetchOnePage(path, method, currentPage, pageSize, options); - if (totalPages === undefined) { - totalPages = paginationInfo.totalPages ?? undefined; - } - - // Add items up to maxItems limit const remainingCapacity = maxItems - allItems.length; - const itemsToAdd = items.slice(0, remainingCapacity); - allItems.push(...itemsToAdd); - - // Stop if we've fetched all available items - const isLastPage = - items.length < pageSize || - (totalPages !== undefined && currentPage >= totalPages) || - items.length === 0; - - if (isLastPage || allItems.length >= maxItems) { - break; - } + allItems.push(...result.items.slice(0, remainingCapacity)); + if (result.isLastPage || allItems.length >= maxItems) break; currentPage++; } - // Merge aggregated items into the first page's real response envelope - if (!firstPageJson) { - throw new Error("No response received from paginated endpoint"); - } - + // Return the results. return { ...firstPageJson, items: allItems, @@ -304,6 +251,63 @@ export class Client { // Private helper functions // ============================================================================= + /** + * Fetches a single page from a paginated endpoint and returns the parsed + * items plus metadata needed to drive fetchMany's aggregation loop. + */ + private async fetchOnePage( + path: string, + method: "GET" | "POST", + currentPage: number, + pageSize: number, + options?: FetchManyOptions + ): Promise<{ + json: Paginated; + items: T[]; + isLastPage: boolean; + totalPages: number | undefined; + }> { + let response: Response; + + // Fetch the page + if (method === "POST") { + // Add pagination to the request body if it's a POST request. + const requestBody = { + ...options?.body, + pagination: { page: currentPage, pageSize }, + }; + response = await this.post(path, requestBody, { signal: options?.signal }); + } else { + // Add pagination to the request params if it's a GET request. + response = await this.get(path, { + params: { page: currentPage, pageSize }, + signal: options?.signal, + }); + } + + // Throw an error if the response is not OK. + if (!response.ok) { + throw new Error(`Failed to fetch ${path}: ${response.status} ${response.statusText}`); + } + + // Parse the response. + const json = (await response.json()) as Paginated; + const { items: rawItems, paginationInfo } = json; + const items: T[] = options?.parseItem + ? rawItems.map(item => options.parseItem!(item)) + : (rawItems as T[]); + + // Determine if this is the last page. + const totalPages = paginationInfo.totalPages ?? undefined; + const isLastPage = + items.length < pageSize || + (totalPages !== undefined && currentPage >= totalPages) || + items.length === 0; + + // Return the results. + return { json, items, isLastPage, totalPages }; + } + /** Constructs the full URL for an API path. */ private url(path: string): string { // Ensure path starts with / From c8a77cf4b67ecf00e95a573f170877b15300de1c Mon Sep 17 00:00:00 2001 From: widal001 Date: Thu, 19 Feb 2026 17:47:26 -0500 Subject: [PATCH 8/9] doc(ts-sdk): Restores dropped `as const` in example Accidentally dropped an `as const` in the withCustomFields() example --- lib/ts-sdk/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ts-sdk/README.md b/lib/ts-sdk/README.md index a41cbacf..a6045bdb 100644 --- a/lib/ts-sdk/README.md +++ b/lib/ts-sdk/README.md @@ -325,7 +325,7 @@ const OpportunitySchema = withCustomFields(OpportunityBaseSchema, { fieldType: CustomFieldType.string, description: "Grant category", }, -}); +} as const); // Parse data with custom fields const opportunity = OpportunitySchema.parse({ From 9a02a222ee44af8b364af905507a1b78f7321f3d Mon Sep 17 00:00:00 2001 From: widal001 Date: Thu, 19 Feb 2026 18:17:25 -0500 Subject: [PATCH 9/9] fix(ts-sdk): Error thrown if search has no results Previously it would error if `Client.opportunity.search()` returned no matching results because pageSize was expected to be > 0 --- lib/ts-sdk/__tests__/schemas/zod/pagination.spec.ts | 3 +-- lib/ts-sdk/src/client/client.ts | 6 ++++-- lib/ts-sdk/src/schemas/zod/pagination.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/ts-sdk/__tests__/schemas/zod/pagination.spec.ts b/lib/ts-sdk/__tests__/schemas/zod/pagination.spec.ts index 3d4b7f8d..967c3756 100644 --- a/lib/ts-sdk/__tests__/schemas/zod/pagination.spec.ts +++ b/lib/ts-sdk/__tests__/schemas/zod/pagination.spec.ts @@ -165,8 +165,7 @@ describe("PaginatedResultsInfo Schema", () => { expect(() => PaginatedResultsInfoSchema.parse({ page: -1, pageSize: 20 })).toThrow(); // Page must be an integer expect(() => PaginatedResultsInfoSchema.parse({ page: 1.5, pageSize: 20 })).toThrow(); - // PageSize must be at least 1 - expect(() => PaginatedResultsInfoSchema.parse({ page: 1, pageSize: 0 })).toThrow(); + // PageSize can't be negative expect(() => PaginatedResultsInfoSchema.parse({ page: 1, pageSize: -1 })).toThrow(); // PageSize must be an integer expect(() => PaginatedResultsInfoSchema.parse({ page: 1, pageSize: 20.5 })).toThrow(); diff --git a/lib/ts-sdk/src/client/client.ts b/lib/ts-sdk/src/client/client.ts index d38f6017..384cba2b 100644 --- a/lib/ts-sdk/src/client/client.ts +++ b/lib/ts-sdk/src/client/client.ts @@ -228,9 +228,11 @@ export class Client { while (allItems.length < maxItems && !firstResult.isLastPage) { const result = await this.fetchOnePage(path, method, currentPage, pageSize, options); + // Add items up to maxItems limit. const remainingCapacity = maxItems - allItems.length; allItems.push(...result.items.slice(0, remainingCapacity)); + // Stop if we've fetched all available items. if (result.isLastPage || allItems.length >= maxItems) break; currentPage++; } @@ -269,7 +271,7 @@ export class Client { }> { let response: Response; - // Fetch the page + // Fetch the page. if (method === "POST") { // Add pagination to the request body if it's a POST request. const requestBody = { @@ -290,7 +292,7 @@ export class Client { throw new Error(`Failed to fetch ${path}: ${response.status} ${response.statusText}`); } - // Parse the response. + // Parse/validate items if parseItem function is provided const json = (await response.json()) as Paginated; const { items: rawItems, paginationInfo } = json; const items: T[] = options?.parseItem diff --git a/lib/ts-sdk/src/schemas/zod/pagination.ts b/lib/ts-sdk/src/schemas/zod/pagination.ts index 32418f4f..3a3a3323 100644 --- a/lib/ts-sdk/src/schemas/zod/pagination.ts +++ b/lib/ts-sdk/src/schemas/zod/pagination.ts @@ -43,8 +43,8 @@ export const PaginatedResultsInfoSchema = z.object({ /** Current page number (indexing starts at 1) */ page: z.number().int().min(1), - /** Number of items per page */ - pageSize: z.number().int().min(1), + /** Number of items per page (0 when the page is empty) */ + pageSize: z.number().int().min(0), /** Total number of items across all pages */ totalItems: z.number().int().nullish(),