diff --git a/e2e/test-server-all-api.test.ts b/e2e/test-server-all-api.test.ts index c47dcffc3..6baf1a6a9 100644 --- a/e2e/test-server-all-api.test.ts +++ b/e2e/test-server-all-api.test.ts @@ -17,6 +17,7 @@ import { resolveOpenApiSpec, findSchemaNameByResourceId, generateObjectsFromSchema, + SpecParser, } from '@stripe/sync-openapi' import destinationPostgres from '@stripe/sync-destination-postgres' import sourceStripe, { type StreamState, EXCLUDED_TABLES } from '@stripe/sync-source-stripe' @@ -140,7 +141,10 @@ async function resolveSpecPath(apiVersion: string): Promise { return resolved.cachePath } -async function syncAllEndpointsForVersion(apiVersion: string): Promise { +async function syncAllEndpointsForVersion( + apiVersion: string, + skip: (reason: string) => void +): Promise { const createdRange = { startUnix: RANGE_START, endUnix: RANGE_END } const openApiSpecPath = await resolveSpecPath(apiVersion) const endpointSet = await resolveEndpointSet({ @@ -166,21 +170,32 @@ async function syncAllEndpointsForVersion(apiVersion: string): Promise { fetchImpl: specFetch, }) - expect(sortedEndpoints.length, `${apiVersion} should expose at least one stream`).toBeGreaterThan( - 0 - ) + if (sortedEndpoints.length === 0) { + await versionTestServer.close().catch(() => {}) + skip(`${apiVersion}: spec exposes no listable endpoints`) + return + } try { // v2_core_events uses ISO timestamps for created filter and opaque page tokens; // the test-server's V2 pagination + subdivision interaction is not yet verified. const TEST_EXCLUDED = new Set([...EXCLUDED_TABLES, 'v2_core_events']) + const syncableTables = new SpecParser().discoverSyncableTables(endpointSet.spec, { + excluded: EXCLUDED_TABLES, + }) const seedable = sortedEndpoints.filter( (ep) => findSchemaNameByResourceId(endpointSet.spec, ep.resourceId) != null && !TEST_EXCLUDED.has(ep.tableName) && + syncableTables.has(ep.tableName) && (!STREAM_FILTER || STREAM_FILTER.has(ep.tableName)) ) + if (seedable.length === 0) { + skip(`${apiVersion}: no syncable streams after filtering`) + return + } + for (let i = 0; i < seedable.length; i += SEED_CONCURRENCY) { const batch = seedable.slice(i, i + SEED_CONCURRENCY) await Promise.all( @@ -404,12 +419,13 @@ describe('test-server API', () => { for (const supportedApiVersion of SUPPORTED_API_VERSIONS) { it( `syncs all supported streams for Stripe API ${supportedApiVersion}`, - async () => { + async (ctx) => { const year = parseInt(supportedApiVersion.slice(0, 4), 10) if (year < 2020) { + ctx.skip(`${supportedApiVersion}: pre-2020 versions are not exercised`) return } - await syncAllEndpointsForVersion(supportedApiVersion) + await syncAllEndpointsForVersion(supportedApiVersion, (reason) => ctx.skip(reason)) }, 3 * 60_000 ) diff --git a/e2e/test-server-sync.test.ts b/e2e/test-server-sync.test.ts index 20d1eb4a8..4f0a87f02 100644 --- a/e2e/test-server-sync.test.ts +++ b/e2e/test-server-sync.test.ts @@ -143,7 +143,7 @@ describe('test-server sync via Docker engine', () => { type: 'stripe', stripe: { api_key: 'sk_test_fake', - api_version: '2025-04-30.basil', + api_version: BUNDLED_API_VERSION, base_url: harness.testServerContainerUrl(), ...opts.sourceOverrides, }, diff --git a/packages/openapi/__tests__/listFnResolver.test.ts b/packages/openapi/__tests__/listFnResolver.test.ts index 9542609bb..92a630785 100644 --- a/packages/openapi/__tests__/listFnResolver.test.ts +++ b/packages/openapi/__tests__/listFnResolver.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it, vi } from 'vitest' -import { buildListFn, buildRetrieveFn, discoverListEndpoints } from '../listFnResolver' +import { buildListFn, buildRetrieveFn } from '../listFnResolver' +import { SpecParser } from '../specParser' import { isDeprecatedOperation } from '../specCleaning' import { minimalStripeOpenApiSpec } from './fixtures/minimalSpec' -describe('discoverListEndpoints', () => { +describe('SpecParser.discoverListEndpoints', () => { + const parser = new SpecParser() + it('maps table names to their API paths', () => { - const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec) + const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec) expect(endpoints.get('customers')).toEqual({ tableName: 'customers', @@ -37,7 +40,7 @@ describe('discoverListEndpoints', () => { }) it('discovers v2 list endpoints using next_page_url format', () => { - const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec) + const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec) expect(endpoints.get('v2_core_accounts')).toEqual({ tableName: 'v2_core_accounts', @@ -89,35 +92,37 @@ describe('discoverListEndpoints', () => { }, }, } - const endpoints = discoverListEndpoints(spec) + const endpoints = parser.discoverListEndpoints(spec) const paths = Array.from(endpoints.values()).map((e) => e.apiPath) expect(paths).not.toContain('/v1/customers/{customer}/sources') }) it('skips endpoints with deprecated: true on the operation', () => { - const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec) + const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec) const tables = Array.from(endpoints.keys()) expect(tables).not.toContain('deprecated_widgets') }) it('skips endpoints with [Deprecated] in the description', () => { - const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec) + const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec) const tables = Array.from(endpoints.keys()) expect(tables).not.toContain('exchange_rates') }) it('skips endpoints that appear in the generated global deprecated paths set', () => { - const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec) + const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec) const tables = Array.from(endpoints.keys()) expect(tables).not.toContain('recipients') expect(tables).toContain('customers') }) it('returns empty map when spec has no paths', () => { - const endpoints = discoverListEndpoints({ openapi: '3.0.0' }) + const endpoints = parser.discoverListEndpoints({ openapi: '3.0.0' }) expect(endpoints.size).toBe(0) }) +}) +describe('buildListFn / buildRetrieveFn', () => { it('uses the injected fetch for list and retrieve calls', async () => { const fetchMock = vi.fn( async () => new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 }) diff --git a/packages/openapi/__tests__/specParser.test.ts b/packages/openapi/__tests__/specParser.test.ts index 9061dc228..986466900 100644 --- a/packages/openapi/__tests__/specParser.test.ts +++ b/packages/openapi/__tests__/specParser.test.ts @@ -470,3 +470,56 @@ describe('SpecParser', () => { }) }) }) + +describe('SpecParser.discoverSyncableTables', () => { + const parser = new SpecParser() + + it('returns the intersection of listable and webhook-updatable resources, resolved to table names', () => { + const tables = parser.discoverSyncableTables(minimalStripeOpenApiSpec) + + expect(tables).toContain('customers') + expect(tables).toContain('products') + expect(tables).toContain('plans') + expect(tables).toContain('checkout_sessions') + expect(tables).toContain('early_fraud_warnings') + }) + + it('excludes resources that are listable but have no webhook events', () => { + const tables = parser.discoverSyncableTables(minimalStripeOpenApiSpec) + + expect(tables).not.toContain('exchange_rates') + expect(tables).not.toContain('recipients') + }) + + it('honors the excluded option', () => { + const baseline = parser.discoverSyncableTables(minimalStripeOpenApiSpec) + expect(baseline).toContain('customers') + + const filtered = parser.discoverSyncableTables(minimalStripeOpenApiSpec, { + excluded: new Set(['customers']), + }) + expect(filtered).not.toContain('customers') + expect(filtered).toContain('products') + }) + + it('honors caller-provided aliases over the defaults', () => { + const tables = parser.discoverSyncableTables(minimalStripeOpenApiSpec, { + aliases: { customer: 'patrons' }, + }) + expect(tables).toContain('patrons') + expect(tables).not.toContain('customers') + }) + + it('returns the same set that SpecParser.parse uses internally', () => { + const parsed = parser.parse(minimalStripeOpenApiSpec) + const parsedTables = new Set(parsed.tables.map((t) => t.tableName)) + const syncable = parser.discoverSyncableTables(minimalStripeOpenApiSpec) + + expect(syncable).toEqual(parsedTables) + }) + + it('returns empty set when spec has no paths', () => { + const spec: OpenApiSpec = { ...minimalStripeOpenApiSpec, paths: {} } + expect(parser.discoverSyncableTables(spec)).toEqual(new Set()) + }) +}) diff --git a/packages/openapi/browser.ts b/packages/openapi/browser.ts index a885cb0f1..51589af46 100644 --- a/packages/openapi/browser.ts +++ b/packages/openapi/browser.ts @@ -1,7 +1,8 @@ // Browser-safe entry. Excludes specFetchHelper (which imports node:fs / node:path) // so consumers in webpack/Next.js client bundles can import SpecParser without errors. -export { SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES } from './specParser.js' +export { SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES, resolveTableName } from './specParser.js' +export type { ListEndpoint, NestedEndpoint } from './specParser.js' export { OPENAPI_COMPATIBILITY_COLUMNS } from './runtimeMappings.js' export { parsedTableToJsonSchema } from './jsonSchemaConverter.js' export type { diff --git a/packages/openapi/index.ts b/packages/openapi/index.ts index bd0e3653f..22b8b7ac4 100644 --- a/packages/openapi/index.ts +++ b/packages/openapi/index.ts @@ -1,5 +1,6 @@ export type * from './types.js' -export { SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES } from './specParser.js' +export { SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES, resolveTableName } from './specParser.js' +export type { ListEndpoint, NestedEndpoint } from './specParser.js' export { OPENAPI_COMPATIBILITY_COLUMNS } from './runtimeMappings.js' export { @@ -8,23 +9,13 @@ export { SUPPORTED_API_VERSIONS, } from './specFetchHelper.js' export { - discoverListEndpoints, - discoverNestedEndpoints, isV2Path, buildListFn, buildRetrieveFn, - resolveTableName, StripeApiRequestError, pickDebugHeaders, } from './listFnResolver.js' -export type { - ListEndpoint, - NestedEndpoint, - ListFn, - ListResult, - RetrieveFn, - ListParams, -} from './listFnResolver.js' +export type { ListFn, ListResult, RetrieveFn, ListParams } from './listFnResolver.js' export { parsedTableToJsonSchema } from './jsonSchemaConverter.js' export { generateObjectsFromSchema, findSchemaNameByResourceId } from './objectGenerator.js' export type { GenerateObjectsOptions } from './objectGenerator.js' diff --git a/packages/openapi/listFnResolver.ts b/packages/openapi/listFnResolver.ts index 648546a93..42c6d3c41 100644 --- a/packages/openapi/listFnResolver.ts +++ b/packages/openapi/listFnResolver.ts @@ -1,7 +1,9 @@ -import type { OpenApiSchemaObject, OpenApiSpec } from './types.js' -import { OPENAPI_RESOURCE_TABLE_ALIASES } from './runtimeMappings.js' - -const SCHEMA_REF_PREFIX = '#/components/schemas/' +/** + * Stripe HTTP API plumbing: list/retrieve callable builders + error types. + * + * Spec-derived metadata (list endpoints, nested endpoints, table-name resolution) + * lives in `specParser.ts` — this module is purely runtime concerns. + */ export type ListParams = { limit?: number @@ -22,197 +24,6 @@ export type ListFn = (params: ListParams) => Promise export type RetrieveFn = (id: string) => Promise -export type ListEndpoint = { - tableName: string - resourceId: string - apiPath: string - supportsCreatedFilter: boolean - supportsLimit: boolean - supportsStartingAfter: boolean - supportsEndingBefore: boolean -} - -export type NestedEndpoint = { - tableName: string - resourceId: string - apiPath: string - parentTableName: string - parentParamName: string - supportsPagination: boolean -} - -export function resolveTableName(resourceId: string, aliases: Record): string { - const alias = aliases[resourceId] - if (alias) return alias - const normalized = resourceId.toLowerCase().replace(/[.]/g, '_') - return normalized.endsWith('s') ? normalized : `${normalized}s` -} - -/** - * Detect whether a response schema describes a list endpoint. - * v1 lists have `object: enum ["list"]` with a `data` array. - * v2 lists have a `data` array with `next_page_url`. - */ -function isListResponseSchema(schema: OpenApiSchemaObject): boolean { - const dataProp = schema.properties?.data - if (!dataProp || !('type' in dataProp) || dataProp.type !== 'array') return false - - const objectProp = schema.properties?.object - if (objectProp && 'enum' in objectProp && objectProp.enum?.includes('list')) return true - - if (schema.properties?.next_page_url) return true - - return false -} - -/** - * Scan the spec for list endpoints (GET paths that return a Stripe list object) - * and return one entry per table. Prefers top-level paths over nested ones. - * Supports both v1 (object: "list") and v2 (next_page_url) response formats. - */ -export function discoverListEndpoints( - spec: OpenApiSpec, - aliases: Record = OPENAPI_RESOURCE_TABLE_ALIASES -): Map { - const endpoints = new Map() - const paths = spec.paths - if (!paths) return endpoints - - for (const [apiPath, pathItem] of Object.entries(paths)) { - if (apiPath.includes('{')) continue - - const getOp = pathItem.get - if (!getOp?.responses) continue - - const responseSchema = getOp.responses['200']?.content?.['application/json']?.schema - if (!responseSchema) continue - - if (!isListResponseSchema(responseSchema)) continue - - const dataProp = responseSchema.properties?.data - if (!dataProp || !('type' in dataProp) || dataProp.type !== 'array') continue - - const itemsRef = dataProp.items - if (!itemsRef || !('$ref' in itemsRef) || typeof itemsRef.$ref !== 'string') continue - if (!itemsRef.$ref.startsWith(SCHEMA_REF_PREFIX)) continue - - const schemaName = itemsRef.$ref.slice(SCHEMA_REF_PREFIX.length) - const schema = spec.components?.schemas?.[schemaName] - if (!schema || '$ref' in schema) continue - - const resourceId = schema['x-resourceId'] - if (!resourceId || typeof resourceId !== 'string') continue - - const tableName = resolveTableName(resourceId, aliases) - if (!endpoints.has(tableName)) { - const params = getOp.parameters ?? [] - const PAGINATION_PARAMS = new Set([ - 'limit', - 'starting_after', - 'ending_before', - 'created', - 'expand', - ]) - const hasRequiredQueryParams = params.some( - (p: { name?: string; in?: string; required?: boolean }) => - p.required === true && p.in === 'query' && !PAGINATION_PARAMS.has(p.name ?? '') - ) - if (hasRequiredQueryParams) continue - - const supportsCreatedFilter = params.some( - (p: { name?: string; in?: string }) => p.name === 'created' && p.in === 'query' - ) - const supportsLimit = params.some( - (p: { name?: string; in?: string }) => p.name === 'limit' && p.in === 'query' - ) - const supportsStartingAfter = params.some( - (p: { name?: string; in?: string }) => p.name === 'starting_after' && p.in === 'query' - ) - const supportsEndingBefore = params.some( - (p: { name?: string; in?: string }) => p.name === 'ending_before' && p.in === 'query' - ) - endpoints.set(tableName, { - tableName, - resourceId, - apiPath, - supportsCreatedFilter, - supportsLimit, - supportsStartingAfter, - supportsEndingBefore, - }) - } - } - - return endpoints -} - -/** - * Scan the spec for nested list endpoints (paths with `{param}` segments that - * return a Stripe list object) and map each to its parent resource. - */ -export function discoverNestedEndpoints( - spec: OpenApiSpec, - topLevelEndpoints: Map, - aliases: Record = OPENAPI_RESOURCE_TABLE_ALIASES -): NestedEndpoint[] { - const nested: NestedEndpoint[] = [] - const paths = spec.paths - if (!paths) return nested - - const topLevelByPath = new Map() - for (const endpoint of topLevelEndpoints.values()) { - topLevelByPath.set(endpoint.apiPath, endpoint) - } - - for (const [apiPath, pathItem] of Object.entries(paths)) { - if (!apiPath.includes('{')) continue - - const getOp = pathItem.get - if (!getOp?.responses) continue - - const responseSchema = getOp.responses['200']?.content?.['application/json']?.schema - if (!responseSchema) continue - - if (!isListResponseSchema(responseSchema)) continue - - const dataProp = responseSchema.properties?.data - if (!dataProp || !('type' in dataProp) || dataProp.type !== 'array') continue - - const itemsRef = dataProp.items - if (!itemsRef || !('$ref' in itemsRef) || typeof itemsRef.$ref !== 'string') continue - if (!itemsRef.$ref.startsWith(SCHEMA_REF_PREFIX)) continue - - const schemaName = itemsRef.$ref.slice(SCHEMA_REF_PREFIX.length) - const schema = spec.components?.schemas?.[schemaName] - if (!schema || '$ref' in schema) continue - - const resourceId = schema['x-resourceId'] - if (!resourceId || typeof resourceId !== 'string') continue - - const paramMatch = apiPath.match(/\{([^}]+)\}/) - if (!paramMatch) continue - const parentParamName = paramMatch[1] - - const parentPath = apiPath.slice(0, apiPath.indexOf('/{')) - const parentEndpoint = topLevelByPath.get(parentPath) - if (!parentEndpoint) continue - - const params = getOp.parameters ?? [] - const supportsPagination = params.some((p: { name?: string }) => p.name === 'limit') - - nested.push({ - tableName: resolveTableName(resourceId, aliases), - resourceId, - apiPath, - parentTableName: parentEndpoint.tableName, - parentParamName, - supportsPagination, - }) - } - - return nested -} - export function isV2Path(apiPath: string): boolean { return apiPath.startsWith('/v2/') } diff --git a/packages/openapi/specParser.ts b/packages/openapi/specParser.ts index c014b7bd6..449e49a9c 100644 --- a/packages/openapi/specParser.ts +++ b/packages/openapi/specParser.ts @@ -10,6 +10,7 @@ import type { import { OPENAPI_COMPATIBILITY_COLUMNS, OPENAPI_RESOURCE_TABLE_ALIASES } from './runtimeMappings.js' const SCHEMA_REF_PREFIX = '#/components/schemas/' +const CRUD_SUFFIXES = ['.created', '.updated', '.deleted'] as const const RESERVED_COLUMNS = new Set([ 'id', @@ -21,12 +22,73 @@ const RESERVED_COLUMNS = new Set([ export { OPENAPI_RESOURCE_TABLE_ALIASES } +/** + * Resolve a Stripe x-resourceId to a canonical table name. + * Pure utility — depends only on aliases, not the spec. + */ +export function resolveTableName( + resourceId: string, + aliases: Record = OPENAPI_RESOURCE_TABLE_ALIASES +): string { + const alias = aliases[resourceId] + if (alias) return alias + const normalized = resourceId.toLowerCase().replace(/[.]/g, '_') + return normalized.endsWith('s') ? normalized : `${normalized}s` +} + +/** A list endpoint at a top-level path (`/v1/customers`). */ +export type ListEndpoint = { + tableName: string + resourceId: string + apiPath: string + supportsCreatedFilter: boolean + supportsLimit: boolean + supportsStartingAfter: boolean + supportsEndingBefore: boolean +} + +/** A nested list endpoint at a parent-scoped path (`/v1/customers/{id}/cards`). */ +export type NestedEndpoint = { + tableName: string + resourceId: string + apiPath: string + parentTableName: string + parentParamName: string + supportsPagination: boolean +} + type ColumnAccumulator = { type: ScalarType nullable: boolean expandableReference: boolean } +/** One normalized record per discovered list-shaped GET endpoint. */ +type RawListPath = { + apiPath: string + isNested: boolean + resourceId: string + schemaName: string + parameters: ReadonlyArray<{ name?: string; in?: string; required?: boolean }> +} + +const PAGINATION_PARAMS = new Set(['limit', 'starting_after', 'ending_before', 'created', 'expand']) + +function hasParam( + parameters: ReadonlyArray<{ name?: string; in?: string }>, + name: string +): boolean { + return parameters.some((p) => p.name === name && p.in === 'query') +} + +function hasNonPaginationRequiredQueryParam( + parameters: ReadonlyArray<{ name?: string; in?: string; required?: boolean }> +): boolean { + return parameters.some( + (p) => p.required === true && p.in === 'query' && !PAGINATION_PARAMS.has(p.name ?? '') + ) +} + export class SpecParser { parse(spec: OpenApiSpec, options: ParseSpecOptions = {}): ParsedOpenApiSpec { const schemas = spec.components?.schemas @@ -55,7 +117,7 @@ export class SpecParser { continue } - const tableName = this.resolveTableName(resourceId, aliases) + const tableName = resolveTableName(resourceId, aliases) if (!allowedTables.has(tableName)) { continue } @@ -138,53 +200,161 @@ export class SpecParser { } /** - * Scan the spec's `paths` for GET endpoints that return Stripe list objects, - * and resolve each listed resource's x-resourceId into a table name. - * Only includes resources that also have webhook create/update/delete events. + * Parse the spec restricted to syncable tables only. + * Combines discoverSyncableTables + parse into a single call. */ - private discoverAllowedTables( + parseSyncable( spec: OpenApiSpec, - aliases: Record, - excluded: Set - ): Set { - const listableIds = this.discoverListableResourceIds(spec, { - includeNested: true, + options: { + aliases?: Record + excluded?: ReadonlySet + } = {} + ): ParsedOpenApiSpec { + const aliases = { ...OPENAPI_RESOURCE_TABLE_ALIASES, ...(options.aliases ?? {}) } + const syncableTables = this.discoverSyncableTables(spec, { + aliases, + excluded: options.excluded, + }) + return this.parse(spec, { + resourceAliases: aliases, + allowedTables: Array.from(syncableTables), }) - const webhookIds = this.discoverWebhookUpdatableResourceIds(spec) + } + + /** + * The canonical list of tables that can be synced from this spec. + * Syncable = listable + webhook-updatable + not excluded. + */ + discoverSyncableTables( + spec: OpenApiSpec, + options: { + aliases?: Record + excluded?: ReadonlySet + } = {} + ): Set { + const aliases = { ...OPENAPI_RESOURCE_TABLE_ALIASES, ...(options.aliases ?? {}) } + const excluded = options.excluded ?? new Set() + const listableIds = this.discoverListableResourceIds(spec, { includeNested: true }) + const webhookIds = this.discoverWebhookUpdatableResourceIds(spec, listableIds) const tables = new Set() for (const resourceId of listableIds) { if (!webhookIds.has(resourceId)) continue - const tableName = this.resolveTableName(resourceId, aliases) - if (!excluded.has(tableName)) { - tables.add(tableName) - } + const tableName = resolveTableName(resourceId, aliases) + if (!excluded.has(tableName)) tables.add(tableName) } return tables } /** - * Extract x-resourceId values for every schema that is returned by a list - * endpoint. Supports both v1 (object: "list") and v2 (next_page_url) formats. + * Discover top-level list endpoints (e.g. `/v1/customers`) and extract their + * runtime metadata (apiPath, capability flags). Excludes endpoints requiring + * non-pagination query parameters at runtime. + */ + discoverListEndpoints( + spec: OpenApiSpec, + aliases: Record = OPENAPI_RESOURCE_TABLE_ALIASES + ): Map { + const endpoints = new Map() + for (const raw of this.iterListPaths(spec)) { + if (raw.isNested) continue + const tableName = resolveTableName(raw.resourceId, aliases) + if (endpoints.has(tableName)) continue + if (hasNonPaginationRequiredQueryParam(raw.parameters)) continue + + endpoints.set(tableName, { + tableName, + resourceId: raw.resourceId, + apiPath: raw.apiPath, + supportsCreatedFilter: hasParam(raw.parameters, 'created'), + supportsLimit: hasParam(raw.parameters, 'limit'), + supportsStartingAfter: hasParam(raw.parameters, 'starting_after'), + supportsEndingBefore: hasParam(raw.parameters, 'ending_before'), + }) + } + return endpoints + } + + /** + * Discover nested list endpoints (e.g. `/v1/customers/{id}/cards`) and link + * each to its parent resource. Endpoints whose parent path isn't in + * `topLevelEndpoints` are skipped. + */ + discoverNestedEndpoints( + spec: OpenApiSpec, + topLevelEndpoints: Map, + aliases: Record = OPENAPI_RESOURCE_TABLE_ALIASES + ): NestedEndpoint[] { + const topLevelByPath = new Map() + for (const endpoint of topLevelEndpoints.values()) { + topLevelByPath.set(endpoint.apiPath, endpoint) + } + + const nested: NestedEndpoint[] = [] + for (const raw of this.iterListPaths(spec)) { + if (!raw.isNested) continue + const paramMatch = raw.apiPath.match(/\{([^}]+)\}/) + if (!paramMatch) continue + const parentPath = raw.apiPath.slice(0, raw.apiPath.indexOf('/{')) + const parent = topLevelByPath.get(parentPath) + if (!parent) continue + + nested.push({ + tableName: resolveTableName(raw.resourceId, aliases), + resourceId: raw.resourceId, + apiPath: raw.apiPath, + parentTableName: parent.tableName, + parentParamName: paramMatch[1]!, + supportsPagination: hasParam(raw.parameters, 'limit'), + }) + } + return nested + } + + /** + * Resolve the canonical table list for schema parsing. + * Delegates to {@link discoverSyncableTables} so the parser and runtime + * registry agree on what is syncable. + */ + private discoverAllowedTables( + spec: OpenApiSpec, + aliases: Record, + excluded: Set + ): Set { + return this.discoverSyncableTables(spec, { aliases, excluded }) + } + + /** + * Extract x-resourceId values for every schema returned by a list endpoint. + * Supports both v1 (object: "list") and v2 (next_page_url) formats. */ discoverListableResourceIds( spec: OpenApiSpec, options: { includeNested: boolean } = { includeNested: false } ): Set { const resourceIds = new Set() - const paths = spec.paths - if (!paths) { - return resourceIds + for (const raw of this.iterListPaths(spec)) { + if (!options.includeNested && raw.isNested) continue + resourceIds.add(raw.resourceId) } + return resourceIds + } - for (const [apiPath, pathItem] of Object.entries(paths)) { - if (!options.includeNested && apiPath.includes('{')) continue + /** + * Walk `spec.paths` and yield one normalized record per GET endpoint whose + * 200 response matches the Stripe list shape (`{ data: [...], object: "list" }` + * or v2 `{ data: [...], next_page_url }`). Shared by every path-discovery + * method on this class so they can't disagree. + */ + private *iterListPaths(spec: OpenApiSpec): Generator { + const paths = spec.paths + if (!paths) return + for (const [apiPath, pathItem] of Object.entries(paths)) { const getOp = pathItem.get if (!getOp?.responses) continue const responseSchema = getOp.responses['200']?.content?.['application/json']?.schema if (!responseSchema) continue - if (!this.isListResponseSchema(responseSchema)) continue const dataProp = responseSchema.properties?.data @@ -199,27 +369,44 @@ export class SpecParser { if (!schema || '$ref' in schema) continue const resourceId = schema['x-resourceId'] - if (resourceId && typeof resourceId === 'string') { - resourceIds.add(resourceId) + if (!resourceId || typeof resourceId !== 'string') continue + + yield { + apiPath, + isNested: apiPath.includes('{'), + resourceId, + schemaName, + parameters: getOp.parameters ?? [], } } - - return resourceIds } /** - * Extract x-resourceId values for every schema that has at least one webhook - * event for create, update, or delete operations. Event schemas are identified - * by the `x-stripeEvent` extension with a type ending in `.created`, `.updated`, - * or `.deleted`. The referenced resource is resolved via `properties.object.$ref`. + * Resource IDs that have at least one CRUD webhook event. + * Merges three signals so v1 and v2 specs both work: + * - `x-stripeEvent` schemas with `properties.object.$ref` (v1 events) + * - `x-stripeEvent.type` prefix matched against listable ids (v2 events) + * - `paths['/v1/webhook_endpoints'].post...enabled_events` enum (older/public specs) */ - discoverWebhookUpdatableResourceIds(spec: OpenApiSpec): Set { + discoverWebhookUpdatableResourceIds( + spec: OpenApiSpec, + listableIds?: ReadonlySet + ): Set { + const ids = listableIds ?? this.discoverListableResourceIds(spec, { includeNested: true }) + const eventTypes = new Set([ + ...this.collectStripeEventTypes(spec), + ...this.collectEnabledEventTypes(spec), + ]) + const fromTypes = this.matchEventTypesToResourceIds(eventTypes, ids) + const fromRef = this.discoverWebhookUpdatableFromExtension(spec) + return new Set([...fromTypes, ...fromRef]) + } + + private discoverWebhookUpdatableFromExtension(spec: OpenApiSpec): Set { const resourceIds = new Set() const schemas = spec.components?.schemas if (!schemas) return resourceIds - const CRUD_SUFFIXES = ['.created', '.updated', '.deleted'] - for (const schema of Object.values(schemas)) { if (!schema || '$ref' in schema) continue @@ -246,6 +433,69 @@ export class SpecParser { return resourceIds } + private collectStripeEventTypes(spec: OpenApiSpec): Set { + const types = new Set() + const schemas = spec.components?.schemas + if (!schemas) return types + for (const schema of Object.values(schemas)) { + if (!schema || '$ref' in schema) continue + const stripeEvent = schema['x-stripeEvent'] + if (!stripeEvent || typeof stripeEvent !== 'object') continue + const eventType = stripeEvent.type + if (eventType && typeof eventType === 'string') types.add(eventType) + } + return types + } + + private collectEnabledEventTypes(spec: OpenApiSpec): Set { + const types = new Set() + const op = spec.paths?.['/v1/webhook_endpoints']?.post as + | { requestBody?: { content?: Record } } + | undefined + const schema = op?.requestBody?.content?.['application/x-www-form-urlencoded']?.schema + if (!schema || '$ref' in schema) return types + const enabledEvents = schema.properties?.enabled_events + if (!enabledEvents || '$ref' in enabledEvents) return types + const items = enabledEvents.items + if (!items || Array.isArray(items) || '$ref' in items) return types + const enumValues = items.enum + if (!Array.isArray(enumValues)) return types + for (const value of enumValues) { + if (typeof value === 'string') types.add(value) + } + return types + } + + /** Match event types like `customer.created` or `v2.core.account.updated` against listable resource ids. */ + private matchEventTypesToResourceIds( + eventTypes: ReadonlySet, + listableIds: ReadonlySet + ): Set { + const out = new Set() + if (eventTypes.size === 0) return out + const candidatePrefixes = new Set() + for (const type of eventTypes) { + const cleaned = type.replace(/\[[^\]]*\]/g, '') + const lastDot = cleaned.lastIndexOf('.') + if (lastDot <= 0) continue + candidatePrefixes.add(cleaned.slice(0, lastDot)) + } + for (const resourceId of listableIds) { + if (candidatePrefixes.has(resourceId)) { + out.add(resourceId) + continue + } + const suffix = `.${resourceId}` + for (const prefix of candidatePrefixes) { + if (prefix.endsWith(suffix)) { + out.add(resourceId) + break + } + } + } + return out + } + /** * Detect whether a response schema describes a list endpoint. * v1 lists have `object: enum ["list"]` with a `data` array. @@ -263,16 +513,6 @@ export class SpecParser { return false } - private resolveTableName(resourceId: string, aliases: Record): string { - const alias = aliases[resourceId] - if (alias) { - return alias - } - - const normalized = resourceId.toLowerCase().replace(/[.]/g, '_') - return normalized.endsWith('s') ? normalized : `${normalized}s` - } - private parseColumns( propCandidates: Map, spec: OpenApiSpec diff --git a/packages/source-stripe/src/__snapshots__/catalog.test.ts.snap b/packages/source-stripe/src/__snapshots__/catalog.test.ts.snap index 13978bd04..2828b5762 100644 --- a/packages/source-stripe/src/__snapshots__/catalog.test.ts.snap +++ b/packages/source-stripe/src/__snapshots__/catalog.test.ts.snap @@ -83,10 +83,12 @@ exports[`catalogFromOpenApi stream list > default: only tables with webhook even [ "accounts", "application_fees", + "billing_alerts", "billing_credit_grants", "billing_meters", "billing_portal_configurations", "charges", + "checkout_sessions", "climate_orders", "climate_products", "coupons", @@ -97,6 +99,7 @@ exports[`catalogFromOpenApi stream list > default: only tables with webhook even "files", "financial_connections_accounts", "identity_verification_sessions", + "invoice_payments", "invoiceitems", "invoices", "issuing_authorizations", @@ -115,16 +118,21 @@ exports[`catalogFromOpenApi stream list > default: only tables with webhook even "promotion_codes", "quotes", "refunds", + "reporting_report_runs", "reporting_report_types", + "reviews", "scheduled_query_runs", "setup_intents", "subscription_schedules", "subscriptions", "tax_ids", "tax_rates", + "terminal_readers", "test_helpers_test_clocks", "topups", "transfers", "treasury_financial_accounts", + "v2_core_accounts", + "v2_core_event_destinations", ] `; diff --git a/packages/source-stripe/src/catalog.test.ts b/packages/source-stripe/src/catalog.test.ts index 6bc94fb44..f6bd8d8b1 100644 --- a/packages/source-stripe/src/catalog.test.ts +++ b/packages/source-stripe/src/catalog.test.ts @@ -4,10 +4,6 @@ import { buildResourceRegistry } from './resourceRegistry.js' import { catalogFromOpenApi } from './catalog.js' import { resolveOpenApiSpec, BUNDLED_API_VERSION } from '@stripe/sync-openapi' -/** - * Snapshot the list of streams produced by discover() with and without - * the webhook filter. Catches unintentional changes to what we sync. - */ describe('catalogFromOpenApi stream list', () => { const resolved = resolveOpenApiSpec({ apiVersion: BUNDLED_API_VERSION }, fetch) const parser = new SpecParser() @@ -23,24 +19,84 @@ describe('catalogFromOpenApi stream list', () => { 'sk_test_fake', apiVersion, undefined, - allowedTables + allowedTables, + undefined, + parsed.tables ) - const catalog = catalogFromOpenApi(parsed.tables, registry) + const catalog = catalogFromOpenApi(registry) const names = catalog.streams.map((s) => s.name).sort() expect(names).toMatchSnapshot() }) + it('every stream in the catalog has supports_realtime_sync = true', async () => { + const { spec, apiVersion } = await resolved + const parsed = parser.parse(spec, { + resourceAliases: OPENAPI_RESOURCE_TABLE_ALIASES, + }) + const allowedTables = new Set(parsed.tables.map((t) => t.tableName)) + const registry = buildResourceRegistry( + spec, + 'sk_test_fake', + apiVersion, + undefined, + allowedTables, + undefined, + parsed.tables + ) + const catalog = catalogFromOpenApi(registry) + for (const stream of catalog.streams) { + expect(stream.metadata?.supports_realtime_sync).toBe(true) + } + }) + it('all listable tables (no webhook filter)', async () => { const { spec, apiVersion } = await resolved - const registry = buildResourceRegistry(spec, 'sk_test_fake', apiVersion) + const allRegistry = buildResourceRegistry(spec, 'sk_test_fake', apiVersion) const parsed = parser.parse(spec, { resourceAliases: OPENAPI_RESOURCE_TABLE_ALIASES, - allowedTables: Object.values(registry).map((r) => r.tableName), + allowedTables: Object.values(allRegistry).map((r) => r.tableName), }) - const catalog = catalogFromOpenApi(parsed.tables, registry) + const registry = buildResourceRegistry( + spec, + 'sk_test_fake', + apiVersion, + undefined, + undefined, + undefined, + parsed.tables + ) + const catalog = catalogFromOpenApi(registry) const names = catalog.streams.map((s) => s.name).sort() expect(names).toMatchSnapshot() }) + + it('every stream has json_schema (no ghost tables)', async () => { + const { spec, apiVersion } = await resolved + const parsed = parser.parse(spec, { + resourceAliases: OPENAPI_RESOURCE_TABLE_ALIASES, + }) + const allowedTables = new Set(parsed.tables.map((t) => t.tableName)) + const registry = buildResourceRegistry( + spec, + 'sk_test_fake', + apiVersion, + undefined, + allowedTables, + undefined, + parsed.tables + ) + const catalog = catalogFromOpenApi(registry) + for (const stream of catalog.streams) { + expect(stream.json_schema, `stream ${stream.name} is missing json_schema`).toBeDefined() + expect(stream.json_schema?.properties).toBeDefined() + } + }) + + it('throws when registry entry has no parsedTable', async () => { + const { spec, apiVersion } = await resolved + const registry = buildResourceRegistry(spec, 'sk_test_fake', apiVersion) + expect(() => catalogFromOpenApi(registry)).toThrow(/no parsedTable/) + }) }) diff --git a/packages/source-stripe/src/catalog.ts b/packages/source-stripe/src/catalog.ts index 4fc6d0286..b63eca330 100644 --- a/packages/source-stripe/src/catalog.ts +++ b/packages/source-stripe/src/catalog.ts @@ -1,51 +1,43 @@ import type { CatalogPayload, Stream } from '@stripe/sync-protocol' import type { ResourceConfig } from './types.js' -import type { ParsedResourceTable } from '@stripe/sync-openapi' import { parsedTableToJsonSchema } from '@stripe/sync-openapi' /** - * Derive a CatalogPayload by merging OpenAPI-parsed tables with registry metadata. - * `_account_id` and `_updated_at` (staleness, see DDR-009) are injected into properties. - * The returned catalog is account-agnostic — call {@link stampAccountIdEnum} to - * add the per-pipeline allow-list before handing it to destinations. + * Derive a CatalogPayload from the registry. Each syncable ResourceConfig must + * carry a `parsedTable`; throws if one is missing (ghost-table guard). */ -export function catalogFromOpenApi( - tables: ParsedResourceTable[], - registry: Record -): CatalogPayload { - const tableMap = new Map(tables.map((t) => [t.tableName, t])) - +export function catalogFromOpenApi(registry: Record): CatalogPayload { const streams: Stream[] = Object.entries(registry) .filter(([, cfg]) => cfg.sync !== false) .sort(([, a], [, b]) => a.order - b.order) .map(([name, cfg]) => { - const table = tableMap.get(cfg.tableName) - const stream: Stream = { - name: cfg.tableName, - primary_key: [['id'], ['_account_id']], - newer_than_field: '_updated_at', - metadata: { resource_name: name }, + if (!cfg.parsedTable) { + throw new Error( + `catalogFromOpenApi: registry entry "${cfg.tableName}" has no parsedTable. ` + + `Pass parsedTables to buildResourceRegistry so every entry carries its schema.` + ) } - if (table) { - const jsonSchema = parsedTableToJsonSchema(table) - const properties = (jsonSchema.properties ?? {}) as Record - properties._account_id = { type: 'string' } - jsonSchema.properties = properties - properties._updated_at = { type: 'integer' } - const required = Array.isArray(jsonSchema.required) ? [...jsonSchema.required] : [] - if (!required.includes('_account_id')) { - required.push('_account_id') - } - if (!required.includes('_updated_at')) { - required.push('_updated_at') - } - jsonSchema.required = required + const jsonSchema = parsedTableToJsonSchema(cfg.parsedTable) + const properties = (jsonSchema.properties ?? {}) as Record + properties._account_id = { type: 'string' } + properties._updated_at = { type: 'integer' } + jsonSchema.properties = properties + const required = Array.isArray(jsonSchema.required) ? [...jsonSchema.required] : [] + if (!required.includes('_account_id')) required.push('_account_id') + if (!required.includes('_updated_at')) required.push('_updated_at') + jsonSchema.required = required - stream.json_schema = jsonSchema + return { + name: cfg.tableName, + primary_key: [['id'], ['_account_id']], + newer_than_field: '_updated_at', + metadata: { + resource_name: name, + supports_realtime_sync: true, + }, + json_schema: jsonSchema, } - - return stream }) return { streams } diff --git a/packages/source-stripe/src/index.test.ts b/packages/source-stripe/src/index.test.ts index 2622e9a40..cf0b4b5a5 100644 --- a/packages/source-stripe/src/index.test.ts +++ b/packages/source-stripe/src/index.test.ts @@ -124,6 +124,12 @@ function makeConfig( supportsCreatedFilter: false, listFn: (() => Promise.resolve({ data: [], has_more: false })) as ResourceConfig['listFn'], retrieveFn: (() => Promise.resolve({})) as ResourceConfig['retrieveFn'], + parsedTable: { + tableName: overrides.tableName, + resourceId: overrides.tableName, + sourceSchemaName: overrides.tableName, + columns: [{ name: 'id', type: 'text' as const, nullable: false }], + }, ...overrides, } as ResourceConfig } diff --git a/packages/source-stripe/src/index.ts b/packages/source-stripe/src/index.ts index 97d09d4bc..e39d0ea29 100644 --- a/packages/source-stripe/src/index.ts +++ b/packages/source-stripe/src/index.ts @@ -11,14 +11,9 @@ import { createSourceMessageFactory, withAbortOnReturn } from '@stripe/sync-prot import defaultSpec from './spec.js' import type { Config } from './spec.js' import type { StripeEvent } from './spec.js' -import { buildResourceRegistry } from './resourceRegistry.js' +import { buildResourceRegistry, EXCLUDED_TABLES } from './resourceRegistry.js' import { catalogFromOpenApi, stampAccountIdEnum } from './catalog.js' -import { - BUNDLED_API_VERSION, - resolveOpenApiSpec, - SpecParser, - OPENAPI_RESOURCE_TABLE_ALIASES, -} from '@stripe/sync-openapi' +import { BUNDLED_API_VERSION, resolveOpenApiSpec, SpecParser } from '@stripe/sync-openapi' import { processStripeEvent } from './process-event.js' import { processWebhookInput, createInputQueue, startWebhookServer } from './src-webhook.js' import { listApiBackfill, errorToConnectionStatus } from './src-list-api.js' @@ -164,17 +159,19 @@ export function createStripeSource( } const resolved = await resolveOpenApiSpec({ apiVersion }, makeApiFetch()) + const parser = new SpecParser() + const parsed = parser.parseSyncable(resolved.spec, { excluded: EXCLUDED_TABLES }) + const syncableTables = new Set(parsed.tables.map((t) => t.tableName)) const registry = buildResourceRegistry( resolved.spec, config.api_key, resolved.apiVersion, - config.base_url + config.base_url, + syncableTables, + undefined, + parsed.tables ) - const parser = new SpecParser() - const parsed = parser.parse(resolved.spec, { - resourceAliases: OPENAPI_RESOURCE_TABLE_ALIASES, - }) - const catalog = catalogFromOpenApi(parsed.tables, registry) + const catalog = catalogFromOpenApi(registry) const frozenCatalog = deepFreeze(catalog) discoverCache.set(apiVersion, frozenCatalog) yield { diff --git a/packages/source-stripe/src/resourceRegistry.ts b/packages/source-stripe/src/resourceRegistry.ts index 65199f22b..8198fe983 100644 --- a/packages/source-stripe/src/resourceRegistry.ts +++ b/packages/source-stripe/src/resourceRegistry.ts @@ -1,8 +1,13 @@ import type { ResourceConfig } from './types.js' -import type { ListFn, ListParams, OpenApiSpec, NestedEndpoint } from '@stripe/sync-openapi' +import type { + ListFn, + ListParams, + OpenApiSpec, + NestedEndpoint, + ParsedResourceTable, +} from '@stripe/sync-openapi' import { - discoverListEndpoints, - discoverNestedEndpoints, + SpecParser, buildListFn, buildRetrieveFn, isV2Path, @@ -101,10 +106,13 @@ export function buildResourceRegistry( apiVersion: string, baseUrl?: string, allowedTables?: Set, - signal?: AbortSignal + signal?: AbortSignal, + parsedTables?: ParsedResourceTable[] ): Record { - const endpoints = discoverListEndpoints(spec) - const nestedEndpoints = discoverNestedEndpoints(spec, endpoints) + const parser = new SpecParser() + const endpoints = parser.discoverListEndpoints(spec) + const nestedEndpoints = parser.discoverNestedEndpoints(spec, endpoints) + const tableMap = new Map(parsedTables?.map((t) => [t.tableName, t])) const registry: Record = {} const seenNested = new Set() @@ -128,6 +136,7 @@ export function buildResourceRegistry( const config: ResourceConfig = { order: 1, tableName, + parsedTable: tableMap.get(tableName), supportsCreatedFilter: endpoint.supportsCreatedFilter, supportsLimit: endpoint.supportsLimit, supportsForwardPagination: isV2 || endpoint.supportsStartingAfter, diff --git a/packages/source-stripe/src/types.ts b/packages/source-stripe/src/types.ts index 678b1be7c..65a3ea4b8 100644 --- a/packages/source-stripe/src/types.ts +++ b/packages/source-stripe/src/types.ts @@ -1,4 +1,4 @@ -import type { ListFn, RetrieveFn } from '@stripe/sync-openapi' +import type { ListFn, RetrieveFn, ParsedResourceTable } from '@stripe/sync-openapi' import type { RevalidateEntityName } from './resourceRegistry.js' /** @@ -32,6 +32,8 @@ export type BaseResourceConfig = { export type ResourceConfig = BaseResourceConfig & { listFn?: ListFn retrieveFn?: RetrieveFn + /** Parsed OpenAPI schema for this resource (used to build catalog json_schema) */ + parsedTable?: ParsedResourceTable /** Whether the list API supports the `limit` parameter */ supportsLimit?: boolean /** Whether the list API supports forward cursor pagination for repeated page fetches. */ @@ -49,97 +51,3 @@ export type ResourceConfig = BaseResourceConfig & { } export type RevalidateEntity = RevalidateEntityName - -export const SUPPORTED_WEBHOOK_EVENTS: string[] = [ - 'charge.captured', - 'charge.expired', - 'charge.failed', - 'charge.pending', - 'charge.refunded', - 'charge.succeeded', - 'charge.updated', - 'customer.deleted', - 'customer.created', - 'customer.updated', - 'coupon.created', - 'coupon.deleted', - 'coupon.updated', - 'checkout.session.async_payment_failed', - 'checkout.session.async_payment_succeeded', - 'checkout.session.completed', - 'checkout.session.expired', - 'customer.subscription.created', - 'customer.subscription.deleted', - 'customer.subscription.paused', - 'customer.subscription.pending_update_applied', - 'customer.subscription.pending_update_expired', - 'customer.subscription.trial_will_end', - 'customer.subscription.resumed', - 'customer.subscription.updated', - 'customer.tax_id.updated', - 'customer.tax_id.created', - 'customer.tax_id.deleted', - 'invoice.created', - 'invoice.deleted', - 'invoice.finalized', - 'invoice.finalization_failed', - 'invoice.paid', - 'invoice.payment_action_required', - 'invoice.payment_failed', - 'invoice.payment_succeeded', - 'invoice.upcoming', - 'invoice.sent', - 'invoice.voided', - 'invoice.marked_uncollectible', - 'invoice.updated', - 'product.created', - 'product.updated', - 'product.deleted', - 'price.created', - 'price.updated', - 'price.deleted', - 'plan.created', - 'plan.updated', - 'plan.deleted', - 'setup_intent.canceled', - 'setup_intent.created', - 'setup_intent.requires_action', - 'setup_intent.setup_failed', - 'setup_intent.succeeded', - 'subscription_schedule.aborted', - 'subscription_schedule.canceled', - 'subscription_schedule.completed', - 'subscription_schedule.created', - 'subscription_schedule.expiring', - 'subscription_schedule.released', - 'subscription_schedule.updated', - 'payment_method.attached', - 'payment_method.automatically_updated', - 'payment_method.detached', - 'payment_method.updated', - 'charge.dispute.created', - 'charge.dispute.funds_reinstated', - 'charge.dispute.funds_withdrawn', - 'charge.dispute.updated', - 'charge.dispute.closed', - 'payment_intent.amount_capturable_updated', - 'payment_intent.canceled', - 'payment_intent.created', - 'payment_intent.partially_funded', - 'payment_intent.payment_failed', - 'payment_intent.processing', - 'payment_intent.requires_action', - 'payment_intent.succeeded', - 'credit_note.created', - 'credit_note.updated', - 'credit_note.voided', - 'radar.early_fraud_warning.created', - 'radar.early_fraud_warning.updated', - 'refund.created', - 'refund.failed', - 'refund.updated', - 'charge.refund.updated', - 'review.closed', - 'review.opened', - 'entitlements.active_entitlement_summary.updated', -] diff --git a/packages/test-utils/src/openapi/endpoints.ts b/packages/test-utils/src/openapi/endpoints.ts index 77171279e..9ebe12dd0 100644 --- a/packages/test-utils/src/openapi/endpoints.ts +++ b/packages/test-utils/src/openapi/endpoints.ts @@ -1,6 +1,5 @@ import { BUNDLED_API_VERSION, - discoverListEndpoints, isV2Path, resolveOpenApiSpec, SpecParser, @@ -50,11 +49,11 @@ export async function resolveEndpointSet(options: { fetchImpl ) - const discovered = discoverListEndpoints(resolved.spec) + const parser = new SpecParser() + const discovered = parser.discoverListEndpoints(resolved.spec) const jsonSchemaMap = new Map>() try { - const parser = new SpecParser() const parsed = parser.parse(resolved.spec, { resourceAliases: OPENAPI_RESOURCE_TABLE_ALIASES, })