diff --git a/src/core.ts b/src/core.ts index 80358df..a789adc 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,9 +1,15 @@ -import { pluralize } from 'inflection'; +import { IncomingMessage, ServerResponse, RequestListener } from 'http'; import { constants } from 'http2'; -import { IncomingMessage, ServerResponse } from 'http'; -import * as v from 'valibot'; -import Negotiator from 'negotiator'; -import {SerializerPair} from './serializers'; +import { pluralize } from 'inflection'; +import { BaseSchema, ObjectSchema } from 'valibot'; +import { SerializerPair } from './serializers'; +import { + handleCreateItem, + handleGetCollection, + handleGetItem, + handleGetRoot, + handleHasMethodAndUrl +} from './handlers'; export interface DataSource { initialize(): Promise; @@ -27,7 +33,7 @@ export interface Resource { routeName?: string; dataSource: DataSource; newId(dataSource: DataSource): string | number | unknown; - schema: v.BaseSchema; + schema: BaseSchema; } interface GenerationStrategy { @@ -38,7 +44,7 @@ interface IdParams { generationStrategy: GenerationStrategy; } -export const resource = (schema: T) => { +export const resource = (schema: T) => { let theIdAttr: string; let theItemName: string; let theCollectionName: string; @@ -56,7 +62,17 @@ export const resource = (schema: T) => { return idGenerationStrategy(dataSource); }, fullText(fullTextAttr: string) { - if (schema.type === 'object' && (schema as unknown as v.ObjectSchema, undefined, Record>).entries[fullTextAttr]?.type === 'string') { + if ( + schema.type === 'object' + && ( + schema as unknown as ObjectSchema< + Record, + undefined, + Record + > + ) + .entries[fullTextAttr]?.type === 'string' + ) { fullTextAttrs.add(fullTextAttr); return this; } @@ -101,106 +117,40 @@ interface CreateServerParams { host?: string; } -const handleGetAll = async (serializerPair: SerializerPair, mediaType: string, dataSource: DataSource, res: ServerResponse) => { - const resData = await dataSource.getMultiple(); // TODO paginated responses per resource - const theFormatted = serializerPair.serialize(resData); - - res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': mediaType, - 'X-Resource-Total-Item-Count': resData.length - }); - res.end(theFormatted); -}; - -const handleGetSingle = async (serializerPair: SerializerPair, mediaType: string, resource: Resource, mainResourceId: string, dataSource: DataSource, res: ServerResponse) => { - const singleResDatum = await dataSource.getSingle(mainResourceId); - - if (singleResDatum) { - const theFormatted = serializerPair.serialize(singleResDatum); - res.writeHead(constants.HTTP_STATUS_OK, { 'Content-Type': mediaType }); - res.end(theFormatted); - return; - } - - res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = `${resource.itemName} Not Found`; - res.end(); - return; -}; - -const handleCreate = async ( - deserializer: SerializerPair, - serializer: SerializerPair, - mediaType: string, - resource: Resource, - dataSource: DataSource, - req: IncomingMessage, - res: ServerResponse -) => { - return new Promise((resolve) => { - let body = Buffer.from(''); - req.on('data', (chunk) => { - body = Buffer.concat([body, chunk]); - }); - req.on('end', async () => { - const bodyStr = body.toString('utf-8'); // TODO use encoding in request header - let bodyDeserialized: object; - try { - bodyDeserialized = deserializer.deserialize(bodyStr); - if (typeof bodyDeserialized !== 'object' || bodyDeserialized === null) { - res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.statusMessage = `Invalid ${resource.itemName}`; - res.end(); - resolve(); - return; - } +type RequestListenerWithReturn< + P extends unknown = unknown, Q extends typeof IncomingMessage = typeof IncomingMessage, R extends typeof ServerResponse = typeof ServerResponse> = ( + ...args: Parameters> +) => P; - bodyDeserialized = await v.parseAsync(resource.schema, bodyDeserialized, { abortEarly: false }); - } catch (err) { - res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.statusMessage = `Invalid ${resource.itemName}`; +interface HandlerState { + handled: boolean; +} - if (Array.isArray(err.issues)) { - // TODO better error reporting, localizable messages - res.end( - err.issues.map((i) => `${i.path.map((p) => p.key).join('.')}:\n${i.message}`) - .join('\n\n') - ) - } else { - res.end(); - } - resolve(); - return; - } +interface ApplicationState { + resources: Set; + serializers: Map; +} - try { - const newId = await resource.newId(dataSource); - const newObject = await dataSource.create({ - ...bodyDeserialized, - [resource.idAttr]: newId, - }); - const theFormatted = serializer.serialize(newObject); - res.writeHead(constants.HTTP_STATUS_OK, {'Content-Type': mediaType}); - res.end(theFormatted); - return; - } catch { - res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; - res.statusMessage = `Could Not Return ${resource.itemName}`; - res.end(); - } +interface MiddlewareArgs { + handlerState: HandlerState; + appState: ApplicationState; + appParams: ApplicationParams; + serverParams: CreateServerParams; +} - resolve(); - }); - }); -}; +export interface Middleware { + (args: MiddlewareArgs): RequestListenerWithReturn> +} export const application = (appParams: ApplicationParams) => { - const resources = new Set(); - const serializers = new Map(); + const applicationState: ApplicationState = { + resources: new Set(), + serializers: new Map() + }; return { contentType(mimeTypePrefix: string, serializerPair: SerializerPair) { - serializers.set(mimeTypePrefix, serializerPair); + applicationState.serializers.set(mimeTypePrefix, serializerPair); return this; }, resource(res: Partial) { @@ -208,131 +158,50 @@ export const application = (appParams: ApplicationParams) => { if (typeof res.dataSource === 'undefined') { throw new Error(`Resource ${res.itemName} must have a data source.`); } - resources.add(res as Resource); + applicationState.resources.add(res as Resource); return this; }, async createServer(serverParams = {} as CreateServerParams) { - const { - baseUrl = '/', - host = 'http://localhost' // TODO not a sensible default... - } = serverParams; - const serverModule = await import('http'); - return serverModule.createServer( - async (req, res) => { - if (!req.method) { - res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { - 'Allow': 'HEAD,GET,POST,PUT,PATCH,DELETE' // TODO check with resources on allowed methods - }); - res.end(); - return; - } - - if (!req.url) { - res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.end(); - return; - } - - const urlObject = new URL(req.url, host); - const urlWithoutBaseRaw = urlObject.pathname.slice(baseUrl.length); - const urlWithoutBase = urlWithoutBaseRaw.length < 1 ? '/' : urlWithoutBaseRaw; - - if (req.method.toUpperCase() === 'GET' && urlWithoutBase === '/') { - const data = { - name: appParams.name - }; - res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': 'application/json', // TODO content negotiation, - // we are using custom headers for links because the standard Link header - // is referring to the document metadata (e.g. author, next page, etc) - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link - 'X-Resource-Link': Array.from(resources) - .map((r) => - `<${baseUrl}/${r.routeName}>; name="${r.collectionName}"`, - // TODO add host? - ) - .join(', ') - }); - res.end(JSON.stringify(data)) - return; - } - - const [, mainResourceRouteName, mainResourceId = ''] = urlWithoutBase.split('/'); - const theResource = Array.from(resources).find((r) => r.routeName === mainResourceRouteName); - if (typeof theResource !== 'undefined') { - await theResource.dataSource.initialize(); - const method = req.method.toUpperCase(); - if (method === 'GET') { - const negotiator = new Negotiator(req); - const availableMediaTypes = Array.from(serializers.keys()); - const theMediaType = negotiator.mediaType(availableMediaTypes); - - if (typeof theMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return; + const server = serverModule.createServer(); + + server.on('request', async (req, res) => { + const middlewareState = await [ + handleHasMethodAndUrl, + handleGetRoot, + handleGetCollection, + handleGetItem, + handleCreateItem, + ] + .reduce( + async (currentHandlerStatePromise, middleware) => { + const currentHandlerState = await currentHandlerStatePromise; + if (currentHandlerState.handled) { + return currentHandlerState; } - const theSerializerPair = serializers.get(theMediaType); - if (typeof theSerializerPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return; - } - - if (mainResourceId === '') { - await handleGetAll(theSerializerPair, theMediaType, theResource.dataSource, res); - return; - } - - await handleGetSingle(theSerializerPair, theMediaType, theResource, mainResourceId, theResource.dataSource, res); - return; - } - - if (method === 'POST') { - if (mainResourceId === '') { - const theDeserializer = serializers.get(req.headers['content-type'] ?? 'application/octet-stream'); - if (typeof theDeserializer === 'undefined') { - res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; - res.end(); - return; - } - - const negotiator = new Negotiator(req); - const availableMediaTypes = Array.from(serializers.keys()); - const theMediaType = negotiator.mediaType(availableMediaTypes); - - if (typeof theMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return; - } - - const theSerializer = serializers.get(theMediaType); - if (typeof theSerializer === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return; - } - - await handleCreate(theDeserializer, theSerializer, theMediaType, theResource, theResource.dataSource, req, res); - return; - } - - res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.end(); - return; - } + return middleware({ + handlerState: currentHandlerState, + appState: applicationState, + appParams, + serverParams + })(req, res); + }, + Promise.resolve({ + handled: false + }) + ); + + if (middlewareState.handled) { + return; + } - return; - } + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + res.statusMessage = 'URL Not Found'; + res.end(); + }); - res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = 'URL Not Found'; - res.end(); - } - ); + return server; } }; }; diff --git a/src/handlers.ts b/src/handlers.ts new file mode 100644 index 0000000..08169f0 --- /dev/null +++ b/src/handlers.ts @@ -0,0 +1,324 @@ +import { constants } from 'http2'; +import Negotiator from 'negotiator'; +import { ValiError } from 'valibot'; +import { Middleware } from './core'; +import { getBody, getMethod, getUrl } from './utils'; + +export const handleHasMethodAndUrl: Middleware = ({}) => (req, res) => { + if (!req.method) { + res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { + 'Allow': 'HEAD,GET,POST,PUT,PATCH,DELETE' // TODO check with resources on allowed methods + }); + res.end(); + return { + handled: true + }; + } + + if (!req.url) { + res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; + res.end(); + return { + handled: true + }; + } + + return { + handled: false + }; +}; + +export const handleGetRoot: Middleware = ({ + appState, + appParams, + serverParams +}) => (req, res) => { + const method = getMethod(req); + if (method !== 'GET') { + return { + handled: false + }; + } + + const baseUrl = serverParams.baseUrl ?? ''; + const { url } = getUrl(req, baseUrl); + if (url !== '/') { + return { + handled: false + }; + } + + 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 + }; + } + + const singleResDatum = { + name: appParams.name + }; + const theFormatted = theSerializerPair.serialize(singleResDatum); + res.writeHead(constants.HTTP_STATUS_OK, { + 'Content-Type': theMediaType, + // we are using custom headers for links because the standard Link header + // is referring to the document metadata (e.g. author, next page, etc) + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link + 'X-Resource-Link': Array.from(appState.resources) + .map((r) => + `<${baseUrl}/${r.routeName}>; name="${r.collectionName}"`, + // TODO add host? + ) + .join(', ') + }); + res.end(theFormatted); + return { + handled: true + }; +}; + +export const handleGetCollection: Middleware = ({ + appState, + serverParams +}) => async (req, res) => { + const method = getMethod(req); + if (method !== 'GET') { + 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 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 resData = await theResource.dataSource.getMultiple(); // TODO paginated responses per resource + const theFormatted = theSerializerPair.serialize(resData); + + res.writeHead(constants.HTTP_STATUS_OK, { + 'Content-Type': theMediaType, + 'X-Resource-Total-Item-Count': resData.length + }); + res.end(theFormatted); + + return { + handled: true + }; +}; + +export const handleGetItem: Middleware = ({ + appState, + serverParams +}) => async (req, res) => { + const method = getMethod(req); + if (method !== 'GET') { + 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 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 singleResDatum = await theResource.dataSource.getSingle(mainResourceId); + if (singleResDatum) { + const theFormatted = theSerializerPair.serialize(singleResDatum); + res.writeHead(constants.HTTP_STATUS_OK, { 'Content-Type': theMediaType }); + res.end(theFormatted); + return { + handled: true + }; + } + + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + res.statusMessage = `${theResource.itemName} Not Found`; + res.end(); + return { + handled: true + }; +}; + +export const handleCreateItem: Middleware = ({ + appState, + serverParams +}) => async (req, res) => { + const method = getMethod(req); + if (method !== 'POST') { + 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 + }; + } + + try { + const newId = await theResource.newId(theResource.dataSource); + const params = bodyDeserialized as Record; + params[theResource.idAttr] = newId; + const newObject = await theResource.dataSource.create(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 99a8c72..d9b6930 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,4 @@ +export * as v from 'valibot'; export * from './core'; -export * from './data-sources'; +export * as dataSources from './data-sources'; +export * as serializers from './serializers'; diff --git a/src/server.ts b/src/server.ts index 5f70945..c82c5e8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,12 +3,11 @@ import { DataSource, Resource, resource, + v, + dataSources, + serializers } from '.'; -import * as v from 'valibot'; -import * as dataSources from './data-sources'; -import * as serializers from './serializers'; - const autoIncrement = async (dataSource: DataSource) => { const data = await dataSource.getMultiple() as Record[]; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..92265c3 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,37 @@ +import { IncomingMessage } from 'http'; +import { SerializerPair } from './serializers'; +import { BaseSchema, parseAsync } from 'valibot'; + +export const getMethod = (req: IncomingMessage) => req.method!.toUpperCase(); + +export const getUrl = (req: IncomingMessage, baseUrl?: string) => { + const urlObject = new URL(req.url!, 'http://localhost'); + const urlWithoutBaseRaw = urlObject.pathname.slice(baseUrl?.length ?? 0); + + return { + url: urlWithoutBaseRaw.length < 1 ? '/' : urlWithoutBaseRaw, + query: urlObject.searchParams + }; +} + +export const getBody = (req: IncomingMessage, deserializer: SerializerPair, schema: BaseSchema) => { + return new Promise((resolve, reject) => { + let body = Buffer.from(''); + req.on('data', (chunk) => { + body = Buffer.concat([body, chunk]); + }); + req.on('end', async () => { + const bodyStr = body.toString('utf-8'); // TODO use encoding in request header + try { + const bodyDeserialized = await parseAsync( + schema, + deserializer.deserialize(bodyStr), + { abortEarly: false } + ); + resolve(bodyDeserialized); + } catch (err) { + reject(err); + } + }); + }); +};