@bagelink/sdk 1.15.558 → 1.15.562
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +544 -344
- package/dist/index.d.cts +53 -4
- package/dist/index.d.mts +53 -4
- package/dist/index.d.ts +53 -4
- package/dist/index.mjs +540 -344
- package/package.json +1 -1
- package/src/index.ts +157 -1
- package/src/openAPITools/functionGenerator.test.ts +44 -0
- package/src/openAPITools/functionGenerator.ts +115 -114
- package/src/openAPITools/index.ts +1 -0
- package/src/openAPITools/moduleGenerator.test.ts +89 -0
- package/src/openAPITools/moduleGenerator.ts +174 -0
- package/src/openAPITools/streamClient.ts +25 -19
- package/src/transport.test.ts +71 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ModuleOwnershipManifest, OpenAPIObject } from './index'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { generateModules, validateOwnershipManifest } from './moduleGenerator'
|
|
4
|
+
|
|
5
|
+
const spec: OpenAPIObject = {
|
|
6
|
+
openapi: '3.1.0',
|
|
7
|
+
info: { title: 'test', version: '1' },
|
|
8
|
+
paths: {
|
|
9
|
+
'/person': {
|
|
10
|
+
get: {
|
|
11
|
+
tags: ['person'],
|
|
12
|
+
responses: {
|
|
13
|
+
'200': {
|
|
14
|
+
description: 'ok',
|
|
15
|
+
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/PersonRead' } } } },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
'/person/{id}/notes': {
|
|
21
|
+
get: {
|
|
22
|
+
tags: ['notes'],
|
|
23
|
+
responses: { '200': { description: 'ok' } },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
components: {
|
|
28
|
+
schemas: {
|
|
29
|
+
PersonRead: { type: 'object', properties: { address: { $ref: '#/components/schemas/Address' } } },
|
|
30
|
+
Address: { type: 'object', properties: { city: { type: 'string' } } },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const manifest: ModuleOwnershipManifest = {
|
|
36
|
+
version: 1,
|
|
37
|
+
modules: [
|
|
38
|
+
{ name: 'notes', tags: ['notes'] },
|
|
39
|
+
{ name: 'person', tags: ['person'] },
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('explicit module generation', () => {
|
|
44
|
+
it('generates deterministic modules with transitive types', () => {
|
|
45
|
+
const generated = generateModules(spec, manifest, '/api')
|
|
46
|
+
expect(generated.map(module => module.name)).toEqual(['notes', 'person'])
|
|
47
|
+
expect(generated[1].schemas).toEqual(['Address', 'PersonRead'])
|
|
48
|
+
expect(generated[1].code).toContain('export function createPersonApi(transport: ApiTransport)')
|
|
49
|
+
expect(generated[1].code).not.toContain('createApiTransport')
|
|
50
|
+
expect(generated[1].code).not.toContain('export const axios')
|
|
51
|
+
expect(generated[1].types).toContain('export type Address')
|
|
52
|
+
expect(generated[0].code).toContain('@endpoint GET /person/{id}/notes')
|
|
53
|
+
expect(generated[0].code).not.toContain('@endpoint GET /person\n')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('copies path parameters only into the owning module', () => {
|
|
57
|
+
const withPathParameters: OpenAPIObject = {
|
|
58
|
+
...spec,
|
|
59
|
+
paths: {
|
|
60
|
+
...spec.paths,
|
|
61
|
+
'/person/{id}': {
|
|
62
|
+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
63
|
+
get: {
|
|
64
|
+
tags: ['person'],
|
|
65
|
+
responses: { '200': { description: 'ok' } },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const generated = generateModules(withPathParameters, manifest, '/api')
|
|
72
|
+
expect(generated.find(module => module.name === 'person')?.code).toContain('axios.get(`/person/${id}`)')
|
|
73
|
+
expect(generated.find(module => module.name === 'notes')?.code).not.toContain('axios.get(`/person/${id}`)')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('reports unowned and multiply-owned operations', () => {
|
|
77
|
+
const invalid: ModuleOwnershipManifest = {
|
|
78
|
+
version: 1,
|
|
79
|
+
modules: [
|
|
80
|
+
{ name: 'one', tags: ['person'] },
|
|
81
|
+
{ name: 'two', tags: ['person'] },
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
expect(validateOwnershipManifest(spec, invalid).map(issue => issue.kind)).toEqual([
|
|
85
|
+
'multiply-owned-operation',
|
|
86
|
+
'unowned-operation',
|
|
87
|
+
])
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OpenAPIObject,
|
|
3
|
+
OperationObject,
|
|
4
|
+
PathItemObject,
|
|
5
|
+
PathsObject,
|
|
6
|
+
ReferenceObject,
|
|
7
|
+
SchemaObject,
|
|
8
|
+
} from './types'
|
|
9
|
+
import { generateModuleFunctions } from './functionGenerator'
|
|
10
|
+
import { generateTypes } from './typeGenerator'
|
|
11
|
+
import { formatType } from './utils'
|
|
12
|
+
|
|
13
|
+
const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const
|
|
14
|
+
|
|
15
|
+
export interface ModuleOwnership {
|
|
16
|
+
/** Stable module id and generated directory name. */
|
|
17
|
+
name: string
|
|
18
|
+
/** Exact OpenAPI tags owned by this module. */
|
|
19
|
+
tags?: string[]
|
|
20
|
+
/** Exact path prefixes owned by this module. */
|
|
21
|
+
pathPrefixes?: string[]
|
|
22
|
+
/** Explicit component schema names additionally owned by this module. */
|
|
23
|
+
schemas?: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ModuleOwnershipManifest {
|
|
27
|
+
version: 1
|
|
28
|
+
modules: ModuleOwnership[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ModuleGenerationResult {
|
|
32
|
+
name: string
|
|
33
|
+
paths: string[]
|
|
34
|
+
schemas: string[]
|
|
35
|
+
code: string
|
|
36
|
+
types: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ConformanceIssue {
|
|
40
|
+
kind: 'invalid-manifest' | 'unowned-operation' | 'multiply-owned-operation' | 'unknown-schema'
|
|
41
|
+
message: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isReference(value: unknown): value is ReferenceObject {
|
|
45
|
+
return Boolean(value && typeof value === 'object' && '$ref' in value)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function operationOwners(path: string, operation: OperationObject, manifest: ModuleOwnershipManifest) {
|
|
49
|
+
return manifest.modules.filter(module =>
|
|
50
|
+
module.pathPrefixes?.some(prefix => path === prefix || path.startsWith(`${prefix}/`))
|
|
51
|
+
|| operation.tags?.some(tag => module.tags?.includes(tag)),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function collectSchemaRefs(value: unknown, refs: Set<string>): void {
|
|
56
|
+
if (!value || typeof value !== 'object') return
|
|
57
|
+
if (isReference(value) && value.$ref.startsWith('#/components/schemas/')) {
|
|
58
|
+
refs.add(value.$ref.slice('#/components/schemas/'.length))
|
|
59
|
+
}
|
|
60
|
+
for (const child of Object.values(value)) collectSchemaRefs(child, refs)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function schemaClosure(openApi: OpenAPIObject, seed: Iterable<string>): string[] {
|
|
64
|
+
const schemas = openApi.components?.schemas ?? {}
|
|
65
|
+
const result = new Set(seed)
|
|
66
|
+
const pending = [...result]
|
|
67
|
+
while (pending.length) {
|
|
68
|
+
const name = pending.pop()!
|
|
69
|
+
const schema = schemas[name]
|
|
70
|
+
if (!schema) continue
|
|
71
|
+
const refs = new Set<string>()
|
|
72
|
+
collectSchemaRefs(schema, refs)
|
|
73
|
+
for (const ref of refs) {
|
|
74
|
+
if (!result.has(ref)) {
|
|
75
|
+
result.add(ref)
|
|
76
|
+
pending.push(ref)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...result].sort()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function moduleFactoryName(name: string): string {
|
|
84
|
+
return `create${name
|
|
85
|
+
.split('-')
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.map(part => part[0]!.toUpperCase() + part.slice(1))
|
|
88
|
+
.join('')}Api`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function validateOwnershipManifest(
|
|
92
|
+
openApi: OpenAPIObject,
|
|
93
|
+
manifest: ModuleOwnershipManifest,
|
|
94
|
+
): ConformanceIssue[] {
|
|
95
|
+
const issues: ConformanceIssue[] = []
|
|
96
|
+
if (manifest.version !== 1) {
|
|
97
|
+
issues.push({ kind: 'invalid-manifest', message: `Unsupported manifest version: ${manifest.version}` })
|
|
98
|
+
}
|
|
99
|
+
const names = manifest.modules.map(module => module.name)
|
|
100
|
+
for (const name of new Set(names.filter((name, index) => names.indexOf(name) !== index))) {
|
|
101
|
+
issues.push({ kind: 'invalid-manifest', message: `Duplicate module name: ${name}` })
|
|
102
|
+
}
|
|
103
|
+
const schemas = openApi.components?.schemas ?? {}
|
|
104
|
+
for (const module of manifest.modules) {
|
|
105
|
+
if (!module.name || !/^[a-z][a-z0-9-]*$/.test(module.name)) {
|
|
106
|
+
issues.push({ kind: 'invalid-manifest', message: `Invalid module name: ${module.name || '(empty)'}` })
|
|
107
|
+
}
|
|
108
|
+
if (!(module.tags?.length || module.pathPrefixes?.length)) {
|
|
109
|
+
issues.push({ kind: 'invalid-manifest', message: `Module ${module.name} must own tags or pathPrefixes` })
|
|
110
|
+
}
|
|
111
|
+
for (const schema of module.schemas ?? []) {
|
|
112
|
+
if (!schemas[schema]) issues.push({ kind: 'unknown-schema', message: `Module ${module.name} owns unknown schema ${schema}` })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const [path, item] of Object.entries(openApi.paths ?? {})) {
|
|
116
|
+
for (const method of METHODS) {
|
|
117
|
+
const operation = item[method]
|
|
118
|
+
if (!operation) continue
|
|
119
|
+
const owners = operationOwners(path, operation, manifest)
|
|
120
|
+
if (owners.length === 0) {
|
|
121
|
+
issues.push({ kind: 'unowned-operation', message: `${method.toUpperCase()} ${path} is not owned` })
|
|
122
|
+
}
|
|
123
|
+
else if (owners.length > 1) {
|
|
124
|
+
issues.push({
|
|
125
|
+
kind: 'multiply-owned-operation',
|
|
126
|
+
message: `${method.toUpperCase()} ${path} is owned by ${owners.map(owner => owner.name).join(', ')}`,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return issues
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function generateModules(
|
|
135
|
+
openApi: OpenAPIObject,
|
|
136
|
+
manifest: ModuleOwnershipManifest,
|
|
137
|
+
baseUrl: string,
|
|
138
|
+
): ModuleGenerationResult[] {
|
|
139
|
+
const issues = validateOwnershipManifest(openApi, manifest)
|
|
140
|
+
if (issues.length) throw new Error(issues.map(issue => issue.message).join('\n'))
|
|
141
|
+
const schemas = openApi.components?.schemas ?? {}
|
|
142
|
+
|
|
143
|
+
return [...manifest.modules]
|
|
144
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
145
|
+
.map((module) => {
|
|
146
|
+
const paths: PathsObject = {}
|
|
147
|
+
const refs = new Set(module.schemas ?? [])
|
|
148
|
+
for (const [path, item] of Object.entries(openApi.paths ?? {})) {
|
|
149
|
+
const ownedItem: PathItemObject = {}
|
|
150
|
+
for (const method of METHODS) {
|
|
151
|
+
const operation = item[method]
|
|
152
|
+
if (operation && operationOwners(path, operation, manifest)[0]?.name === module.name) {
|
|
153
|
+
;(ownedItem as Record<string, unknown>)[method] = operation
|
|
154
|
+
collectSchemaRefs(operation, refs)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (Object.keys(ownedItem).length) {
|
|
158
|
+
if (item.parameters) ownedItem.parameters = item.parameters
|
|
159
|
+
paths[path] = ownedItem
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const ownedSchemas = schemaClosure(openApi, refs)
|
|
163
|
+
const subset = Object.fromEntries(
|
|
164
|
+
ownedSchemas.map(name => [name, schemas[name]]).filter((entry): entry is [string, SchemaObject | ReferenceObject] => Boolean(entry[1])),
|
|
165
|
+
)
|
|
166
|
+
return {
|
|
167
|
+
name: module.name,
|
|
168
|
+
paths: Object.keys(paths).sort(),
|
|
169
|
+
schemas: ownedSchemas.map(formatType),
|
|
170
|
+
code: generateModuleFunctions(paths, baseUrl, moduleFactoryName(module.name)),
|
|
171
|
+
types: generateTypes(subset),
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
}
|
|
@@ -11,7 +11,7 @@ export { StreamController, type StreamEventMap } from './StreamController'
|
|
|
11
11
|
|
|
12
12
|
export interface SSEStreamOptions {
|
|
13
13
|
/** Additional headers to send with the request */
|
|
14
|
-
headers?: Record<string, string>
|
|
14
|
+
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>)
|
|
15
15
|
/** Whether to include credentials (cookies) with the request */
|
|
16
16
|
withCredentials?: boolean
|
|
17
17
|
/**
|
|
@@ -30,6 +30,10 @@ export interface SSEStreamOptions {
|
|
|
30
30
|
reconnectDelayMs?: number
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
async function resolveHeaders(headers: SSEStreamOptions['headers']): Promise<Record<string, string>> {
|
|
34
|
+
return typeof headers === 'function' ? await headers() : (headers ?? {})
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
/**
|
|
34
38
|
* Creates an SSE stream consumer with elegant event-based API
|
|
35
39
|
* @param url - The SSE endpoint URL
|
|
@@ -52,7 +56,7 @@ export function createSSEStream<TEventMap extends StreamEventMap = StreamEventMa
|
|
|
52
56
|
options: SSEStreamOptions = {}
|
|
53
57
|
): StreamController<TEventMap> {
|
|
54
58
|
const {
|
|
55
|
-
headers
|
|
59
|
+
headers,
|
|
56
60
|
withCredentials = true,
|
|
57
61
|
lastEventId: initialLastEventId,
|
|
58
62
|
maxReconnects = 5,
|
|
@@ -67,10 +71,11 @@ export function createSSEStream<TEventMap extends StreamEventMap = StreamEventMa
|
|
|
67
71
|
|
|
68
72
|
async function connect(): Promise<void> {
|
|
69
73
|
if (controller.signal.aborted) return
|
|
74
|
+
const resolvedHeaders = await resolveHeaders(headers)
|
|
70
75
|
const requestHeaders: Record<string, string> = {
|
|
71
76
|
'Accept': 'text/event-stream',
|
|
72
77
|
'Cache-Control': 'no-cache',
|
|
73
|
-
...
|
|
78
|
+
...resolvedHeaders,
|
|
74
79
|
}
|
|
75
80
|
if (lastEventId !== undefined) {
|
|
76
81
|
requestHeaders['Last-Event-ID'] = lastEventId
|
|
@@ -213,26 +218,27 @@ export function createSSEStreamPost<TEventMap extends StreamEventMap = StreamEve
|
|
|
213
218
|
body: TBody,
|
|
214
219
|
options: SSEStreamOptions = {}
|
|
215
220
|
): StreamController<TEventMap> {
|
|
216
|
-
const { headers
|
|
221
|
+
const { headers, withCredentials = true } = options
|
|
217
222
|
const controller = new AbortController()
|
|
218
223
|
|
|
219
224
|
const stream = new StreamController<TEventMap>(() => { controller.abort() })
|
|
220
225
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
226
|
+
// Resolve headers inside the request so async auth providers work for SSE too.
|
|
227
|
+
resolveHeaders(headers)
|
|
228
|
+
.then(resolvedHeaders => fetch(url, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: {
|
|
231
|
+
'Accept': 'text/event-stream',
|
|
232
|
+
'Content-Type': 'application/json',
|
|
233
|
+
'Cache-Control': 'no-cache',
|
|
234
|
+
...resolvedHeaders,
|
|
235
|
+
},
|
|
236
|
+
credentials: withCredentials ? 'include' : 'same-origin',
|
|
237
|
+
body: JSON.stringify(body),
|
|
238
|
+
signal: controller.signal,
|
|
239
|
+
// Disable keepalive for streaming
|
|
240
|
+
keepalive: false,
|
|
241
|
+
}))
|
|
236
242
|
.then(async (response) => {
|
|
237
243
|
if (!response.ok) {
|
|
238
244
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { AxiosAdapter } from 'axios'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { createApiTransport } from './index'
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllGlobals()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe('createApiTransport', () => {
|
|
10
|
+
it('keeps Axios configuration isolated per transport', async () => {
|
|
11
|
+
const requests: Array<{ baseURL?: string, authorization?: string }> = []
|
|
12
|
+
const adapter: AxiosAdapter = async (config) => {
|
|
13
|
+
requests.push({
|
|
14
|
+
baseURL: config.baseURL,
|
|
15
|
+
authorization: config.headers.get('Authorization') as string | undefined,
|
|
16
|
+
})
|
|
17
|
+
return { config, data: {}, headers: {}, status: 200, statusText: 'OK' }
|
|
18
|
+
}
|
|
19
|
+
const first = createApiTransport({
|
|
20
|
+
baseURL: 'https://first.example/api',
|
|
21
|
+
auth: { getToken: () => 'first-token' },
|
|
22
|
+
axiosConfig: { adapter },
|
|
23
|
+
})
|
|
24
|
+
const second = createApiTransport({
|
|
25
|
+
baseURL: 'https://second.example/api',
|
|
26
|
+
auth: { getToken: () => 'second-token', tokenPrefix: 'Token' },
|
|
27
|
+
axiosConfig: { adapter },
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
await first.axios.get('/items')
|
|
31
|
+
await second.axios.get('/items')
|
|
32
|
+
|
|
33
|
+
expect(requests).toEqual([
|
|
34
|
+
{ baseURL: 'https://first.example/api', authorization: 'Bearer first-token' },
|
|
35
|
+
{ baseURL: 'https://second.example/api', authorization: 'Token second-token' },
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('applies transport auth, headers, credentials, and stream base URL to SSE', async () => {
|
|
40
|
+
let request: { url: string, init: RequestInit } | undefined
|
|
41
|
+
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
|
42
|
+
request = { url, init }
|
|
43
|
+
return new Response(new ReadableStream({
|
|
44
|
+
start(controller) {
|
|
45
|
+
controller.enqueue(new TextEncoder().encode('event: done\ndata: {}\n\n'))
|
|
46
|
+
controller.close()
|
|
47
|
+
},
|
|
48
|
+
}), { status: 200 })
|
|
49
|
+
}))
|
|
50
|
+
const transport = createApiTransport({
|
|
51
|
+
baseURL: '/api',
|
|
52
|
+
streamBaseURL: 'https://streams.example',
|
|
53
|
+
withCredentials: false,
|
|
54
|
+
auth: { getToken: async () => 'secret' },
|
|
55
|
+
axiosConfig: { headers: { 'X-Client': 'sdk' } },
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
transport.createSSEStreamPost('/events', { message: 'hello' }, {
|
|
59
|
+
headers: { 'X-Request': 'stream' },
|
|
60
|
+
})
|
|
61
|
+
await vi.waitFor(() => expect(request).toBeDefined())
|
|
62
|
+
|
|
63
|
+
expect(request?.url).toBe('https://streams.example/events')
|
|
64
|
+
expect(request?.init.credentials).toBe('same-origin')
|
|
65
|
+
expect(request?.init.headers).toMatchObject({
|
|
66
|
+
Authorization: 'Bearer secret',
|
|
67
|
+
'X-Client': 'sdk',
|
|
68
|
+
'X-Request': 'stream',
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|