@bagelink/sdk 1.15.558 → 1.15.565

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.
@@ -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
- ...headers,
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 = {}, withCredentials = true } = options
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
- // Start the fetch request
222
- fetch(url, {
223
- method: 'POST',
224
- headers: {
225
- 'Accept': 'text/event-stream',
226
- 'Content-Type': 'application/json',
227
- 'Cache-Control': 'no-cache',
228
- ...headers,
229
- },
230
- credentials: withCredentials ? 'include' : 'same-origin',
231
- body: JSON.stringify(body),
232
- signal: controller.signal,
233
- // Disable keepalive for streaming
234
- keepalive: false,
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
+ })