Skip to content

Commit

Permalink
Implement serializing and normalizing
Browse files Browse the repository at this point in the history
  • Loading branch information
kjellmorten committed Sep 19, 2024
1 parent b6d0933 commit d25c6c6
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 25 deletions.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
70 changes: 69 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)
})
22 changes: 16 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
setStructureInKeys?: boolean
}

const firstIfArray = (data: unknown) => (Array.isArray(data) ? data[0] : data)

const setActionData = (
Expand All @@ -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)
},
Expand Down
31 changes: 31 additions & 0 deletions src/utils/parseFormData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
63 changes: 50 additions & 13 deletions src/utils/parseFormData.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isObject } from './is.js'

const parseObject = (value: string) => {
try {
return JSON.parse(value)
Expand All @@ -13,29 +15,64 @@ const parseValue = (value?: string) =>
? parseObject(decodeURIComponent(fixLineBreak(value)).replace(/\+/g, ' '))
: undefined

function reducePair(
obj: Record<string, unknown>,
[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<string, unknown> {
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<string, unknown> {
const keys = parseKey(key)
return setOnTarget(target, keys, parseValue(value))
}

export default function parseFormData(data: unknown) {
if (typeof data === 'string') {
return data
.split('&')
.map((pair) => pair.split('=') as [string, string | undefined])
.reduce(reducePair, {})
.reduce(reducePair, undefined)
} else {
return undefined
}
Expand Down
50 changes: 50 additions & 0 deletions src/utils/stringifyFormData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
27 changes: 23 additions & 4 deletions src/utils/stringifyFormData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down

0 comments on commit d25c6c6

Please sign in to comment.