diff --git a/README.md b/README.md index e9c3a88..4c66a50 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,13 @@ normalizing keys postfixed with brackets, the brackets are removed and the value of all keys with the same name, are joined into one array in the order they appear. -The adapter has no options. +Available options: +- `setStructureInKeys`: When this is `true`, we'll serialize key and values so + that the structure of value is set in the key, with the leaf values as + values. `{ data: [{ id: 'ent1 }] }` will for instance be serialized to the + key `data[0][id]` and the value `'ent1'`. Default behavior (or when + `setStructureInKeys` is `false`) is to use the first level as key (`data` in + this case, and JSON stringify the rest as value. ### Form transformer diff --git a/src/index.test.ts b/src/index.test.ts index 3ca0cf7..768f05e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -19,9 +19,19 @@ test('should be an Integreat adapter', () => { // Tests -- prepareOptions test('should prepare options', () => { + const options = { setStructureInKeys: true } + const serviceId = 'entries' + const expected = { setStructureInKeys: true } + + const ret = adapter.prepareOptions(options, serviceId) + + assert.deepEqual(ret, expected) +}) + +test('should remove unknown options', () => { const options = { dontKnow: 'whatthisis' } const serviceId = 'entries' - const expected = {} + const expected = { setStructureInKeys: false } const ret = adapter.prepareOptions(options, serviceId) @@ -81,6 +91,35 @@ test('should normalize response data', async () => { assert.deepEqual(ret, expected) }) +test('should normalize form data serialized with setStructureInKeys', async () => { + const options = { setStructureInKeys: true } // We normalize this kind of data regardless of this flag, but it will often be set in these cases + const action = { + type: 'SET', + payload: { + type: 'entry', + data: 'items[0][value]=1&items[0][text]=Several+words+here&items[1][value]=2&items[1][text]=More+words+here', + }, + meta: { ident: { id: 'johnf' } }, + } + const expected = { + type: 'SET', + payload: { + type: 'entry', + data: { + items: [ + { value: 1, text: 'Several words here' }, + { value: 2, text: 'More words here' }, + ], + }, + }, + meta: { ident: { id: 'johnf' } }, + } + + const ret = await adapter.normalize(action, options) + + assert.deepEqual(ret, expected) +}) + // Tests -- serialize test('should serialize payload', async () => { @@ -155,3 +194,32 @@ test('should serialize first item when data is an array', async () => { assert.deepEqual(ret, expected) }) + +test('should serialize when setStructureInKeys is true', async () => { + const options = { setStructureInKeys: true } + const action = { + type: 'SET', + payload: { + type: 'entry', + data: { + items: [ + { value: 1, text: 'Several words here' }, + { value: 2, text: 'More words here' }, + ], + }, + }, + meta: { ident: { id: 'johnf' } }, + } + const expected = { + type: 'SET', + payload: { + type: 'entry', + data: 'items[0][value]=1&items[0][text]=Several+words+here&items[1][value]=2&items[1][text]=More+words+here', + }, + meta: { ident: { id: 'johnf' } }, + } + + const ret = await adapter.serialize(action, options) + + assert.deepEqual(ret, expected) +}) diff --git a/src/index.ts b/src/index.ts index ead83e3..15f549e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,10 @@ import parseFormData from './utils/parseFormData.js' import stringifyFormData from './utils/stringifyFormData.js' import type { Action, Adapter } from 'integreat' +export interface Options extends Record { + setStructureInKeys?: boolean +} + const firstIfArray = (data: unknown) => (Array.isArray(data) ? data[0] : data) const setActionData = ( @@ -23,20 +27,26 @@ const setActionData = ( }) const adapter: Adapter = { - prepareOptions(_options, _serviceId) { - return {} + prepareOptions({ setStructureInKeys = false }: Options, _serviceId) { + return { setStructureInKeys } }, - async normalize(action, _options) { + async normalize(action, _options: Options) { const payloadData = parseFormData(action.payload.data) const responseData = parseFormData(action.response?.data) return setActionData(action, payloadData, responseData) }, - async serialize(action, _options) { - const payloadData = stringifyFormData(firstIfArray(action.payload.data)) - const responseData = stringifyFormData(firstIfArray(action.response?.data)) + async serialize(action, { setStructureInKeys }: Options) { + const payloadData = stringifyFormData( + firstIfArray(action.payload.data), + setStructureInKeys, + ) + const responseData = stringifyFormData( + firstIfArray(action.response?.data), + setStructureInKeys, + ) return setActionData(action, payloadData, responseData) }, diff --git a/src/utils/parseFormData.test.ts b/src/utils/parseFormData.test.ts index 28d9522..658c023 100644 --- a/src/utils/parseFormData.test.ts +++ b/src/utils/parseFormData.test.ts @@ -76,6 +76,37 @@ test('should normalize form data with objects', () => { assert.deepEqual(ret, expected) }) +test('should normalize keys with structure', () => { + const data = 'value=1&object[id]=ent1&object[type]=entry' + const expected = { + value: 1, + object: { id: 'ent1', type: 'entry' }, + } + + const ret = parseFormData(data) + + assert.deepEqual(ret, expected) +}) + +test('should normalize keys with structure with many levels', () => { + const data = + 'value=1&object[array][0][id]=ent1&object[array][0][type]=entry&object[array][0][tags][0]=news&object[array][0][tags][1]=politics&object[array][1][id]=ent2&object[array][1][type]=entry&object[array][1][tags][0]=sports&empty' + const expected = { + value: 1, + object: { + array: [ + { id: 'ent1', type: 'entry', tags: ['news', 'politics'] }, + { id: 'ent2', type: 'entry', tags: ['sports'] }, + ], + }, + empty: undefined, + } + + const ret = parseFormData(data) + + assert.deepEqual(ret, expected) +}) + test('should keep date string as string', () => { const data = 'value=1&date=2024-05-11T16%3A43%3A11.000Z' const expected = { diff --git a/src/utils/parseFormData.ts b/src/utils/parseFormData.ts index 505908e..049ceda 100644 --- a/src/utils/parseFormData.ts +++ b/src/utils/parseFormData.ts @@ -1,3 +1,5 @@ +import { isObject } from './is.js' + const parseObject = (value: string) => { try { return JSON.parse(value) @@ -13,21 +15,56 @@ const parseValue = (value?: string) => ? parseObject(decodeURIComponent(fixLineBreak(value)).replace(/\+/g, ' ')) : undefined -function reducePair( - obj: Record, - [key, value]: [string, string | undefined], +function prepareKeyPart(part: string) { + if (part === ']') { + return '' + } + const num = Number.parseInt(part, 10) + return Number.isNaN(num) + ? part.endsWith(']') + ? part.slice(0, -1) + : part + : num +} + +function parseKey(key: string): (string | number)[] { + return key.split('[').map(prepareKeyPart) +} + +function ensureArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : value === undefined ? [] : [value] +} + +function ensureObject(value: unknown): Record { + return isObject(value) ? value : {} +} + +function setOnTarget( + target: unknown, + keys: (string | number)[], + value: string | undefined, ) { - if (key.endsWith('[]')) { - // When key ends in brackets, we have an array. This may be one of several - // keys with items for the array, so combine them. - const actualKey = key.slice(0, key.length - 2) - const existingValue = obj[actualKey] // eslint-disable-line security/detect-object-injection - const existingArray = Array.isArray(existingValue) ? existingValue : [] - return { ...obj, [actualKey]: [...existingArray, parseValue(value)] } + const [key, ...restKeys] = keys + const isArr = key === '' || typeof key === 'number' + const currentTarget = isArr ? ensureArray(target) : ensureObject(target) + const nextTarget = + key === '' ? undefined : currentTarget[key as keyof typeof currentTarget] + const nextValue = + restKeys.length === 0 ? value : setOnTarget(nextTarget, restKeys, value) + if (key === '') { + ;(currentTarget as unknown[]).push(nextValue) } else { - // Parse a single value. - return { ...obj, [key]: parseValue(value) } + ;(currentTarget as unknown[])[key as number] = nextValue // This handles both arrays and object, but we have forced the typing to array, just to satisfy TS without writing extra logic. } + return currentTarget +} + +function reducePair( + target: unknown, + [key, value]: [string, string | undefined], +): unknown[] | Record { + const keys = parseKey(key) + return setOnTarget(target, keys, parseValue(value)) } export default function parseFormData(data: unknown) { @@ -35,7 +72,7 @@ export default function parseFormData(data: unknown) { return data .split('&') .map((pair) => pair.split('=') as [string, string | undefined]) - .reduce(reducePair, {}) + .reduce(reducePair, undefined) } else { return undefined } diff --git a/src/utils/stringifyFormData.test.ts b/src/utils/stringifyFormData.test.ts index 1d6cf7d..c04fecb 100644 --- a/src/utils/stringifyFormData.test.ts +++ b/src/utils/stringifyFormData.test.ts @@ -92,6 +92,56 @@ test('should serialize object', () => { assert.equal(ret, expected) }) +test('should serialize object when setStructureInKeys is true', () => { + const setStructureInKeys = true + const data = { + value: 1, + object: { id: 'ent1', type: 'entry' }, + } + const expected = 'value=1&object[id]=ent1&object[type]=entry' + + const ret = stringifyFormData(data, setStructureInKeys) + + assert.equal(ret, expected) +}) + +test('should serialize array of objects when setStructureInKeys is true', () => { + const setStructureInKeys = true + const data = { + value: 1, + array: [ + { id: 'ent1', type: 'entry' }, + { id: 'ent2', type: 'entry' }, + ], + } + const expected = + 'value=1&array[0][id]=ent1&array[0][type]=entry&array[1][id]=ent2&array[1][type]=entry' + + const ret = stringifyFormData(data, setStructureInKeys) + + assert.equal(ret, expected) +}) + +test('should serialize array and objects over several levels when setStructureInKeys is true', () => { + const setStructureInKeys = true + const data = { + value: 1, + object: { + array: [ + { id: 'ent1', type: 'entry', tags: ['news', 'politics'] }, + { id: 'ent2', type: 'entry', tags: ['sports'] }, + ], + }, + empty: undefined, + } + const expected = + 'value=1&object[array][0][id]=ent1&object[array][0][type]=entry&object[array][0][tags][0]=news&object[array][0][tags][1]=politics&object[array][1][id]=ent2&object[array][1][type]=entry&object[array][1][tags][0]=sports&empty' + + const ret = stringifyFormData(data, setStructureInKeys) + + assert.equal(ret, expected) +}) + test('should serialize object with one key', () => { const data = { text: 'Several words here', diff --git a/src/utils/stringifyFormData.ts b/src/utils/stringifyFormData.ts index 5b3aae2..5632e9b 100644 --- a/src/utils/stringifyFormData.ts +++ b/src/utils/stringifyFormData.ts @@ -13,19 +13,38 @@ const fixLineBreak = (value: unknown) => const formatValue = (value: unknown) => encodeURIComponent(formatObject(fixLineBreak(value))).replace(/%20/g, '+') +const formatKeyValue = (key: string, value: unknown) => + value === undefined ? key : `${key}=${formatValue(value)}` + +function moveStructureToKeys([key, value]: [string, unknown]): + | string + | string[] { + if (Array.isArray(value) || isObject(value)) { + return Object.entries(value).flatMap(([k, v]) => + moveStructureToKeys([`${key}[${k}]`, v]), + ) + } else { + return formatKeyValue(key, value) + } +} + function mapEntry([key, value]: [string, unknown]) { if (Array.isArray(value)) { // Return one key (with bracket postfix) for each value in an array - return value.map((val) => `${key}[]=${formatValue(val)}`) + return value.map((val) => formatKeyValue(`${key}[]`, val)) } else { // Format a single value - return value === undefined ? key : `${key}=${formatValue(value)}` + return formatKeyValue(key, value) } } -export default function stringifyFormData(data?: unknown) { +export default function stringifyFormData( + data?: unknown, + setStructureInKeys = false, +) { if (isObject(data)) { - return Object.entries(data).flatMap(mapEntry).join('&') + const fn = setStructureInKeys ? moveStructureToKeys : mapEntry + return Object.entries(data).flatMap(fn).join('&') } else { return undefined }