Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions e2e/test-server-all-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -140,7 +141,10 @@ async function resolveSpecPath(apiVersion: string): Promise<string> {
return resolved.cachePath
}

async function syncAllEndpointsForVersion(apiVersion: string): Promise<void> {
async function syncAllEndpointsForVersion(
apiVersion: string,
skip: (reason: string) => void
): Promise<void> {
const createdRange = { startUnix: RANGE_START, endUnix: RANGE_END }
const openApiSpecPath = await resolveSpecPath(apiVersion)
const endpointSet = await resolveEndpointSet({
Expand All @@ -166,21 +170,32 @@ async function syncAllEndpointsForVersion(apiVersion: string): Promise<void> {
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(
Expand Down Expand Up @@ -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
)
Expand Down
2 changes: 1 addition & 1 deletion e2e/test-server-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
23 changes: 14 additions & 9 deletions packages/openapi/__tests__/listFnResolver.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 })
Expand Down
53 changes: 53 additions & 0 deletions packages/openapi/__tests__/specParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
})
3 changes: 2 additions & 1 deletion packages/openapi/browser.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
15 changes: 3 additions & 12 deletions packages/openapi/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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'
Expand Down
Loading
Loading