Skip to content
Open
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions lib/ts-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -357,6 +355,8 @@ type Opportunity = z.infer<typeof OpportunitySchema>;
// 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:
Expand Down
33 changes: 33 additions & 0 deletions lib/ts-sdk/__tests__/client/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).extraField).toBe("preserved");
expect(result.items).toHaveLength(3);
});

it("throws when parseItem throws (validation failure)", async () => {
server.use(
http.get(
Expand Down
127 changes: 123 additions & 4 deletions lib/ts-sdk/__tests__/client/opportunities.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -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");
});
});

// =============================================================================
Expand Down Expand Up @@ -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);
});
});

// =============================================================================
Expand Down Expand Up @@ -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)
// =========================================================================
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});
Expand Down
51 changes: 21 additions & 30 deletions lib/ts-sdk/__tests__/extensions/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading