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
57 changes: 57 additions & 0 deletions docs/plans/2026-03-29-openapi-pure-package-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Design: Make `packages/openapi` a Pure Utility (No Transport)

## Problem

`packages/openapi` currently owns HTTP infrastructure: `transport.ts` (proxy logic, undici) and
direct `fetch` calls in `specFetchHelper.ts` and `listFnResolver.ts`. This caused a duplication
of proxy logic between `openapi/transport.ts` and `source-stripe/src/transport.ts` when proxy
support was added in commit `e4149eef`.

The package should be a pure Stripe OpenAPI utility — it may know _what_ to fetch (Stripe-specific
GitHub URLs, commit SHA resolution, version mapping, pagination patterns) but it should not own
_how_ to make HTTP calls (proxy config, undici, NO_PROXY logic).

## Decision

Keep all existing logic in `packages/openapi` — including `specFetchHelper.ts` (the GitHub/Stripe
spec-fetching logic is Stripe-specific and belongs here). Remove the transport infrastructure by
injecting a `fetch` function from the caller instead.

## Design

### API changes

```ts
// specFetchHelper.ts
resolveOpenApiSpec(config: ResolveSpecConfig, fetch: typeof globalThis.fetch): Promise<ResolvedOpenApiSpec>

// listFnResolver.ts
buildListFn(apiKey: string, apiPath: string, fetch: typeof globalThis.fetch, apiVersion?: string, baseUrl?: string): ListFn
buildRetrieveFn(apiKey: string, apiPath: string, fetch: typeof globalThis.fetch, apiVersion?: string, baseUrl?: string): RetrieveFn
```

`fetch` is a **required** parameter — making it optional would silently fall back to
`globalThis.fetch`, which breaks behind Stripe's proxy and defeats the purpose.

### Files removed from `packages/openapi`

- `transport.ts` — deleted entirely
- `undici` — removed from `package.json`

### Call sites updated in `packages/source-stripe`

- `src/index.ts` — passes `fetchWithProxy` to `resolveOpenApiSpec`
- `src/resourceRegistry.ts` — passes `fetchWithProxy` to `buildListFn` / `buildRetrieveFn`

### Tests

- `packages/openapi/__tests__/transport.test.ts` — deleted (tests a file that no longer exists)
- `packages/openapi/__tests__/specFetchHelper.test.ts` — update proxy test to pass a mock fetch
- `packages/openapi/__tests__/listFnResolver.test.ts` — update proxy test to pass a mock fetch

## What does NOT change

- `specFetchHelper.ts` stays in `packages/openapi` — the GitHub repo, commit SHA resolution, and
version-to-date mapping are Stripe-specific OpenAPI knowledge, not source-stripe business logic
- `source-stripe/src/transport.ts` is untouched
- No behavioral changes — proxy behaviour is identical, just the plumbing moves to the caller
79 changes: 27 additions & 52 deletions packages/openapi/__tests__/listFnResolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { buildListFn, buildRetrieveFn, discoverListEndpoints } from '../listFnResolver'
import { minimalStripeOpenApiSpec } from './fixtures/minimalSpec'

describe('discoverListEndpoints', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

it('maps table names to their API paths', () => {
const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec)

Expand Down Expand Up @@ -93,53 +88,33 @@ describe('discoverListEndpoints', () => {
expect(endpoints.size).toBe(0)
})

it('routes list and retrieve fetches through the proxy helper', async () => {
const originalHttpsProxy = process.env.HTTPS_PROXY
process.env.HTTPS_PROXY = 'http://proxy.example.test:8080'

const fetchMock = vi.fn(async (_input: URL | string, init?: RequestInit) => {
expect(init?.dispatcher).toBeDefined()
return new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)

try {
const list = buildListFn('sk_test_fake', '/v1/customers')
const retrieve = buildRetrieveFn('sk_test_fake', '/v1/customers')

await list({ limit: 1 })
await retrieve('cus_123')

expect(fetchMock).toHaveBeenCalledTimes(2)
} finally {
if (originalHttpsProxy === undefined) {
delete process.env.HTTPS_PROXY
} else {
process.env.HTTPS_PROXY = originalHttpsProxy
}
}
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 })
)
const list = buildListFn('sk_test_fake', '/v1/customers', fetchMock)
const retrieve = buildRetrieveFn('sk_test_fake', '/v1/customers', fetchMock)
await list({ limit: 1 })
await retrieve('cus_123')
expect(fetchMock).toHaveBeenCalledTimes(2)
})

it('bypasses the proxy for localhost base URLs', async () => {
const originalHttpsProxy = process.env.HTTPS_PROXY
process.env.HTTPS_PROXY = 'http://proxy.example.test:8080'

const fetchMock = vi.fn(async (_input: URL | string, init?: RequestInit) => {
expect(init?.dispatcher).toBeUndefined()
return new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)

try {
const list = buildListFn('sk_test_fake', '/v1/customers', undefined, 'http://localhost:12111')
await list({ limit: 1 })
expect(fetchMock).toHaveBeenCalledTimes(1)
} finally {
if (originalHttpsProxy === undefined) {
delete process.env.HTTPS_PROXY
} else {
process.env.HTTPS_PROXY = originalHttpsProxy
}
}
it('uses the injected fetch for localhost base URLs', async () => {
const fetchMock = vi.fn(
async () => new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 })
)
const list = buildListFn(
'sk_test_fake',
'/v1/customers',
fetchMock,
undefined,
'http://localhost:12111'
)
await list({ limit: 1 })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('http://localhost:12111'),
expect.anything()
)
})
})
97 changes: 45 additions & 52 deletions packages/openapi/__tests__/specFetchHelper.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { resolveOpenApiSpec } from '../specFetchHelper'
import { minimalStripeOpenApiSpec } from './fixtures/minimalSpec'

Expand All @@ -10,23 +10,20 @@ async function createTempDir(prefix: string): Promise<string> {
}

describe('resolveOpenApiSpec', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

it('prefers explicit local spec path over cache and network', async () => {
const tempDir = await createTempDir('openapi-explicit')
const specPath = path.join(tempDir, 'spec3.json')
await fs.writeFile(specPath, JSON.stringify(minimalStripeOpenApiSpec), 'utf8')
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const fetchMock = vi.fn().mockRejectedValue(new Error('fetch should not be called'))

const result = await resolveOpenApiSpec({
apiVersion: '2020-08-27',
openApiSpecPath: specPath,
cacheDir: tempDir,
})
const result = await resolveOpenApiSpec(
{
apiVersion: '2020-08-27',
openApiSpecPath: specPath,
cacheDir: tempDir,
},
fetchMock
)

expect(result.source).toBe('explicit_path')
expect(fetchMock).not.toHaveBeenCalled()
Expand All @@ -37,13 +34,15 @@ describe('resolveOpenApiSpec', () => {
const tempDir = await createTempDir('openapi-cache')
const cachePath = path.join(tempDir, '2020-08-27.spec3.sdk.json')
await fs.writeFile(cachePath, JSON.stringify(minimalStripeOpenApiSpec), 'utf8')
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const fetchMock = vi.fn().mockRejectedValue(new Error('fetch should not be called'))

const result = await resolveOpenApiSpec({
apiVersion: '2020-08-27',
cacheDir: tempDir,
})
const result = await resolveOpenApiSpec(
{
apiVersion: '2020-08-27',
cacheDir: tempDir,
},
fetchMock
)

expect(result.source).toBe('cache')
expect(result.cachePath).toBe(cachePath)
Expand All @@ -60,12 +59,14 @@ describe('resolveOpenApiSpec', () => {
}
return new Response(JSON.stringify(minimalStripeOpenApiSpec), { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)

const result = await resolveOpenApiSpec({
apiVersion: '2020-08-27',
cacheDir: tempDir,
})
const result = await resolveOpenApiSpec(
{
apiVersion: '2020-08-27',
cacheDir: tempDir,
},
fetchMock
)

expect(result.source).toBe('github')
expect(result.commitSha).toBe('abc123def456')
Expand All @@ -76,36 +77,23 @@ describe('resolveOpenApiSpec', () => {
await fs.rm(tempDir, { recursive: true, force: true })
})

it('uses the configured proxy for GitHub fetches', async () => {
it('uses the injected fetch for GitHub fetches', async () => {
const tempDir = await createTempDir('openapi-fetch-proxy')
const originalHttpsProxy = process.env.HTTPS_PROXY
process.env.HTTPS_PROXY = 'http://proxy.example.test:8080'

const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
expect(init?.dispatcher).toBeDefined()

const fetchMock = vi.fn(async (input: URL | string) => {
const url = String(input)
if (url.includes('/commits')) {
return new Response(JSON.stringify([{ sha: 'abc123def456' }]), { status: 200 })
}
return new Response(JSON.stringify(minimalStripeOpenApiSpec), { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)

try {
const result = await resolveOpenApiSpec({
apiVersion: '2020-08-27',
cacheDir: tempDir,
})

const result = await resolveOpenApiSpec(
{ apiVersion: '2020-08-27', cacheDir: tempDir },
fetchMock
)
expect(result.source).toBe('github')
expect(fetchMock).toHaveBeenCalledTimes(2)
} finally {
if (originalHttpsProxy === undefined) {
delete process.env.HTTPS_PROXY
} else {
process.env.HTTPS_PROXY = originalHttpsProxy
}
await fs.rm(tempDir, { recursive: true, force: true })
}
})
Expand All @@ -116,24 +104,29 @@ describe('resolveOpenApiSpec', () => {
await fs.writeFile(specPath, JSON.stringify({ openapi: '3.0.0' }), 'utf8')

await expect(
resolveOpenApiSpec({
apiVersion: '2020-08-27',
openApiSpecPath: specPath,
})
resolveOpenApiSpec(
{
apiVersion: '2020-08-27',
openApiSpecPath: specPath,
},
vi.fn().mockRejectedValue(new Error('fetch should not be called'))
)
).rejects.toThrow(/components|schemas/i)
await fs.rm(tempDir, { recursive: true, force: true })
})

it('fails fast when GitHub resolution fails and no explicit spec path is set', async () => {
const tempDir = await createTempDir('openapi-fail-fast')
const fetchMock = vi.fn(async () => new Response('boom', { status: 500 }))
vi.stubGlobal('fetch', fetchMock)

await expect(
resolveOpenApiSpec({
apiVersion: '2020-08-27',
cacheDir: tempDir,
})
resolveOpenApiSpec(
{
apiVersion: '2020-08-27',
cacheDir: tempDir,
},
fetchMock
)
).rejects.toThrow(/Failed to resolve Stripe OpenAPI commit/)
await fs.rm(tempDir, { recursive: true, force: true })
})
Expand Down
Loading