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..a6045bdb 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); +} 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: 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 8a21b105..384ba27c 100644 --- a/lib/ts-sdk/__tests__/client/opportunities.spec.ts +++ b/lib/ts-sdk/__tests__/client/opportunities.spec.ts @@ -1,6 +1,22 @@ 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, { + 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 +32,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 +147,30 @@ describe("Opportunities", () => { await expect(client.opportunities.get(OPP_UUID_1)).rejects.toThrow("500"); }); + + it("parses get result 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 +278,33 @@ describe("Opportunities", () => { expect(requestCount).toBe(3); expect(result.items).toHaveLength(5); }); + + it("parses list results 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 +452,41 @@ describe("Opportunities", () => { await expect(client.opportunities.search({ query: "test" })).rejects.toThrow("500"); }); + it("parses search results 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) // ========================================================================= @@ -467,8 +586,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); @@ -522,8 +641,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/__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/__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/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" diff --git a/lib/ts-sdk/src/client/client.ts b/lib/ts-sdk/src/client/client.ts index 7d48f640..384cba2b 100644 --- a/lib/ts-sdk/src/client/client.ts +++ b/lib/ts-sdk/src/client/client.ts @@ -212,91 +212,104 @@ 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 totalItems: number | 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 startPage = options?.page ?? 1; - const json = (await response.json()) as Paginated; + // 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)]; - const { items: rawItems, paginationInfo } = json; + // 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); - // Parse/validate items if parseItem function is provided - const items: T[] = options?.parseItem - ? rawItems.map(item => options.parseItem!(item)) - : (rawItems as T[]); - - // Store pagination metadata from first response - if (totalItems === undefined) { - totalItems = paginationInfo.totalItems ?? undefined; - totalPages = paginationInfo.totalPages ?? undefined; - } - - // Add items up to maxItems limit + // 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)); + // Stop if we've fetched all available items. + if (result.isLastPage || allItems.length >= maxItems) break; currentPage++; } + // Return the results. return { - status: 200, - message: "Success", + ...firstPageJson, items: allItems, paginationInfo: { + ...firstPageJson.paginationInfo, page: 1, pageSize: allItems.length, - totalItems, - totalPages, }, - }; + } as Paginated; } // ============================================================================= // 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/validate items if parseItem function is provided + 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 / diff --git a/lib/ts-sdk/src/client/opportunities.ts b/lib/ts-sdk/src/client/opportunities.ts index fe72fcf9..25637c79 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,42 @@ 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`, { + // 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, 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); - - // Merge the aggregated items with metadata from first page - return { - ...result, - sortInfo: firstPageResponse.sortInfo, - filterInfo: firstPageResponse.filterInfo, - }; + return result as Filtered, OppFilters>; } // ############################################################################ @@ -239,12 +281,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 +303,6 @@ export class Opportunities { } const json = await response.json(); - return OpportunitiesFilteredResponseSchema.parse(json); + return FilteredSchema(schema, OppFiltersSchema).parse(json) as Filtered, OppFilters>; } } 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(); } 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(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ed3cf21..ed4d8a2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,14 +33,16 @@ catalogs: overrides: '@isaacs/brace-expansion@<=5.0.0': '>=5.0.1' + ajv@<8.18.0: '>=8.18.0' axios@<=1.13.4: '>=1.13.5' + devalue@<=5.6.2: '>=5.6.3' diff@<8.0.3: '>=8.0.3' - fast-xml-parser@>=4.3.6 <=5.3.3: '>=5.3.4' + fast-xml-parser@>=4.1.3 <5.3.6: '>=5.3.6' glob@>=10.2.0 <10.5.0: '>=10.5.0' lodash@>=4.0.0 <=4.17.22: '>=4.17.23' + minimatch@<10.2.1: '>=10.2.1' qs@<6.14.1: '>=6.14.2' - tar@<7.5.7: '>=7.5.7' - tar@=7.5.1: '>=7.5.2' + tar@<7.5.8: '>=7.5.8' undici@>=7.0.0 <7.18.2: '>=7.18.2' importers: @@ -309,11 +311,11 @@ importers: specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.31)(tsx@4.21.0)(yaml@2.8.2)) ajv: - specifier: ^8.17.1 - version: 8.17.1 + specifier: '>=8.18.0' + version: 8.18.0 ajv-formats: specifier: ^3.0.1 - version: 3.0.1(ajv@8.17.1) + version: 3.0.1(ajv@8.18.0) eslint: specifier: ^9.38.0 version: 9.38.0 @@ -372,8 +374,8 @@ importers: specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.27) ajv: - specifier: ^8.17.1 - version: 8.17.1 + specifier: '>=8.18.0' + version: 8.18.0 astro: specifier: ^5.16.11 version: 5.16.11(@types/node@20.19.31)(rollup@4.57.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -2075,14 +2077,6 @@ packages: '@types/node': optional: true - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -3362,7 +3356,7 @@ packages: ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: - ajv: ^8.5.0 + ajv: '>=8.18.0' peerDependenciesMeta: ajv: optional: true @@ -3370,7 +3364,7 @@ packages: ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: - ajv: ^8.0.0 + ajv: '>=8.18.0' peerDependenciesMeta: ajv: optional: true @@ -3378,16 +3372,13 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: - ajv: ^8.0.0 + ajv: '>=8.18.0' peerDependenciesMeta: ajv: optional: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -3535,8 +3526,9 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.3: + resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} + engines: {node: 20 || >=22} base-64@1.0.0: resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} @@ -3575,11 +3567,9 @@ packages: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.2: + resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -3821,9 +3811,6 @@ packages: compute-lcm@1.1.2: resolution: {integrity: sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -4039,8 +4026,8 @@ packages: resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} engines: {node: '>=18'} - devalue@5.6.2: - resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -4406,8 +4393,8 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-xml-parser@5.3.4: - resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} + fast-xml-parser@5.3.7: + resolution: {integrity: sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==} hasBin: true fastq@1.20.1: @@ -5131,9 +5118,6 @@ packages: resolution: {integrity: sha512-pXe9H1m6IgIpXmE5JSb8epilNTGsmTb2iPohAXpOdhqGFbQjNeHHsZxU+C8w6T81GZxSPFLeUoqDJmzxx5IGuw==} deprecated: Please switch to @apidevtools/json-schema-ref-parser - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -5498,20 +5482,9 @@ packages: resolution: {integrity: sha512-bjdr2xW1dBCMsMGGsUeqM4eFI60m94+szhxWys+B1ztIt6gWSfeGBdSVCIawezeHYLYn0j6zrsXdQS/JllBzww==} engines: {node: '>=6'} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@7.4.6: - resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} - engines: {node: '>=10'} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@10.2.2: + resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + engines: {node: 18 || 20 || >=22} minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -5912,10 +5885,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -6508,8 +6477,8 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - tar@7.5.7: - resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} + tar@7.5.9: + resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} engines: {node: '>=18'} temporal-polyfill@0.3.0: @@ -6880,9 +6849,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} @@ -7447,8 +7413,8 @@ snapshots: '@apidevtools/openapi-schemas': 2.1.0 '@apidevtools/swagger-methods': 3.0.2 '@jsdevtools/ono': 7.1.3 - ajv: 8.17.1 - ajv-draft-04: 1.0.0(ajv@8.17.1) + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) call-me-maybe: 1.0.2 openapi-types: 12.1.3 @@ -8533,7 +8499,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 10.2.2 transitivePeerDependencies: - supports-color @@ -8555,28 +8521,28 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: - ajv: 6.12.6 + ajv: 8.18.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 10.2.2 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color '@eslint/eslintrc@3.3.3': dependencies: - ajv: 6.12.6 + ajv: 8.18.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 10.2.2 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -8939,12 +8905,6 @@ snapshots: optionalDependencies: '@types/node': 20.19.31 - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.1': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 @@ -9158,8 +9118,8 @@ snapshots: '@jsonforms/core@3.7.0': dependencies: '@types/json-schema': 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 2.1.1(ajv@8.18.0) lodash: 4.17.23 '@jsonforms/react@3.7.0(@jsonforms/core@3.7.0)(react@18.3.1)': @@ -9519,9 +9479,9 @@ snapshots: '@scalar/json-magic': 0.11.0 '@scalar/openapi-types': 0.5.3 '@scalar/openapi-upgrader': 0.1.8 - ajv: 8.17.1 - ajv-draft-04: 1.0.0(ajv@8.17.1) - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + ajv-formats: 3.0.1(ajv@8.18.0) jsonpointer: 5.0.1 leven: 4.1.0 yaml: 2.8.2 @@ -9943,7 +9903,7 @@ snapshots: '@swagger-api/apidom-error': 1.2.2 '@types/ramda': 0.30.2 axios: 1.13.5 - minimatch: 7.4.6 + minimatch: 10.2.2 process: 0.11.10 ramda: 0.30.1 ramda-adjunct: 5.1.0(ramda@0.30.1) @@ -10347,7 +10307,7 @@ snapshots: debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 - minimatch: 9.0.5 + minimatch: 10.2.2 semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 @@ -10361,7 +10321,7 @@ snapshots: '@typescript-eslint/types': 8.53.1 '@typescript-eslint/visitor-keys': 8.53.1 debug: 4.4.3 - minimatch: 9.0.5 + minimatch: 10.2.2 semver: 7.7.3 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -10409,7 +10369,7 @@ snapshots: dependencies: '@babel/code-frame': 7.28.6 '@inquirer/prompts': 8.2.0(@types/node@20.19.31) - ajv: 8.17.1 + ajv: 8.18.0 change-case: 5.4.4 env-paths: 3.0.0 globby: 16.1.0 @@ -10418,7 +10378,7 @@ snapshots: picocolors: 1.1.1 prettier: 3.8.1 semver: 7.7.3 - tar: 7.5.7 + tar: 7.5.9 temporal-polyfill: 0.3.0 vscode-languageserver: 9.0.1 vscode-languageserver-textdocument: 1.0.12 @@ -10648,26 +10608,19 @@ snapshots: acorn@8.15.0: {} - ajv-draft-04@1.0.0(ajv@8.17.1): + ajv-draft-04@1.0.0(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 - ajv-formats@2.1.1(ajv@8.17.1): + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 - ajv-formats@3.0.1(ajv@8.17.1): + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 + ajv: 8.18.0 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -10777,7 +10730,7 @@ snapshots: cssesc: 3.0.0 debug: 4.4.3 deterministic-object-hash: 2.0.2 - devalue: 5.6.2 + devalue: 5.6.3 diff: 8.0.3 dlv: 1.1.3 dset: 3.1.4 @@ -10941,7 +10894,7 @@ snapshots: bail@2.0.2: {} - balanced-match@1.0.2: {} + balanced-match@4.0.3: {} base-64@1.0.0: {} @@ -10999,14 +10952,9 @@ snapshots: widest-line: 5.0.0 wrap-ansi: 9.0.2 - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: + brace-expansion@5.0.2: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.3 braces@3.0.3: dependencies: @@ -11220,8 +11168,6 @@ snapshots: validate.io-function: 1.0.2 validate.io-integer-array: 1.0.0 - concat-map@0.0.1: {} - content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -11450,7 +11396,7 @@ snapshots: dependencies: base-64: 1.0.0 - devalue@5.6.2: {} + devalue@5.6.3: {} devlop@1.1.0: dependencies: @@ -11746,7 +11692,7 @@ snapshots: '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 8.18.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -11765,7 +11711,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 10.2.2 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -11785,7 +11731,7 @@ snapshots: '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 8.18.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -11804,7 +11750,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 10.2.2 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -11970,7 +11916,7 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-parser@5.3.4: + fast-xml-parser@5.3.7: dependencies: strnum: 2.1.2 @@ -12149,7 +12095,7 @@ snapshots: glob@13.0.0: dependencies: - minimatch: 10.1.1 + minimatch: 10.2.2 minipass: 7.1.2 path-scurry: 2.0.1 @@ -12158,7 +12104,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 10.2.2 once: 1.4.0 path-is-absolute: 1.0.1 @@ -13030,8 +12976,6 @@ snapshots: js-yaml: 3.14.2 ono: 4.0.11 - json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -13660,21 +13604,9 @@ snapshots: dependencies: lodash: 4.17.23 - minimatch@10.1.1: + minimatch@10.2.2: dependencies: - '@isaacs/brace-expansion': 5.0.1 - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - minimatch@7.4.6: - dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.2 minimist@1.2.8: {} @@ -13796,7 +13728,7 @@ snapshots: openapi-sampler@1.6.2: dependencies: '@types/json-schema': 7.0.15 - fast-xml-parser: 5.3.4 + fast-xml-parser: 5.3.7 json-pointer: 0.6.2 openapi-server-url-templating@1.3.0: @@ -14032,8 +13964,6 @@ snapshots: proxy-from-env@1.1.0: {} - punycode@2.3.1: {} - pure-rand@6.1.0: {} qs@6.14.2: @@ -14913,7 +14843,7 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tar@7.5.7: + tar@7.5.9: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -14933,13 +14863,13 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 7.2.3 - minimatch: 3.1.2 + minimatch: 10.2.2 test-exclude@7.0.1: dependencies: '@istanbuljs/schema': 0.1.3 glob: 13.0.0 - minimatch: 9.0.5 + minimatch: 10.2.2 tiny-inflate@1.0.3: {} @@ -15224,10 +15154,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - url-parse@1.5.10: dependencies: querystringify: 2.2.0 @@ -15645,8 +15571,8 @@ snapshots: yaml-language-server@1.19.2: dependencies: '@vscode/l10n': 0.0.18 - ajv: 8.17.1 - ajv-draft-04: 1.0.0(ajv@8.17.1) + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) lodash: 4.17.23 prettier: 3.8.1 request-light: 0.5.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 56076d2b..0fb4d626 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,18 +6,6 @@ packages: - lib/ts-sdk - website -overrides: - "@isaacs/brace-expansion@<=5.0.0": ">=5.0.1" - axios@<=1.13.4: ">=1.13.5" - diff@<8.0.3: ">=8.0.3" - fast-xml-parser@>=4.3.6 <=5.3.3: ">=5.3.4" - glob@>=10.2.0 <10.5.0: ">=10.5.0" - lodash@>=4.0.0 <=4.17.22: ">=4.17.23" - qs@<6.14.1: ">=6.14.2" - tar@<7.5.7: ">=7.5.7" - tar@=7.5.1: ">=7.5.2" - undici@>=7.0.0 <7.18.2: ">=7.18.2" - catalog: "@types/node": ^20.19.23 "@typespec/compiler": ^1.9.0 @@ -27,3 +15,17 @@ catalog: "@typespec/openapi3": ^1.9.0 "@typespec/rest": ^0.79.0 "@typespec/versioning": ^0.79.0 + +overrides: + "@isaacs/brace-expansion@<=5.0.0": ">=5.0.1" + ajv@<8.18.0: ">=8.18.0" + axios@<=1.13.4: ">=1.13.5" + devalue@<=5.6.2: ">=5.6.3" + diff@<8.0.3: ">=8.0.3" + fast-xml-parser@>=4.1.3 <5.3.6: ">=5.3.6" + glob@>=10.2.0 <10.5.0: ">=10.5.0" + lodash@>=4.0.0 <=4.17.22: ">=4.17.23" + minimatch@<10.2.1: ">=10.2.1" + qs@<6.14.1: ">=6.14.2" + tar@<7.5.8: ">=7.5.8" + undici@>=7.0.0 <7.18.2: ">=7.18.2"