|
|
@@ -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<T = object> { |
|
|
|
initialize(): Promise<unknown>; |
|
|
@@ -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 = <T extends v.BaseSchema>(schema: T) => { |
|
|
|
export const resource = <T extends BaseSchema>(schema: T) => { |
|
|
|
let theIdAttr: string; |
|
|
|
let theItemName: string; |
|
|
|
let theCollectionName: string; |
|
|
@@ -56,7 +62,17 @@ export const resource = <T extends v.BaseSchema>(schema: T) => { |
|
|
|
return idGenerationStrategy(dataSource); |
|
|
|
}, |
|
|
|
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') { |
|
|
|
if ( |
|
|
|
schema.type === 'object' |
|
|
|
&& ( |
|
|
|
schema as unknown as ObjectSchema< |
|
|
|
Record<string, BaseSchema>, |
|
|
|
undefined, |
|
|
|
Record<string, string> |
|
|
|
> |
|
|
|
) |
|
|
|
.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<void>((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<RequestListener<Q, R>> |
|
|
|
) => 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<Resource>; |
|
|
|
serializers: Map<string, SerializerPair>; |
|
|
|
} |
|
|
|
|
|
|
|
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<HandlerState | Promise<HandlerState>> |
|
|
|
} |
|
|
|
|
|
|
|
export const application = (appParams: ApplicationParams) => { |
|
|
|
const resources = new Set<Resource>(); |
|
|
|
const serializers = new Map<string, SerializerPair>(); |
|
|
|
const applicationState: ApplicationState = { |
|
|
|
resources: new Set<Resource>(), |
|
|
|
serializers: new Map<string, SerializerPair>() |
|
|
|
}; |
|
|
|
|
|
|
|
return { |
|
|
|
contentType(mimeTypePrefix: string, serializerPair: SerializerPair) { |
|
|
|
serializers.set(mimeTypePrefix, serializerPair); |
|
|
|
applicationState.serializers.set(mimeTypePrefix, serializerPair); |
|
|
|
return this; |
|
|
|
}, |
|
|
|
resource(res: Partial<Resource>) { |
|
|
@@ -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<HandlerState>({ |
|
|
|
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; |
|
|
|
} |
|
|
|
}; |
|
|
|
}; |