From 082f29215bdf8660e85ab54805e0ff1f2287cd07 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Wed, 13 Mar 2024 13:57:38 +0800 Subject: [PATCH] Implement all resource endpoints Complete implementation of PUT, PATCH, and DELETE. --- src/core.ts | 73 ++++++-- src/data-sources/file-jsonl.ts | 8 +- src/handlers.ts | 331 ++++++++++++++++++++++++--------- src/index.ts | 2 +- src/server.ts | 35 ++-- 5 files changed, 330 insertions(+), 119 deletions(-) diff --git a/src/core.ts b/src/core.ts index 8a09bd7..31e716e 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,14 +1,15 @@ -import { IncomingMessage, ServerResponse, RequestListener } from 'http'; +import * as http from 'http'; +import * as https from 'https'; import { constants } from 'http2'; import { pluralize } from 'inflection'; import { BaseSchema, ObjectSchema } from 'valibot'; import { SerializerPair } from './serializers'; import { - handleCreateItem, handleDeleteItem, + handleCreateItem, handleDeleteItem, handleEmplaceItem, handleGetCollection, handleGetItem, handleGetRoot, - handleHasMethodAndUrl, + handleHasMethodAndUrl, handlePatchItem, } from './handlers'; export interface DataSource { @@ -26,18 +27,33 @@ export interface ApplicationParams { dataSource?: (resource: Resource) => DataSource; } -export interface Resource { +interface ResourceFactory { + shouldCheckSerializersOnDelete(b: boolean): this; + shouldThrow404OnDeletingNotFound(b: boolean): this; + id(newIdAttr: string, params: IdParams): this; + fullText(fullTextAttr: string): this; + name(n: string): this; + collection(n: string): this; + route(n: string): this; +} + +export interface ResourceData { idAttr: string; itemName?: string; collectionName?: string; routeName?: string; - dataSource: DataSource; newId(dataSource: DataSource): string | number | unknown; schema: BaseSchema; throws404OnDeletingNotFound: boolean; checksSerializersOnDelete: boolean; } +export type Resource = ResourceData & ResourceFactory; + +export interface ResourceWithDataSource extends Resource { + dataSource: DataSource; +} + interface GenerationStrategy { (dataSource: DataSource, ...args: unknown[]): Promise; } @@ -46,7 +62,7 @@ interface IdParams { generationStrategy: GenerationStrategy; } -export const resource = (schema: T) => { +export const resource = (schema: T): Resource => { let theIdAttr: string; let theItemName: string; let theCollectionName: string; @@ -127,17 +143,23 @@ export const resource = (schema: T) => { get schema() { return schema; } - }; + } as Resource; }; interface CreateServerParams { baseUrl?: string; host?: string; + cert?: string; + key?: string; + requestTimeout?: number; } type RequestListenerWithReturn< - P extends unknown = unknown, Q extends typeof IncomingMessage = typeof IncomingMessage, R extends typeof ServerResponse = typeof ServerResponse> = ( - ...args: Parameters> + P extends unknown = unknown, + Q extends typeof http.IncomingMessage = typeof http.IncomingMessage, + R extends typeof http.ServerResponse = typeof http.ServerResponse +> = ( + ...args: Parameters> ) => P; interface HandlerState { @@ -145,7 +167,7 @@ interface HandlerState { } interface ApplicationState { - resources: Set; + resources: Set; serializers: Map; } @@ -160,9 +182,15 @@ export interface Middleware { (args: MiddlewareArgs): RequestListenerWithReturn> } -export const application = (appParams: ApplicationParams) => { +export interface Application { + contentType(mimeTypePrefix: string, serializerPair: SerializerPair): this; + resource(resRaw: Partial): this; + createServer(serverParams?: CreateServerParams): http.Server | https.Server; +} + +export const application = (appParams: ApplicationParams): Application => { const applicationState: ApplicationState = { - resources: new Set(), + resources: new Set(), serializers: new Map() }; @@ -171,25 +199,36 @@ export const application = (appParams: ApplicationParams) => { applicationState.serializers.set(mimeTypePrefix, serializerPair); return this; }, - resource(res: Partial) { + resource(resRaw: Partial) { + const res = resRaw as Partial; res.dataSource = res.dataSource ?? appParams.dataSource?.(res as Resource); if (typeof res.dataSource === 'undefined') { throw new Error(`Resource ${res.itemName} must have a data source.`); } - applicationState.resources.add(res as Resource); + applicationState.resources.add(res as ResourceWithDataSource); return this; }, - async createServer(serverParams = {} as CreateServerParams) { - const serverModule = await import('http'); - const server = serverModule.createServer(); + createServer(serverParams = {} as CreateServerParams) { + const server = 'key' in serverParams && 'cert' in serverParams + ? https.createServer({ + key: serverParams.key, + cert: serverParams.cert, + requestTimeout: serverParams.requestTimeout + }) + : http.createServer({ + requestTimeout: serverParams.requestTimeout + }); server.on('request', async (req, res) => { + // TODO return method not allowed error when operations are not allowed on a resource const middlewareState = await [ handleHasMethodAndUrl, handleGetRoot, handleGetCollection, handleGetItem, handleCreateItem, + handleEmplaceItem, + handlePatchItem, handleDeleteItem, ] .reduce( diff --git a/src/data-sources/file-jsonl.ts b/src/data-sources/file-jsonl.ts index 1fa8bd6..0f0c951 100644 --- a/src/data-sources/file-jsonl.ts +++ b/src/data-sources/file-jsonl.ts @@ -59,11 +59,15 @@ export class DataSource> implements DataSourceI async emplace(id: string, data: Partial) { const existing = await this.getSingle(id); + const dataToEmplace = { + ...data, + [this.resource.idAttr]: id, // TODO properly serialize it to data source. + }; if (existing) { const newData = this.data.map((d) => { if (d[this.resource.idAttr as string].toString() === id) { - return data; + return dataToEmplace; } return d; @@ -74,7 +78,7 @@ export class DataSource> implements DataSourceI return data as T; } - return this.create(data); + return this.create(dataToEmplace); } async patch(id: string, data: Partial) { diff --git a/src/handlers.ts b/src/handlers.ts index 2d08d9b..9737f13 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -1,6 +1,6 @@ import { constants } from 'http2'; import Negotiator from 'negotiator'; -import { ValiError } from 'valibot'; +import * as v from 'valibot'; import {Middleware} from './core'; import { getBody, getMethod, getUrl } from './utils'; @@ -296,88 +296,127 @@ export const handleDeleteItem: Middleware = ({ }; }; -// export const handlePatchItem: Middleware = ({ -// appState, -// serverParams, -// }) => async (req, res) => { -// const method = getMethod(req); -// if (method !== 'PATCH') { -// return { -// handled: false -// }; -// } -// -// const baseUrl = serverParams.baseUrl ?? ''; -// const { url } = getUrl(req, baseUrl); -// -// const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); -// if (mainResourceId === '') { -// return { -// handled: false -// } -// } -// -// const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); -// if (typeof theResource === 'undefined') { -// return { -// handled: false -// }; -// } -// -// const theDeserializer = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream'); -// if (typeof theDeserializer === 'undefined') { -// res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; -// res.end(); -// return { -// handled: true -// }; -// } -// -// const negotiator = new Negotiator(req); -// const availableMediaTypes = Array.from(appState.serializers.keys()); -// const theMediaType = negotiator.mediaType(availableMediaTypes); -// if (typeof theMediaType === 'undefined') { -// res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; -// res.end(); -// return { -// handled: true -// }; -// } -// -// const theSerializerPair = appState.serializers.get(theMediaType); -// if (typeof theSerializerPair === 'undefined') { -// res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; -// res.end(); -// return { -// handled: true -// }; -// } -// -// await theResource.dataSource.initialize(); -// let bodyDeserialized: unknown; -// try { -// bodyDeserialized = await getBody(req, theDeserializer, theResource.schema); -// } catch (errRaw) { -// const err = errRaw as ValiError; -// res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; -// res.statusMessage = `Invalid ${theResource.itemName}`; -// -// if (Array.isArray(err.issues)) { -// // TODO better error reporting, localizable messages -// const theFormatted = theSerializerPair.serialize( -// err.issues.map((i) => ( -// `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` -// )) -// ); -// res.end(theFormatted); -// } else { -// res.end(); -// } -// return { -// handled: true -// }; -// } -// }; +export const handlePatchItem: Middleware = ({ + appState, + serverParams, +}) => async (req, res) => { + const method = getMethod(req); + if (method !== 'PATCH') { + return { + handled: false + }; + } + + const baseUrl = serverParams.baseUrl ?? ''; + const { url } = getUrl(req, baseUrl); + + const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); + if (mainResourceId === '') { + return { + handled: false + } + } + + const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); + if (typeof theResource === 'undefined') { + return { + handled: false + }; + } + + const theDeserializer = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream'); + if (typeof theDeserializer === 'undefined') { + res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; + res.end(); + return { + handled: true + }; + } + + const negotiator = new Negotiator(req); + const availableMediaTypes = Array.from(appState.serializers.keys()); + const theMediaType = negotiator.mediaType(availableMediaTypes); + if (typeof theMediaType === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; + res.end(); + return { + handled: true + }; + } + + const theSerializerPair = appState.serializers.get(theMediaType); + if (typeof theSerializerPair === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; + res.end(); + return { + handled: true + }; + } + + await theResource.dataSource.initialize(); + const existing = await theResource.dataSource.getSingle(mainResourceId); + if (!existing) { + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + res.statusMessage = `${theResource.itemName} Not Found`; + res.end(); + return { + handled: true + }; + } + + let bodyDeserialized: unknown; + try { + const schema = theResource.schema.type === 'object' ? theResource.schema as v.ObjectSchema : theResource.schema + bodyDeserialized = await getBody( + req, + theDeserializer, + schema.type === 'object' + ? v.partial( + schema as v.ObjectSchema, + (schema as v.ObjectSchema).rest, + (schema as v.ObjectSchema).pipe + ) + : schema + ); + } catch (errRaw) { + const err = errRaw as v.ValiError; + res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; + res.statusMessage = `Invalid ${theResource.itemName}`; + + if (Array.isArray(err.issues)) { + // TODO better error reporting, localizable messages + const theFormatted = theSerializerPair.serialize( + err.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )) + ); + res.end(theFormatted); + } else { + res.end(); + } + return { + handled: true + }; + } + + try { + const params = bodyDeserialized as Record; + await theResource.dataSource.initialize(); + const newObject = await theResource.dataSource.patch(mainResourceId, params); + const theFormatted = theSerializerPair.serialize(newObject); + res.writeHead(constants.HTTP_STATUS_OK, { + 'Content-Type': theMediaType, + }); + res.end(theFormatted); + } catch { + res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; + res.statusMessage = `Could Not Return ${theResource.itemName}`; + res.end(); + } + return { + handled: true + }; +}; export const handleCreateItem: Middleware = ({ appState, @@ -435,13 +474,13 @@ export const handleCreateItem: Middleware = ({ handled: true }; } + // TODO determine serializer pair before running the middlewares - await theResource.dataSource.initialize(); let bodyDeserialized: unknown; try { bodyDeserialized = await getBody(req, theDeserializer, theResource.schema); } catch (errRaw) { - const err = errRaw as ValiError; + const err = errRaw as v.ValiError; res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; res.statusMessage = `Invalid ${theResource.itemName}`; @@ -465,6 +504,7 @@ export const handleCreateItem: Middleware = ({ const newId = await theResource.newId(theResource.dataSource); const params = bodyDeserialized as Record; params[theResource.idAttr] = newId; + await theResource.dataSource.initialize(); const newObject = await theResource.dataSource.create(params); const theFormatted = theSerializerPair.serialize(newObject); res.writeHead(constants.HTTP_STATUS_OK, { @@ -481,3 +521,124 @@ export const handleCreateItem: Middleware = ({ handled: true }; } + +export const handleEmplaceItem: Middleware = ({ + appState, + serverParams +}) => async (req, res) => { + const method = getMethod(req); + if (method !== 'PUT') { + return { + handled: false + }; + } + + const baseUrl = serverParams.baseUrl ?? ''; + const { url } = getUrl(req, baseUrl); + + const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); + if (mainResourceId === '') { + return { + handled: false + } + } + + const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); + if (typeof theResource === 'undefined') { + return { + handled: false + }; + } + + const theDeserializer = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream'); + if (typeof theDeserializer === 'undefined') { + res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; + res.end(); + return { + handled: true + }; + } + + const negotiator = new Negotiator(req); + const availableMediaTypes = Array.from(appState.serializers.keys()); + const theMediaType = negotiator.mediaType(availableMediaTypes); + if (typeof theMediaType === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; + res.end(); + return { + handled: true + }; + } + + const theSerializerPair = appState.serializers.get(theMediaType); + if (typeof theSerializerPair === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; + res.end(); + return { + handled: true + }; + } + // TODO determine serializer pair before running the middlewares + + let bodyDeserialized: unknown; + try { + const schema = theResource.schema.type === 'object' ? theResource.schema as v.ObjectSchema : theResource.schema + //console.log(schema); + bodyDeserialized = await getBody( + req, + theDeserializer, + schema.type === 'object' + ? v.merge([ + schema as v.ObjectSchema, + v.object({ + [theResource.idAttr]: v.transform( + v.any(), + input => input.toString(), // TODO serialize/deserialize ID values + v.literal(mainResourceId) + ) + }) + ]) + : schema + ); + } catch (errRaw) { + const err = errRaw as v.ValiError; + res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; + res.statusMessage = `Invalid ${theResource.itemName}`; + + if (Array.isArray(err.issues)) { + // TODO better error reporting, localizable messages + const theFormatted = theSerializerPair.serialize( + err.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )) + ); + res.end(theFormatted); + } else { + res.end(); + } + return { + handled: true + }; + } + + try { + const newId = await theResource.newId(theResource.dataSource); + const params = bodyDeserialized as Record; + params[theResource.idAttr] = newId; + await theResource.dataSource.initialize(); + const newObject = await theResource.dataSource.emplace(mainResourceId, params); + const theFormatted = theSerializerPair.serialize(newObject); + res.writeHead(constants.HTTP_STATUS_OK, { + 'Content-Type': theMediaType, + 'Location': `${serverParams.baseUrl}/${theResource.routeName}/${newId}` + }); + res.end(theFormatted); + } catch { + res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; + res.statusMessage = `Could Not Return ${theResource.itemName}`; + res.end(); + } + return { + handled: true + }; +} diff --git a/src/index.ts b/src/index.ts index d9b6930..76c8025 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export * as v from 'valibot'; export * from './core'; +export * as valibot from 'valibot'; export * as dataSources from './data-sources'; export * as serializers from './serializers'; diff --git a/src/server.ts b/src/server.ts index c82c5e8..a7158dd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,7 +3,7 @@ import { DataSource, Resource, resource, - v, + valibot, dataSources, serializers } from '.'; @@ -49,9 +49,12 @@ const TEXT_SERIALIZER_PAIR = { deserialize: (str: string) => str as T }; -const Piano = resource(v.object({ - brand: v.string() -})) +const Piano = resource(valibot.object( + { + brand: valibot.string() + }, + valibot.never() +)) .name('Piano') .id('id', { generationStrategy: autoIncrement, @@ -59,13 +62,16 @@ const Piano = resource(v.object({ // TODO implement authentication and RBAC on each resource -const User = resource(v.object({ - firstName: v.string(), - middleName: v.string(), - lastName: v.string(), - bio: v.string(), - createdAt: v.date() -})) +const User = resource(valibot.object( + { + firstName: valibot.string(), + middleName: valibot.string(), + lastName: valibot.string(), + bio: valibot.string(), + createdAt: valibot.date() + }, + valibot.never() +)) .name('User') .fullText('bio') .id('id', { @@ -82,8 +88,9 @@ const app = application({ .resource(Piano) .resource(User); -app.createServer({ +const server = app.createServer({ baseUrl: '/api' -}).then((server) => { - server.listen(3000); }); + +server.listen(3000); +