@@ -1,9 +1,7 @@ | |||||
import {dataSources, Resource} from '../../src'; | |||||
import {DataSource} from '../../src/backend/data-source'; | import {DataSource} from '../../src/backend/data-source'; | ||||
import {BaseDataSource} from '../../src/common/data-source'; | |||||
export const autoIncrement = async (dataSource: BaseDataSource) => { | |||||
const data = await (dataSource as DataSource).getMultiple() as Record<string, string>[]; | |||||
export const autoIncrement = async (dataSource: DataSource) => { | |||||
const data = await dataSource.getMultiple() as Record<string, string>[]; | |||||
const highestId = data.reduce<number>( | const highestId = data.reduce<number>( | ||||
(highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId), | (highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId), | ||||
@@ -16,5 +14,3 @@ export const autoIncrement = async (dataSource: BaseDataSource) => { | |||||
return 1; | return 1; | ||||
}; | }; | ||||
export const dataSource = (resource: Resource) => new dataSources.jsonlFile.DataSource(resource, 'examples/basic'); |
@@ -1,10 +1,10 @@ | |||||
import { | import { | ||||
application, | |||||
application, dataSources, | |||||
resource, | resource, | ||||
validation as v, | validation as v, | ||||
} from '../../src'; | } from '../../src'; | ||||
import {TEXT_SERIALIZER_PAIR} from './serializers'; | import {TEXT_SERIALIZER_PAIR} from './serializers'; | ||||
import {autoIncrement, dataSource} from './data-source'; | |||||
import {autoIncrement} from './data-source'; | |||||
const Piano = resource(v.object( | const Piano = resource(v.object( | ||||
{ | { | ||||
@@ -14,18 +14,18 @@ const Piano = resource(v.object( | |||||
)) | )) | ||||
.name('Piano') | .name('Piano') | ||||
.route('pianos') | .route('pianos') | ||||
.id('id', { | |||||
generationStrategy: autoIncrement, | |||||
serialize: (id) => id?.toString() ?? '0', | |||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | |||||
schema: v.number(), | |||||
}) | |||||
.canFetchItem() | .canFetchItem() | ||||
.canFetchCollection() | .canFetchCollection() | ||||
.canCreate() | .canCreate() | ||||
.canEmplace() | .canEmplace() | ||||
.canPatch() | .canPatch() | ||||
.canDelete(); | |||||
.canDelete() | |||||
.id('id', { | |||||
generationStrategy: autoIncrement, | |||||
serialize: (id) => id?.toString() ?? '0', | |||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | |||||
schema: v.number(), | |||||
}); | |||||
const User = resource(v.object( | const User = resource(v.object( | ||||
{ | { | ||||
@@ -37,15 +37,15 @@ const User = resource(v.object( | |||||
}, | }, | ||||
v.never() | v.never() | ||||
)) | )) | ||||
.name('User') | |||||
.route('users') | |||||
.fullText('bio') | |||||
.id('id' as const, { | .id('id' as const, { | ||||
generationStrategy: autoIncrement, | generationStrategy: autoIncrement, | ||||
serialize: (id) => id?.toString() ?? '0', | serialize: (id) => id?.toString() ?? '0', | ||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | ||||
schema: v.number(), | schema: v.number(), | ||||
}) | |||||
.name('User') | |||||
.route('users') | |||||
.fullText('bio'); | |||||
}); | |||||
const app = application({ | const app = application({ | ||||
name: 'piano-service', | name: 'piano-service', | ||||
@@ -55,7 +55,7 @@ const app = application({ | |||||
.resource(User); | .resource(User); | ||||
const backend = app.createBackend({ | const backend = app.createBackend({ | ||||
dataSource, | |||||
dataSource: new dataSources.jsonlFile.DataSource('examples/basic'), | |||||
}); | }); | ||||
const server = backend.createHttpServer({ | const server = backend.createHttpServer({ | ||||
@@ -1,12 +1,12 @@ | |||||
import {ApplicationState, ContentNegotiation, Resource} from '../common'; | |||||
import {BaseDataSource} from '../common/data-source'; | |||||
import {ApplicationState, ContentNegotiation} from '../common'; | |||||
import {DataSource} from './data-source'; | |||||
export interface BackendState { | export interface BackendState { | ||||
app: ApplicationState; | app: ApplicationState; | ||||
dataSource: (resource: Resource) => BaseDataSource; | |||||
dataSource: DataSource; | |||||
cn: ContentNegotiation; | cn: ContentNegotiation; | ||||
showTotalItemCountOnGetCollection: boolean; | showTotalItemCountOnGetCollection: boolean; | ||||
throws404OnDeletingNotFound: boolean; | |||||
throwsErrorOnDeletingNotFound: boolean; | |||||
checksSerializersOnDelete: boolean; | checksSerializersOnDelete: boolean; | ||||
showTotalItemCountOnCreateItem: boolean; | showTotalItemCountOnCreateItem: boolean; | ||||
} | } | ||||
@@ -1,25 +1,11 @@ | |||||
import * as v from 'valibot'; | |||||
import {ApplicationState, FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE, Resource} from '../common'; | import {ApplicationState, FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE, Resource} from '../common'; | ||||
import http from 'http'; | import http from 'http'; | ||||
import {createServer, CreateServerParams} from './http/server'; | import {createServer, CreateServerParams} from './http/server'; | ||||
import https from 'https'; | import https from 'https'; | ||||
import {BackendState} from './common'; | import {BackendState} from './common'; | ||||
import {BaseDataSource} from '../common/data-source'; | |||||
import {DataSource} from './data-source'; | import {DataSource} from './data-source'; | ||||
export interface BackendResource< | |||||
DataSourceType extends BaseDataSource = DataSource, | |||||
ResourceSchema extends v.BaseSchema = v.BaseSchema, | |||||
ResourceName extends string = string, | |||||
ResourceRouteName extends string = string, | |||||
IdAttr extends string = string, | |||||
IdSchema extends v.BaseSchema = v.BaseSchema | |||||
> extends Resource<ResourceSchema, ResourceName, ResourceRouteName, IdAttr, IdSchema> { | |||||
newId(dataSource: DataSourceType): string | number | unknown; | |||||
dataSource: DataSourceType; | |||||
} | |||||
export interface BackendBuilder<T extends BaseDataSource = BaseDataSource> { | |||||
export interface BackendBuilder<T extends DataSource = DataSource> { | |||||
showTotalItemCountOnGetCollection(b?: boolean): this; | showTotalItemCountOnGetCollection(b?: boolean): this; | ||||
showTotalItemCountOnCreateItem(b?: boolean): this; | showTotalItemCountOnCreateItem(b?: boolean): this; | ||||
checksSerializersOnDelete(b?: boolean): this; | checksSerializersOnDelete(b?: boolean): this; | ||||
@@ -30,7 +16,7 @@ export interface BackendBuilder<T extends BaseDataSource = BaseDataSource> { | |||||
export interface CreateBackendParams { | export interface CreateBackendParams { | ||||
app: ApplicationState; | app: ApplicationState; | ||||
dataSource: (resource: Resource) => BaseDataSource; | |||||
dataSource: DataSource; | |||||
} | } | ||||
export const createBackend = (params: CreateBackendParams) => { | export const createBackend = (params: CreateBackendParams) => { | ||||
@@ -44,7 +30,7 @@ export const createBackend = (params: CreateBackendParams) => { | |||||
}, | }, | ||||
showTotalItemCountOnGetCollection: false, | showTotalItemCountOnGetCollection: false, | ||||
showTotalItemCountOnCreateItem: false, | showTotalItemCountOnCreateItem: false, | ||||
throws404OnDeletingNotFound: false, | |||||
throwsErrorOnDeletingNotFound: false, | |||||
checksSerializersOnDelete: false, | checksSerializersOnDelete: false, | ||||
}; | }; | ||||
@@ -58,7 +44,7 @@ export const createBackend = (params: CreateBackendParams) => { | |||||
return this; | return this; | ||||
}, | }, | ||||
throwsErrorOnDeletingNotFound(b = true) { | throwsErrorOnDeletingNotFound(b = true) { | ||||
backendState.throws404OnDeletingNotFound = b; | |||||
backendState.throwsErrorOnDeletingNotFound = b; | |||||
return this; | return this; | ||||
}, | }, | ||||
checksSerializersOnDelete(b = true) { | checksSerializersOnDelete(b = true) { | ||||
@@ -1,13 +1,34 @@ | |||||
import {BaseDataSource} from '../common/data-source'; | |||||
import * as v from 'valibot'; | |||||
import {Resource} from '../common'; | |||||
export interface DataSource<T = object, Q = object> extends BaseDataSource { | |||||
type IsCreated = boolean; | |||||
type TotalCount = number; | |||||
type DeleteResult = unknown; | |||||
export interface DataSource<Schema = object, Query = object, ID = string> { | |||||
initialize(): Promise<unknown>; | initialize(): Promise<unknown>; | ||||
getTotalCount?(query?: Q): Promise<number>; | |||||
getMultiple(query?: Q): Promise<T[]>; | |||||
getById(id: string): Promise<T | null>; | |||||
getSingle?(query?: Q): Promise<T | null>; | |||||
create(data: T): Promise<T>; | |||||
delete(id: string): Promise<unknown>; | |||||
emplace(id: string, data: T): Promise<[T, boolean]>; | |||||
patch(id: string, data: Partial<T>): Promise<T | null>; | |||||
getTotalCount?(query?: Query): Promise<TotalCount>; | |||||
getMultiple(query?: Query): Promise<Schema[]>; | |||||
getById(id: ID): Promise<Schema | null>; | |||||
getSingle?(query?: Query): Promise<Schema | null>; | |||||
create(data: Schema): Promise<Schema>; | |||||
delete(id: ID): Promise<DeleteResult>; | |||||
emplace(id: ID, data: Schema): Promise<[Schema, IsCreated]>; | |||||
patch(id: ID, data: Partial<Schema>): Promise<Schema | null>; | |||||
prepareResource(resource: Resource): void; | |||||
newId(): Promise<ID>; | |||||
} | |||||
export interface ResourceIdConfig<IdSchema extends v.BaseSchema> { | |||||
generationStrategy: GenerationStrategy; | |||||
serialize: (id: unknown) => string; | |||||
deserialize: (id: string) => v.Output<IdSchema>; | |||||
schema: IdSchema; | |||||
} | |||||
export interface GenerationStrategy { | |||||
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>; | |||||
} | } |
@@ -1,18 +1,64 @@ | |||||
import {readFile, writeFile} from 'fs/promises'; | import {readFile, writeFile} from 'fs/promises'; | ||||
import {join} from 'path'; | import {join} from 'path'; | ||||
import {DataSource as DataSourceInterface} from '../data-source'; | |||||
import {DataSource as DataSourceInterface, ResourceIdConfig} from '../data-source'; | |||||
import {Resource} from '../..'; | import {Resource} from '../..'; | ||||
import * as v from 'valibot'; | |||||
declare module '../..' { | |||||
interface BaseResourceState { | |||||
idAttr: string; | |||||
idConfig: ResourceIdConfig<v.BaseSchema> | |||||
} | |||||
interface Resource< | |||||
Schema extends v.BaseSchema = v.BaseSchema, | |||||
CurrentName extends string = string, | |||||
CurrentRouteName extends string = string, | |||||
CurrentIdAttr extends string = string, | |||||
IdSchema extends v.BaseSchema = v.BaseSchema | |||||
> { | |||||
dataSource?: DataSourceInterface; | |||||
id<NewIdAttr extends CurrentIdAttr, TheIdSchema extends IdSchema>( | |||||
newIdAttr: NewIdAttr, | |||||
params: ResourceIdConfig<TheIdSchema> | |||||
): Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, TheIdSchema>; | |||||
fullText(fullTextAttr: string): this; | |||||
} | |||||
} | |||||
export class DataSource<T extends Record<string, string>> implements DataSourceInterface<T> { | export class DataSource<T extends Record<string, string>> implements DataSourceInterface<T> { | ||||
private readonly path: string; | |||||
private path?: string; | |||||
private resource?: Resource; | |||||
data: T[] = []; | data: T[] = []; | ||||
constructor(private readonly resource: Resource, baseDir = '') { | |||||
this.path = join(baseDir, `${this.resource.state.routeName}.jsonl`); | |||||
constructor(private readonly baseDir = '') { | |||||
// noop | |||||
} | |||||
prepareResource< | |||||
Schema extends v.BaseSchema = v.BaseSchema, | |||||
CurrentName extends string = string, | |||||
CurrentRouteName extends string = string | |||||
>(resource: Resource<Schema, CurrentName, CurrentRouteName>) { | |||||
this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`); | |||||
resource.dataSource = resource.dataSource ?? this; | |||||
const originalResourceId = resource.id; | |||||
resource.id = <NewIdAttr extends string, NewIdSchema extends v.BaseSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => { | |||||
originalResourceId(newIdAttr, params); | |||||
return resource as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>; | |||||
}; | |||||
this.resource = resource; | |||||
} | } | ||||
async initialize() { | async initialize() { | ||||
if (typeof this.path !== 'string') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
try { | try { | ||||
const fileContents = await readFile(this.path, 'utf-8'); | const fileContents = await readFile(this.path, 'utf-8'); | ||||
const lines = fileContents.split('\n'); | const lines = fileContents.split('\n'); | ||||
@@ -30,9 +76,38 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
return [...this.data]; | return [...this.data]; | ||||
} | } | ||||
async newId() { | |||||
const idConfig = this.resource?.state.shared.get('idConfig') as ResourceIdConfig<any>; | |||||
if (typeof idConfig === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theNewId = await idConfig.generationStrategy(this); | |||||
return theNewId as string; | |||||
} | |||||
async getById(idSerialized: string) { | async getById(idSerialized: string) { | ||||
const id = this.resource.state.idConfig.deserialize(idSerialized); | |||||
const foundData = this.data.find((s) => s[this.resource.state.idAttr as string] === id); | |||||
if (typeof this.resource === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
if (typeof this.path !== 'string') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||||
if (typeof theIdAttr === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const idAttr = theIdAttr as string; | |||||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||||
if (typeof theIdConfigRaw === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||||
const id = theIdConfig.deserialize(idSerialized); | |||||
const foundData = this.data.find((s) => s[idAttr] === id); | |||||
if (foundData) { | if (foundData) { | ||||
return { | return { | ||||
@@ -44,12 +119,31 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
} | } | ||||
async create(data: T) { | async create(data: T) { | ||||
if (typeof this.resource === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
if (typeof this.path !== 'string') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||||
if (typeof theIdAttr === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const idAttr = theIdAttr as string; | |||||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||||
if (typeof theIdConfigRaw === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||||
const newData = { | const newData = { | ||||
...data | ...data | ||||
} as Record<string, unknown>; | } as Record<string, unknown>; | ||||
if (this.resource.state.idAttr in newData) { | |||||
newData[this.resource.state.idAttr] = this.resource.state.idConfig.deserialize(newData[this.resource.state.idAttr] as string); | |||||
if (idAttr in newData) { | |||||
newData[idAttr] = theIdConfig.deserialize(newData[idAttr] as string); | |||||
} | } | ||||
const newCollection = [ | const newCollection = [ | ||||
@@ -63,10 +157,29 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
} | } | ||||
async delete(idSerialized: string) { | async delete(idSerialized: string) { | ||||
if (typeof this.resource === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
if (typeof this.path !== 'string') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||||
if (typeof theIdAttr === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const idAttr = theIdAttr as string; | |||||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||||
if (typeof theIdConfigRaw === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||||
const oldDataLength = this.data.length; | const oldDataLength = this.data.length; | ||||
const id = this.resource.state.idConfig.deserialize(idSerialized); | |||||
const newData = this.data.filter((s) => !(s[this.resource.state.idAttr] === id)); | |||||
const id = theIdConfig.deserialize(idSerialized); | |||||
const newData = this.data.filter((s) => !(s[idAttr] === id)); | |||||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | ||||
@@ -74,17 +187,36 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
} | } | ||||
async emplace(idSerialized: string, dataWithId: T) { | async emplace(idSerialized: string, dataWithId: T) { | ||||
if (typeof this.resource === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
if (typeof this.path !== 'string') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||||
if (typeof theIdAttr === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const idAttr = theIdAttr as string; | |||||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||||
if (typeof theIdConfigRaw === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||||
const existing = await this.getById(idSerialized); | const existing = await this.getById(idSerialized); | ||||
const id = this.resource.state.idConfig.deserialize(idSerialized); | |||||
const { [this.resource.state.idAttr]: idFromResource, ...data } = dataWithId; | |||||
const id = theIdConfig.deserialize(idSerialized); | |||||
const { [idAttr]: idFromResource, ...data } = dataWithId; | |||||
const dataToEmplace = { | const dataToEmplace = { | ||||
...data, | ...data, | ||||
[this.resource.state.idAttr]: id, | |||||
[idAttr]: id, | |||||
} as T; | } as T; | ||||
if (existing) { | if (existing) { | ||||
const newData = this.data.map((d) => { | const newData = this.data.map((d) => { | ||||
if (d[this.resource.state.idAttr] === id) { | |||||
if (d[idAttr] === id) { | |||||
return dataToEmplace; | return dataToEmplace; | ||||
} | } | ||||
@@ -101,6 +233,25 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
} | } | ||||
async patch(idSerialized: string, data: Partial<T>) { | async patch(idSerialized: string, data: Partial<T>) { | ||||
if (typeof this.resource === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
if (typeof this.path !== 'string') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||||
if (typeof theIdAttr === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const idAttr = theIdAttr as string; | |||||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||||
if (typeof theIdConfigRaw === 'undefined') { | |||||
throw new Error('Resource not prepared.'); | |||||
} | |||||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||||
const existing = await this.getById(idSerialized); | const existing = await this.getById(idSerialized); | ||||
if (!existing) { | if (!existing) { | ||||
return null; | return null; | ||||
@@ -111,9 +262,9 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
...data, | ...data, | ||||
} | } | ||||
const id = this.resource.state.idConfig.deserialize(idSerialized); | |||||
const id = theIdConfig.deserialize(idSerialized); | |||||
const newData = this.data.map((d) => { | const newData = this.data.map((d) => { | ||||
if (d[this.resource.state.idAttr as string] === id) { | |||||
if (d[idAttr] === id) { | |||||
return newItem; | return newItem; | ||||
} | } | ||||
@@ -1,7 +1,5 @@ | |||||
import {RequestDecorator} from '../../../common'; | import {RequestDecorator} from '../../../common'; | ||||
import {DataSource} from '../../../data-source'; | |||||
import {Resource} from '../../../../common'; | import {Resource} from '../../../../common'; | ||||
import {BackendResource} from '../../../core'; | |||||
declare module '../../../common' { | declare module '../../../common' { | ||||
interface RequestContext { | interface RequestContext { | ||||
@@ -11,14 +9,16 @@ declare module '../../../common' { | |||||
} | } | ||||
export const decorateRequestWithResource: RequestDecorator = (req) => { | export const decorateRequestWithResource: RequestDecorator = (req) => { | ||||
const [, resourceRouteName, resourceId = ''] = req.url?.split('/') ?? []; | |||||
const [, resourceRouteName, resourceId] = req.url?.split('/') ?? []; | |||||
const resource = Array.from(req.backend.app.resources) | const resource = Array.from(req.backend.app.resources) | ||||
.find((r) => r.state.routeName === resourceRouteName) as BackendResource | undefined; | |||||
.find((r) => r.state.routeName === resourceRouteName) as Resource | undefined; | |||||
if (typeof resource !== 'undefined') { | if (typeof resource !== 'undefined') { | ||||
req.backend.dataSource.prepareResource(resource); | |||||
req.resource = resource; | req.resource = resource; | ||||
req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource; | |||||
req.resourceId = resourceId; | |||||
if (resourceId?.trim().length > 0) { | |||||
req.resourceId = resourceId; | |||||
} | |||||
} | } | ||||
return req; | return req; | ||||
@@ -2,7 +2,6 @@ import { constants } from 'http2'; | |||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {HttpMiddlewareError, PlainResponse, Middleware} from './server'; | import {HttpMiddlewareError, PlainResponse, Middleware} from './server'; | ||||
import {LinkMap} from './utils'; | import {LinkMap} from './utils'; | ||||
import {BackendResource} from '../core'; | |||||
export const handleGetRoot: Middleware = (req) => { | export const handleGetRoot: Middleware = (req) => { | ||||
const { backend, basePath } = req; | const { backend, basePath } = req; | ||||
@@ -41,12 +40,19 @@ export const handleGetRoot: Middleware = (req) => { | |||||
}; | }; | ||||
export const handleGetCollection: Middleware = async (req) => { | export const handleGetCollection: Middleware = async (req) => { | ||||
const { query, resource: resourceRaw, backend } = req; | |||||
const { query, resource, backend } = req; | |||||
if (typeof resourceRaw === 'undefined') { | |||||
throw new Error('No resource'); | |||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | } | ||||
const resource = resourceRaw as BackendResource; | |||||
let data: v.Output<typeof resource.schema>[]; | let data: v.Output<typeof resource.schema>[]; | ||||
let totalItemCount: number | undefined; | let totalItemCount: number | undefined; | ||||
@@ -80,12 +86,19 @@ export const handleGetCollection: Middleware = async (req) => { | |||||
}; | }; | ||||
export const handleGetItem: Middleware = async (req) => { | export const handleGetItem: Middleware = async (req) => { | ||||
const { resource: resourceRaw, resourceId } = req; | |||||
const { resource, resourceId } = req; | |||||
if (typeof resourceRaw === 'undefined') { | |||||
throw new Error('No resource'); | |||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | } | ||||
const resource = resourceRaw as BackendResource; | |||||
if (typeof resourceId === 'undefined') { | if (typeof resourceId === 'undefined') { | ||||
throw new HttpMiddlewareError( | throw new HttpMiddlewareError( | ||||
@@ -135,12 +148,19 @@ export const handleGetItem: Middleware = async (req) => { | |||||
}; | }; | ||||
export const handleDeleteItem: Middleware = async (req) => { | export const handleDeleteItem: Middleware = async (req) => { | ||||
const { resource: resourceRaw, resourceId, backend } = req; | |||||
const { resource, resourceId, backend } = req; | |||||
if (typeof resourceRaw === 'undefined') { | |||||
throw new Error('No resource'); | |||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | } | ||||
const resource = resourceRaw as BackendResource; | |||||
if (typeof resourceId === 'undefined') { | if (typeof resourceId === 'undefined') { | ||||
throw new HttpMiddlewareError( | throw new HttpMiddlewareError( | ||||
@@ -161,7 +181,7 @@ export const handleDeleteItem: Middleware = async (req) => { | |||||
}); | }); | ||||
} | } | ||||
if (!existing && backend!.throws404OnDeletingNotFound) { | |||||
if (!existing && backend!.throwsErrorOnDeletingNotFound) { | |||||
throw new HttpMiddlewareError('deleteNonExistingResource', { | throw new HttpMiddlewareError('deleteNonExistingResource', { | ||||
statusCode: constants.HTTP_STATUS_NOT_FOUND | statusCode: constants.HTTP_STATUS_NOT_FOUND | ||||
}); | }); | ||||
@@ -185,12 +205,19 @@ export const handleDeleteItem: Middleware = async (req) => { | |||||
}; | }; | ||||
export const handlePatchItem: Middleware = async (req) => { | export const handlePatchItem: Middleware = async (req) => { | ||||
const { resource: resourceRaw, resourceId, body } = req; | |||||
const { resource, resourceId, body } = req; | |||||
if (typeof resourceRaw === 'undefined') { | |||||
throw new Error('No resource'); | |||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | } | ||||
const resource = resourceRaw as BackendResource; | |||||
if (typeof resourceId === 'undefined') { | if (typeof resourceId === 'undefined') { | ||||
throw new HttpMiddlewareError( | throw new HttpMiddlewareError( | ||||
@@ -232,24 +259,37 @@ export const handlePatchItem: Middleware = async (req) => { | |||||
statusMessage: 'resourcePatched', | statusMessage: 'resourcePatched', | ||||
body: newObject, | body: newObject, | ||||
}); | }); | ||||
// TODO finish the rest of the handlers!!! | |||||
}; | }; | ||||
export const handleCreateItem: Middleware = async (req) => { | export const handleCreateItem: Middleware = async (req) => { | ||||
const { resource: resourceRaw, body, backend, basePath } = req; | |||||
const { resource, body, backend, basePath } = req; | |||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resourceRaw === 'undefined') { | |||||
throw new Error('No resource'); | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | |||||
const idAttrRaw = resource.state.shared.get('idAttr'); | |||||
if (typeof idAttrRaw === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | } | ||||
const resource = resourceRaw as BackendResource; | |||||
const idAttr = idAttrRaw as string; | |||||
let newId; | let newId; | ||||
let params: v.Output<typeof resource.schema>; | let params: v.Output<typeof resource.schema>; | ||||
try { | try { | ||||
newId = await resource.newId(resource.dataSource); | |||||
newId = await resource.dataSource.newId(); | |||||
params = { ...body as Record<string, unknown> }; | params = { ...body as Record<string, unknown> }; | ||||
params[resource.state.idAttr] = newId; | |||||
params[idAttr] = newId; | |||||
} catch (cause) { | } catch (cause) { | ||||
throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', { | throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', { | ||||
cause, | cause, | ||||
@@ -296,18 +336,33 @@ export const handleCreateItem: Middleware = async (req) => { | |||||
} | } | ||||
export const handleEmplaceItem: Middleware = async (req) => { | export const handleEmplaceItem: Middleware = async (req) => { | ||||
const { resource: resourceRaw, resourceId, basePath, body, backend } = req; | |||||
const { resource, resourceId, basePath, body, backend } = req; | |||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resourceRaw === 'undefined') { | |||||
throw new Error('No resource'); | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | |||||
const idAttrRaw = resource.state.shared.get('idAttr'); | |||||
if (typeof idAttrRaw === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | } | ||||
const resource = resourceRaw as BackendResource; | |||||
const idAttr = idAttrRaw as string; | |||||
let newObject: v.Output<typeof resource.schema>; | let newObject: v.Output<typeof resource.schema>; | ||||
let isCreated: boolean; | let isCreated: boolean; | ||||
try { | try { | ||||
const params = { ...body as Record<string, unknown> }; | const params = { ...body as Record<string, unknown> }; | ||||
params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string); | |||||
params[idAttr] = resourceId; | |||||
[newObject, isCreated] = await resource.dataSource.emplace(resourceId!, params); | [newObject, isCreated] = await resource.dataSource.emplace(resourceId!, params); | ||||
} catch (cause) { | } catch (cause) { | ||||
throw new HttpMiddlewareError('unableToEmplaceResource', { | throw new HttpMiddlewareError('unableToEmplaceResource', { | ||||
@@ -13,9 +13,6 @@ import { | |||||
handleGetRoot, | handleGetRoot, | ||||
handlePatchItem, | handlePatchItem, | ||||
} from './handlers'; | } from './handlers'; | ||||
import { | |||||
BackendResource, | |||||
} from '../core'; | |||||
import {getBody} from './utils'; | import {getBody} from './utils'; | ||||
import {decorateRequestWithBackend} from './decorators/backend'; | import {decorateRequestWithBackend} from './decorators/backend'; | ||||
import {decorateRequestWithMethod} from './decorators/method'; | import {decorateRequestWithMethod} from './decorators/method'; | ||||
@@ -94,14 +91,18 @@ export interface Middleware<Req extends RequestContext = RequestContext> { | |||||
(req: Req): undefined | Response | Promise<undefined | Response>; | (req: Req): undefined | Response | Promise<undefined | Response>; | ||||
} | } | ||||
const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, mainResourceId = '') => { | |||||
const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, mainResourceId?: string) => { | |||||
const middlewares = [] as [string, Middleware, v.BaseSchema?][]; | const middlewares = [] as [string, Middleware, v.BaseSchema?][]; | ||||
if (typeof resource === 'undefined') { | if (typeof resource === 'undefined') { | ||||
return middlewares; | return middlewares; | ||||
} | } | ||||
if (mainResourceId === '') { | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
return middlewares; | |||||
} | |||||
if (typeof mainResourceId !== 'string') { | |||||
if (resource.state.canFetchCollection) { | if (resource.state.canFetchCollection) { | ||||
middlewares.push(['GET', handleGetCollection]); | middlewares.push(['GET', handleGetCollection]); | ||||
} | } | ||||
@@ -116,14 +117,16 @@ const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, m | |||||
} | } | ||||
if (resource.state.canEmplace) { | if (resource.state.canEmplace) { | ||||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | ||||
const idAttr = resource.state.shared.get('idAttr') as string; | |||||
const idConfig = resource.state.shared.get('idConfig') as any; | |||||
const putSchema = ( | const putSchema = ( | ||||
schema.type === 'object' | schema.type === 'object' | ||||
? v.merge([ | ? v.merge([ | ||||
schema as v.ObjectSchema<any>, | schema as v.ObjectSchema<any>, | ||||
v.object({ | v.object({ | ||||
[resource.state.idAttr]: v.transform( | |||||
[idAttr]: v.transform( | |||||
v.any(), | v.any(), | ||||
input => resource.state.idConfig.serialize(input), | |||||
input => idConfig!.serialize(input), | |||||
v.literal(mainResourceId) | v.literal(mainResourceId) | ||||
) | ) | ||||
}) | }) | ||||
@@ -178,13 +181,18 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
if (typeof req.resource === 'undefined') { | if (typeof req.resource === 'undefined') { | ||||
throw new HttpMiddlewareError('resourceNotFound', { | throw new HttpMiddlewareError('resourceNotFound', { | ||||
statusCode: constants.HTTP_STATUS_NOT_FOUND | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof req.resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | }); | ||||
} | } | ||||
const resource = req.resource as BackendResource; | |||||
try { | try { | ||||
await resource.dataSource.initialize(); | |||||
await req.resource.dataSource.initialize(); | |||||
} catch (cause) { | } catch (cause) { | ||||
throw new HttpMiddlewareError( | throw new HttpMiddlewareError( | ||||
'unableToInitializeResourceDataSource', | 'unableToInitializeResourceDataSource', | ||||
@@ -211,8 +219,6 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
} | } | ||||
if (schema) { | if (schema) { | ||||
const availableSerializers = Array.from(req.backend.app.mediaTypes.values()); | |||||
const availableCharsets = Array.from(req.backend.app.charsets.values()); | |||||
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; | const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; | ||||
const fragments = contentTypeHeader.split(';'); | const fragments = contentTypeHeader.split(';'); | ||||
const mediaType = fragments[0]; | const mediaType = fragments[0]; | ||||
@@ -227,9 +233,22 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
? charsetRaw.slice(1, -1).trim() | ? charsetRaw.slice(1, -1).trim() | ||||
: charsetRaw.trim() | : charsetRaw.trim() | ||||
) | ) | ||||
const deserializerPair = availableSerializers.find((l) => l.name === mediaType); | |||||
const encodingPair = availableCharsets.find((l) => l.name === charset); | |||||
(req as unknown as Record<string, unknown>).body = await getBody(req, schema, encodingPair, deserializerPair); | |||||
const theBodyBuffer = await getBody(req); | |||||
const encodingPair = req.backend.app.charsets.get(charset); | |||||
if (typeof encodingPair === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToDecodeResource', { | |||||
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, | |||||
}); | |||||
} | |||||
const deserializerPair = req.backend.app.mediaTypes.get(mediaType); | |||||
if (typeof deserializerPair === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToDeserializeResource', { | |||||
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, | |||||
}); | |||||
} | |||||
const theBodyStr = encodingPair.decode(theBodyBuffer); | |||||
const theBody = deserializerPair.deserialize(theBodyStr); | |||||
req.body = await v.parseAsync(schema, theBody, { abortEarly: false, abortPipeEarly: false }); | |||||
} | } | ||||
const result = await middleware(req); | const result = await middleware(req); | ||||
@@ -284,7 +303,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse<RequestContext>) => { | const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse<RequestContext>) => { | ||||
const req = await decorateRequest(reqRaw); | const req = await decorateRequest(reqRaw); | ||||
const middlewares = getAllowedMiddlewares(req.resource, req.resourceId ?? ''); | |||||
const middlewares = getAllowedMiddlewares(req.resource, req.resourceId); | |||||
const processRequestFn = processRequest(middlewares); | const processRequestFn = processRequest(middlewares); | ||||
let middlewareState: Response; | let middlewareState: Response; | ||||
try { | try { | ||||
@@ -1,30 +1,19 @@ | |||||
import {IncomingMessage} from 'http'; | import {IncomingMessage} from 'http'; | ||||
import {MediaType, Charset} from '../../common'; | |||||
import {BaseSchema, parseAsync} from 'valibot'; | |||||
export const getBody = ( | export const getBody = ( | ||||
req: IncomingMessage, | req: IncomingMessage, | ||||
schema: BaseSchema, | |||||
encodingPair?: Charset, | |||||
deserializer?: MediaType, | |||||
) => new Promise((resolve, reject) => { | |||||
) => new Promise<Buffer>((resolve, reject) => { | |||||
let body = Buffer.from(''); | let body = Buffer.from(''); | ||||
req.on('data', (chunk) => { | req.on('data', (chunk) => { | ||||
body = Buffer.concat([body, chunk]); | body = Buffer.concat([body, chunk]); | ||||
}); | }); | ||||
req.on('end', async () => { | req.on('end', async () => { | ||||
const bodyStr = encodingPair?.decode(body) ?? body.toString(); | |||||
try { | |||||
const bodyDeserialized = await parseAsync( | |||||
schema, | |||||
deserializer?.deserialize(bodyStr) ?? body, | |||||
{abortEarly: false}, | |||||
); | |||||
resolve(bodyDeserialized); | |||||
} catch (err) { | |||||
reject(err); | |||||
} | |||||
resolve(body); | |||||
}); | }); | ||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}) | |||||
}); | }); | ||||
interface LinkMapEntry { | interface LinkMapEntry { | ||||
@@ -24,7 +24,11 @@ export interface ApplicationBuilder { | |||||
mediaType(mediaType: MediaType): this; | mediaType(mediaType: MediaType): this; | ||||
language(language: Language): this; | language(language: Language): this; | ||||
charset(charset: Charset): this; | charset(charset: Charset): this; | ||||
resource<T extends v.BaseSchema>(resRaw: Resource<T>): this; | |||||
resource< | |||||
Schema extends v.BaseSchema, | |||||
CurrentItemName extends string = string, | |||||
CurrentRouteName extends string = string | |||||
>(resRaw: Resource<Schema, CurrentItemName, CurrentRouteName>): this; | |||||
createBackend(params: Omit<CreateBackendParams, 'app'>): BackendBuilder; | createBackend(params: Omit<CreateBackendParams, 'app'>): BackendBuilder; | ||||
createClient(params: Omit<CreateClientParams, 'app'>): ClientBuilder; | createClient(params: Omit<CreateClientParams, 'app'>): ClientBuilder; | ||||
} | } | ||||
@@ -22,6 +22,8 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ | |||||
'unableToSerializeResponse', | 'unableToSerializeResponse', | ||||
'unableToEncodeResponse', | 'unableToEncodeResponse', | ||||
'unableToDeleteResource', | 'unableToDeleteResource', | ||||
'unableToDeserializeResource', | |||||
'unableToDecodeResource', | |||||
'resourceDeleted', | 'resourceDeleted', | ||||
'unableToDeserializeRequest', | 'unableToDeserializeRequest', | ||||
'patchNonExistingResource', | 'patchNonExistingResource', | ||||
@@ -64,6 +66,8 @@ export const FALLBACK_LANGUAGE = { | |||||
unableToDeleteResource: 'Unable To Delete $RESOURCE', | unableToDeleteResource: 'Unable To Delete $RESOURCE', | ||||
languageNotAcceptable: 'Language Not Acceptable', | languageNotAcceptable: 'Language Not Acceptable', | ||||
encodingNotAcceptable: 'Encoding Not Acceptable', | encodingNotAcceptable: 'Encoding Not Acceptable', | ||||
unableToDeserializeResource: 'Unable To Deserialize $RESOURCE', | |||||
unableToDecodeResource: 'Unable To Decode $RESOURCE', | |||||
mediaTypeNotAcceptable: 'Media Type Not Acceptable', | mediaTypeNotAcceptable: 'Media Type Not Acceptable', | ||||
methodNotAllowed: 'Method Not Allowed', | methodNotAllowed: 'Method Not Allowed', | ||||
urlNotFound: 'URL Not Found', | urlNotFound: 'URL Not Found', | ||||
@@ -1,24 +1,12 @@ | |||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {BaseDataSource, GenerationStrategy} from './data-source'; | |||||
export interface ResourceIdConfig<IdSchema extends v.BaseSchema, DataSource extends BaseDataSource = BaseDataSource> { | |||||
generationStrategy: GenerationStrategy<DataSource>; | |||||
serialize: (id: unknown) => string; | |||||
deserialize: (id: string) => v.Output<IdSchema>; | |||||
schema: IdSchema; | |||||
} | |||||
export interface ResourceState< | export interface ResourceState< | ||||
ItemName extends string = string, | ItemName extends string = string, | ||||
RouteName extends string = string, | |||||
IdAttr extends string = string, | |||||
IdSchema extends v.BaseSchema = v.BaseSchema | |||||
RouteName extends string = string | |||||
> { | > { | ||||
idAttr: IdAttr; | |||||
shared: Map<string, unknown>; | |||||
itemName: ItemName; | itemName: ItemName; | ||||
routeName: RouteName; | routeName: RouteName; | ||||
idConfig: ResourceIdConfig<IdSchema>; | |||||
fullTextAttrs: Set<string>; | |||||
canCreate: boolean; | canCreate: boolean; | ||||
canFetchCollection: boolean; | canFetchCollection: boolean; | ||||
canFetchItem: boolean; | canFetchItem: boolean; | ||||
@@ -30,20 +18,12 @@ export interface ResourceState< | |||||
export interface Resource< | export interface Resource< | ||||
Schema extends v.BaseSchema = v.BaseSchema, | Schema extends v.BaseSchema = v.BaseSchema, | ||||
CurrentName extends string = string, | CurrentName extends string = string, | ||||
CurrentRouteName extends string = string, | |||||
CurrentIdAttr extends string = string, | |||||
IdSchema extends v.BaseSchema = v.BaseSchema | |||||
CurrentRouteName extends string = string | |||||
> { | > { | ||||
dataSource?: unknown; | |||||
schema: Schema; | schema: Schema; | ||||
state: ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||||
id<NewIdAttr extends CurrentIdAttr, TheIdSchema extends v.BaseSchema>( | |||||
newIdAttr: NewIdAttr, | |||||
params: ResourceIdConfig<TheIdSchema> | |||||
): Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, TheIdSchema>; | |||||
fullText(fullTextAttr: string): this; | |||||
name<NewName extends CurrentName>(n: NewName): Resource<Schema, NewName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||||
route<NewRouteName extends CurrentRouteName>(n: NewRouteName): Resource<Schema, CurrentName, NewRouteName, CurrentIdAttr, IdSchema>; | |||||
state: ResourceState<CurrentName, CurrentRouteName>; | |||||
name<NewName extends CurrentName>(n: NewName): Resource<Schema, NewName, CurrentRouteName>; | |||||
route<NewRouteName extends CurrentRouteName>(n: NewRouteName): Resource<Schema, CurrentName, NewRouteName>; | |||||
canFetchCollection(b?: boolean): this; | canFetchCollection(b?: boolean): this; | ||||
canFetchItem(b?: boolean): this; | canFetchItem(b?: boolean): this; | ||||
canCreate(b?: boolean): this; | canCreate(b?: boolean): this; | ||||
@@ -55,25 +35,23 @@ export interface Resource< | |||||
export const resource = < | export const resource = < | ||||
Schema extends v.BaseSchema, | Schema extends v.BaseSchema, | ||||
CurrentName extends string = string, | CurrentName extends string = string, | ||||
CurrentRouteName extends string = string, | |||||
CurrentIdAttr extends string = string, | |||||
IdSchema extends v.BaseSchema = v.BaseSchema | |||||
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema> => { | |||||
CurrentRouteName extends string = string | |||||
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName> => { | |||||
const resourceState = { | const resourceState = { | ||||
fullTextAttrs: new Set<string>(), | |||||
shared: new Map(), | |||||
canCreate: false, | canCreate: false, | ||||
canFetchCollection: false, | canFetchCollection: false, | ||||
canFetchItem: false, | canFetchItem: false, | ||||
canPatch: false, | canPatch: false, | ||||
canEmplace: false, | canEmplace: false, | ||||
canDelete: false, | canDelete: false, | ||||
} as ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||||
} as ResourceState<CurrentName, CurrentRouteName>; | |||||
return { | return { | ||||
get state(): ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema> { | |||||
get state(): ResourceState<CurrentName, CurrentRouteName> { | |||||
return Object.freeze({ | return Object.freeze({ | ||||
...resourceState | ...resourceState | ||||
}) as unknown as ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||||
}) as unknown as ResourceState<CurrentName, CurrentRouteName>; | |||||
}, | }, | ||||
canFetchCollection(b = true) { | canFetchCollection(b = true) { | ||||
resourceState.canFetchCollection = b; | resourceState.canFetchCollection = b; | ||||
@@ -99,42 +77,24 @@ export const resource = < | |||||
resourceState.canDelete = b; | resourceState.canDelete = b; | ||||
return this; | return this; | ||||
}, | }, | ||||
id<NewIdAttr extends CurrentIdAttr, NewIdSchema extends IdSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) { | |||||
resourceState.idAttr = newIdAttr; | |||||
resourceState.idConfig = params; | |||||
return this as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>; | |||||
}, | |||||
newId(dataSource: BaseDataSource) { | |||||
return resourceState?.idConfig?.generationStrategy?.(dataSource); | |||||
id(idName, config) { | |||||
resourceState.shared.set('idAttr', idName); | |||||
resourceState.shared.set('idConfig', config); | |||||
return this; | |||||
}, | }, | ||||
fullText(fullTextAttr: string) { | |||||
if ( | |||||
schema.type === 'object' | |||||
&& ( | |||||
schema as unknown as v.ObjectSchema< | |||||
Record<string, v.BaseSchema>, | |||||
undefined, | |||||
Record<string, string> | |||||
> | |||||
) | |||||
.entries[fullTextAttr]?.type === 'string' | |||||
) { | |||||
resourceState.fullTextAttrs?.add(fullTextAttr); | |||||
return this; | |||||
} | |||||
throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`); | |||||
fullText(attrName) { | |||||
const fullTextAttrs = (resourceState.shared.get('fullText') ?? new Set()) as Set<string>; | |||||
fullTextAttrs.add(attrName); | |||||
resourceState.shared.set('fullText', fullTextAttrs); | |||||
return this; | |||||
}, | }, | ||||
name<NewName extends CurrentName>(n: NewName) { | name<NewName extends CurrentName>(n: NewName) { | ||||
resourceState.itemName = n; | resourceState.itemName = n; | ||||
return this as Resource<Schema, NewName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||||
return this as Resource<Schema, NewName, CurrentRouteName>; | |||||
}, | }, | ||||
route<NewRouteName extends CurrentRouteName>(n: NewRouteName) { | route<NewRouteName extends CurrentRouteName>(n: NewRouteName) { | ||||
resourceState.routeName = n; | resourceState.routeName = n; | ||||
return this as Resource<Schema, CurrentName, NewRouteName, CurrentIdAttr, IdSchema>; | |||||
}, | |||||
get idAttr() { | |||||
return resourceState.idAttr; | |||||
return this as Resource<Schema, CurrentName, NewRouteName>; | |||||
}, | }, | ||||
get itemName() { | get itemName() { | ||||
return resourceState.itemName; | return resourceState.itemName; | ||||
@@ -145,9 +105,7 @@ export const resource = < | |||||
get schema() { | get schema() { | ||||
return schema; | return schema; | ||||
}, | }, | ||||
} as Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||||
} as Resource<Schema, CurrentName, CurrentRouteName>; | |||||
}; | }; | ||||
export type ResourceType<R extends Resource> = v.Output<R['schema']>; | export type ResourceType<R extends Resource> = v.Output<R['schema']>; | ||||
export type ResourceTypeWithId<R extends Resource> = ResourceType<R> & Record<R['state']['idAttr'], v.Output<R['state']['idConfig']['schema']>>; |
@@ -25,9 +25,11 @@ import { dataSources } from '../../src/backend'; | |||||
import { application, resource, validation as v, Resource } from '../../src'; | import { application, resource, validation as v, Resource } from '../../src'; | ||||
const PORT = 3000; | const PORT = 3000; | ||||
const HOST = 'localhost'; | |||||
const ACCEPT_CHARSET = 'utf-8'; | |||||
const HOST = '127.0.0.1'; | |||||
const ACCEPT = 'application/json'; | const ACCEPT = 'application/json'; | ||||
const ACCEPT_LANGUAGE = 'en'; | |||||
const CONTENT_TYPE_CHARSET = 'utf-8'; | |||||
const CONTENT_TYPE = ACCEPT; | |||||
const autoIncrement = async (dataSource: DataSource) => { | const autoIncrement = async (dataSource: DataSource) => { | ||||
const data = await dataSource.getMultiple() as Record<string, string>[]; | const data = await dataSource.getMultiple() as Record<string, string>[]; | ||||
@@ -74,7 +76,7 @@ describe('yasumi', () => { | |||||
.name('Piano' as const) | .name('Piano' as const) | ||||
.route('pianos' as const) | .route('pianos' as const) | ||||
.id('id' as const, { | .id('id' as const, { | ||||
generationStrategy: autoIncrement as any, | |||||
generationStrategy: autoIncrement, | |||||
serialize: (id) => id?.toString() ?? '0', | serialize: (id) => id?.toString() ?? '0', | ||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | ||||
schema: v.number(), | schema: v.number(), | ||||
@@ -90,7 +92,7 @@ describe('yasumi', () => { | |||||
const backend = app | const backend = app | ||||
.createBackend({ | .createBackend({ | ||||
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), | |||||
dataSource: new dataSources.jsonlFile.DataSource(baseDir), | |||||
}) | }) | ||||
.throwsErrorOnDeletingNotFound(); | .throwsErrorOnDeletingNotFound(); | ||||
@@ -146,8 +148,8 @@ describe('yasumi', () => { | |||||
path: '/api/pianos', | path: '/api/pianos', | ||||
method: 'GET', | method: 'GET', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Accept-Language': 'en', | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -165,7 +167,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual([]); | expect(resData).toEqual([]); | ||||
resolve(); | resolve(); | ||||
@@ -190,8 +192,8 @@ describe('yasumi', () => { | |||||
path: '/api/pianos', | path: '/api/pianos', | ||||
method: 'HEAD', | method: 'HEAD', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Accept-Language': 'en', | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -247,7 +249,8 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/1', | path: '/api/pianos/1', | ||||
method: 'GET', | method: 'GET', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -264,7 +267,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual(data); | expect(resData).toEqual(data); | ||||
resolve(); | resolve(); | ||||
@@ -290,7 +293,8 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/1', | path: '/api/pianos/1', | ||||
method: 'HEAD', | method: 'HEAD', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -320,7 +324,8 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/2', | path: '/api/pianos/2', | ||||
method: 'GET', | method: 'GET', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -351,7 +356,8 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/2', | path: '/api/pianos/2', | ||||
method: 'HEAD', | method: 'HEAD', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -406,8 +412,9 @@ describe('yasumi', () => { | |||||
path: '/api/pianos', | path: '/api/pianos', | ||||
method: 'POST', | method: 'POST', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -424,7 +431,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual({ | expect(resData).toEqual({ | ||||
...newData, | ...newData, | ||||
@@ -478,8 +485,9 @@ describe('yasumi', () => { | |||||
path: `/api/pianos/${data.id}`, | path: `/api/pianos/${data.id}`, | ||||
method: 'PATCH', | method: 'PATCH', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -496,7 +504,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual({ | expect(resData).toEqual({ | ||||
...data, | ...data, | ||||
@@ -525,8 +533,9 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/2', | path: '/api/pianos/2', | ||||
method: 'PATCH', | method: 'PATCH', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -582,8 +591,9 @@ describe('yasumi', () => { | |||||
path: `/api/pianos/${newData.id}`, | path: `/api/pianos/${newData.id}`, | ||||
method: 'PUT', | method: 'PUT', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -600,7 +610,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual(newData); | expect(resData).toEqual(newData); | ||||
resolve(); | resolve(); | ||||
@@ -628,8 +638,9 @@ describe('yasumi', () => { | |||||
path: `/api/pianos/${id}`, | path: `/api/pianos/${id}`, | ||||
method: 'PUT', | method: 'PUT', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -646,7 +657,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual({ | expect(resData).toEqual({ | ||||
...newData, | ...newData, | ||||
@@ -698,7 +709,8 @@ describe('yasumi', () => { | |||||
path: `/api/pianos/${data.id}`, | path: `/api/pianos/${data.id}`, | ||||
method: 'DELETE', | method: 'DELETE', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -728,7 +740,8 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/2', | path: '/api/pianos/2', | ||||
method: 'DELETE', | method: 'DELETE', | ||||
headers: { | headers: { | ||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||