Browse Source

Refactor codebase

Put each function to their appropriate files.
master
TheoryOfNekomata 5 months ago
parent
commit
41384883ad
5 changed files with 452 additions and 221 deletions
  1. +85
    -216
      src/core.ts
  2. +324
    -0
      src/handlers.ts
  3. +3
    -1
      src/index.ts
  4. +3
    -4
      src/server.ts
  5. +37
    -0
      src/utils.ts

+ 85
- 216
src/core.ts View File

@@ -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;
}
};
};

+ 324
- 0
src/handlers.ts View File

@@ -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<string, unknown>;
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
};
}

+ 3
- 1
src/index.ts View File

@@ -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';

+ 3
- 4
src/server.ts View File

@@ -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<string, string>[];



+ 37
- 0
src/utils.ts View File

@@ -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);
}
});
});
};

Loading…
Cancel
Save