Files
Stephen Zhouandautofix-ci[bot] 226110d611 feat(web): set up @dify/openapi-content-mapper foundations (#44627)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-10-26 05:06:34 +00:00

423 lines
16 KiB
JavaScript

/* oxlint-disable vitest/no-import-node-test, antfu/no-import-dist -- Integration tests exercise the built package in native Node, including its module hooks. */
import assert from 'node:assert/strict'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { test } from 'node:test'
import { pathToFileURL } from 'node:url'
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { QueryClient } from '@tanstack/query-core'
import { invokeRpcOperation } from '../dist/client.js'
import { ApiError, createClient, client as defaultClient } from '../dist/index.js'
import { describeOperations } from '../dist/operation.js'
import { createQueryKey, createQueryOperation } from '../dist/query-runtime.js'
import { compileRuntime, transformSpec } from '../dist/tooling/transform.js'
import { mediaSpec } from './media.mjs'
import '../dist/tooling/register.js'
/* oxlint-enable vitest/no-import-node-test, antfu/no-import-dist */
const { api, query: generatedQuery } = await import('../examples/petstore/pets.openapi.yaml')
const pet = { id: 1, name: 'Mochi', status: 'available' }
test('wildcard response media preserve Blob types, specific matches and HTTP errors', async () => {
const response = (...media) => ({ content: Object.fromEntries(media.map((type) => [type, {}])) })
const operation = describeOperations({
openapi: '3.1.0',
paths: {
'/files': {
get: {
operationId: 'file',
responses: {
'2XX': { $ref: '#/components/responses/File' },
201: response('application/json', '*/*'),
202: response('text/*', '*/*'),
203: response('application/*', '*/*'),
206: response('text/plain'),
},
},
},
},
components: { responses: { File: response('*/*') } },
}).get('file')
let reply
const client = createClient({ baseUrl: 'https://example.test', fetch: async () => reply })
for (const [status, contentType, expected] of [
[200, 'text/plain', 'blob'],
[200, 'application/json', 'blob'],
[200, 'text/event-stream', 'blob'],
[201, 'application/json', 'json'],
[201, 'text/plain', 'blob'],
[202, 'text/event-stream', 'text'],
[203, 'application/json', 'blob'],
[206, 'text/plain', 'text'],
]) {
reply = new Response('123', { status, headers: { 'content-type': contentType } })
const value = await client.request(operation)
if (expected === 'blob') {
assert(value instanceof Blob, `${status} ${contentType}`)
assert.equal(await value.text(), '123')
} else assert.equal(value, expected === 'json' ? 123 : '123')
}
reply = Response.json({ code: 'DENIED' }, { status: 403 })
await assert.rejects(client.request(operation), (error) => {
assert(error instanceof ApiError)
assert.deepEqual(error.data, { code: 'DENIED' })
return true
})
})
test('document sidecar automatically binds only API and query exports in Node', async () => {
const dir = await mkdtemp(join(import.meta.dirname, '.config-'))
try {
const file = join(dir, 'api.openapi')
await writeFile(
file,
JSON.stringify({
openapi: '3.1.0',
paths: {
...Object.fromEntries(
['then', 'api', 'query', 'default'].map((operationId, index) => [
index ? `/pets/${operationId}` : '/pets',
{ get: { operationId, responses: { 200: { description: 'OK' } } } },
]),
),
},
}),
)
await writeFile(
`${file}.config.ts`,
`
import { createClient } from '@dify/openapi-content-mapper';
export type Context = { user?: string };
export default {
queryKeyPrefix: ['app'],
createClient: ({ context }) => createClient({
baseUrl: 'https://bound.test',
fetch: async (url) => Response.json({ url: String(url), user: context?.user }),
}),
};
`,
)
const bound = await import(pathToFileURL(file).href)
assert.deepEqual(Object.keys(bound), ['api', 'query'])
assert.equal((await bound.api.pets.get()).url, 'https://bound.test/pets')
assert.equal(
(await bound.api.pets.get(undefined, { context: { user: 'alice' } })).user,
'alice',
)
const options = bound.query.pets.get.queryOptions({ input: {}, context: { user: 'bob' } })
assert.equal((await options.queryFn({ signal: new AbortController().signal })).user, 'bob')
assert.deepEqual(bound.query.pets.key(), [['app', 'pets'], {}])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('document configuration shares transport and keys without leaking per-call context', async () => {
const operation = { method: 'get', path: '/pets' }
const seen = []
const errors = []
const config = {
queryKeyPrefix: ['console'],
createClient: ({ context, signal }) =>
createClient({
baseUrl: `https://${context.user}.example.test`,
fetch: async (url, init) => {
seen.push([String(url), init.signal === signal])
return Response.json(pet)
},
}),
onError: (error) => errors.push(error),
}
const query = createQueryOperation(operation, ['pets', 'get'], config)
const controller = new AbortController()
await Promise.all([
invokeRpcOperation(
operation,
{},
{ context: { user: 'alice' }, signal: controller.signal },
config,
),
query.queryOptions({ context: { user: 'bob' } }).queryFn({ signal: controller.signal }),
invokeRpcOperation(operation, {}, { context: { user: 'carol' } }, config),
])
assert.deepEqual(seen.map(([url]) => url).sort(), [
'https://alice.example.test/pets',
'https://bob.example.test/pets',
'https://carol.example.test/pets',
])
assert(seen.every(([, forwarded]) => forwarded))
assert.deepEqual(createQueryKey([], config)(), [['console'], {}])
assert.deepEqual(query.queryKey()[0], ['console', 'pets', 'get'])
const failure = new Error('failed')
const override = createClient({
baseUrl: 'https://override.test',
fetch: async () => {
throw failure
},
})
await assert.rejects(
invokeRpcOperation(operation, {}, { client: override }, config),
(error) => error === failure,
)
assert.deepEqual(errors, [failure])
})
test('nested client sends params/query/body, headers and cancellation through Fetch', async () => {
const calls = []
const client = createClient({
baseUrl: 'https://example.test/v1',
headers: { Authorization: 'Bearer test-only' },
fetch: async (url, init) => {
calls.push({ url: String(url), ...init })
if (init.method === 'DELETE') return new Response(null, { status: 204 })
return Response.json(pet)
},
})
const controller = new AbortController()
assert.deepEqual(
await api.pets.byPetId.get({ params: { petId: 1 } }, { client, signal: controller.signal }),
pet,
)
assert.equal(calls[0].url, 'https://example.test/v1/pets/1')
assert.equal(calls[0].headers.get('authorization'), 'Bearer test-only')
assert.equal(calls[0].signal, controller.signal)
await api.pets.get({ query: { status: 'available', limit: 0 } }, { client })
assert.match(calls[1].url, /limit=0/)
assert.match(calls[1].url, /status=available/)
await api.pets.post({ body: { name: '雪团' } }, { client, headers: { 'X-Test': '1' } })
assert.equal(calls[2].body, '{"name":"雪团"}')
assert.equal(calls[2].headers.get('content-type'), 'application/json')
assert.equal(calls[2].headers.get('x-test'), '1')
assert.equal(await api.pets.byPetId.delete({ params: { petId: 1 } }, { client }), undefined)
await assert.rejects(api.pets.byPetId.get({}, { client }), /Missing required path/)
await assert.rejects(api.pets.post({}, { client }), /Missing required request body/)
assert.equal(calls.length, 4, 'Missing required input must fail before sending a request')
})
test('HTTP errors preserve status and payload; abort errors are not swallowed', async () => {
const client = createClient({
baseUrl: 'https://example.test',
fetch: async () => Response.json({ message: 'Missing' }, { status: 404 }),
})
await assert.rejects(api.pets.byPetId.get({ params: { petId: 1 } }, { client }), (error) => {
assert(error instanceof ApiError)
assert.equal(error.response.status, 404)
assert.deepEqual(error.data, { message: 'Missing' })
return true
})
client.setConfig({
fetch: async (_url, init) => {
init.signal.throwIfAborted()
return Response.json(pet)
},
})
const reason = new Error('cancelled')
await assert.rejects(
api.pets.byPetId.get({ params: { petId: 1 } }, { client, signal: AbortSignal.abort(reason) }),
(error) => error === reason,
)
})
test('OpenAPI serializers escape path segments, encode query arrays/objects, and apply parameter overrides', async () => {
const spec = {
openapi: '3.1.0',
paths: {
'/files/{key}': {
parameters: [
{ name: 'key', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'tag', in: 'query', required: true },
],
get: {
operationId: 'getFile',
parameters: [
{ name: 'tag', in: 'query', required: false },
{ name: 'filter', in: 'query', style: 'deepObject', explode: true },
{ name: 'x-tags', in: 'header', style: 'simple' },
],
responses: { 200: { description: 'ok' } },
},
},
},
}
const operation = describeOperations(spec).get('getFile')
let observed
const client = createClient({
baseUrl: 'https://example.test/base/',
fetch: async (url, options) => {
observed = { url, options }
return new Response('ok', { headers: { 'Content-Type': 'text/plain' } })
},
})
assert.equal(
await client.request(operation, {
path: { key: 'a/b c' },
query: { tag: ['one&two', 'three'], filter: { active: false } },
header: { 'x-tags': ['a', 'b'] },
}),
'ok',
)
assert.equal(observed.url.pathname, '/base/files/a%2Fb%20c')
assert.deepEqual(observed.url.searchParams.getAll('tag'), ['one&two', 'three'])
assert.equal(observed.url.searchParams.get('filter[active]'), 'false')
assert.equal(observed.options.headers.get('x-tags'), 'a,b')
await client.request(operation, { path: { key: 'ok' } })
})
test('existing oRPC TanStack Query helpers infer and execute nested calls', async () => {
const before = defaultClient.getConfig()
defaultClient.setConfig({
baseUrl: 'https://example.test',
fetch: async () => Response.json(pet),
})
const queryClient = new QueryClient()
try {
const query = createTanstackQueryUtils(api)
const options = query.pets.byPetId.get.queryOptions({ input: { params: { petId: 1 } } })
assert.deepEqual(await queryClient.fetchQuery(options), pet)
assert.deepEqual(queryClient.getQueryData(options.queryKey), pet)
const generatedOptions = generatedQuery.pets.byPetId.get.queryOptions({
input: { params: { petId: 1 } },
})
assert.deepEqual(await queryClient.fetchQuery(generatedOptions), pet)
assert.deepEqual(generatedQuery.pets.key(), query.pets.key())
assert.deepEqual(generatedQuery.pets.byPetId.get.key(), query.pets.byPetId.get.key())
} finally {
defaultClient.setConfig({ baseUrl: undefined, fetch: undefined, headers: undefined, ...before })
queryClient.clear()
}
})
test('mapper and runtime reject the same invalid documents and route names', () => {
const operation = (id) => ({ operationId: id, responses: { 200: { description: 'ok' } } })
for (const [spec, expected] of [
[
{
openapi: '3.1.0',
paths: {
'/x': {
get: { ...operation('x'), responses: { 200: { $ref: 'remote.yaml#/Response' } } },
},
},
},
/Only local JSON Pointer/,
],
[
{
openapi: '3.1.0',
paths: { '/x': { get: operation('a') }, '/x/get': { get: operation('b') } },
},
/collides with an existing member/,
],
[
{ openapi: '3.1.0', paths: { '/x': { get: operation('a'), post: operation('a') } } },
/Duplicate operationId/,
],
[
{ openapi: '3.1.0', paths: {}, components: { schemas: { X: { $ref: '#/missing' } } } },
/Unresolved reference/,
],
['openapi: 3.1.0\npaths: {}\nx-value: .inf', /non-finite numbers/],
['openapi: 3.1.0\npaths: {}\n? [complex, key]\n: value', /keys must be strings or numbers/],
['openapi: 3.1.0\npaths: {}\nx-value: &value {}\nx-alias: *value', /alias/i],
]) {
const content = typeof spec === 'string' ? spec : JSON.stringify(spec)
const result = transformSpec(content, 'spec.openapi')
assert.match(result.diagnostics[0].messageText, expected)
assert.throws(() => compileRuntime(content, 'spec.openapi'), expected)
}
})
test('nested typed headers reach Fetch alongside transport headers without filling schema defaults', async () => {
const calls = []
const client = createClient({
baseUrl: 'https://example.test',
headers: { Authorization: 'Bearer shared' },
fetch: async (url, init) => {
calls.push({ url, ...init })
return Response.json({})
},
})
const operation = {
method: 'post',
path: '/settings',
parameters: [
{ in: 'header', name: 'Idempotency-Key', required: true, style: 'simple', explode: false },
{ in: 'header', name: 'X-Tags', required: false, style: 'simple', explode: false },
],
}
const result = await invokeRpcOperation(
operation,
{ headers: { 'Idempotency-Key': 'key', 'X-Tags': ['a', 'b'] } },
{ client, headers: { 'X-Transport': 'extra' } },
)
assert.equal(calls[0].headers.get('Idempotency-Key'), 'key')
assert.equal(calls[0].headers.get('X-Tags'), 'a,b')
assert.equal(calls[0].headers.get('Authorization'), 'Bearer shared')
assert.equal(calls[0].headers.get('X-Transport'), 'extra')
assert.deepEqual(result, {})
await assert.rejects(invokeRpcOperation(operation, {}, { client }), /Missing required header/)
})
test('request media defaults agree with types and JSON responses preserve wire values', async () => {
const operations = [...describeOperations(mediaSpec).values()]
const operation = (path) => operations.find((operation) => operation.path === path)
const calls = []
const client = createClient({
baseUrl: 'https://example.test',
fetch: async (_url, init) => {
calls.push(init)
return Response.json({ inline: 'inline', referenced: 'referenced' })
},
})
await client.request(operation('/mixed'), { body: { kind: 'standard' } })
assert.equal(calls.at(-1).headers.get('content-type'), 'application/json')
await client.request(operation('/mixed'), {
body: { kind: 'vendor' },
contentType: 'application/vnd.example+json',
})
assert.equal(calls.at(-1).headers.get('content-type'), 'application/vnd.example+json')
assert.equal(calls.at(-1).body, '{"kind":"vendor"}')
await client.request(operation('/single'), { body: { kind: 'single' } })
assert.equal(calls.at(-1).headers.get('content-type'), 'application/vnd.example+json')
await assert.rejects(
client.request(operation('/vendors'), { body: { kind: 'second' } }),
/content type/,
)
await assert.rejects(client.request(operation('/vendors')), /Missing required request body/)
assert.equal(calls.length, 3, 'Ambiguous media and missing bodies fail before sending')
await client.request(operation('/vendors'), {
body: { kind: 'second' },
contentType: 'application/vnd.second+json',
})
assert.equal(calls.at(-1).headers.get('content-type'), 'application/vnd.second+json')
assert.deepEqual(await client.request(operation('/binary')), {
inline: 'inline',
referenced: 'referenced',
})
})
test('downloads preserve filenames, encoded names, media type and bytes', async () => {
const bytes = new Uint8Array([0x50, 0x4b, 0x00, 0xff])
for (const [disposition, name] of [
['attachment; filename="agent.ifpkg"', 'agent.ifpkg'],
['attachment; filename="fallback.zip"; filename*=UTF-8\'\'%E7%9F%A5%E8%AF%86.zip', '知识.zip'],
['attachment', 'blob'],
]) {
const client = createClient({
baseUrl: 'https://example.test',
fetch: async () =>
new Response(bytes, {
headers: {
'content-type': 'application/zip',
'content-disposition': disposition,
},
}),
})
const result = await client.request({ method: 'get', path: '/download' })
assert(result instanceof File)
assert.equal(result.name, name)
assert.equal(result.type, 'application/zip')
assert.deepEqual(new Uint8Array(await result.arrayBuffer()), bytes)
}
})