diff --git a/package.json b/package.json index 118e151..ce6825c 100644 --- a/package.json +++ b/package.json @@ -293,6 +293,7 @@ "ssh2": "^1.17.0" }, "devDependencies": { + "@toon-format/toon": "^2.3.0", "@types/js-yaml": "^4.0.9", "@types/jsdom": "^28.0.3", "@types/json-schema": "^7.0.15", @@ -312,12 +313,16 @@ "zod": "^4.3.6" }, "peerDependencies": { + "@toon-format/toon": "^2.0.0", "fastest-validator": "^1.17.0", "joi": "^17.0.0", "yup": "^1.0.0", "zod": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { + "@toon-format/toon": { + "optional": true + }, "fastest-validator": { "optional": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f541b0..9e2a5af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: specifier: ^3.25.2 version: 3.25.2(zod@4.4.3) devDependencies: + '@toon-format/toon': + specifier: ^2.3.0 + version: 2.3.0 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -730,6 +733,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@toon-format/toon@2.3.0': + resolution: {integrity: sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2135,6 +2141,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@toon-format/toon@2.3.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 diff --git a/src/http/types.ts b/src/http/types.ts index 66f25c7..41fa41f 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -133,6 +133,7 @@ export const MimeTypes = { // Application JSON: 'application/json', XML: 'application/xml', + TOON: 'application/toon', JAVASCRIPT: 'application/javascript', FORM: 'application/x-www-form-urlencoded', OCTET_STREAM: 'application/octet-stream', diff --git a/src/index.ts b/src/index.ts index 4f17e7f..635e479 100644 --- a/src/index.ts +++ b/src/index.ts @@ -437,11 +437,13 @@ export { jsonCodec, csvCodec, textCodec, + xmlCodec, + createToonCodec, selectCodecForAccept, selectCodecForContentType, resolveCodecs, } from './utils/content-codecs.js' -export type { Codec } from './utils/content-codecs.js' +export type { Codec, ToonEncoder, ToonCodecOptions } from './utils/content-codecs.js' // ID Generation (sid - replacement for nanoid) export { diff --git a/src/utils/content-codecs.ts b/src/utils/content-codecs.ts index 891a6de..0f4ccad 100644 --- a/src/utils/content-codecs.ts +++ b/src/utils/content-codecs.ts @@ -201,7 +201,218 @@ export const textCodec: Codec = { decode: (body: string) => body, } -export const defaultCodecs: Codec[] = [jsonCodec, textCodec, csvCodec] +// ───────────────────────────────────────────────────────────────────────────── +// XML Codec +// ───────────────────────────────────────────────────────────────────────────── + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function escapeXmlText(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>') +} + +function unescapeXmlText(value: string): string { + return value.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim() +} + +function xmlLeafToString(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value) + } + return JSON.stringify(value) +} + +/** + * Serializes a JS value into an XML element. + * + * Arrays are expanded as repeated `` children so decode can tell a + * single-key object apart from a single-element array. This is lossy for + * empty arrays (indistinguishable from null/undefined) and for objects that + * happen to have a property literally named "item". + */ +function encodeXmlElement(tag: string, value: unknown): string { + if (value === null || value === undefined) { + return `<${tag}/>` + } + + if (Array.isArray(value)) { + if (value.length === 0) return `<${tag}/>` + const items = value.map((item) => encodeXmlElement('item', item)).join('') + return `<${tag}>${items}` + } + + if (isPlainObject(value)) { + const keys = Object.keys(value) + if (keys.length === 0) return `<${tag}/>` + const inner = keys.map((key) => encodeXmlElement(key, value[key])).join('') + return `<${tag}>${inner}` + } + + const text = xmlLeafToString(value) + if (text === '') return `<${tag}/>` + return `<${tag}>${escapeXmlText(text)}` +} + +function stringifyXml(value: unknown): string { + return encodeXmlElement('root', value) +} + +interface XmlNode { + tag: string + children: XmlNode[] + text: string +} + +function parseXmlElement(input: string, start: number): { node: XmlNode; pos: number } { + let pos = start + 1 // consume '<' + const tagStart = pos + while (pos < input.length && !/[\s/>]/.test(input[pos]!)) pos += 1 + const tag = input.slice(tagStart, pos) + + while (pos < input.length && input[pos] !== '>' && !(input[pos] === '/' && input[pos + 1] === '>')) { + pos += 1 + } + + if (input[pos] === '/' && input[pos + 1] === '>') { + return { node: { tag, children: [], text: '' }, pos: pos + 2 } + } + + pos += 1 // consume '>' + const node: XmlNode = { tag, children: [], text: '' } + let textBuffer = '' + + while (pos < input.length) { + if (input.startsWith('', pos) + pos = end === -1 ? input.length : end + 1 + break + } + if (input[pos] === '<') { + if (input.startsWith('', pos) + pos = end === -1 ? input.length : end + 3 + continue + } + const parsed = parseXmlElement(input, pos) + node.children.push(parsed.node) + pos = parsed.pos + continue + } + textBuffer += input[pos] + pos += 1 + } + + node.text = unescapeXmlText(textBuffer) + return { node, pos } +} + +function xmlNodeToValue(node: XmlNode): unknown { + if (node.children.length === 0) { + return node.text === '' ? null : node.text + } + + if (node.children.length > 1) { + const tags = new Set(node.children.map((child) => child.tag)) + if (tags.size === 1) { + return node.children.map(xmlNodeToValue) + } + } else if (node.children[0]!.tag === 'item') { + return [xmlNodeToValue(node.children[0]!)] + } + + const obj: Record = {} + for (const child of node.children) { + const value = xmlNodeToValue(child) + if (Object.prototype.hasOwnProperty.call(obj, child.tag)) { + const existing = obj[child.tag] + obj[child.tag] = Array.isArray(existing) ? [...existing, value] : [existing, value] + } else { + obj[child.tag] = value + } + } + return obj +} + +function parseXml(body: string): unknown { + let pos = 0 + const skipWhitespace = () => { + while (pos < body.length && /\s/.test(body[pos]!)) pos += 1 + } + + skipWhitespace() + while (body.startsWith('', pos) + pos = end === -1 ? body.length : end + 1 + skipWhitespace() + } + + if (body[pos] !== '<') { + throw new SyntaxError('Invalid XML: expected an element') + } + + const { node } = parseXmlElement(body, pos) + return xmlNodeToValue(node) +} + +export const xmlCodec: Codec = { + name: 'xml', + contentTypes: ['application/xml', 'text/xml'], + encode: (value: unknown) => stringifyXml(value), + decode: (body: string) => parseXml(body), +} + +// ───────────────────────────────────────────────────────────────────────────── +// TOON Codec (opt-in adapter for @toon-format/toon) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Minimal shape of the `@toon-format/toon` package's `encode`/`decode` + * exports. Raffel never imports `@toon-format/toon` itself — the caller + * installs it and passes it in, same pattern as the zod/yup/joi validation + * adapters. + */ +export interface ToonEncoder { + encode: (value: unknown, options?: Record) => string + decode: (input: string, options?: Record) => unknown +} + +export interface ToonCodecOptions { + /** Content types this codec should match on the Accept/Content-Type headers. */ + contentTypes?: string[] + /** Options forwarded to `toon.encode()`. */ + encodeOptions?: Record + /** Options forwarded to `toon.decode()`. */ + decodeOptions?: Record +} + +/** + * Creates a TOON (Token-Oriented Object Notation) codec for use with + * `resolveCodecs()`. Requires the optional peer dependency `@toon-format/toon`. + * + * @example + * ```ts + * import { encode, decode } from '@toon-format/toon' + * import { createToonCodec, resolveCodecs } from 'raffel' + * + * const codecs = resolveCodecs([createToonCodec({ encode, decode })]) + * ``` + */ +export function createToonCodec(toon: ToonEncoder, options: ToonCodecOptions = {}): Codec { + const contentTypes = options.contentTypes ?? ['application/toon', 'text/toon'] + return { + name: 'toon', + contentTypes, + encode: (value: unknown) => toon.encode(value, options.encodeOptions), + decode: (body: string) => toon.decode(body, options.decodeOptions), + } +} + +export const defaultCodecs: Codec[] = [jsonCodec, textCodec, csvCodec, xmlCodec] export function resolveCodecs(codecs?: Codec[], fallback: Codec[] = defaultCodecs): Codec[] { if (!codecs || codecs.length === 0) return fallback diff --git a/test/adapters/http.int.test.ts b/test/adapters/http.int.test.ts index 14a45d4..606a584 100644 --- a/test/adapters/http.int.test.ts +++ b/test/adapters/http.int.test.ts @@ -183,7 +183,7 @@ describe('HttpAdapter', () => { const { status, body } = await request('/greet', { method: 'POST', body: { ok: true }, - headers: { Accept: 'application/xml' }, + headers: { Accept: 'application/pdf' }, }) expect(status).toBe(406) @@ -200,7 +200,7 @@ describe('HttpAdapter', () => { const { status, body } = await request('/greet', { method: 'POST', body: { ok: true }, - headers: { 'Content-Type': 'application/xml' }, + headers: { 'Content-Type': 'application/pdf' }, }) expect(status).toBe(415) diff --git a/test/adapters/jsonrpc.int.test.ts b/test/adapters/jsonrpc.int.test.ts index 5434306..75102ec 100644 --- a/test/adapters/jsonrpc.int.test.ts +++ b/test/adapters/jsonrpc.int.test.ts @@ -566,7 +566,7 @@ describe('JSON-RPC 2.0 Adapter', () => { try { const response = await fetch(`http://localhost:${port}/`, { method: 'POST', - headers: { 'Content-Type': 'application/xml' }, + headers: { 'Content-Type': 'application/pdf' }, body: '{"jsonrpc":"2.0","method":"test","id":1}', }) diff --git a/test/utils/content-codecs.unit.test.ts b/test/utils/content-codecs.unit.test.ts index e52e070..b487d4d 100644 --- a/test/utils/content-codecs.unit.test.ts +++ b/test/utils/content-codecs.unit.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect } from 'vitest' +import { encode as toonEncode, decode as toonDecode } from '@toon-format/toon' import { jsonCodec, textCodec, csvCodec, + xmlCodec, + createToonCodec, defaultCodecs, selectCodecForContentType, selectCodecForAccept, @@ -219,7 +222,17 @@ describe('Content Codecs', () => { }) it('returns null for unrecognized content type', () => { - expect(selectCodecForContentType('application/xml', defaultCodecs)).toBeNull() + expect(selectCodecForContentType('application/pdf', defaultCodecs)).toBeNull() + }) + + it('returns xmlCodec for application/xml', () => { + const codec = selectCodecForContentType('application/xml', defaultCodecs) + expect(codec).toBe(xmlCodec) + }) + + it('returns xmlCodec for text/xml', () => { + const codec = selectCodecForContentType('text/xml', defaultCodecs) + expect(codec).toBe(xmlCodec) }) it('returns jsonCodec for application/vnd.api+json via wildcard pattern', () => { @@ -262,10 +275,15 @@ describe('Content Codecs', () => { }) it('returns null when no codec matches', () => { - const codec = selectCodecForAccept('application/xml', defaultCodecs, jsonCodec) + const codec = selectCodecForAccept('application/pdf', defaultCodecs, jsonCodec) expect(codec).toBeNull() }) + it('returns xmlCodec for application/xml', () => { + const codec = selectCodecForAccept('application/xml', defaultCodecs, jsonCodec) + expect(codec).toBe(xmlCodec) + }) + it('handles */* wildcard accept', () => { const codec = selectCodecForAccept('*/*', defaultCodecs, jsonCodec) // */* should match the first codec @@ -302,16 +320,28 @@ describe('Content Codecs', () => { expect(result.find((c) => c.name === 'csv')).toBe(csvCodec) }) - it('adds novel codecs alongside defaults', () => { - const xmlCodec: Codec = { + it('adds novel codecs alongside defaults, deduplicating by name', () => { + const customXml: Codec = { name: 'xml', contentTypes: ['application/xml'], encode: () => '', decode: (b) => b, } - const result = resolveCodecs([xmlCodec]) - expect(result).toHaveLength(4) // xml + json + text + csv - expect(result[0]).toBe(xmlCodec) + const result = resolveCodecs([customXml]) + expect(result).toHaveLength(4) // custom xml + json + text + csv (default xml deduplicated) + expect(result[0]).toBe(customXml) + }) + + it('adds a genuinely novel codec alongside all defaults', () => { + const yamlCodec: Codec = { + name: 'yaml', + contentTypes: ['application/yaml'], + encode: () => '', + decode: (b) => b, + } + const result = resolveCodecs([yamlCodec]) + expect(result).toHaveLength(5) // yaml + json + text + csv + xml + expect(result[0]).toBe(yamlCodec) }) it('deduplicates by name case-insensitively', () => { @@ -342,15 +372,149 @@ describe('Content Codecs', () => { expect(result[2]).toBe(jsonCodec) expect(result[3]).toBe(textCodec) expect(result[4]).toBe(csvCodec) + expect(result[5]).toBe(xmlCodec) }) }) describe('defaultCodecs', () => { - it('has json, text, and csv codecs in order', () => { - expect(defaultCodecs).toHaveLength(3) + it('has json, text, csv, and xml codecs in order', () => { + expect(defaultCodecs).toHaveLength(4) expect(defaultCodecs[0]).toBe(jsonCodec) expect(defaultCodecs[1]).toBe(textCodec) expect(defaultCodecs[2]).toBe(csvCodec) + expect(defaultCodecs[3]).toBe(xmlCodec) + }) + }) + + describe('xmlCodec', () => { + it('has name "xml"', () => { + expect(xmlCodec.name).toBe('xml') + }) + + it('has correct content types', () => { + expect(xmlCodec.contentTypes).toContain('application/xml') + expect(xmlCodec.contentTypes).toContain('text/xml') + }) + + it('encodes a flat object', () => { + expect(xmlCodec.encode({ name: 'Alice', age: 30 })).toBe( + 'Alice30' + ) + }) + + it('encodes null as a self-closing root', () => { + expect(xmlCodec.encode(null)).toBe('') + }) + + it('encodes undefined as a self-closing root', () => { + expect(xmlCodec.encode(undefined)).toBe('') + }) + + it('encodes a primitive', () => { + expect(xmlCodec.encode(42)).toBe('42') + expect(xmlCodec.encode('hello')).toBe('hello') + }) + + it('encodes an array of objects using repeated elements', () => { + const xml = xmlCodec.encode([{ id: 1 }, { id: 2 }]) + expect(xml).toBe('12') + }) + + it('encodes a nested array property', () => { + const xml = xmlCodec.encode({ tags: ['a', 'b'] }) + expect(xml).toBe('ab') + }) + + it('escapes XML-special characters in text content', () => { + const xml = xmlCodec.encode({ text: ' & "b"' }) + expect(xml).toBe('<a> & "b"') + }) + + it('decodes a flat object', () => { + expect(xmlCodec.decode('Alice30')).toEqual({ + name: 'Alice', + age: '30', + }) + }) + + it('decodes a self-closing root as null', () => { + expect(xmlCodec.decode('')).toBeNull() + }) + + it('throws when decoding non-XML content', () => { + expect(() => xmlCodec.decode('{"not":"xml"}')).toThrow(SyntaxError) + expect(() => xmlCodec.decode('')).toThrow(SyntaxError) + }) + + it('decodes an array of objects', () => { + const result = xmlCodec.decode('12') + expect(result).toEqual([{ id: '1' }, { id: '2' }]) + }) + + it('decodes a nested array property', () => { + const result = xmlCodec.decode('ab') + expect(result).toEqual({ tags: ['a', 'b'] }) + }) + + it('unescapes XML entities', () => { + const result = xmlCodec.decode('<a> & b') + expect(result).toEqual({ text: ' & b' }) + }) + + it('roundtrips nested objects and arrays', () => { + const data = { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }], count: 2 } + const decoded = xmlCodec.decode(xmlCodec.encode(data)) + expect(decoded).toEqual({ + users: [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ], + count: '2', + }) + }) + + it('ignores the outer tag name when decoding foreign XML', () => { + const result = xmlCodec.decode('true') + expect(result).toEqual({ ok: 'true' }) + }) + }) + + describe('createToonCodec()', () => { + it('has name "toon"', () => { + const codec = createToonCodec({ encode: toonEncode, decode: toonDecode }) + expect(codec.name).toBe('toon') + }) + + it('has default content types', () => { + const codec = createToonCodec({ encode: toonEncode, decode: toonDecode }) + expect(codec.contentTypes).toContain('application/toon') + expect(codec.contentTypes).toContain('text/toon') + }) + + it('allows overriding content types', () => { + const codec = createToonCodec( + { encode: toonEncode, decode: toonDecode }, + { contentTypes: ['application/vnd.toon'] } + ) + expect(codec.contentTypes).toEqual(['application/vnd.toon']) + }) + + it('encodes using the injected toon encoder', () => { + const codec = createToonCodec({ encode: toonEncode, decode: toonDecode }) + expect(codec.encode({ name: 'Alice', age: 30 })).toBe(toonEncode({ name: 'Alice', age: 30 })) + }) + + it('roundtrips through encode/decode', () => { + const codec = createToonCodec({ encode: toonEncode, decode: toonDecode }) + const data = { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] } + expect(codec.decode(codec.encode(data))).toEqual(data) + }) + + it('registers alongside defaults via resolveCodecs()', () => { + const toonCodec = createToonCodec({ encode: toonEncode, decode: toonDecode }) + const codecs = resolveCodecs([toonCodec]) + expect(codecs).toHaveLength(5) + expect(selectCodecForAccept('application/toon', codecs, jsonCodec)).toBe(toonCodec) }) }) })