From 31f4d34875c9c140e06d06956e52c3c786c9076d Mon Sep 17 00:00:00 2001 From: jckail Date: Wed, 8 Jul 2026 23:50:54 -0700 Subject: [PATCH] Generate an OpenAPI 3.1 document from the zod contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap Phase 4 item ("OpenAPI from zod"). Lets third parties integrate or generate clients without the TypeScript package. - core/contracts: `buildOpenApiDocument()` — component schemas produced by `z.toJSONSchema` (JSON Schema 2020-12, which OpenAPI 3.1 adopts) so they can't drift from the runtime validators; paths are a thin hand-authored map over those components. Covers the full v1 surface. Public endpoints marked security-free; Clerk bearer scheme documented. 5 unit tests. - web: public `GET /api/v1/openapi.json`. - api-client: `getOpenApiDocument()`. - docs: docs/api.md endpoint. Verification: 99 tests pass (87 core incl. 5 new + 12 bot); typecheck + lint clean across all workspaces. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/app/api/v1/openapi.json/route.ts | 10 + docs/api.md | 4 + packages/api-client/src/index.ts | 5 + packages/core/src/contracts/index.ts | 3 + packages/core/src/contracts/openapi.ts | 474 ++++++++++++++++++ packages/core/test/openapi.test.ts | 69 +++ 6 files changed, 565 insertions(+) create mode 100644 apps/web/src/app/api/v1/openapi.json/route.ts create mode 100644 packages/core/src/contracts/openapi.ts create mode 100644 packages/core/test/openapi.test.ts diff --git a/apps/web/src/app/api/v1/openapi.json/route.ts b/apps/web/src/app/api/v1/openapi.json/route.ts new file mode 100644 index 0000000..d4a58f8 --- /dev/null +++ b/apps/web/src/app/api/v1/openapi.json/route.ts @@ -0,0 +1,10 @@ +import { buildOpenApiDocument } from "@pointup/core/contracts"; +import { NextResponse } from "next/server"; + +/** + * Public OpenAPI 3.1 document for the v1 API, generated from the zod contracts. + * Lets third parties integrate (or generate clients) without the TS package. + */ +export function GET() { + return NextResponse.json(buildOpenApiDocument()); +} diff --git a/docs/api.md b/docs/api.md index 9c4b227..008e9fa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -47,6 +47,10 @@ Every surface — web app, mobile, browser extension — talks to the same versi Unauthenticated liveness check used by the ALB. Returns `{ "status": "ok" }`. +### `GET /api/v1/openapi.json` + +Public OpenAPI 3.1 document for this API, generated from the same zod contracts the server validates against (component schemas via `z.toJSONSchema`). Point Swagger UI or a client generator at it, or fetch via `client.getOpenApiDocument()`. + ### `GET /api/v1/providers` The catalog of supported loyalty programs. Public — surfaces use it to render link forms before sign-in. `kind` is one of `airline`, `hotel`, `credit_card`, `rail`, or `shopping`, covering airlines, hotels, transferable credit card currencies (Chase Ultimate Rewards, Amex Membership Rewards, Capital One, Citi ThankYou, Bilt), rail (Amtrak Guest Rewards), and shopping portals (Rakuten). diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index e23403e..fffa5ac 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -81,6 +81,11 @@ export class PointUpClient { return this.request("GET", "/api/v1/providers"); } + /** The OpenAPI 3.1 document describing this API (public). */ + getOpenApiDocument(): Promise> { + return this.request("GET", "/api/v1/openapi.json"); + } + listLoyaltyAccounts(): Promise { return this.request("GET", "/api/v1/loyalty-accounts"); } diff --git a/packages/core/src/contracts/index.ts b/packages/core/src/contracts/index.ts index f152ebb..bf39b6c 100644 --- a/packages/core/src/contracts/index.ts +++ b/packages/core/src/contracts/index.ts @@ -794,3 +794,6 @@ export function toIngestDealPageResultDto( markdownExcerpt: result.markdownExcerpt, }; } + +// OpenAPI 3.1 document generated from the schemas above. +export * from "./openapi"; diff --git a/packages/core/src/contracts/openapi.ts b/packages/core/src/contracts/openapi.ts new file mode 100644 index 0000000..3cc3c38 --- /dev/null +++ b/packages/core/src/contracts/openapi.ts @@ -0,0 +1,474 @@ +import { z } from "zod"; + +import { + activityEventDtoSchema, + apiErrorSchema, + balanceDtoSchema, + bulkUpdateMembershipRequestSchema, + bulkUpdateMembershipResultDtoSchema, + chatAssistantRequestSchema, + chatAssistantResponseSchema, + createPortfolioShareRequestSchema, + createTripGoalRequestSchema, + deletedAccountDtoSchema, + importPortfolioRequestSchema, + importPortfolioResultDtoSchema, + ingestDealPageResultDtoSchema, + linkLoyaltyAccountRequestSchema, + loyaltyAccountDtoSchema, + portfolioExportDtoSchema, + portfolioShareDtoSchema, + portfolioSummaryDtoSchema, + providerDtoSchema, + publicPortfolioSnapshotDtoSchema, + recordManualBalanceRequestSchema, + scrapeDealRequestSchema, + syncLoyaltyAccountRequestSchema, + syncOutcomeDtoSchema, + tripGoalDtoSchema, + updateLoyaltyAccountRequestSchema, + updateTripGoalRequestSchema, + valueAdviceDtoSchema, +} from "./index"; + +/** + * OpenAPI 3.1 document generated from the zod wire contracts — the same schemas + * the server validates against and `@pointup/api-client` is typed from. The + * component schemas are produced by `z.toJSONSchema` (JSON Schema 2020-12, which + * OpenAPI 3.1 adopts wholesale), so they cannot drift from the runtime + * validators. Paths are a thin hand-authored map over those components. + * + * Framework-free and dependency-light (only zod) so any surface can serve it — + * the web app exposes it at `GET /api/v1/openapi.json`. + */ + +/** Named component schemas → their zod source. */ +const COMPONENT_SCHEMAS = { + ProviderDto: providerDtoSchema, + LoyaltyAccountDto: loyaltyAccountDtoSchema, + BalanceDto: balanceDtoSchema, + PortfolioSummaryDto: portfolioSummaryDtoSchema, + PortfolioExportDto: portfolioExportDtoSchema, + ActivityEventDto: activityEventDtoSchema, + TripGoalDto: tripGoalDtoSchema, + SyncOutcomeDto: syncOutcomeDtoSchema, + ValueAdviceDto: valueAdviceDtoSchema, + DeletedAccountDto: deletedAccountDtoSchema, + PortfolioShareDto: portfolioShareDtoSchema, + PublicPortfolioSnapshotDto: publicPortfolioSnapshotDtoSchema, + IngestDealPageResultDto: ingestDealPageResultDtoSchema, + ChatAssistantResponse: chatAssistantResponseSchema, + ImportPortfolioResultDto: importPortfolioResultDtoSchema, + BulkUpdateMembershipResultDto: bulkUpdateMembershipResultDtoSchema, + ApiError: apiErrorSchema, + LinkLoyaltyAccountRequest: linkLoyaltyAccountRequestSchema, + UpdateLoyaltyAccountRequest: updateLoyaltyAccountRequestSchema, + BulkUpdateMembershipRequest: bulkUpdateMembershipRequestSchema, + RecordManualBalanceRequest: recordManualBalanceRequestSchema, + SyncLoyaltyAccountRequest: syncLoyaltyAccountRequestSchema, + CreateTripGoalRequest: createTripGoalRequestSchema, + UpdateTripGoalRequest: updateTripGoalRequestSchema, + ImportPortfolioRequest: importPortfolioRequestSchema, + ChatAssistantRequest: chatAssistantRequestSchema, + ScrapeDealRequest: scrapeDealRequestSchema, + CreatePortfolioShareRequest: createPortfolioShareRequestSchema, +} as const satisfies Record; + +type ComponentName = keyof typeof COMPONENT_SCHEMAS; + +type Json = Record; + +function ref(name: ComponentName): Json { + return { $ref: `#/components/schemas/${name}` }; +} +function arrayOf(name: ComponentName): Json { + return { type: "array", items: ref(name) }; +} +function jsonContent(schema: Json): Json { + return { "application/json": { schema } }; +} +function body(name: ComponentName, required = true): Json { + return { required, content: jsonContent(ref(name)) }; +} +function jsonResponse(description: string, schema: Json): Json { + return { description, content: jsonContent(schema) }; +} + +/** Standard error responses shared by authenticated JSON endpoints. */ +const ERROR_RESPONSES: Json = { + "400": jsonResponse("Request failed schema validation", ref("ApiError")), + "401": jsonResponse("Not authenticated", ref("ApiError")), +}; +const NOT_FOUND: Json = { + "404": jsonResponse("Not found or not owned by the caller", ref("ApiError")), +}; + +/** A single-item id path parameter. */ +const ID_PARAM: Json = { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, +}; + +export interface BuildOpenApiOptions { + /** e.g. "https://app.example.com"; defaults to a relative server. */ + readonly serverUrl?: string; + readonly version?: string; +} + +export function buildOpenApiDocument(options: BuildOpenApiOptions = {}): Json { + const schemas: Json = {}; + for (const [name, schema] of Object.entries(COMPONENT_SCHEMAS)) { + const jsonSchema = z.toJSONSchema(schema, { + target: "draft-2020-12", + }) as Json; + delete jsonSchema.$schema; + schemas[name] = jsonSchema; + } + + return { + openapi: "3.1.0", + info: { + title: "PointUp API", + version: options.version ?? "1.0.0", + description: + "Versioned HTTP API for PointUp / PointBot. Generated from the zod wire contracts (@pointup/core/contracts).", + }, + servers: [{ url: options.serverUrl ?? "/" }], + security: [{ clerkSession: [] }], + components: { + securitySchemes: { + clerkSession: { + type: "http", + scheme: "bearer", + description: + "Clerk session. Browsers send the session cookie automatically; native surfaces send Authorization: Bearer .", + }, + }, + schemas, + }, + paths: { + "/api/health": { + get: { + summary: "Liveness check", + security: [], + responses: { + "200": jsonResponse("Service is up", { + type: "object", + properties: { status: { type: "string" } }, + required: ["status"], + }), + }, + }, + }, + "/api/v1/providers": { + get: { + summary: "List supported loyalty programs", + security: [], + responses: { "200": jsonResponse("Provider catalog", arrayOf("ProviderDto")) }, + }, + }, + "/api/v1/summary": { + get: { + summary: "Portfolio summary", + responses: { + "200": jsonResponse("Aggregated portfolio", ref("PortfolioSummaryDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/loyalty-accounts": { + get: { + summary: "List the caller's accounts", + responses: { + "200": jsonResponse("Accounts", arrayOf("LoyaltyAccountDto")), + ...ERROR_RESPONSES, + }, + }, + post: { + summary: "Link a loyalty account", + requestBody: body("LinkLoyaltyAccountRequest"), + responses: { + "201": jsonResponse("Created", { + type: "object", + properties: { accountId: { type: "string" } }, + required: ["accountId"], + }), + ...ERROR_RESPONSES, + }, + }, + patch: { + summary: "Bulk-edit membership numbers", + requestBody: body("BulkUpdateMembershipRequest"), + responses: { + "200": jsonResponse("Per-item outcome", ref("BulkUpdateMembershipResultDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/loyalty-accounts/{id}": { + parameters: [ID_PARAM], + get: { + summary: "Get one account", + responses: { + "200": jsonResponse("Account", ref("LoyaltyAccountDto")), + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + patch: { + summary: "Update one account", + requestBody: body("UpdateLoyaltyAccountRequest"), + responses: { + "200": jsonResponse("Updated account", ref("LoyaltyAccountDto")), + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + delete: { + summary: "Unlink one account", + responses: { + "204": { description: "Unlinked" }, + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + }, + "/api/v1/loyalty-accounts/{id}/balances": { + parameters: [ID_PARAM], + post: { + summary: "Record a manual balance", + requestBody: body("RecordManualBalanceRequest"), + responses: { + "201": jsonResponse("Updated account", ref("LoyaltyAccountDto")), + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + }, + "/api/v1/loyalty-accounts/{id}/sync": { + parameters: [ID_PARAM], + post: { + summary: "Sync one account", + requestBody: { required: false, content: jsonContent(ref("SyncLoyaltyAccountRequest")) }, + responses: { + "200": jsonResponse("Sync outcome", ref("SyncOutcomeDto")), + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + }, + "/api/v1/sync": { + post: { + summary: "Sync all accounts", + responses: { + "200": jsonResponse("Per-account outcomes", arrayOf("SyncOutcomeDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/activity": { + get: { + summary: "Recent activity feed", + parameters: [{ name: "limit", in: "query", schema: { type: "integer" } }], + responses: { + "200": jsonResponse("Activity events", arrayOf("ActivityEventDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/expiring": { + get: { + summary: "Accounts expiring soon", + responses: { + "200": jsonResponse("Expiring accounts", arrayOf("LoyaltyAccountDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/export": { + get: { + summary: "Export accounts + history (json or csv)", + parameters: [ + { name: "format", in: "query", schema: { type: "string", enum: ["json", "csv"] } }, + ], + responses: { + "200": { + description: "Portfolio export", + content: { + "application/json": { schema: ref("PortfolioExportDto") }, + "text/csv": { schema: { type: "string" } }, + }, + }, + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/import": { + post: { + summary: "Import accounts + balances from a CSV export", + requestBody: body("ImportPortfolioRequest"), + responses: { + "201": jsonResponse("Import result", ref("ImportPortfolioResultDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/calendar.ics": { + get: { + summary: "iCalendar feed of expiration dates", + responses: { + "200": { + description: "iCalendar document", + content: { "text/calendar": { schema: { type: "string" } } }, + }, + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/goals": { + get: { + summary: "List trip goals", + responses: { + "200": jsonResponse("Trip goals", arrayOf("TripGoalDto")), + ...ERROR_RESPONSES, + }, + }, + post: { + summary: "Create a trip goal", + requestBody: body("CreateTripGoalRequest"), + responses: { + "201": jsonResponse("Created goal", ref("TripGoalDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/goals/{id}": { + parameters: [ID_PARAM], + patch: { + summary: "Update a trip goal", + requestBody: body("UpdateTripGoalRequest"), + responses: { + "200": jsonResponse("Updated goal", ref("TripGoalDto")), + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + delete: { + summary: "Delete a trip goal", + responses: { + "204": { description: "Deleted" }, + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + }, + "/api/v1/demo": { + post: { + summary: "Seed a demo portfolio", + responses: { + "201": jsonResponse("Seed result", { + type: "object", + properties: { + accountIds: { type: "array", items: { type: "string" } }, + goalId: { type: "string" }, + }, + }), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/assistant/chat": { + post: { + summary: "Grounded portfolio assistant chat", + requestBody: body("ChatAssistantRequest"), + responses: { + "200": jsonResponse("Assistant reply", ref("ChatAssistantResponse")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/value-advice": { + get: { + summary: "Transfer + deal value advice", + responses: { + "200": jsonResponse("Value advice", ref("ValueAdviceDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/deals/scrape": { + post: { + summary: "Scrape a deal page and re-rank advice", + requestBody: body("ScrapeDealRequest"), + responses: { + "200": jsonResponse("Scrape result", ref("IngestDealPageResultDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/shares": { + get: { + summary: "List share links", + responses: { + "200": jsonResponse("Share links", arrayOf("PortfolioShareDto")), + ...ERROR_RESPONSES, + }, + }, + post: { + summary: "Create a share link", + requestBody: body("CreatePortfolioShareRequest", false), + responses: { + "201": jsonResponse("Created share", ref("PortfolioShareDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/shares/{id}": { + parameters: [ID_PARAM], + delete: { + summary: "Revoke a share link", + responses: { + "204": { description: "Revoked" }, + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + }, + "/api/v1/public/share/{token}": { + parameters: [ + { name: "token", in: "path", required: true, schema: { type: "string" } }, + ], + get: { + summary: "Public portfolio snapshot for a share token", + security: [], + responses: { + "200": jsonResponse("Public snapshot", ref("PublicPortfolioSnapshotDto")), + "404": jsonResponse("Token missing, revoked, or expired", ref("ApiError")), + }, + }, + }, + "/api/v1/loyalty-accounts/deleted": { + get: { + summary: "Recently unlinked accounts (restore window)", + responses: { + "200": jsonResponse("Deleted accounts", arrayOf("DeletedAccountDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/loyalty-accounts/{id}/restore": { + parameters: [ID_PARAM], + post: { + summary: "Restore a soft-deleted account", + responses: { + "200": jsonResponse("Restored account", ref("LoyaltyAccountDto")), + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + }, + }, + }; +} diff --git a/packages/core/test/openapi.test.ts b/packages/core/test/openapi.test.ts new file mode 100644 index 0000000..64c8634 --- /dev/null +++ b/packages/core/test/openapi.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { buildOpenApiDocument } from "../src/contracts/openapi"; + +// Minimal shape assertions — enough to catch a broken generator without +// pinning the whole document. +interface OpenApiDoc { + openapi: string; + info: { title: string; version: string }; + paths: Record>; + components: { schemas: Record; securitySchemes: Record }; +} + +describe("buildOpenApiDocument", () => { + const doc = buildOpenApiDocument() as unknown as OpenApiDoc; + + it("is a valid OpenAPI 3.1 document with info", () => { + expect(doc.openapi).toBe("3.1.0"); + expect(doc.info.title).toBe("PointUp API"); + expect(doc.info.version).toBe("1.0.0"); + }); + + it("documents the core endpoints", () => { + for (const path of [ + "/api/health", + "/api/v1/providers", + "/api/v1/summary", + "/api/v1/loyalty-accounts", + "/api/v1/loyalty-accounts/{id}", + "/api/v1/goals", + "/api/v1/assistant/chat", + "/api/v1/value-advice", + ]) { + expect(doc.paths[path], `missing path ${path}`).toBeDefined(); + } + // The bulk-edit endpoint (added recently) is present as a PATCH collection op. + expect(doc.paths["/api/v1/loyalty-accounts"]!.patch).toBeDefined(); + }); + + it("registers component schemas generated from the contracts", () => { + for (const name of [ + "LoyaltyAccountDto", + "PortfolioSummaryDto", + "ProviderDto", + "BulkUpdateMembershipRequest", + "ApiError", + ]) { + expect(doc.components.schemas[name], `missing schema ${name}`).toBeDefined(); + } + // Generated schemas must not leak the JSON Schema dialect marker. + for (const schema of Object.values(doc.components.schemas)) { + expect((schema as Record).$schema).toBeUndefined(); + } + }); + + it("marks public endpoints as security-free and defaults others to auth", () => { + expect((doc.paths["/api/v1/providers"]!.get as { security: unknown[] }).security).toEqual([]); + expect(doc.components.securitySchemes.clerkSession).toBeDefined(); + }); + + it("references components rather than inlining everything at the top level", () => { + const providers = doc.paths["/api/v1/providers"]!.get as { + responses: { "200": { content: Record } }; + }; + expect(providers.responses["200"].content["application/json"]!.schema.items.$ref).toBe( + "#/components/schemas/ProviderDto", + ); + }); +});