diff --git a/shared/api/core/attachModels.ts b/shared/api/core/attachModels.ts index ba7c05b79..8dc93a471 100644 --- a/shared/api/core/attachModels.ts +++ b/shared/api/core/attachModels.ts @@ -16,29 +16,72 @@ export const ProductOptions = z.object({ export const ExtAttachBodySchema = z .object({ // Customer / Entity Info - - customer_id: z + customer_id: z.string().describe("Your unique identifier for the customer"), + // Product Info + product_id: z .string() - .describe("ID of the customer to attach the product to"), + .nullish() + .describe( + "Product ID, set when creating the product in the Autumn dashboard", + ), + + entity_id: z + .string() + .nullish() + .describe( + "If attaching a product to an entity, can be used to auto create the entity", + ), customer_data: CustomerDataSchema.optional().describe( - "Customer data if using attach to auto create customer", + "If auto creating a customer, the properties from this field will be used.", ), - entity_id: z.string().nullish(), - entity_data: EntityDataSchema.optional(), + entity_data: EntityDataSchema.optional().describe( + "If attaching a product to an entity and auto creating the entity, the properties from this field will be used. feature_id is required.", + ), - // Product Info - product_id: z.string().nullish(), - product_ids: z.array(z.string()).min(1).nullish(), - options: z.array(FeatureOptionsSchema).nullish(), - free_trial: z.boolean().optional(), + product_ids: z + .array(z.string()) + .min(1) + .nullish() + .describe( + "Can be used to attach multiple products to the customer at once. For example, attaching a main product and an add-on.", + ), + + options: z + .array(FeatureOptionsSchema) + .nullish() + .describe("Pass in quantities for prepaid features"), + + free_trial: z + .boolean() + .optional() + .describe( + "If the product has a free trial, this field can be used to disable it when attaching (by passing in false)", + ), // Others - success_url: z.string().optional(), - force_checkout: z.boolean().optional(), - checkout_session_params: z.any().optional(), - reward: z.string().or(z.array(z.string())).optional(), + success_url: z + .string() + .optional() + .describe("URL to redirect to after the purchase is successful"), + force_checkout: z + .boolean() + .optional() + .describe( + "Always return a Stripe Checkout URL, even if the customer's card is already on file", + ), + checkout_session_params: z + .any() + .optional() + .describe( + "Additional parameters to pass onto Stripe when creating the checkout session", + ), + reward: z + .string() + .or(z.array(z.string())) + .optional() + .describe("An Autumn promo_code or reward_id to apply at checkout"), invoice: z.boolean().optional(), // Checkout params diff --git a/shared/api/core/coreOpenApi.ts b/shared/api/core/coreOpenApi.ts index 08e15a4f2..20fd207da 100644 --- a/shared/api/core/coreOpenApi.ts +++ b/shared/api/core/coreOpenApi.ts @@ -4,6 +4,11 @@ import { ExtAttachBodySchema, ExtCheckoutParamsSchema, } from "@api/models.js"; +import { + createJSDocDescription, + docLink, + example, +} from "@api/openApiHelpers.js"; import type { ZodOpenApiPathsObject } from "zod-openapi"; import { CheckParamsSchema, CheckResultSchema } from "./checkModels.js"; import { @@ -19,29 +24,82 @@ import { TrackResultSchema, } from "./coreOpModels.js"; +const attachJsDoc = createJSDocDescription({ + description: + "Enables a product for a customer and processes payment if their payment method is already on file.", + whenToUse: + "Use this when the customer already has a payment method saved. For new customers without payment info, use `checkout` instead.", + params: ExtAttachBodySchema, + examples: [ + example({ + values: { + customer_id: "cus_123", + product_id: "pro_plan", + }, + }), + example({ + values: { + customer_id: "cus_123", + product_id: "pro_plan", + entity_id: "entity_123", + }, + description: "Attach to a specific entity", + }), + example({ + values: { + customer_id: "cus_123", + product_id: "pro_plan", + success_url: "https://example.com/success", + }, + description: "With a success URL", + }), + ], + methodName: "attach", + docs: [ + docLink({ + url: "https://docs.useautumn.com/core-concepts/attach", + title: "Product Attachments", + }), + docLink({ + url: "https://docs.useautumn.com/payments/overview", + title: "Payment Processing", + }), + ], +}); + export const coreOps: ZodOpenApiPathsObject = { "/attach": { post: { summary: "Attach Product", - description: - "Enables a product and handles a payment if the customer's card is already on file.", + description: attachJsDoc, tags: ["core"], requestBody: { content: { "application/json": { schema: ExtAttachBodySchema, - example: { - customer_id: "123", - product_id: "pro", + examples: { + basic: { + summary: "Attach a product immediately", + description: + "Enable a product for a customer with immediate activation", + value: { + customer_id: "cus_123", + product_id: "pro_plan", + }, + }, }, }, }, }, responses: { "200": { - description: "200 OK", - content: { "application/json": { schema: AttachResultSchema } }, + description: "Product attached successfully", + content: { + "application/json": { + schema: AttachResultSchema, + }, + }, }, }, }, diff --git a/shared/api/models.ts b/shared/api/models.ts index 63545cb4f..ff6a643a7 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -5,6 +5,10 @@ export * from "./core/checkoutModels.js"; export * from "./core/coreOpenApi.js"; export * from "./core/coreOpModels.js"; +// Helpers +export * from "./openApiHelpers.js"; +export * from "./utils/zodToJSDoc.js"; + // Customers export * from "./customers/apiCustomer.js"; diff --git a/shared/api/openApiHelpers.ts b/shared/api/openApiHelpers.ts index b40395442..881964bc0 100644 --- a/shared/api/openApiHelpers.ts +++ b/shared/api/openApiHelpers.ts @@ -1,50 +1,165 @@ -/** - * Standard error responses for OpenAPI endpoints - * Use this to keep error responses consistent across all endpoints - */ -export const standardErrorResponses = { - "400": { - description: "Bad Request", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/AutumnError" }, - }, - }, - }, - "401": { - description: "Unauthorized", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/AutumnError" }, - }, - }, - }, - "404": { - description: "Not Found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/AutumnError" }, - }, - }, - }, - "500": { - description: "Internal Server Error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/AutumnError" }, - }, - }, - }, -} as const; +import type { z } from "zod/v4"; + +export interface JSDocParam { + name: string; + description: string; + optional?: boolean; +} + +interface JSDocExample { + description?: string; + values: Record; +} + +interface JSDocLink { + url: string; + title: string; +} + +interface JSDocOptions { + description: string; + whenToUse?: string; + params: z.ZodObject; + docs?: JSDocLink[]; + examples?: JSDocExample[]; + methodName?: string; + returns?: string; + throws?: Array<{ type: string; description: string }>; +} /** - * Merge standard error responses with custom success responses + * Generates a formatted JSDoc description string for OpenAPI specs + * that Stainless will convert to SDK documentation. */ -export function withErrorResponses>( - successResponses: T, -) { - return { - ...successResponses, - ...standardErrorResponses, +export function createJSDocDescription(options: JSDocOptions): string { + const extractParamsFromSchema = ( + schema: z.ZodObject, + ): JSDocParam[] => { + const params: JSDocParam[] = []; + const shape = schema.shape; + + for (const [fieldName, fieldSchema] of Object.entries(shape)) { + // biome-ignore lint/suspicious/noExplicitAny: accessing Zod internal properties + const zodField = fieldSchema as any; + const def = zodField._def; + + let description = ""; + if (zodField.description) { + description = zodField.description; + } else if (def?.description) { + description = def.description; + } + + if (description) { + params.push({ + name: fieldName, + description, + optional: + def?.typeName === "ZodOptional" || + def?.typeName === "ZodNullable" || + def?.defaultValue !== undefined, + }); + } + } + + return params; }; + + const parts: string[] = []; + + // Main description + parts.push(options.description); + + // When to use section + if (options.whenToUse) { + parts.push(""); + parts.push(options.whenToUse); + } + + // Parameters - extract from Zod schema + const params = extractParamsFromSchema(options.params); + if (params.length > 0) { + parts.push(""); + for (const param of params) { + const optional = param.optional ? " (optional)" : ""; + parts.push(`@param ${param.name} - ${param.description}${optional}`); + } + } + + // Returns + if (options.returns) { + parts.push(""); + parts.push(`@returns ${options.returns}`); + } + + // Throws + if (options.throws && options.throws.length > 0) { + parts.push(""); + for (const error of options.throws) { + parts.push(`@throws {${error.type}} ${error.description}`); + } + } + + // Examples + if (options.examples && options.examples.length > 0) { + const methodName = options.methodName || "method"; + + for (const example of options.examples) { + parts.push(""); + parts.push("@example"); + parts.push("```typescript"); + + // Add example description as a comment if provided + if (example.description) { + parts.push(`// ${example.description}`); + } + + // Format the example object + const exampleStr = JSON.stringify(example.values, null, 2) + .split("\n") + .map((line, idx) => (idx === 0 ? line : ` ${line}`)) + .join("\n"); + + parts.push(`const response = await client.${methodName}(${exampleStr});`); + parts.push("```"); + } + } + + // Documentation links + if (options.docs && options.docs.length > 0) { + parts.push(""); + for (const doc of options.docs) { + parts.push(`@see {@link ${doc.url}|${doc.title}}`); + } + } + + return parts.join("\n"); +} + +/** + * Shorthand helper for creating parameter definitions + */ +export function param( + name: string, + description: string, + optional = false, +): JSDocParam { + return { name, description, optional }; +} + +/** + * Shorthand helper for creating example definitions + */ +export function example(options: { + values: Record; + description?: string; +}): JSDocExample { + return { values: options.values, description: options.description }; +} + +/** + * Shorthand helper for creating documentation link definitions + */ +export function docLink(options: { url: string; title: string }): JSDocLink { + return { url: options.url, title: options.title }; } diff --git a/shared/api/utils/zodToJSDoc.ts b/shared/api/utils/zodToJSDoc.ts new file mode 100644 index 000000000..4f14ebe27 --- /dev/null +++ b/shared/api/utils/zodToJSDoc.ts @@ -0,0 +1,116 @@ +import type { z } from "zod/v4"; +import type { JSDocParam } from "../openApiHelpers.js"; + +type ZodAnyObject = z.ZodObject; +type ZodAnyField = z.ZodTypeAny; + +/** + * Extracts parameter information from a Zod schema to create JSDoc params + * + * @param schema - A Zod object schema (e.g., z.object({ ... })) + * @returns Array of JSDocParam objects with name, description, and optional flag + * + * @example + * ```typescript + * const AttachSchema = z.object({ + * customer_id: z.string().describe("The customer ID"), + * product_id: z.string().describe("The product ID"), + * entity_id: z.string().optional().describe("Optional entity ID"), + * }); + * + * const params = extractParamsFromSchema(AttachSchema); + * // Returns: [ + * // { name: "customer_id", description: "The customer ID", optional: false }, + * // { name: "product_id", description: "The product ID", optional: false }, + * // { name: "entity_id", description: "Optional entity ID", optional: true }, + * // ] + * ``` + */ +export function extractParamsFromSchema(schema: ZodAnyObject): JSDocParam[] { + const params: JSDocParam[] = []; + + // Get the shape of the object schema + const shape = schema.shape; + + for (const [fieldName, fieldSchema] of Object.entries(shape)) { + const zodField = fieldSchema as ZodAnyField; + + // Extract description from .describe() or .meta() + let description = ""; + // biome-ignore lint/suspicious/noExplicitAny: accessing Zod internal properties + const def = (zodField as any)._def; + if (zodField.description) { + description = zodField.description; + } else if (def?.description) { + description = def.description; + } + + // Check if field is optional/nullable + const isOptional = isZodFieldOptional(zodField); + + // Only add params that have descriptions (to avoid cluttering docs) + if (description) { + params.push({ + name: fieldName, + description, + optional: isOptional, + }); + } + } + + return params; +} + +/** + * Checks if a Zod field is optional or nullable + */ +function isZodFieldOptional(field: ZodAnyField): boolean { + // biome-ignore lint/suspicious/noExplicitAny: accessing Zod internal properties + const def = (field as any)._def; + + // Check for .optional() + if (def?.typeName === "ZodOptional") { + return true; + } + + // Check for .nullish() + if (def?.typeName === "ZodNullable") { + return true; + } + + // Check if it's wrapped in optional/nullable + if (def?.innerType) { + return isZodFieldOptional(def.innerType); + } + + // Check for default values (also makes it optional) + if (def?.defaultValue !== undefined) { + return true; + } + + return false; +} + +/** + * Creates a filtered list of params from a schema, including only specified fields + * + * @param schema - A Zod object schema + * @param includeFields - Array of field names to include in the output + * @returns Filtered array of JSDocParam objects + * + * @example + * ```typescript + * const params = filterSchemaParams(AttachSchema, [ + * 'customer_id', + * 'product_id', + * 'entity_id' + * ]); + * ``` + */ +export function filterSchemaParams( + schema: ZodAnyObject, + includeFields: string[], +): JSDocParam[] { + const allParams = extractParamsFromSchema(schema); + return allParams.filter((param) => includeFields.includes(param.name)); +}