@@ -77,3 +77,7 @@ See [docs folder](./docs) for more details. | |||
https://url.spec.whatwg.org/#urlencoded-parsing | |||
https://url.spec.whatwg.org/#interface-urlsearchparams | |||
- 303 See Other | |||
https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303 |
@@ -47,7 +47,7 @@ export type RequestDecorator = (req: RequestContext) => RequestContext | Promise | |||
export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator; | |||
// TODO put this in HTTP | |||
export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; | |||
export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'QUERY'; | |||
export interface AllowedMiddlewareSpecification<Schema extends BaseSchema = BaseSchema> { | |||
method: Method; | |||
@@ -71,3 +71,5 @@ export const getAllowString = (middlewares: AllowedMiddlewareSpecification[]) => | |||
const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); | |||
return allowedMethods.join(','); | |||
} | |||
export * from './data-source'; |
@@ -1,6 +1,6 @@ | |||
import * as v from 'valibot'; | |||
import {Resource} from '../../common'; | |||
import {DataSourceQueryExpression} from './queries'; | |||
import {DataSourceQuery} from './queries'; | |||
type IsCreated = boolean; | |||
@@ -11,7 +11,7 @@ type DeleteResult = unknown; | |||
export interface DataSource< | |||
ItemData extends object = object, | |||
ID extends unknown = unknown, | |||
Query extends DataSourceQueryExpression = DataSourceQueryExpression, | |||
Query extends DataSourceQuery = DataSourceQuery, | |||
> { | |||
initialize(): Promise<unknown>; | |||
getTotalCount?(query?: Query): Promise<TotalCount>; | |||
@@ -36,3 +36,5 @@ export interface ResourceIdConfig<IdSchema extends v.BaseSchema> { | |||
export interface GenerationStrategy { | |||
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>; | |||
} | |||
export * from './queries'; |
@@ -1,49 +1,60 @@ | |||
export const DIRECTIVE_MAP = { | |||
'=': 'equals', | |||
'!=': 'not-equals', | |||
'>=': 'greater-than-equal', | |||
'<=': 'less-than-equal', | |||
'>': 'greater-than', | |||
'<': 'less-than', | |||
}; | |||
export const DATA_SOURCE_QUERY_OPERATORS = Object.keys(DIRECTIVE_MAP) as (keyof typeof DIRECTIVE_MAP)[]; | |||
export type DataSourceQueryOperator = typeof DATA_SOURCE_QUERY_OPERATORS[number]; | |||
export interface DataSourceQueryExpression { | |||
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; | |||
rhs: string | number | boolean | RegExp; | |||
} | |||
export interface DataSourceQueryOrGrouping { | |||
interface DataSourceQueryOrGrouping { | |||
type: 'or'; | |||
expressions: DataSourceQueryExpression[]; | |||
} | |||
export interface DataSourceQueryAndGrouping { | |||
interface DataSourceQueryAndGrouping { | |||
type: 'and'; | |||
expressions: DataSourceQueryOrGrouping[]; | |||
} | |||
export type DataSourceQuery = DataSourceQueryAndGrouping; | |||
interface ProcessEntryBase { | |||
type: string; | |||
} | |||
export interface ProcessEntryString extends ProcessEntryBase { | |||
const AVAILABLE_MATCH_TYPES = [ | |||
'startsWith', | |||
'endsWith', | |||
'includes', | |||
'regexp', | |||
] as const; | |||
type MatchType = typeof AVAILABLE_MATCH_TYPES[number]; | |||
interface ProcessEntryString extends ProcessEntryBase { | |||
type: 'string'; | |||
startsWith?: boolean; | |||
endsWith?: boolean; | |||
caseInsensitive?: boolean; | |||
includes?: boolean; | |||
matchType?: MatchType; | |||
caseInsensitiveMatch?: boolean; | |||
} | |||
export interface ProcessEntryNumber extends ProcessEntryBase { | |||
interface ProcessEntryNumber extends ProcessEntryBase { | |||
type: 'number'; | |||
decimal?: boolean; | |||
} | |||
export interface ProcessEntryBoolean extends ProcessEntryBase { | |||
interface ProcessEntryBoolean extends ProcessEntryBase { | |||
type: 'boolean'; | |||
truthyStrings?: string[]; | |||
} | |||
@@ -52,13 +63,19 @@ export type ProcessEntry = ProcessEntryString | ProcessEntryNumber | ProcessEntr | |||
interface ConvertToDataSourceQueryCollectionOptions { | |||
processEntries: Record<string, ProcessEntry>; | |||
//coerceValues: CoerceValues; | |||
//stringSearch: StringSearch; | |||
} | |||
const normalizeRhs = (rhs: string, coerceValues?: ProcessEntry) => { | |||
const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<string, ProcessEntry>) => { | |||
const coerceValues = processEntriesMap?.[lhs] ?? { | |||
type: 'string' | |||
}; | |||
if (coerceValues?.type === 'number') { | |||
return Number(rhs); | |||
return { | |||
lhs, | |||
operator: '=', | |||
rhs: Number(rhs) | |||
} as DataSourceQueryExpression; | |||
} | |||
if (coerceValues?.type === 'boolean') { | |||
@@ -67,15 +84,61 @@ const normalizeRhs = (rhs: string, coerceValues?: ProcessEntry) => { | |||
'true', | |||
]; | |||
return truthyStrings.includes(rhs); | |||
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; | |||
} | |||
return rhs?.toString() ?? ''; | |||
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 convertToDataSourceQueryCollection = ( | |||
export const convertToDataSourceQuery = ( | |||
q: URLSearchParams, | |||
options = {} as ConvertToDataSourceQueryCollectionOptions, | |||
): DataSourceQueryAndGrouping => { | |||
@@ -87,7 +150,7 @@ export const convertToDataSourceQueryCollection = ( | |||
expressions: [], | |||
}; | |||
const existingLhs = existingOr.expressions.find((ex) => ex.lhs === key); | |||
const rhs = normalizeRhs(value, options.processEntries?.[key]); | |||
const newExpression = normalizeRhs(key, value, options.processEntries); | |||
if (typeof existingLhs === 'undefined') { | |||
return { | |||
@@ -97,11 +160,7 @@ export const convertToDataSourceQueryCollection = ( | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: key, | |||
operator: '=', | |||
rhs, | |||
}, | |||
newExpression, | |||
], | |||
}, | |||
], | |||
@@ -117,11 +176,7 @@ export const convertToDataSourceQueryCollection = ( | |||
...existingOr, | |||
expressions: [ | |||
...(existingOr.expressions ?? []), | |||
{ | |||
lhs: key, | |||
operator: '=', | |||
rhs, | |||
}, | |||
newExpression, | |||
], | |||
} | |||
)), | |||
@@ -1,6 +1,6 @@ | |||
import { constants } from 'http2'; | |||
import * as v from 'valibot'; | |||
import {Middleware} from '../../../common'; | |||
import {Middleware, convertToDataSourceQuery} from '../../../common'; | |||
import {ErrorPlainResponse, PlainResponse} from '../response'; | |||
import assert from 'assert'; | |||
import { | |||
@@ -10,16 +10,19 @@ import { | |||
PatchContentType, | |||
} from '../../../../common'; | |||
// TODO add handleQueryCollection() | |||
export const handleGetCollection: Middleware = async (req, res) => { | |||
const { query, resource, backend } = req; | |||
let data: v.Output<typeof resource.schema>[]; | |||
let totalItemCount: number | undefined; | |||
try { | |||
// TODO querying mechanism | |||
data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource | |||
// check which attributes have specifics on the queries (e.g. fuzzy search on strings) | |||
const dataSourceQuery = convertToDataSourceQuery(query); | |||
data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource | |||
if (backend!.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(query); | |||
totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery); | |||
} | |||
} catch (cause) { | |||
throw new ErrorPlainResponse( | |||
@@ -18,7 +18,7 @@ export interface ClientState { | |||
export interface ClientBuilder { | |||
language(languageCode: ClientState['language']['name']): this; | |||
charset(charset: ClientState['charset']['name']): this; | |||
mediaTyoe(mediaType: ClientState['mediaType']['name']): this; | |||
mediaType(mediaType: ClientState['mediaType']['name']): this; | |||
} | |||
export interface CreateClientParams { | |||
@@ -34,7 +34,7 @@ export const createClient = (params: CreateClientParams) => { | |||
}; | |||
return { | |||
mediaTyoe(mediaTypeName) { | |||
mediaType(mediaTypeName) { | |||
const mediaType = clientState.app.mediaTypes.get(mediaTypeName); | |||
clientState.mediaType = mediaType ?? FALLBACK_MEDIA_TYPE; | |||
return this; | |||
@@ -1,10 +1,10 @@ | |||
import {describe, expect, it} from 'vitest'; | |||
import { convertToDataSourceQueryCollection } from '../../src/backend'; | |||
import { convertToDataSourceQuery } from '../../src/backend'; | |||
describe('query', () => { | |||
it('returns a data source query collection from search params', () => { | |||
const q = new URLSearchParams(); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
const collection = convertToDataSourceQuery(q); | |||
expect(collection.expressions).toBeInstanceOf(Array); | |||
}); | |||
@@ -13,7 +13,7 @@ describe('query', () => { | |||
attr: '2', | |||
attr2: '2', | |||
}); | |||
const collection = convertToDataSourceQueryCollection( | |||
const collection = convertToDataSourceQuery( | |||
q, | |||
{ | |||
processEntries: { | |||
@@ -57,7 +57,7 @@ describe('query', () => { | |||
attr3: 'true', | |||
attr4: 'false', | |||
}); | |||
const collection = convertToDataSourceQueryCollection( | |||
const collection = convertToDataSourceQuery( | |||
q, | |||
{ | |||
processEntries: { | |||
@@ -121,7 +121,7 @@ describe('query', () => { | |||
const q = new URLSearchParams({ | |||
attr: 'foo' | |||
}); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
const collection = convertToDataSourceQuery(q); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
@@ -144,7 +144,7 @@ describe('query', () => { | |||
['attr', 'foo'], | |||
['attr2', 'bar'], | |||
]); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
const collection = convertToDataSourceQuery(q); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
@@ -177,7 +177,7 @@ describe('query', () => { | |||
['attr', 'foo'], | |||
['attr', 'bar'], | |||
]); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
const collection = convertToDataSourceQuery(q); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
@@ -208,7 +208,7 @@ describe('query', () => { | |||
['attr', 'bar'], | |||
['attr2', 'baz'], | |||
]); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
const collection = convertToDataSourceQuery(q); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||