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