Browse Source

Implement all resource endpoints

Complete implementation of PUT, PATCH, and DELETE.
master
TheoryOfNekomata 9 months ago
parent
commit
082f29215b
5 changed files with 330 additions and 119 deletions
  1. +56
    -17
      src/core.ts
  2. +6
    -2
      src/data-sources/file-jsonl.ts
  3. +246
    -85
      src/handlers.ts
  4. +1
    -1
      src/index.ts
  5. +21
    -14
      src/server.ts

+ 56
- 17
src/core.ts View File

@@ -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<T = object> {
@@ -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<string | number | unknown>;
}
@@ -46,7 +62,7 @@ interface IdParams {
generationStrategy: GenerationStrategy;
}

export const resource = <T extends BaseSchema>(schema: T) => {
export const resource = <T extends BaseSchema>(schema: T): Resource => {
let theIdAttr: string;
let theItemName: string;
let theCollectionName: string;
@@ -127,17 +143,23 @@ export const resource = <T extends BaseSchema>(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<RequestListener<Q, R>>
P extends unknown = unknown,
Q extends typeof http.IncomingMessage = typeof http.IncomingMessage,
R extends typeof http.ServerResponse = typeof http.ServerResponse
> = (
...args: Parameters<http.RequestListener<Q, R>>
) => P;

interface HandlerState {
@@ -145,7 +167,7 @@ interface HandlerState {
}

interface ApplicationState {
resources: Set<Resource>;
resources: Set<ResourceWithDataSource>;
serializers: Map<string, SerializerPair>;
}

@@ -160,9 +182,15 @@ export interface Middleware {
(args: MiddlewareArgs): RequestListenerWithReturn<HandlerState | Promise<HandlerState>>
}

export const application = (appParams: ApplicationParams) => {
export interface Application {
contentType(mimeTypePrefix: string, serializerPair: SerializerPair): this;
resource(resRaw: Partial<Resource>): this;
createServer(serverParams?: CreateServerParams): http.Server | https.Server;
}

export const application = (appParams: ApplicationParams): Application => {
const applicationState: ApplicationState = {
resources: new Set<Resource>(),
resources: new Set<ResourceWithDataSource>(),
serializers: new Map<string, SerializerPair>()
};

@@ -171,25 +199,36 @@ export const application = (appParams: ApplicationParams) => {
applicationState.serializers.set(mimeTypePrefix, serializerPair);
return this;
},
resource(res: Partial<Resource>) {
resource(resRaw: Partial<Resource>) {
const res = resRaw as Partial<ResourceWithDataSource>;
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(


+ 6
- 2
src/data-sources/file-jsonl.ts View File

@@ -59,11 +59,15 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI

async emplace(id: string, data: Partial<T>) {
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<T extends Record<string, string>> implements DataSourceI
return data as T;
}

return this.create(data);
return this.create(dataToEmplace);
}

async patch(id: string, data: Partial<T>) {


+ 246
- 85
src/handlers.ts View File

@@ -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<any> : theResource.schema
bodyDeserialized = await getBody(
req,
theDeserializer,
schema.type === 'object'
? v.partial(
schema as v.ObjectSchema<any>,
(schema as v.ObjectSchema<any>).rest,
(schema as v.ObjectSchema<any>).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<string, unknown>;
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<string, unknown>;
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<any> : theResource.schema
//console.log(schema);
bodyDeserialized = await getBody(
req,
theDeserializer,
schema.type === 'object'
? v.merge([
schema as v.ObjectSchema<any>,
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<string, unknown>;
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
};
}

+ 1
- 1
src/index.ts View File

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

+ 21
- 14
src/server.ts View File

@@ -3,7 +3,7 @@ import {
DataSource,
Resource,
resource,
v,
valibot,
dataSources,
serializers
} from '.';
@@ -49,9 +49,12 @@ const TEXT_SERIALIZER_PAIR = {
deserialize: <T>(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);


Loading…
Cancel
Save