Browse Source

Add content negotiation utilities

Add content negotiation for encodings.
master
TheoryOfNekomata 9 months ago
parent
commit
0ee4d502cc
8 changed files with 197 additions and 233 deletions
  1. +7
    -1
      examples/basic/server.ts
  2. +83
    -10
      src/core.ts
  3. +6
    -6
      src/data-sources/file-jsonl.ts
  4. +6
    -0
      src/encodings/index.ts
  5. +3
    -0
      src/encodings/utf-8.ts
  6. +66
    -208
      src/handlers.ts
  7. +1
    -0
      src/index.ts
  8. +25
    -8
      src/utils.ts

+ 7
- 1
examples/basic/server.ts View File

@@ -2,7 +2,8 @@ import {
application,
resource,
valibot as v,
serializers
serializers,
encodings,
} from '../../src';
import {TEXT_SERIALIZER_PAIR} from './serializers';
import {autoIncrement, dataSource} from './data-source';
@@ -16,6 +17,8 @@ const Piano = resource(v.object(
.name('Piano')
.id('id', {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
});

// TODO implement authentication and RBAC on each resource
@@ -34,6 +37,8 @@ const User = resource(v.object(
.fullText('bio')
.id('id', {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
});

const app = application({
@@ -43,6 +48,7 @@ const app = application({
.contentType('application/json', serializers.applicationJson)
.contentType('text/json', serializers.textJson)
.contentType('text/plain', TEXT_SERIALIZER_PAIR)
.encoding('utf-8', encodings.utf8)
.resource(Piano)
.resource(User);



+ 83
- 10
src/core.ts View File

@@ -11,6 +11,9 @@ import {
handleGetRoot,
handleHasMethodAndUrl, handlePatchItem,
} from './handlers';
import Negotiator from 'negotiator';
import {getMethod, getUrl} from './utils';
import {EncodingPair} from './encodings';

export interface DataSource<T = object> {
initialize(): Promise<unknown>;
@@ -46,6 +49,8 @@ export interface ResourceData<T extends BaseSchema> {
schema: T;
throws404OnDeletingNotFound: boolean;
checksSerializersOnDelete: boolean;
idSerializer: NonNullable<IdParams['serialize']>;
idDeserializer: NonNullable<IdParams['deserialize']>;
}

export type Resource<T extends BaseSchema = any> = ResourceData<T> & ResourceFactory;
@@ -60,6 +65,8 @@ interface GenerationStrategy {

interface IdParams {
generationStrategy: GenerationStrategy;
serialize?: (id: unknown) => string;
deserialize?: (id: string) => unknown;
}

export const resource = <T extends BaseSchema>(schema: T): Resource<T> => {
@@ -67,7 +74,9 @@ export const resource = <T extends BaseSchema>(schema: T): Resource<T> => {
let theItemName: string;
let theCollectionName: string;
let theRouteName: string;
let idGenerationStrategy: GenerationStrategy;
let theIdGenerationStrategy: GenerationStrategy;
let theIdSerializer: IdParams['serialize'];
let theIdDeserializer: IdParams['deserialize'];
let throw404OnDeletingNotFound = true;
let checkSerializersOnDelete = false;
const fullTextAttrs = new Set<string>();
@@ -87,13 +96,21 @@ export const resource = <T extends BaseSchema>(schema: T): Resource<T> => {
get throws404OnDeletingNotFound() {
return throw404OnDeletingNotFound;
},
get idSerializer() {
return theIdSerializer;
},
get idDeserializer() {
return theIdDeserializer;
},
id(newIdAttr: string, params: IdParams) {
theIdAttr = newIdAttr;
idGenerationStrategy = params.generationStrategy;
theIdGenerationStrategy = params.generationStrategy;
theIdSerializer = params.serialize;
theIdDeserializer = params.deserialize;
return this;
},
newId(dataSource: DataSource) {
return idGenerationStrategy(dataSource);
return theIdGenerationStrategy(dataSource);
},
fullText(fullTextAttr: string) {
if (
@@ -166,9 +183,10 @@ interface HandlerState {
handled: boolean;
}

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

interface MiddlewareArgs {
@@ -176,6 +194,12 @@ interface MiddlewareArgs {
appState: ApplicationState;
appParams: ApplicationParams;
serverParams: CreateServerParams;
requestBodyEncodingPair: EncodingPair;
responseBodySerializerPair: SerializerPair;
responseMediaType: string;
method: string;
url: string;
query: URLSearchParams;
}

export interface Middleware {
@@ -184,19 +208,25 @@ export interface Middleware {

export interface Application {
contentType(mimeTypePrefix: string, serializerPair: SerializerPair): this;
encoding(encoding: string, encodingPair: EncodingPair): this;
resource(resRaw: Partial<Resource>): this;
createServer(serverParams?: CreateServerParams): http.Server | https.Server;
}

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

return {
contentType(mimeTypePrefix: string, serializerPair: SerializerPair) {
applicationState.serializers.set(mimeTypePrefix, serializerPair);
appState.serializers.set(mimeTypePrefix, serializerPair);
return this;
},
encoding(encoding: string, encodingPair: EncodingPair) {
appState.encodings.set(encoding, encodingPair);
return this;
},
resource(resRaw: Partial<Resource>) {
@@ -205,7 +235,7 @@ export const application = (appParams: ApplicationParams): Application => {
if (typeof res.dataSource === 'undefined') {
throw new Error(`Resource ${res.itemName} must have a data source.`);
}
applicationState.resources.add(res as ResourceWithDataSource);
appState.resources.add(res as ResourceWithDataSource);
return this;
},
createServer(serverParams = {} as CreateServerParams) {
@@ -220,6 +250,43 @@ export const application = (appParams: ApplicationParams): Application => {
});

server.on('request', async (req, res) => {
const method = getMethod(req);
const baseUrl = serverParams.baseUrl ?? '';
const { url, query } = getUrl(req, baseUrl);

const negotiator = new Negotiator(req);
const availableMediaTypes = Array.from(appState.serializers.keys());
const mediaType = negotiator.mediaType(availableMediaTypes);

if (typeof mediaType === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE;
res.end();
return;
}

const serializer = appState.serializers.get(mediaType);
if (typeof serializer === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE;
res.end();
return;
}

const availableEncodings = Array.from(appState.encodings.keys());
const encoding = negotiator.encoding(availableEncodings);

if (typeof encoding === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE;
res.end();
return;
}

const encodingPair = appState.encodings.get(encoding);
if (typeof encodingPair === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE;
res.end();
return;
}

// TODO return method not allowed error when operations are not allowed on a resource
const middlewareState = await [
handleHasMethodAndUrl,
@@ -240,9 +307,15 @@ export const application = (appParams: ApplicationParams): Application => {

return middleware({
handlerState: currentHandlerState,
appState: applicationState,
appState,
appParams,
serverParams
serverParams,
responseBodySerializerPair: serializer,
responseMediaType: mediaType,
method,
url,
query,
requestBodyEncodingPair: encodingPair,
})(req, res);
},
Promise.resolve<HandlerState>({


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

@@ -7,7 +7,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI

data: T[] = [];

constructor(private readonly resource: Resource, private readonly baseDir = '') {
constructor(private readonly resource: Resource, baseDir = '') {
this.path = join(baseDir, `${this.resource.collectionName}.jsonl`);
}

@@ -26,7 +26,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}

async getSingle(id: string) {
const foundData = this.data.find((s) => s[this.resource.idAttr as string].toString() === id);
const foundData = this.data.find((s) => this.resource.idSerializer(s[this.resource.idAttr as string]) === id);

if (foundData) {
return {
@@ -51,7 +51,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
async delete(id: string) {
const oldDataLength = this.data.length;

const newData = this.data.filter((s) => !(s[this.resource.idAttr as string].toString() === id));
const newData = this.data.filter((s) => !(this.resource.idSerializer(s[this.resource.idAttr as string]) === id));

await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n'));

@@ -62,12 +62,12 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
const existing = await this.getSingle(id);
const dataToEmplace = {
...data,
[this.resource.idAttr]: id, // TODO properly serialize it to data source.
[this.resource.idAttr]: this.resource.idDeserializer(id),
};

if (existing) {
const newData = this.data.map((d) => {
if (d[this.resource.idAttr as string].toString() === id) {
if (this.resource.idSerializer(d[this.resource.idAttr as string]) === id) {
return dataToEmplace;
}

@@ -96,7 +96,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}

const newData = this.data.map((d) => {
if (d[this.resource.idAttr as string].toString() === id) {
if (this.resource.idSerializer(d[this.resource.idAttr as string]) === id) {
return newItem;
}



+ 6
- 0
src/encodings/index.ts View File

@@ -0,0 +1,6 @@
export * as utf8 from './utf-8';

export interface EncodingPair {
encode: (str: string) => Buffer;
decode: (buf: Buffer) => string;
}

+ 3
- 0
src/encodings/utf-8.ts View File

@@ -0,0 +1,3 @@
export const encode = (str: string) => Buffer.from(str, 'utf-8');

export const decode = (buf: Buffer) => buf.toString('utf-8');

+ 66
- 208
src/handlers.ts View File

@@ -1,10 +1,10 @@
import { constants } from 'http2';
import Negotiator from 'negotiator';
import * as v from 'valibot';
import {Middleware} from './core';
import { getBody, getMethod, getUrl } from './utils';
import {getBody, getDeserializerObjects, getMethod, getUrl} from './utils';
import {IncomingMessage, ServerResponse} from 'http';

export const handleHasMethodAndUrl: Middleware = ({}) => (req, res) => {
export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, res: ServerResponse) => {
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
@@ -31,56 +31,36 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req, res) => {
export const handleGetRoot: Middleware = ({
appState,
appParams,
serverParams
}) => (req, res) => {
const method = getMethod(req);
responseBodySerializerPair,
serverParams,
responseMediaType,
method,
url,
}) => (_req: IncomingMessage, res: ServerResponse) => {
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);
const theFormatted = responseBodySerializerPair.serialize(singleResDatum);
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': theMediaType,
'Content-Type': responseMediaType,
// 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?
`<${serverParams.baseUrl}/${r.routeName}>; name="${r.collectionName}"`,
)
.join(', ')
});
@@ -92,8 +72,10 @@ export const handleGetRoot: Middleware = ({

export const handleGetCollection: Middleware = ({
appState,
serverParams
}) => async (req, res) => {
serverParams,
responseBodySerializerPair,
responseMediaType,
}) => async (req: IncomingMessage, res: ServerResponse) => {
const method = getMethod(req);
if (method !== 'GET') {
return {
@@ -118,33 +100,13 @@ export const handleGetCollection: Middleware = ({
};
}

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

try {
await theResource.dataSource.initialize();
const resData = await theResource.dataSource.getMultiple(); // TODO paginated responses per resource
const theFormatted = theSerializerPair.serialize(resData);
const theFormatted = responseBodySerializerPair.serialize(resData);

res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': theMediaType,
'Content-Type': responseMediaType,
'X-Resource-Total-Item-Count': resData.length
});
res.end(theFormatted);
@@ -160,8 +122,10 @@ export const handleGetCollection: Middleware = ({

export const handleGetItem: Middleware = ({
appState,
serverParams
}) => async (req, res) => {
serverParams,
responseBodySerializerPair,
responseMediaType,
}) => async (req: IncomingMessage, res: ServerResponse) => {
const method = getMethod(req);
if (method !== 'GET') {
return {
@@ -186,32 +150,12 @@ export const handleGetItem: Middleware = ({
};
}

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

try {
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});
const theFormatted = responseBodySerializerPair.serialize(singleResDatum);
res.writeHead(constants.HTTP_STATUS_OK, {'Content-Type': responseMediaType});
res.end(theFormatted);
return {
handled: true
@@ -236,18 +180,15 @@ export const handleGetItem: Middleware = ({

export const handleDeleteItem: Middleware = ({
appState,
serverParams
}) => async (req, res) => {
const method = getMethod(req);
method,
url,
}) => async (_req: IncomingMessage, res: ServerResponse) => {
if (method !== 'DELETE') {
return {
handled: false
};
}

const baseUrl = serverParams.baseUrl ?? '';
const { url } = getUrl(req, baseUrl);

const [, mainResourceRouteName, mainResourceId = ''] = url.split('/');
if (mainResourceId === '') {
return {
@@ -262,28 +203,6 @@ export const handleDeleteItem: Middleware = ({
};
}

if (theResource.checksSerializersOnDelete) {
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
};
}
}

try {
await theResource.dataSource.initialize();
const response = await theResource.dataSource.delete(mainResourceId);
@@ -311,18 +230,17 @@ export const handleDeleteItem: Middleware = ({

export const handlePatchItem: Middleware = ({
appState,
serverParams,
}) => async (req, res) => {
const method = getMethod(req);
method,
url,
responseBodySerializerPair,
responseMediaType,
}) => async (req: IncomingMessage, res: ServerResponse) => {
if (method !== 'PATCH') {
return {
handled: false
};
}

const baseUrl = serverParams.baseUrl ?? '';
const { url } = getUrl(req, baseUrl);

const [, mainResourceRouteName, mainResourceId = ''] = url.split('/');
if (mainResourceId === '') {
return {
@@ -337,8 +255,8 @@ export const handlePatchItem: Middleware = ({
};
}

const theDeserializer = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream');
if (typeof theDeserializer === 'undefined') {
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req);
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') {
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE;
res.end();
return {
@@ -346,26 +264,6 @@ export const handlePatchItem: Middleware = ({
};
}

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) {
@@ -382,7 +280,8 @@ export const handlePatchItem: Middleware = ({
const schema = theResource.schema.type === 'object' ? theResource.schema as v.ObjectSchema<any> : theResource.schema
bodyDeserialized = await getBody(
req,
theDeserializer,
requestBodyDeserializerPair,
requestBodyEncodingPair,
schema.type === 'object'
? v.partial(
schema as v.ObjectSchema<any>,
@@ -398,7 +297,7 @@ export const handlePatchItem: Middleware = ({

if (Array.isArray(err.issues)) {
// TODO better error reporting, localizable messages
const theFormatted = theSerializerPair.serialize(
const theFormatted = responseBodySerializerPair.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
))
@@ -416,9 +315,9 @@ export const handlePatchItem: Middleware = ({
const params = bodyDeserialized as Record<string, unknown>;
await theResource.dataSource.initialize();
const newObject = await theResource.dataSource.patch(mainResourceId, params);
const theFormatted = theSerializerPair.serialize(newObject);
const theFormatted = responseBodySerializerPair.serialize(newObject);
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': theMediaType,
'Content-Type': responseMediaType,
});
res.end(theFormatted);
} catch {
@@ -433,18 +332,18 @@ export const handlePatchItem: Middleware = ({

export const handleCreateItem: Middleware = ({
appState,
serverParams
}) => async (req, res) => {
const method = getMethod(req);
serverParams,
method,
url,
responseMediaType,
responseBodySerializerPair,
}) => async (req: IncomingMessage, res: ServerResponse) => {
if (method !== 'POST') {
return {
handled: false
};
}

const baseUrl = serverParams.baseUrl ?? '';
const { url } = getUrl(req, baseUrl);

const [, mainResourceRouteName, mainResourceId = ''] = url.split('/');
if (mainResourceId !== '') {
return {
@@ -459,8 +358,8 @@ export const handleCreateItem: Middleware = ({
};
}

const theDeserializer = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream');
if (typeof theDeserializer === 'undefined') {
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req);
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') {
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE;
res.end();
return {
@@ -468,30 +367,9 @@ export const handleCreateItem: Middleware = ({
};
}

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 {
bodyDeserialized = await getBody(req, theDeserializer, theResource.schema);
bodyDeserialized = await getBody(req, requestBodyDeserializerPair, requestBodyEncodingPair, theResource.schema);
} catch (errRaw) {
const err = errRaw as v.ValiError;
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
@@ -499,7 +377,7 @@ export const handleCreateItem: Middleware = ({

if (Array.isArray(err.issues)) {
// TODO better error reporting, localizable messages
const theFormatted = theSerializerPair.serialize(
const theFormatted = responseBodySerializerPair.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
))
@@ -519,9 +397,9 @@ export const handleCreateItem: Middleware = ({
const params = bodyDeserialized as Record<string, unknown>;
params[theResource.idAttr] = newId;
const newObject = await theResource.dataSource.create(params);
const theFormatted = theSerializerPair.serialize(newObject);
const theFormatted = responseBodySerializerPair.serialize(newObject);
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': theMediaType,
'Content-Type': responseMediaType,
'Location': `${serverParams.baseUrl}/${theResource.routeName}/${newId}`
});
res.end(theFormatted);
@@ -537,18 +415,18 @@ export const handleCreateItem: Middleware = ({

export const handleEmplaceItem: Middleware = ({
appState,
serverParams
}) => async (req, res) => {
const method = getMethod(req);
serverParams,
method,
url,
responseBodySerializerPair,
responseMediaType,
}) => async (req: IncomingMessage, res: ServerResponse) => {
if (method !== 'PUT') {
return {
handled: false
};
}

const baseUrl = serverParams.baseUrl ?? '';
const { url } = getUrl(req, baseUrl);

const [, mainResourceRouteName, mainResourceId = ''] = url.split('/');
if (mainResourceId === '') {
return {
@@ -563,8 +441,8 @@ export const handleEmplaceItem: Middleware = ({
};
}

const theDeserializer = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream');
if (typeof theDeserializer === 'undefined') {
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req);
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') {
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE;
res.end();
return {
@@ -572,41 +450,21 @@ export const handleEmplaceItem: Middleware = ({
};
}

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,
requestBodyDeserializerPair,
requestBodyEncodingPair,
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
input => theResource.idSerializer(input),
v.literal(mainResourceId)
)
})
@@ -620,7 +478,7 @@ export const handleEmplaceItem: Middleware = ({

if (Array.isArray(err.issues)) {
// TODO better error reporting, localizable messages
const theFormatted = theSerializerPair.serialize(
const theFormatted = responseBodySerializerPair.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
))
@@ -638,15 +496,15 @@ export const handleEmplaceItem: Middleware = ({
await theResource.dataSource.initialize();
const params = bodyDeserialized as Record<string, unknown>;
const [newObject, isCreated] = await theResource.dataSource.emplace(mainResourceId, params);
const theFormatted = theSerializerPair.serialize(newObject);
const theFormatted = responseBodySerializerPair.serialize(newObject);
if (isCreated) {
res.writeHead(constants.HTTP_STATUS_CREATED, {
'Content-Type': theMediaType,
'Content-Type': responseMediaType,
'Location': `${serverParams.baseUrl}/${theResource.routeName}/${mainResourceId}`
});
} else {
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': theMediaType,
'Content-Type': responseMediaType,
});
}
res.end(theFormatted);


+ 1
- 0
src/index.ts View File

@@ -2,3 +2,4 @@ export * from './core';
export * as valibot from './validation';
export * as dataSources from './data-sources';
export * as serializers from './serializers';
export * as encodings from './encodings';

+ 25
- 8
src/utils.ts View File

@@ -1,8 +1,20 @@
import { IncomingMessage } from 'http';
import { SerializerPair } from './serializers';
import { BaseSchema, parseAsync } from 'valibot';
import {IncomingMessage} from 'http';
import {SerializerPair} from './serializers';
import {BaseSchema, parseAsync} from 'valibot';
import { URL } from 'url';
import {EncodingPair} from './encodings';
import {ApplicationState} from './core';

export const getMethod = (req: IncomingMessage) => req.method!.toUpperCase();
export const getDeserializerObjects = (appState: ApplicationState, req: IncomingMessage) => {
const deserializerPair = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream');
const encodingPair = appState.encodings.get(req.headers['content-encoding'] ?? 'utf-8');
return {
deserializerPair,
encodingPair,
};
};

export const getMethod = (req: IncomingMessage) => req.method!.trim().toUpperCase();

export const getUrl = (req: IncomingMessage, baseUrl?: string) => {
const urlObject = new URL(req.url!, 'http://localhost');
@@ -10,23 +22,28 @@ export const getUrl = (req: IncomingMessage, baseUrl?: string) => {

return {
url: urlWithoutBaseRaw.length < 1 ? '/' : urlWithoutBaseRaw,
query: urlObject.searchParams
query: urlObject.searchParams,
};
}

export const getBody = (req: IncomingMessage, deserializer: SerializerPair, schema: BaseSchema) => {
export const getBody = (
req: IncomingMessage,
deserializer: SerializerPair,
encodingPair: EncodingPair,
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
const bodyStr = encodingPair.decode(body);
try {
const bodyDeserialized = await parseAsync(
schema,
deserializer.deserialize(bodyStr),
{ abortEarly: false }
{abortEarly: false},
);
resolve(bodyDeserialized);
} catch (err) {


Loading…
Cancel
Save