Include QUERY method when fetch collection permission is granted for a resource.master
@@ -1,6 +1,5 @@ | |||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {Resource} from '../../common'; | |||||
import {DataSourceQuery} from './queries'; | |||||
import {Resource, DataSourceQuery} from '../../common'; | |||||
type IsCreated = boolean; | type IsCreated = boolean; | ||||
@@ -36,5 +35,3 @@ export interface ResourceIdConfig<IdSchema extends v.BaseSchema> { | |||||
export interface GenerationStrategy { | export interface GenerationStrategy { | ||||
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>; | (dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>; | ||||
} | } | ||||
export * from './queries'; |
@@ -1,2 +1 @@ | |||||
export * from './common'; | export * from './common'; | ||||
export * from './queries'; |
@@ -18,9 +18,10 @@ import { | |||||
getAcceptPostString, | getAcceptPostString, | ||||
LanguageDefaultErrorStatusMessageKey, | LanguageDefaultErrorStatusMessageKey, | ||||
PATCH_CONTENT_MAP_TYPE, PATCH_CONTENT_TYPES, | PATCH_CONTENT_MAP_TYPE, PATCH_CONTENT_TYPES, | ||||
PatchContentType, | |||||
PatchContentType, queryMediaTypes, | |||||
Resource, | Resource, | ||||
} from '../../../common'; | } from '../../../common'; | ||||
import {DataSource} from '../../data-source'; | |||||
import { | import { | ||||
handleGetRoot, handleOptions, | handleGetRoot, handleOptions, | ||||
} from './handlers/default'; | } from './handlers/default'; | ||||
@@ -31,6 +32,7 @@ import { | |||||
handleGetCollection, | handleGetCollection, | ||||
handleGetItem, | handleGetItem, | ||||
handlePatchItem, | handlePatchItem, | ||||
handleQueryCollection, | |||||
} from './handlers/resource'; | } from './handlers/resource'; | ||||
import {getBody, isTextMediaType} from './utils'; | import {getBody, isTextMediaType} from './utils'; | ||||
import {decorateRequestWithBackend} from './decorators/backend'; | import {decorateRequestWithBackend} from './decorators/backend'; | ||||
@@ -38,7 +40,6 @@ import {decorateRequestWithMethod} from './decorators/method'; | |||||
import {decorateRequestWithUrl} from './decorators/url'; | import {decorateRequestWithUrl} from './decorators/url'; | ||||
import {ErrorPlainResponse, PlainResponse} from './response'; | import {ErrorPlainResponse, PlainResponse} from './response'; | ||||
import EventEmitter from 'events'; | import EventEmitter from 'events'; | ||||
import {DataSource} from '../../data-source'; | |||||
type RequiredResource = Required<Pick<RequestContext, 'resource'>>['resource']; | type RequiredResource = Required<Pick<RequestContext, 'resource'>>['resource']; | ||||
@@ -112,6 +113,11 @@ const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<BaseRes | |||||
}; | }; | ||||
// TODO add a way to define custom middlewares | // TODO add a way to define custom middlewares | ||||
const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [ | const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [ | ||||
{ | |||||
method: 'QUERY', | |||||
middleware: handleQueryCollection, | |||||
allowed: (resource) => resource.state.canFetchCollection, | |||||
}, | |||||
{ | { | ||||
method: 'GET', | method: 'GET', | ||||
middleware: handleGetCollection, | middleware: handleGetCollection, | ||||
@@ -211,7 +217,55 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
return currentHandlerState; | return currentHandlerState; | ||||
} | } | ||||
if (typeof constructBodySchema === 'function') { | |||||
if (effectiveMethod === 'QUERY') { | |||||
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; | |||||
const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';'); | |||||
const mediaType = fragments[0]; | |||||
const charsetParam = ( | |||||
fragments | |||||
.map((s) => s.trim()) | |||||
.find((f) => f.startsWith('charset=')) | |||||
?? ( | |||||
isTextMediaType(mediaType) | |||||
? 'charset=utf-8' | |||||
: 'charset=binary' | |||||
) | |||||
); | |||||
const [_charsetKey, charsetRaw] = charsetParam.split('=').map((s) => s.trim()); | |||||
const charset = ( | |||||
( | |||||
(charsetRaw.startsWith('"') && charsetRaw.endsWith('"')) | |||||
|| (charsetRaw.startsWith("'") && charsetRaw.endsWith("'")) | |||||
) | |||||
? charsetRaw.slice(1, -1).trim() | |||||
: charsetRaw.trim() | |||||
) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary'); | |||||
const theBodyBuffer = await getBody(req); | |||||
const encodingPair = req.backend.app.charsets.get(charset); | |||||
if (typeof encodingPair === 'undefined') { | |||||
throw new ErrorPlainResponse('unableToDecodeResource', { | |||||
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, | |||||
res: theRes, | |||||
}); | |||||
} | |||||
const deserializerPair = Object.values(queryMediaTypes) | |||||
.find((a) => a.name === mediaType); | |||||
if (typeof deserializerPair === 'undefined') { | |||||
throw new ErrorPlainResponse( | |||||
'unableToDeserializeRequest', | |||||
{ | |||||
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, | |||||
res: theRes, | |||||
}, | |||||
); | |||||
} | |||||
const theBodyStr = encodingPair.decode(theBodyBuffer); | |||||
req.body = deserializerPair.deserialize(theBodyStr); | |||||
} else if (typeof constructBodySchema === 'function') { | |||||
const bodySchema = constructBodySchema(req.resource, req.resourceId); | const bodySchema = constructBodySchema(req.resource, req.resourceId); | ||||
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; | const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; | ||||
const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';'); | const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';'); | ||||
@@ -1,27 +1,77 @@ | |||||
import { constants } from 'http2'; | import { constants } from 'http2'; | ||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {Middleware, convertToDataSourceQuery} from '../../../common'; | |||||
import {Middleware} from '../../../common'; | |||||
import {ErrorPlainResponse, PlainResponse} from '../response'; | import {ErrorPlainResponse, PlainResponse} from '../response'; | ||||
import assert from 'assert'; | import assert from 'assert'; | ||||
import { | import { | ||||
applyDelta, | |||||
applyDelta, DataSourceQuery, | |||||
Delta, | Delta, | ||||
PATCH_CONTENT_MAP_TYPE, | PATCH_CONTENT_MAP_TYPE, | ||||
PatchContentType, | PatchContentType, | ||||
queryMediaTypes, | |||||
} from '../../../../common'; | } from '../../../../common'; | ||||
// TODO add handleQueryCollection() | // TODO add handleQueryCollection() | ||||
export const handleQueryCollection: Middleware = async (req, res) => { | |||||
const { | |||||
body, | |||||
resource, | |||||
backend, | |||||
} = req; | |||||
let data: v.Output<typeof resource.schema>[]; | |||||
let totalItemCount: number | undefined; | |||||
try { | |||||
// check which attributes have specifics on the queries (e.g. fuzzy search on strings) | |||||
const dataSourceQuery = body as DataSourceQuery; | |||||
data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource | |||||
if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { | |||||
totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery); | |||||
} | |||||
} catch (cause) { | |||||
throw new ErrorPlainResponse( | |||||
'unableToFetchResourceCollection', | |||||
{ | |||||
cause, | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
res, | |||||
} | |||||
); | |||||
} | |||||
const headers: Record<string, string> = {}; | |||||
if (typeof totalItemCount !== 'undefined') { | |||||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||||
} | |||||
return new PlainResponse({ | |||||
headers, | |||||
statusCode: constants.HTTP_STATUS_OK, | |||||
statusMessage: 'resourceCollectionFetched', | |||||
body: data, | |||||
res, | |||||
}); | |||||
}; | |||||
export const handleGetCollection: Middleware = async (req, res) => { | export const handleGetCollection: Middleware = async (req, res) => { | ||||
const { query, resource, backend } = req; | |||||
const { | |||||
// TODO don't turn query into URLSearchParams just yet | |||||
query, | |||||
resource, | |||||
backend, | |||||
} = req; | |||||
let data: v.Output<typeof resource.schema>[]; | let data: v.Output<typeof resource.schema>[]; | ||||
let totalItemCount: number | undefined; | let totalItemCount: number | undefined; | ||||
try { | try { | ||||
// check which attributes have specifics on the queries (e.g. fuzzy search on strings) | // check which attributes have specifics on the queries (e.g. fuzzy search on strings) | ||||
const dataSourceQuery = convertToDataSourceQuery(query); | |||||
const dataSourceQuery = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||||
query.toString() | |||||
// TODO compute processEntry options based on resource attribute metadata (e.g. fulltext, queryable attributes - firstname, lastname, middlename) | |||||
); | |||||
data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource | data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource | ||||
if (backend!.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { | |||||
if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { | |||||
totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery); | totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery); | ||||
} | } | ||||
} catch (cause) { | } catch (cause) { | ||||
@@ -8,6 +8,7 @@ export * from './delta'; | |||||
export * from './media-type'; | export * from './media-type'; | ||||
export * from './resource'; | export * from './resource'; | ||||
export * from './language'; | export * from './language'; | ||||
export * from './queries'; | |||||
export * as validation from './validation'; | export * as validation from './validation'; | ||||
export interface ContentNegotiation { | export interface ContentNegotiation { | ||||
@@ -37,6 +37,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ | |||||
...LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS, | ...LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS, | ||||
'ok', | 'ok', | ||||
'resourceCollectionFetched', | 'resourceCollectionFetched', | ||||
'resourceCollectionQueried', | |||||
'resourceFetched', | 'resourceFetched', | ||||
'resourceDeleted', | 'resourceDeleted', | ||||
'resourcePatched', | 'resourcePatched', | ||||
@@ -80,6 +81,7 @@ export const FALLBACK_LANGUAGE = { | |||||
ok: 'OK', | ok: 'OK', | ||||
provideOptions: 'Provide Options', | provideOptions: 'Provide Options', | ||||
resourceCollectionFetched: '$RESOURCE Collection Fetched', | resourceCollectionFetched: '$RESOURCE Collection Fetched', | ||||
resourceCollectionQueried: '$RESOURCE Collection Queried', | |||||
resourceFetched: '$RESOURCE Fetched', | resourceFetched: '$RESOURCE Fetched', | ||||
resourceNotFound: '$RESOURCE Not Found', | resourceNotFound: '$RESOURCE Not Found', | ||||
deleteNonExistingResource: 'Delete Non-Existing $RESOURCE', | deleteNonExistingResource: 'Delete Non-Existing $RESOURCE', | ||||
@@ -0,0 +1,43 @@ | |||||
const DATA_SOURCE_QUERY_OPERATORS = [ | |||||
'=', | |||||
'!=', | |||||
'>=', | |||||
'<=', | |||||
'>', | |||||
'<', | |||||
'LIKE', | |||||
'ILIKE', | |||||
'REGEXP', | |||||
] as const; | |||||
type DataSourceQueryOperator = typeof DATA_SOURCE_QUERY_OPERATORS[number]; | |||||
interface DataSourceQueryOperatorExpression { | |||||
lhs: string; | |||||
operator: DataSourceQueryOperator; | |||||
rhs: string | number | boolean | RegExp; | |||||
} | |||||
interface DataSourceQueryOrGrouping { | |||||
type: 'or'; | |||||
expressions: DataSourceQueryOperatorExpression[]; | |||||
} | |||||
interface DataSourceQueryAndGrouping { | |||||
type: 'and'; | |||||
expressions: DataSourceQueryOrGrouping[]; | |||||
} | |||||
export type DataSourceQueryExpression = DataSourceQueryOperatorExpression; | |||||
export type DataSourceQuery = DataSourceQueryAndGrouping; | |||||
export interface QueryMediaType< | |||||
Name extends string = string, | |||||
SerializeOptions extends {} = {}, | |||||
DeserializeOptions extends {} = {} | |||||
> { | |||||
name: Name; | |||||
serialize: (object: DataSourceQuery, opts?: SerializeOptions) => string; | |||||
deserialize: (s: string, opts?: DeserializeOptions) => DataSourceQuery; | |||||
} |
@@ -0,0 +1,2 @@ | |||||
export * from './common'; | |||||
export * as queryMediaTypes from './media-types'; |
@@ -0,0 +1,208 @@ | |||||
import {QueryMediaType, DataSourceQuery, DataSourceQueryExpression} from '../../common'; | |||||
interface ProcessEntryBase { | |||||
type: string; | |||||
} | |||||
const AVAILABLE_MATCH_TYPES = [ | |||||
'startsWith', | |||||
'endsWith', | |||||
'includes', | |||||
'regexp', | |||||
] as const; | |||||
type MatchType = typeof AVAILABLE_MATCH_TYPES[number]; | |||||
interface ProcessEntryString extends ProcessEntryBase { | |||||
type: 'string'; | |||||
matchType?: MatchType; | |||||
caseInsensitiveMatch?: boolean; | |||||
} | |||||
interface ProcessEntryNumber extends ProcessEntryBase { | |||||
type: 'number'; | |||||
decimal?: boolean; | |||||
} | |||||
interface ProcessEntryBoolean extends ProcessEntryBase { | |||||
type: 'boolean'; | |||||
truthyStrings?: string[]; | |||||
} | |||||
export type ProcessEntry = ProcessEntryString | ProcessEntryNumber | ProcessEntryBoolean; | |||||
export const name = 'application/x-www-form-urlencoded' as const; | |||||
const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<string, ProcessEntry>) => { | |||||
const coerceValues = processEntriesMap?.[lhs] ?? { | |||||
type: 'string' | |||||
}; | |||||
if (coerceValues?.type === 'number') { | |||||
return { | |||||
lhs, | |||||
operator: '=', | |||||
rhs: Number(rhs) | |||||
} as DataSourceQueryExpression; | |||||
} | |||||
if (coerceValues?.type === 'boolean') { | |||||
const truthyStrings = [ | |||||
...(coerceValues.truthyStrings ?? []).map((s) => s.trim().toLowerCase()), | |||||
'true', | |||||
]; | |||||
return { | |||||
lhs, | |||||
operator: '=', | |||||
rhs: truthyStrings.includes(rhs) | |||||
} as DataSourceQueryExpression; | |||||
} | |||||
if (coerceValues?.type === 'string') { | |||||
switch (coerceValues?.matchType) { | |||||
case 'startsWith': { | |||||
return { | |||||
lhs, | |||||
operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', | |||||
rhs: `%${rhs}`, | |||||
} as DataSourceQueryExpression; | |||||
} | |||||
case 'endsWith': { | |||||
return { | |||||
lhs, | |||||
operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', | |||||
rhs: `${rhs}%`, | |||||
} as DataSourceQueryExpression; | |||||
} | |||||
case 'includes': { | |||||
return { | |||||
lhs, | |||||
operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', | |||||
rhs: `%${rhs}%`, | |||||
} as DataSourceQueryExpression; | |||||
} | |||||
case 'regexp': { | |||||
return { | |||||
lhs, | |||||
operator: 'REGEXP', | |||||
rhs: new RegExp(rhs, coerceValues.caseInsensitiveMatch ? 'i' : ''), | |||||
} as DataSourceQueryExpression; | |||||
} | |||||
default: | |||||
break; | |||||
} | |||||
return { | |||||
lhs, | |||||
operator: '=', | |||||
rhs, | |||||
} as DataSourceQueryExpression; | |||||
} | |||||
const unknownCoerceValues = coerceValues as unknown as Record<string, string>; | |||||
throw new Error(`Invalid coercion type: ${unknownCoerceValues.type}`); | |||||
// this will be sent to the data source, e.g. the SQL query | |||||
// we can also make this function act as a "sanitizer" | |||||
} | |||||
interface SerializeOptions {} | |||||
interface DeserializeOptions { | |||||
processEntries?: Record<string, ProcessEntry>; | |||||
} | |||||
export const deserialize: QueryMediaType<typeof name, SerializeOptions, DeserializeOptions>['deserialize'] = ( | |||||
s: string, | |||||
options = {} as DeserializeOptions | |||||
) => { | |||||
const q = new URLSearchParams(s); | |||||
return Array.from(q.entries()).reduce( | |||||
(queries, [key, value]) => { | |||||
const existingOr = queries.expressions | |||||
.find((ex) => ex.expressions.some((ex2) => ex2.lhs === key)) ?? { | |||||
type: 'or', | |||||
expressions: [], | |||||
}; | |||||
const existingLhs = existingOr.expressions.find((ex) => ex.lhs === key); | |||||
const newExpression = normalizeRhs(key, value, options.processEntries); | |||||
if (typeof existingLhs === 'undefined') { | |||||
return { | |||||
...queries, | |||||
expressions: [ | |||||
...queries.expressions, | |||||
{ | |||||
type: 'or', | |||||
expressions: [ | |||||
newExpression, | |||||
], | |||||
}, | |||||
], | |||||
}; | |||||
} | |||||
return { | |||||
...queries, | |||||
expressions: queries.expressions.map((ex) => ( | |||||
ex.expressions.some((ex2) => ex2.lhs !== key) | |||||
? ex | |||||
: { | |||||
...existingOr, | |||||
expressions: [ | |||||
...(existingOr.expressions ?? []), | |||||
newExpression, | |||||
], | |||||
} | |||||
)), | |||||
}; | |||||
}, | |||||
{ | |||||
type: 'and', | |||||
expressions: [], | |||||
} as DataSourceQuery | |||||
) | |||||
}; | |||||
export const serialize: QueryMediaType<typeof name, SerializeOptions, DeserializeOptions>['serialize'] = (q: DataSourceQuery) => ( | |||||
new URLSearchParams( | |||||
q.expressions.map( | |||||
(ex) => ex.expressions.map((ex2) => { | |||||
if (ex2.rhs instanceof RegExp) { | |||||
if (ex2.operator !== 'REGEXP') { | |||||
throw new Error(`Invalid rhs given for operator: ${ex2.lhs} ${ex2.operator} <rhs>`); | |||||
} | |||||
return [ex2.lhs, ex2.rhs.toString()]; | |||||
} | |||||
switch (typeof ex2.rhs) { | |||||
case 'string': { | |||||
switch (ex2.operator) { | |||||
case 'ILIKE': | |||||
case 'LIKE': | |||||
return [ex2.lhs, ex2.rhs.replace(/^%+/, '').replace(/%+$/, '')]; | |||||
case '=': | |||||
return [ex2.lhs, ex2.rhs]; | |||||
default: | |||||
break; | |||||
} | |||||
throw new Error(`Invalid operator given for lhs: ${ex2.lhs} <op> ${ex2.rhs}`); | |||||
} | |||||
case 'number': { | |||||
return [ex2.lhs, ex2.rhs.toString()]; | |||||
} | |||||
case 'boolean': { | |||||
return [ex2.lhs, ex2.rhs ? 'true' : 'false']; | |||||
} | |||||
default: | |||||
break; | |||||
} | |||||
throw new Error(`Unknown type for rhs: ${ex2.lhs} ${ex2.operator} <rhs>`); | |||||
}) | |||||
) | |||||
.flat() | |||||
) | |||||
.toString() | |||||
); |
@@ -0,0 +1 @@ | |||||
export * as applicationXWwwFormUrlencoded from './application/x-www-form-urlencoded'; |
@@ -1,10 +1,10 @@ | |||||
import {describe, expect, it} from 'vitest'; | import {describe, expect, it} from 'vitest'; | ||||
import { convertToDataSourceQuery } from '../../src/backend'; | |||||
import { queryMediaTypes } from '../../src/common'; | |||||
describe('query', () => { | describe('query', () => { | ||||
it('returns a data source query collection from search params', () => { | it('returns a data source query collection from search params', () => { | ||||
const q = new URLSearchParams(); | const q = new URLSearchParams(); | ||||
const collection = convertToDataSourceQuery(q); | |||||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize(q.toString()); | |||||
expect(collection.expressions).toBeInstanceOf(Array); | expect(collection.expressions).toBeInstanceOf(Array); | ||||
}); | }); | ||||
@@ -13,8 +13,8 @@ describe('query', () => { | |||||
attr: '2', | attr: '2', | ||||
attr2: '2', | attr2: '2', | ||||
}); | }); | ||||
const collection = convertToDataSourceQuery( | |||||
q, | |||||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||||
q.toString(), | |||||
{ | { | ||||
processEntries: { | processEntries: { | ||||
attr: { | attr: { | ||||
@@ -57,8 +57,8 @@ describe('query', () => { | |||||
attr3: 'true', | attr3: 'true', | ||||
attr4: 'false', | attr4: 'false', | ||||
}); | }); | ||||
const collection = convertToDataSourceQuery( | |||||
q, | |||||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||||
q.toString(), | |||||
{ | { | ||||
processEntries: { | processEntries: { | ||||
attr: { | attr: { | ||||
@@ -121,7 +121,9 @@ describe('query', () => { | |||||
const q = new URLSearchParams({ | const q = new URLSearchParams({ | ||||
attr: 'foo' | attr: 'foo' | ||||
}); | }); | ||||
const collection = convertToDataSourceQuery(q); | |||||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||||
q.toString() | |||||
); | |||||
expect(collection).toEqual({ | expect(collection).toEqual({ | ||||
type: 'and', | type: 'and', | ||||
expressions: [ | expressions: [ | ||||
@@ -144,7 +146,9 @@ describe('query', () => { | |||||
['attr', 'foo'], | ['attr', 'foo'], | ||||
['attr2', 'bar'], | ['attr2', 'bar'], | ||||
]); | ]); | ||||
const collection = convertToDataSourceQuery(q); | |||||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||||
q.toString() | |||||
); | |||||
expect(collection).toEqual({ | expect(collection).toEqual({ | ||||
type: 'and', | type: 'and', | ||||
expressions: [ | expressions: [ | ||||
@@ -177,7 +181,9 @@ describe('query', () => { | |||||
['attr', 'foo'], | ['attr', 'foo'], | ||||
['attr', 'bar'], | ['attr', 'bar'], | ||||
]); | ]); | ||||
const collection = convertToDataSourceQuery(q); | |||||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||||
q.toString() | |||||
); | |||||
expect(collection).toEqual({ | expect(collection).toEqual({ | ||||
type: 'and', | type: 'and', | ||||
expressions: [ | expressions: [ | ||||
@@ -208,7 +214,9 @@ describe('query', () => { | |||||
['attr', 'bar'], | ['attr', 'bar'], | ||||
['attr2', 'baz'], | ['attr2', 'baz'], | ||||
]); | ]); | ||||
const collection = convertToDataSourceQuery(q); | |||||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||||
q.toString() | |||||
); | |||||
expect(collection).toEqual({ | expect(collection).toEqual({ | ||||
type: 'and', | type: 'and', | ||||
expressions: [ | expressions: [ | ||||