@@ -71,5 +71,3 @@ export const getAllowString = (middlewares: AllowedMiddlewareSpecification[]) => | |||||
const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); | const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); | ||||
return allowedMethods.join(','); | return allowedMethods.join(','); | ||||
} | } | ||||
export * from './data-source'; |
@@ -1,5 +1,5 @@ | |||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {Resource, DataSourceQuery} from '../../common'; | |||||
import {Resource, QueryAndGrouping} from '../common'; | |||||
type IsCreated = boolean; | type IsCreated = boolean; | ||||
@@ -7,6 +7,8 @@ type TotalCount = number; | |||||
type DeleteResult = unknown; | type DeleteResult = unknown; | ||||
export type DataSourceQuery = QueryAndGrouping; | |||||
export interface DataSource< | export interface DataSource< | ||||
ItemData extends object = object, | ItemData extends object = object, | ||||
ID extends unknown = unknown, | ID extends unknown = unknown, |
@@ -1 +0,0 @@ | |||||
export * from './common'; |
@@ -1,194 +0,0 @@ | |||||
const DATA_SOURCE_QUERY_OPERATORS = [ | |||||
'=', | |||||
'!=', | |||||
'>=', | |||||
'<=', | |||||
'>', | |||||
'<', | |||||
'LIKE', | |||||
'ILIKE', | |||||
'REGEXP' | |||||
] as const; | |||||
type DataSourceQueryOperator = typeof DATA_SOURCE_QUERY_OPERATORS[number]; | |||||
interface DataSourceQueryExpression { | |||||
lhs: string; | |||||
operator: DataSourceQueryOperator; | |||||
rhs: string | number | boolean | RegExp; | |||||
} | |||||
interface DataSourceQueryOrGrouping { | |||||
type: 'or'; | |||||
expressions: DataSourceQueryExpression[]; | |||||
} | |||||
interface DataSourceQueryAndGrouping { | |||||
type: 'and'; | |||||
expressions: DataSourceQueryOrGrouping[]; | |||||
} | |||||
export type DataSourceQuery = DataSourceQueryAndGrouping; | |||||
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; | |||||
interface ConvertToDataSourceQueryCollectionOptions { | |||||
processEntries: Record<string, ProcessEntry>; | |||||
} | |||||
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" | |||||
} | |||||
export const convertToDataSourceQuery = ( | |||||
q: URLSearchParams, | |||||
options = {} as ConvertToDataSourceQueryCollectionOptions, | |||||
): DataSourceQueryAndGrouping => { | |||||
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 DataSourceQueryAndGrouping | |||||
) | |||||
// all the queries are to be treated as "AND", as suggested by the & character to separate the query param entries | |||||
}; | |||||
// TODO add more operators |
@@ -2,4 +2,5 @@ export * from './core'; | |||||
export * from './common'; | export * from './common'; | ||||
export * from './data-source'; | export * from './data-source'; | ||||
// TODO publish to separate library | |||||
export * as http from './servers/http'; | export * as http from './servers/http'; |
@@ -1,13 +1,15 @@ | |||||
import {RequestDecorator} from '../../../../common'; | import {RequestDecorator} from '../../../../common'; | ||||
const METHOD_SPOOF_HEADER_NAME = 'x-original-method' as const; | |||||
const METHOD_SPOOF_ORIGINAL_METHOD = 'POST' as const; | |||||
const WHITELISTED_METHODS = [ | const WHITELISTED_METHODS = [ | ||||
'QUERY' | 'QUERY' | ||||
] as const; | ] as const; | ||||
export const decorateRequestWithMethod: RequestDecorator = (req) => { | export const decorateRequestWithMethod: RequestDecorator = (req) => { | ||||
req.method = req.method?.trim().toUpperCase() ?? ''; | req.method = req.method?.trim().toUpperCase() ?? ''; | ||||
if (req.method === 'POST') { | |||||
const spoofedMethod = req.headers['x-original-method']; | |||||
if (req.method === METHOD_SPOOF_ORIGINAL_METHOD) { | |||||
const spoofedMethod = req.headers[METHOD_SPOOF_HEADER_NAME]; | |||||
if (Array.isArray(spoofedMethod)) { | if (Array.isArray(spoofedMethod)) { | ||||
return req; | return req; | ||||
} | } | ||||
@@ -1,7 +1,12 @@ | |||||
export interface MediaType<Name extends string = string> { | |||||
export interface MediaType< | |||||
Name extends string = string, | |||||
T extends object = object, | |||||
SerializeOpts extends unknown[] = [], | |||||
DeserializeOpts extends unknown[] = [] | |||||
> { | |||||
name: Name; | name: Name; | ||||
serialize: <T>(object: T) => string; | |||||
deserialize: <T>(s: string) => T; | |||||
serialize: (object: T, ...args: SerializeOpts) => string; | |||||
deserialize: (s: string, ...args: DeserializeOpts) => T; | |||||
} | } | ||||
export const FALLBACK_MEDIA_TYPE = { | export const FALLBACK_MEDIA_TYPE = { | ||||
@@ -1,4 +1,6 @@ | |||||
const DATA_SOURCE_QUERY_OPERATORS = [ | |||||
import {MediaType} from '../media-type'; | |||||
const OPERATORS = [ | |||||
'=', | '=', | ||||
'!=', | '!=', | ||||
'>=', | '>=', | ||||
@@ -10,34 +12,40 @@ const DATA_SOURCE_QUERY_OPERATORS = [ | |||||
'REGEXP', | 'REGEXP', | ||||
] as const; | ] as const; | ||||
type DataSourceQueryOperator = typeof DATA_SOURCE_QUERY_OPERATORS[number]; | |||||
type QueryOperator = typeof OPERATORS[number]; | |||||
type QueryExpressionValue = string | number | boolean; | |||||
interface DataSourceQueryOperatorExpression { | |||||
interface QueryOperatorExpression { | |||||
lhs: string; | lhs: string; | ||||
operator: DataSourceQueryOperator; | |||||
rhs: string | number | boolean | RegExp; | |||||
operator: QueryOperator; | |||||
rhs: QueryExpressionValue | RegExp; | |||||
} | |||||
interface QueryFunctionExpression { | |||||
name: string; | |||||
args: QueryExpressionValue[]; | |||||
} | } | ||||
interface DataSourceQueryOrGrouping { | |||||
export type QueryAnyExpression = QueryOperatorExpression | QueryFunctionExpression; | |||||
export interface QueryOrGrouping { | |||||
type: 'or'; | type: 'or'; | ||||
expressions: DataSourceQueryOperatorExpression[]; | |||||
expressions: QueryAnyExpression[]; | |||||
} | } | ||||
interface DataSourceQueryAndGrouping { | |||||
export interface QueryAndGrouping { | |||||
type: 'and'; | type: 'and'; | ||||
expressions: DataSourceQueryOrGrouping[]; | |||||
expressions: QueryOrGrouping[]; | |||||
} | } | ||||
export type DataSourceQueryExpression = DataSourceQueryOperatorExpression; | |||||
export type DataSourceQuery = DataSourceQueryAndGrouping; | |||||
export interface QueryMediaType< | export interface QueryMediaType< | ||||
Name extends string = string, | Name extends string = string, | ||||
SerializeOptions extends {} = {}, | SerializeOptions extends {} = {}, | ||||
DeserializeOptions extends {} = {} | DeserializeOptions extends {} = {} | ||||
> { | |||||
name: Name; | |||||
serialize: (object: DataSourceQuery, opts?: SerializeOptions) => string; | |||||
deserialize: (s: string, opts?: DeserializeOptions) => DataSourceQuery; | |||||
} | |||||
> extends MediaType< | |||||
Name, | |||||
QueryAndGrouping, | |||||
[SerializeOptions], | |||||
[DeserializeOptions] | |||||
> {} |
@@ -1,4 +1,9 @@ | |||||
import {QueryMediaType, DataSourceQuery, DataSourceQueryExpression} from '../../common'; | |||||
import { | |||||
QueryMediaType, | |||||
QueryAndGrouping, | |||||
QueryAnyExpression, | |||||
QueryOrGrouping, | |||||
} from '../../common'; | |||||
interface ProcessEntryBase { | interface ProcessEntryBase { | ||||
type: string; | type: string; | ||||
@@ -34,16 +39,17 @@ export type ProcessEntry = ProcessEntryString | ProcessEntryNumber | ProcessEntr | |||||
export const name = 'application/x-www-form-urlencoded' as const; | export const name = 'application/x-www-form-urlencoded' as const; | ||||
const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<string, ProcessEntry>) => { | const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<string, ProcessEntry>) => { | ||||
const coerceValues = processEntriesMap?.[lhs] ?? { | |||||
const defaultCoerceValues = { | |||||
type: 'string' | type: 'string' | ||||
}; | |||||
} as ProcessEntry; | |||||
const coerceValues = processEntriesMap?.[lhs] ?? defaultCoerceValues; | |||||
if (coerceValues?.type === 'number') { | if (coerceValues?.type === 'number') { | ||||
return { | return { | ||||
lhs, | lhs, | ||||
operator: '=', | operator: '=', | ||||
rhs: Number(rhs) | rhs: Number(rhs) | ||||
} as DataSourceQueryExpression; | |||||
} as QueryAnyExpression; | |||||
} | } | ||||
if (coerceValues?.type === 'boolean') { | if (coerceValues?.type === 'boolean') { | ||||
@@ -56,7 +62,7 @@ const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<strin | |||||
lhs, | lhs, | ||||
operator: '=', | operator: '=', | ||||
rhs: truthyStrings.includes(rhs) | rhs: truthyStrings.includes(rhs) | ||||
} as DataSourceQueryExpression; | |||||
} as QueryAnyExpression; | |||||
} | } | ||||
if (coerceValues?.type === 'string') { | if (coerceValues?.type === 'string') { | ||||
@@ -66,28 +72,28 @@ const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<strin | |||||
lhs, | lhs, | ||||
operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', | operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', | ||||
rhs: `%${rhs}`, | rhs: `%${rhs}`, | ||||
} as DataSourceQueryExpression; | |||||
} as QueryAnyExpression; | |||||
} | } | ||||
case 'endsWith': { | case 'endsWith': { | ||||
return { | return { | ||||
lhs, | lhs, | ||||
operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', | operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', | ||||
rhs: `${rhs}%`, | rhs: `${rhs}%`, | ||||
} as DataSourceQueryExpression; | |||||
} as QueryAnyExpression; | |||||
} | } | ||||
case 'includes': { | case 'includes': { | ||||
return { | return { | ||||
lhs, | lhs, | ||||
operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', | operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', | ||||
rhs: `%${rhs}%`, | rhs: `%${rhs}%`, | ||||
} as DataSourceQueryExpression; | |||||
} as QueryAnyExpression; | |||||
} | } | ||||
case 'regexp': { | case 'regexp': { | ||||
return { | return { | ||||
lhs, | lhs, | ||||
operator: 'REGEXP', | operator: 'REGEXP', | ||||
rhs: new RegExp(rhs, coerceValues.caseInsensitiveMatch ? 'i' : ''), | rhs: new RegExp(rhs, coerceValues.caseInsensitiveMatch ? 'i' : ''), | ||||
} as DataSourceQueryExpression; | |||||
} as QueryAnyExpression; | |||||
} | } | ||||
default: | default: | ||||
break; | break; | ||||
@@ -97,12 +103,12 @@ const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<strin | |||||
lhs, | lhs, | ||||
operator: '=', | operator: '=', | ||||
rhs, | rhs, | ||||
} as DataSourceQueryExpression; | |||||
} as QueryAnyExpression; | |||||
} | } | ||||
const unknownCoerceValues = coerceValues as unknown as Record<string, string>; | const unknownCoerceValues = coerceValues as unknown as Record<string, string>; | ||||
throw new Error(`Invalid coercion type: ${unknownCoerceValues.type}`); | throw new Error(`Invalid coercion type: ${unknownCoerceValues.type}`); | ||||
// this will be sent to the data source, e.g. the SQL query | |||||
// this will be sent to the data source, e.g., the SQL query | |||||
// we can also make this function act as a "sanitizer" | // we can also make this function act as a "sanitizer" | ||||
} | } | ||||
@@ -112,19 +118,35 @@ interface DeserializeOptions { | |||||
processEntries?: Record<string, ProcessEntry>; | processEntries?: Record<string, ProcessEntry>; | ||||
} | } | ||||
export const deserialize: QueryMediaType<typeof name, SerializeOptions, DeserializeOptions>['deserialize'] = ( | |||||
s: string, | |||||
options = {} as DeserializeOptions | |||||
) => { | |||||
const doesGroupHaveExpression = (ex2: QueryAnyExpression, key: string) => { | |||||
if ('operator' in ex2) { | |||||
return ex2.lhs === key; | |||||
} | |||||
if ('name' in ex2) { | |||||
return ex2.name === key; | |||||
} | |||||
return false; | |||||
}; | |||||
export const deserialize: QueryMediaType< | |||||
typeof name, | |||||
SerializeOptions, | |||||
DeserializeOptions | |||||
>['deserialize'] = (s: string, options = {} as DeserializeOptions) => { | |||||
const q = new URLSearchParams(s); | const q = new URLSearchParams(s); | ||||
return Array.from(q.entries()).reduce( | return Array.from(q.entries()).reduce( | ||||
(queries, [key, value]) => { | (queries, [key, value]) => { | ||||
const existingOr = queries.expressions | |||||
.find((ex) => ex.expressions.some((ex2) => ex2.lhs === key)) ?? { | |||||
const defaultOr = { | |||||
type: 'or', | type: 'or', | ||||
expressions: [], | expressions: [], | ||||
}; | |||||
const existingLhs = existingOr.expressions.find((ex) => ex.lhs === key); | |||||
} as QueryOrGrouping; | |||||
const existingOr = queries.expressions.find((ex) => ( | |||||
ex.expressions.some((ex2) => doesGroupHaveExpression(ex2, key)) | |||||
)) ?? defaultOr; | |||||
const existingLhs = existingOr.expressions.find((ex) => doesGroupHaveExpression(ex, key)); | |||||
const newExpression = normalizeRhs(key, value, options.processEntries); | const newExpression = normalizeRhs(key, value, options.processEntries); | ||||
if (typeof existingLhs === 'undefined') { | if (typeof existingLhs === 'undefined') { | ||||
@@ -145,7 +167,7 @@ export const deserialize: QueryMediaType<typeof name, SerializeOptions, Deserial | |||||
return { | return { | ||||
...queries, | ...queries, | ||||
expressions: queries.expressions.map((ex) => ( | expressions: queries.expressions.map((ex) => ( | ||||
ex.expressions.some((ex2) => ex2.lhs !== key) | |||||
ex.expressions.some((ex2) => !doesGroupHaveExpression(ex2, key)) | |||||
? ex | ? ex | ||||
: { | : { | ||||
...existingOr, | ...existingOr, | ||||
@@ -160,49 +182,58 @@ export const deserialize: QueryMediaType<typeof name, SerializeOptions, Deserial | |||||
{ | { | ||||
type: 'and', | type: 'and', | ||||
expressions: [], | expressions: [], | ||||
} as DataSourceQuery | |||||
} as QueryAndGrouping | |||||
) | ) | ||||
}; | }; | ||||
export const serialize: QueryMediaType<typeof name, SerializeOptions, DeserializeOptions>['serialize'] = (q: DataSourceQuery) => ( | |||||
const serializeExpression = (ex2: QueryAnyExpression) => { | |||||
if ('name' in ex2) { | |||||
return [ex2.name, `(${ex2.args.map((s) => s.toString()).join(',')})`]; | |||||
} | |||||
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>`); | |||||
}; | |||||
export const serialize: QueryMediaType< | |||||
typeof name, | |||||
SerializeOptions, | |||||
DeserializeOptions | |||||
>['serialize'] = (q: QueryAndGrouping) => ( | |||||
new URLSearchParams( | 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() | |||||
q.expressions.flatMap((ex) => ( | |||||
ex.expressions.map((ex2) => serializeExpression(ex2)) | |||||
)) | |||||
) | ) | ||||
.toString() | .toString() | ||||
); | ); |