Browse Source

Refactor codebase

Respect content negotiation.
master
TheoryOfNekomata 9 months ago
parent
commit
83069a3e4a
9 changed files with 1109 additions and 526 deletions
  1. +2
    -2
      examples/basic/data-source.ts
  2. +13
    -11
      examples/basic/server.ts
  3. +415
    -284
      src/core.ts
  4. +12
    -8
      src/data-sources/file-jsonl.ts
  5. +2
    -0
      src/encodings/utf-8.ts
  6. +507
    -165
      src/handlers.ts
  7. +97
    -0
      src/languages/en/index.ts
  8. +2
    -0
      src/serializers/application/json.ts
  9. +59
    -56
      test/e2e/default.test.ts

+ 2
- 2
examples/basic/data-source.ts View File

@@ -9,10 +9,10 @@ export const autoIncrement = async (dataSource: DataSource) => {
);

if (Number.isFinite(highestId)) {
return (highestId + 1).toString();
return (highestId + 1);
}

return "1";
return 1;
};

export const dataSource = (resource: Resource) => new dataSources.jsonlFile.DataSource(resource, 'examples/basic');

+ 13
- 11
examples/basic/server.ts View File

@@ -20,12 +20,12 @@ const Piano = resource(v.object(
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
})
.allowFetchItem()
.allowFetchCollection()
.allowCreate()
.allowEmplace()
.allowPatch()
.allowDelete();
.canFetchItem()
.canFetchCollection()
.canCreate()
.canEmplace()
.canPatch()
.canDelete();

const User = resource(v.object(
{
@@ -56,7 +56,9 @@ const app = application({
.resource(Piano)
.resource(User);

const server = app.createServer({
const backend = app.createBackend();

const server = backend.createServer({
baseUrl: '/api'
});

@@ -65,8 +67,8 @@ server.listen(3000);
setTimeout(() => {
// Allow user operations after 5 seconds from startup
User
.allowFetchItem()
.allowFetchCollection()
.allowCreate()
.allowPatch();
.canFetchItem()
.canFetchCollection()
.canCreate()
.canPatch();
}, 5000);

+ 415
- 284
src/core.ts View File

@@ -14,9 +14,17 @@ import {
import Negotiator from 'negotiator';
import {getMethod, getUrl} from './utils';
import {EncodingPair} from './encodings';
import * as en from './languages/en';
import * as utf8 from './encodings/utf-8';
import * as applicationJson from './serializers/application/json';

// TODO define ResourceState
// TODO separate frontend and backend factory methods
// TODO complete content negotiation and default (fallback) messages collection

export interface DataSource<T = object> {
initialize(): Promise<unknown>;
getTotalCount?(): Promise<number>;
getMultiple(): Promise<T[]>;
getSingle(id: string): Promise<T | null>;
create(data: Partial<T>): Promise<T>;
@@ -30,52 +38,36 @@ export interface ApplicationParams {
dataSource?: (resource: Resource) => DataSource;
}

interface ResourceFactory {
shouldCheckSerializersOnDelete(b: boolean): this;
shouldThrow404OnDeletingNotFound(b: boolean): this;
export interface Resource<T extends BaseSchema = any> {
newId(dataSource: DataSource): string | number | unknown;
schema: T;
state: {
idAttr: string;
itemName?: string;
collectionName?: string;
routeName?: string;
idSerializer: NonNullable<IdParams['serialize']>;
idDeserializer: NonNullable<IdParams['deserialize']>;
canCreate: boolean;
canFetchCollection: boolean;
canFetchItem: boolean;
canPatch: boolean;
canEmplace: boolean;
canDelete: boolean;
};
id(newIdAttr: string, params: IdParams): this;
fullText(fullTextAttr: string): this;
name(n: string): this;
collection(n: string): this;
route(n: string): this;
allowFetchCollection(): this;
allowFetchItem(): this;
allowCreate(): this;
allowPatch(): this;
allowEmplace(): this;
allowDelete(): this;
revokeFetchCollection(): this;
revokeFetchItem(): this;
revokeCreate(): this;
revokePatch(): this;
revokeEmplace(): this;
revokeDelete(): this;
canFetchCollection(b?: boolean): this;
canFetchItem(b?: boolean): this;
canCreate(b?: boolean): this;
canPatch(b?: boolean): this;
canEmplace(b?: boolean): this;
canDelete(b?: boolean): this;
}

export interface ResourceData<T extends BaseSchema> {
idAttr: string;
itemName?: string;
collectionName?: string;
routeName?: string;
newId(dataSource: DataSource): string | number | unknown;
schema: T;
throws404OnDeletingNotFound: boolean;
checksSerializersOnDelete: boolean;
idSerializer: NonNullable<IdParams['serialize']>;
idDeserializer: NonNullable<IdParams['deserialize']>;
}

export interface ResourcePermissions {
canCreate: boolean;
canFetchCollection: boolean;
canFetchItem: boolean;
canPatch: boolean;
canEmplace: boolean;
canDelete: boolean;
}

export type Resource<T extends BaseSchema = any> = ResourceData<T> & ResourceFactory & ResourcePermissions;

export interface ResourceWithDataSource<T extends BaseSchema = any> extends Resource<T> {
dataSource: DataSource;
}
@@ -93,145 +85,98 @@ interface IdParams {
const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => {
const middlewares = [] as [string, Middleware][];
if (mainResourceId === '') {
if (resource.canFetchCollection) {
if (resource.state.canFetchCollection) {
middlewares.push(['GET', handleGetCollection]);
}
if (resource.canCreate) {
if (resource.state.canCreate) {
middlewares.push(['POST', handleCreateItem]);
}
return middlewares;
}

if (resource.canFetchItem) {
if (resource.state.canFetchItem) {
middlewares.push(['GET', handleGetItem]);
}
if (resource.canEmplace) {
if (resource.state.canEmplace) {
middlewares.push(['PUT', handleEmplaceItem]);
}
if (resource.canPatch) {
if (resource.state.canPatch) {
middlewares.push(['PATCH', handlePatchItem]);
}
if (resource.canDelete) {
if (resource.state.canDelete) {
middlewares.push(['DELETE', handleDeleteItem]);
}

return middlewares;
};

interface ResourceState {
idAttr: string
itemName: string
collectionName: string
routeName: string
idGenerationStrategy: GenerationStrategy
idSerializer: IdParams['serialize']
idDeserializer: IdParams['deserialize']
fullTextAttrs: Set<string>;
canCreate: boolean;
canFetchCollection: boolean;
canFetchItem: boolean;
canPatch: boolean;
canEmplace: boolean;
canDelete: boolean;
}

export const resource = <T extends BaseSchema>(schema: T): Resource<T> => {
let theIdAttr: string;
let theItemName: string;
let theCollectionName: string;
let theRouteName: string;
let theIdGenerationStrategy: GenerationStrategy;
let theIdSerializer: IdParams['serialize'];
let theIdDeserializer: IdParams['deserialize'];
let throw404OnDeletingNotFound = true;
let checkSerializersOnDelete = false;
const fullTextAttrs = new Set<string>();
let canCreate = false;
let canFetchCollection = false;
let canFetchItem = false;
let canPatch = false;
let canEmplace = false;
let canDelete = false;
const resourceState = {
fullTextAttrs: new Set<string>(),
canCreate: false,
canFetchCollection: false,
canFetchItem: false,
canPatch: false,
canEmplace: false,
canDelete: false,
} as Partial<ResourceState>;

return {
allowFetchCollection() {
canFetchCollection = true;
return this;
},
allowFetchItem() {
canFetchItem = true;
return this;
},
allowCreate() {
canCreate = true;
return this;
},
allowPatch() {
canPatch = true;
return this;
},
allowEmplace() {
canEmplace = true;
return this;
},
allowDelete() {
canDelete = true;
return this;
},
revokeFetchCollection() {
canFetchCollection = false;
return this;
get state(): ResourceState {
return Object.freeze({
...resourceState
}) as unknown as ResourceState;
},
revokeFetchItem() {
canFetchItem = false;
canFetchCollection(b = true) {
resourceState.canFetchCollection = b;
return this;
},
revokeCreate() {
canCreate = false;
canFetchItem(b = true) {
resourceState.canFetchItem = b;
return this;
},
revokePatch() {
canPatch = false;
canCreate(b = true) {
resourceState.canCreate = b;
return this;
},
revokeEmplace() {
canEmplace = false;
canPatch(b = true) {
resourceState.canPatch = b;
return this;
},
revokeDelete() {
canDelete = false;
return this;
},
get canCreate() {
return canCreate;
},
get canFetchCollection() {
return canFetchCollection;
},
get canFetchItem() {
return canFetchItem;
},
get canPatch() {
return canPatch;
},
get canEmplace() {
return canEmplace;
},
get canDelete() {
return canDelete;
},
shouldCheckSerializersOnDelete(b = true) {
checkSerializersOnDelete = b;
canEmplace(b = true) {
resourceState.canEmplace = b;
return this;
},
get checksSerializersOnDelete() {
return checkSerializersOnDelete;
},
shouldThrow404OnDeletingNotFound(b = true) {
throw404OnDeletingNotFound = b;
canDelete(b = true) {
resourceState.canDelete = b;
return this;
},
get throws404OnDeletingNotFound() {
return throw404OnDeletingNotFound;
},
get idSerializer() {
return theIdSerializer;
},
get idDeserializer() {
return theIdDeserializer;
},
id(newIdAttr: string, params: IdParams) {
theIdAttr = newIdAttr;
theIdGenerationStrategy = params.generationStrategy;
theIdSerializer = params.serialize;
theIdDeserializer = params.deserialize;
resourceState.idAttr = newIdAttr;
resourceState.idGenerationStrategy = params.generationStrategy;
resourceState.idSerializer = params.serialize;
resourceState.idDeserializer = params.deserialize;
return this;
},
newId(dataSource: DataSource) {
return theIdGenerationStrategy(dataSource);
return resourceState?.idGenerationStrategy?.(dataSource);
},
fullText(fullTextAttr: string) {
if (
@@ -245,38 +190,38 @@ export const resource = <T extends BaseSchema>(schema: T): Resource<T> => {
)
.entries[fullTextAttr]?.type === 'string'
) {
fullTextAttrs.add(fullTextAttr);
resourceState.fullTextAttrs?.add(fullTextAttr);
return this;
}

throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`);
},
name(n: string) {
theItemName = n;
theCollectionName = theCollectionName ?? pluralize(theItemName).toLowerCase();
theRouteName = theRouteName ?? theCollectionName;
resourceState.itemName = n;
resourceState.collectionName = resourceState.collectionName ?? pluralize(n).toLowerCase();
resourceState.routeName = resourceState.routeName ?? resourceState.collectionName;
return this;
},
collection(n: string) {
theCollectionName = n;
theRouteName = theRouteName ?? theCollectionName;
resourceState.collectionName = n;
resourceState.routeName = resourceState.routeName ?? n;
return this;
},
route(n: string) {
theRouteName = n;
resourceState.routeName = n;
return this;
},
get idAttr() {
return theIdAttr;
return resourceState.idAttr;
},
get collectionName() {
return theCollectionName;
return resourceState.collectionName;
},
get itemName() {
return theItemName;
return resourceState.itemName;
},
get routeName() {
return theRouteName;
return resourceState.routeName;
},
get schema() {
return schema;
@@ -306,18 +251,78 @@ interface HandlerState {

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

type MessageBody = string | string[] | (string | string[])[];

export interface MessageCollection {
statusMessages: {
unableToInitializeResourceDataSource(resource: Resource): string;
unableToFetchResourceCollection(resource: Resource): string;
unableToFetchResource(resource: Resource): string;
languageNotAcceptable(): string;
encodingNotAcceptable(): string;
mediaTypeNotAcceptable(): string;
methodNotAllowed(): string;
urlNotFound(): string;
badRequest(): string;
ok(): string;
resourceCollectionFetched(resource: Resource): string;
resourceFetched(resource: Resource): string;
resourceNotFound(resource: Resource): string;
deleteNonExistingResource(resource: Resource): string;
unableToSerializeResponse(): string;
unableToEncodeResponse(): string;
unableToDeleteResource(resource: Resource): string;
resourceDeleted(resource: Resource): string;
unableToDeserializeRequest(): string;
patchNonExistingResource(resource: Resource): string;
unableToPatchResource(resource: Resource): string;
invalidResourcePatch(resource: Resource): string;
invalidResource(resource: Resource): string;
resourcePatched(resource: Resource): string;
resourceCreated(resource: Resource): string;
resourceReplaced(resource: Resource): string;
},
bodies: {
languageNotAcceptable(): MessageBody;
encodingNotAcceptable(): MessageBody;
mediaTypeNotAcceptable(): MessageBody;
}
}

export interface BackendState {
fallback: {
language: string;
encoding: string;
serializer: string;
}
errorHeaders: {
language?: string;
encoding?: string;
serializer?: string;
}
showTotalItemCountOnGetCollection: boolean;
throws404OnDeletingNotFound: boolean;
checksSerializersOnDelete: boolean;
showTotalItemCountOnCreateItem: boolean;
}

interface MiddlewareArgs {
handlerState: HandlerState;
backendState: BackendState;
appState: ApplicationState;
appParams: ApplicationParams;
serverParams: CreateServerParams;
requestBodyEncodingPair: EncodingPair;
responseBodySerializerPair: SerializerPair;
responseMediaType: string;
responseBodyLanguage: [string, MessageCollection];
responseBodyEncoding: [string, EncodingPair];
responseBodyMediaType: [string, SerializerPair];
errorResponseBodyLanguage: [string, MessageCollection];
errorResponseBodyEncoding: [string, EncodingPair];
errorResponseBodyMediaType: [string, SerializerPair];
resource: ResourceWithDataSource;
resourceId: string;
query: URLSearchParams;
@@ -327,20 +332,41 @@ export interface Middleware {
(args: MiddlewareArgs): RequestListenerWithReturn<HandlerState | Promise<HandlerState>>
}

export interface Backend {
showTotalItemCountOnGetCollection(b?: boolean): this;
showTotalItemCountOnCreateItem(b?: boolean): this;
checksSerializersOnDelete(b?: boolean): this;
throws404OnDeletingNotFound(b?: boolean): this;
createServer(serverParams?: CreateServerParams): http.Server | https.Server;
}

export interface Client {
setLanguage(languageCode: string): this;
setEncoding(encoding: string): this;
setContentType(contentType: string): this;
}

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

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

appState.languages.set(en.code, en.messages);
appState.encodings.set(utf8.name, utf8);
appState.serializers.set(applicationJson.name, applicationJson);

return {
contentType(mimeTypePrefix: string, serializerPair: SerializerPair) {
appState.serializers.set(mimeTypePrefix, serializerPair);
@@ -350,148 +376,253 @@ export const application = (appParams: ApplicationParams): Application => {
appState.encodings.set(encoding, encodingPair);
return this;
},
language(languageCode: string, messageCollection: MessageCollection) {
appState.languages.set(languageCode, messageCollection);
return this;
},
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.`);
throw new Error(`Resource ${res.state!.itemName} must have a data source.`);
}
appState.resources.add(res as ResourceWithDataSource);
return this;
},
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) => {
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 responseMediaType = negotiator.mediaType(availableMediaTypes);

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

const responseBodySerializerPair = appState.serializers.get(responseMediaType);
if (typeof responseBodySerializerPair === '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 requestBodyEncodingPair = appState.encodings.get(encoding);
if (typeof requestBodyEncodingPair === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE;
res.end();
return;
}

const middlewareArgs: Omit<MiddlewareArgs, 'resource' | 'resourceId'> = {
handlerState: {
handled: false
},
appState,
appParams,
serverParams,
responseBodySerializerPair,
responseMediaType,
query,
requestBodyEncodingPair,
};

const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res);
if (methodAndUrl.handled) {
return;
createClient(): Client {
const clientState = {
contentType: applicationJson.name,
encoding: utf8.name,
language: en.code
};

return {
setContentType(contentType: string) {
clientState.contentType = contentType;
return this;
},
setEncoding(encoding: string) {
clientState.encoding = encoding;
return this;
},
setLanguage(languageCode: string) {
clientState.language = languageCode;
return this;
}
} satisfies Client;
},
createBackend(): Backend {
const backendState: BackendState = {
fallback: {
language: en.code,
encoding: utf8.name,
serializer: applicationJson.name
},
errorHeaders: {
// undefined follows user accept headers strictly
//
language: undefined,
encoding: undefined,
serializer: undefined,
},
showTotalItemCountOnGetCollection: false,
showTotalItemCountOnCreateItem: false,
throws404OnDeletingNotFound: false,
checksSerializersOnDelete: false,

};

return {
showTotalItemCountOnGetCollection(b = true) {
backendState.showTotalItemCountOnGetCollection = b;
return this;
},
showTotalItemCountOnCreateItem(b = true) {
backendState.showTotalItemCountOnCreateItem = b;
return this;
},
throws404OnDeletingNotFound(b = true) {
backendState.throws404OnDeletingNotFound = b;
return this;
},
checksSerializersOnDelete(b = true) {
backendState.checksSerializersOnDelete = b;
return this;
},
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) => {
const method = getMethod(req);
const baseUrl = serverParams.baseUrl ?? '';
const { url, query } = getUrl(req, baseUrl);

const negotiator = new Negotiator(req);
const languageCandidate = negotiator.language(Array.from(appState.languages.keys())) ?? backendState.fallback.language;
const encodingCandidate = negotiator.encoding(Array.from(appState.encodings.keys())) ?? backendState.fallback.encoding;
const contentTypeCandidate = negotiator.mediaType(Array.from(appState.serializers.keys())) ?? backendState.fallback.serializer;

const availableLanguages = Array.from(appState.languages.entries());
const fallbackMessageCollection = en.messages as MessageCollection;
const fallbackSerializerPair = applicationJson as SerializerPair;
const fallbackEncoding = utf8 as EncodingPair;

const errorLanguageCode = backendState.errorHeaders.language ?? backendState.fallback.language;
const errorMessageCollection = appState.languages.get(errorLanguageCode) ?? fallbackMessageCollection;

const errorContentType = backendState.errorHeaders.serializer ?? backendState.fallback.serializer;
const errorSerializerPair = appState.serializers.get(errorContentType) ?? fallbackSerializerPair;

const errorEncodingKey = backendState.errorHeaders.encoding ?? backendState.fallback.encoding;
const errorEncoding = appState.encodings.get(errorEncodingKey) ?? fallbackEncoding;

const [currentLanguageCode, currentLanguageMessages] = availableLanguages.find(([code]) => code === languageCandidate) ?? [];
if (typeof currentLanguageCode === 'undefined' || typeof currentLanguageMessages === 'undefined') {
const data = errorMessageCollection.bodies.languageNotAcceptable();
const responseRaw = errorSerializerPair.serialize(data);
const response = errorEncoding.encode(responseRaw);
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, {
'Content-Language': errorLanguageCode,
'Content-Type': errorContentType,
'Content-Encoding': errorEncodingKey,
});
res.statusMessage = errorMessageCollection.statusMessages.languageNotAcceptable();
res.end(response);
return;
}

const availableMediaTypes = Array.from(appState.serializers.entries());
const [currentContentTypeMimeType, responseMediaTypeEntry] = availableMediaTypes.find(([key]) => key === contentTypeCandidate) ?? [];
if (typeof currentContentTypeMimeType === 'undefined' || typeof responseMediaTypeEntry === 'undefined') {
const data = errorMessageCollection.bodies.languageNotAcceptable();
const responseRaw = errorSerializerPair.serialize(data);
const response = errorEncoding.encode(responseRaw);
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, {
'Content-Language': errorLanguageCode,
'Content-Type': errorContentType,
'Content-Encoding': errorEncodingKey,
});
res.statusMessage = errorMessageCollection.statusMessages.mediaTypeNotAcceptable();
res.end(response);
return;
}

const availableEncodings = Array.from(appState.encodings.entries());
const [currentEncoding, responseBodyEncodingEntry] = availableEncodings.find(([key]) => key === encodingCandidate) ?? [];
if (typeof currentEncoding === 'undefined' || typeof responseBodyEncodingEntry === 'undefined') {
const data = errorMessageCollection.bodies.languageNotAcceptable();
const responseRaw = errorSerializerPair.serialize(data);
const response = errorEncoding.encode(responseRaw);
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, {
'Content-Language': errorLanguageCode,
'Content-Type': errorContentType,
'Content-Encoding': errorEncodingKey,
});
res.statusMessage = errorMessageCollection.statusMessages.encodingNotAcceptable();
res.end(response);
return;
}

const middlewareArgs: Omit<MiddlewareArgs, 'resource' | 'resourceId'> = {
handlerState: {
handled: false
},
appState,
appParams,
backendState,
serverParams,
query,
responseBodyEncoding: [currentEncoding, responseBodyEncodingEntry],
responseBodyMediaType: [currentContentTypeMimeType, responseMediaTypeEntry],
responseBodyLanguage: [currentLanguageCode, currentLanguageMessages],
errorResponseBodyMediaType: [errorContentType, errorSerializerPair],
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
};

const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res);
if (methodAndUrl.handled) {
return;
}

if (url === '/') {
const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res);
if (middlewareState.handled) {
return;
}

if (url === '/') {
const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res);
if (middlewareState.handled) {
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, {
Allow: 'HEAD, GET'
});
res.statusMessage = errorMessageCollection.statusMessages.methodNotAllowed();
res.end();
return;
}

const [, resourceRouteName, resourceId = ''] = url.split('/');
const resource = Array.from(appState.resources).find((r) => r.state!.routeName === resourceRouteName);
if (typeof resource === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = errorMessageCollection.statusMessages.urlNotFound();
res.end();
return;
}

const middlewares = getAllowedMiddlewares(resource, resourceId);
const middlewareState = await middlewares
.reduce(
async (currentHandlerStatePromise, [middlewareMethod, middleware]) => {
const currentHandlerState = await currentHandlerStatePromise;
if (method !== middlewareMethod) {
return currentHandlerState;
}

if (currentHandlerState.handled) {
return currentHandlerState;
}

return middleware({
...middlewareArgs,
handlerState: currentHandlerState,
resource,
resourceId: resourceId,
})(req, res);
},
Promise.resolve<HandlerState>({
handled: false
})
);

if (middlewareState.handled) {
return;
}

if (middlewares.length > 0) {
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, {
Allow: middlewares.map((m) => m[0]).join(', ')
});
res.statusMessage = errorMessageCollection.statusMessages.methodNotAllowed();
res.end();
return;
}

res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = errorMessageCollection.statusMessages.urlNotFound();
res.end();
return;
}

res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, {
Allow: 'HEAD, GET'
});
res.end();
return;
}

const [, resourceRouteName, resourceId = ''] = url.split('/');
const resource = Array.from(appState.resources).find((r) => r.routeName === resourceRouteName);
if (typeof resource === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = 'URL Not Found';
res.end();
return;
}

const middlewares = getAllowedMiddlewares(resource, resourceId);
const middlewareState = await middlewares
.reduce(
async (currentHandlerStatePromise, [middlewareMethod, middleware]) => {
const currentHandlerState = await currentHandlerStatePromise;
if (method !== middlewareMethod) {
return currentHandlerState;
}

if (currentHandlerState.handled) {
return currentHandlerState;
}

return middleware({
...middlewareArgs,
handlerState: currentHandlerState,
resource,
resourceId: resourceId,
})(req, res);
},
Promise.resolve<HandlerState>({
handled: false
})
);

if (middlewareState.handled) {
return;
}

if (middlewares.length > 0) {
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, {
Allow: middlewares.map((m) => m[0]).join(', ')
});
res.end();
return;
return server;
}

res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = 'URL Not Found';
res.end();
return;
});

return server;
}
} satisfies Backend;
},
};
};

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

@@ -8,7 +8,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
data: T[] = [];

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

async initialize() {
@@ -21,12 +21,16 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}
}

async getTotalCount() {
return this.data.length;
}

async getMultiple() {
return [...this.data];
}

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

if (foundData) {
return {
@@ -42,8 +46,8 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
...data
} as Record<string, unknown>;

if (this.resource.idAttr in newData) {
newData[this.resource.idAttr] = this.resource.idDeserializer(newData[this.resource.idAttr] as string);
if (this.resource.state.idAttr in newData) {
newData[this.resource.state.idAttr] = this.resource.state.idDeserializer(newData[this.resource.state.idAttr] as string);
}

const newCollection = [
@@ -59,7 +63,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) => !(this.resource.idSerializer(s[this.resource.idAttr as string]) === id));
const newData = this.data.filter((s) => !(this.resource.state.idSerializer(s[this.resource.state.idAttr as string]) === id));

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

@@ -70,12 +74,12 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
const existing = await this.getSingle(id);
const dataToEmplace = {
...data,
[this.resource.idAttr]: this.resource.idDeserializer(id),
[this.resource.state.idAttr]: this.resource.state.idDeserializer(id),
};

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

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

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



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

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

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

export const name = 'utf-8';

+ 507
- 165
src/handlers.ts View File

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

export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, res: ServerResponse) => {
export const handleHasMethodAndUrl: Middleware = ({
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
}) => (req: IncomingMessage, res: ServerResponse) => {
if (!req.method) {
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, {
'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE'
'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE',
'Content-Language': errorLanguageCode,

});
res.statusMessage = errorMessageCollection.statusMessages.methodNotAllowed();
res.end();
return {
handled: true
@@ -17,6 +22,7 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage,

if (!req.url) {
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
res.statusMessage = errorMessageCollection.statusMessages.badRequest();
res.end();
return {
handled: true
@@ -31,25 +37,68 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage,
export const handleGetRoot: Middleware = ({
appState,
appParams,
responseBodySerializerPair,
serverParams,
responseMediaType,
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair],
responseBodyLanguage: [languageCode, responseBodyMessageCollection],
responseBodyEncoding: [encodingKey, encoding],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
}) => (_req: IncomingMessage, res: ServerResponse) => {
const singleResDatum = {
name: appParams.name
};
const theFormatted = responseBodySerializerPair.serialize(singleResDatum);
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': responseMediaType,

let serialized;
try {
serialized = responseBodySerializerPair.serialize(singleResDatum);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let theFormatted;
try {
theFormatted = encoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

const theHeaders: Record<string, string> = {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
};

const registeredResources = Array.from(appState.resources);
const availableResources = registeredResources.filter((r) => (
r.canFetchCollection
|| r.canCreate
));

if (availableResources.length > 0) {
// 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)
theHeaders['X-Resource-Link'] = availableResources
.map((r) =>
`<${serverParams.baseUrl}/${r.routeName}>; name="${r.collectionName}"`,
`<${serverParams.baseUrl}/${r.state.routeName}>; name="${r.state.collectionName}"`,
)
.join(', ')
});
.join(', ');
}
res.writeHead(constants.HTTP_STATUS_OK, theHeaders);
res.statusMessage = responseBodyMessageCollection.statusMessages.ok();
res.end(theFormatted);
return {
handled: true
@@ -57,130 +106,242 @@ export const handleGetRoot: Middleware = ({
};

export const handleGetCollection: Middleware = ({
appState,
serverParams,
responseBodySerializerPair,
responseMediaType,
}) => async (req: IncomingMessage, res: ServerResponse) => {
const baseUrl = serverParams.baseUrl ?? '';
const { url } = getUrl(req, baseUrl);

const [, mainResourceRouteName, mainResourceId = ''] = url.split('/');
if (mainResourceId !== '') {
resource,
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair],
responseBodyLanguage: [languageCode, responseBodyMessageCollection],
responseBodyEncoding: [encodingKey, encoding],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
backendState,
}) => async (_req: IncomingMessage, res: ServerResponse) => {
try {
await resource.dataSource.initialize();
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource);
res.end();
return {
handled: false
}
handled: true
};
}

const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName);
if (typeof theResource === 'undefined') {
let resData: Object[];
let totalItemCount: number | undefined;
try {
// TODO querying mechanism
resData = await resource.dataSource.getMultiple(); // TODO paginated responses per resource
if (backendState.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResourceCollection(resource);
res.end();
return {
handled: false
handled: true
};
}

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

res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': responseMediaType,
'X-Resource-Total-Item-Count': resData.length
serialized = responseBodySerializerPair.serialize(resData);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.end(theFormatted);
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let theFormatted;
try {
theFormatted = encoding.encode(serialized);
} catch {
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR;
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
};

if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}

res.writeHead(constants.HTTP_STATUS_OK, headers);
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCollectionFetched(resource);
res.end(theFormatted);
return {
handled: true
};
};

export const handleGetItem: Middleware = ({
appState,
serverParams,
responseBodySerializerPair,
responseMediaType,
}) => async (req: IncomingMessage, res: ServerResponse) => {
const method = getMethod(req);
if (method !== 'GET') {
resourceId,
resource,
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair],
responseBodyLanguage: [languageCode, responseBodyMessageCollection],
responseBodyEncoding: [encodingKey, encoding],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
}) => async (_req: IncomingMessage, res: ServerResponse) => {
try {
await resource.dataSource.initialize();
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource);
res.end();
return {
handled: false
handled: true
};
}

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

const [, mainResourceRouteName, mainResourceId = ''] = url.split('/');
if (mainResourceId === '') {
let singleResDatum: Object | null = null;
try {
singleResDatum = await resource.dataSource.getSingle(resourceId);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResource(resource);
res.end();
return {
handled: false
}
handled: true
};
}

const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName);
if (typeof theResource === 'undefined') {
let serialized: string | null;
try {
serialized = singleResDatum === null ? null : responseBodySerializerPair.serialize(singleResDatum);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: false
handled: true,
};
}

let theFormatted;
try {
await theResource.dataSource.initialize();
const singleResDatum = await theResource.dataSource.getSingle(mainResourceId);
if (singleResDatum) {
const theFormatted = responseBodySerializerPair.serialize(singleResDatum);
res.writeHead(constants.HTTP_STATUS_OK, {'Content-Type': responseMediaType});
res.end(theFormatted);
return {
handled: true
};
}

res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = `${theResource.itemName} Not Found`;
theFormatted = serialized === null ? null : encoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true
handled: true,
};
} catch (err) {
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR;
res.end();
}

if (theFormatted) {
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
});
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceFetched(resource)
res.end(theFormatted);
return {
handled: true
};
}

res.writeHead(constants.HTTP_STATUS_NOT_FOUND, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.resourceNotFound(resource);
res.end();
return {
handled: true
};
};


export const handleDeleteItem: Middleware = ({
resource,
resourceId,
responseBodyLanguage: [languageCode, responseBodyMessageCollection],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
backendState,
}) => async (_req: IncomingMessage, res: ServerResponse) => {
try {
await resource.dataSource.initialize();
const response = await resource.dataSource.delete(resourceId);
if (typeof response !== 'undefined' && !response && resource.throws404OnDeletingNotFound) {
res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = `${resource.itemName} Not Found`;
} else {
res.statusCode = constants.HTTP_STATUS_NO_CONTENT;
}
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource);
res.end();
return {
handled: true
};
}

let response;
try {
response = await resource.dataSource.delete(resourceId);
} catch {
// TODO error handling
// what if item is already deleted? Should we hide it by returning no content or throw a 404?
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToDeleteResource(resource);
res.end();
return {
handled: true
};
}

const throwOnNotFound = !response && backendState.throws404OnDeletingNotFound;

res.writeHead(
throwOnNotFound
? constants.HTTP_STATUS_NOT_FOUND
: constants.HTTP_STATUS_NO_CONTENT,

throwOnNotFound
? {
'Content-Language': errorLanguageCode,
// TODO provide more details
}
: {
'Content-Language': languageCode,
}
);
res.statusMessage = (
throwOnNotFound
? errorMessageCollection.statusMessages.deleteNonExistingResource(resource)
: responseBodyMessageCollection.statusMessages.resourceDeleted(resource)
);

if (throwOnNotFound) {
// TODO provide error message
res.end();
return {
handled: true
};
}

res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR;
res.end();
return {
handled: true
@@ -189,25 +350,59 @@ export const handleDeleteItem: Middleware = ({

export const handlePatchItem: Middleware = ({
appState,
responseBodySerializerPair,
responseMediaType,
resource,
resourceId,
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair],
responseBodyLanguage: [languageCode, responseBodyMessageCollection],
responseBodyEncoding: [encodingKey, encoding],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
errorResponseBodyMediaType: [errorMediaType, errorSerializerPair],
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding],
}) => async (req: IncomingMessage, res: ServerResponse) => {
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req);
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') {
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE;
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToDeserializeRequest();
res.end();
return {
handled: true
};
}

try {
await resource.dataSource.initialize();
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource);
res.end();
return {
handled: true
};
}

let existing: object | null;
try {
existing = await resource.dataSource.getSingle(resourceId);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResource(resource);
res.end();
return {
handled: true
};
}

await resource.dataSource.initialize();
const existing = await resource.dataSource.getSingle(resourceId);
if (!existing) {
res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = `${resource.itemName} Not Found`;
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.patchNonExistingResource(resource);
res.end();
return {
handled: true
@@ -231,54 +426,113 @@ export const handlePatchItem: Middleware = ({
);
} catch (errRaw) {
const err = errRaw as v.ValiError;
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
res.statusMessage = `Invalid ${resource.itemName}`;

if (Array.isArray(err.issues)) {
// TODO better error reporting, localizable messages
const theFormatted = responseBodySerializerPair.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
))
);
res.end(theFormatted);
} else {
const headers: Record<string, string> = {
'Content-Language': languageCode,
};
if (!Array.isArray(err.issues)) {
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers)
res.statusMessage = errorMessageCollection.statusMessages.invalidResourcePatch(resource);
res.end();
return {
handled: true,
};
}
// TODO better error reporting, localizable messages
// TODO handle error handlers' errors
const serialized = errorSerializerPair.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
);
const theFormatted = errorEncoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {
...headers,
'Content-Type': errorMediaType,
'Content-Encoding': errorEncodingKey,
})
res.statusMessage = errorMessageCollection.statusMessages.invalidResourcePatch(resource);
res.end(theFormatted);
return {
handled: true
handled: true,
};
}

const params = bodyDeserialized as Record<string, unknown>;

let newObject: object | null;
try {
const params = bodyDeserialized as Record<string, unknown>;
await resource.dataSource.initialize();
const newObject = await resource.dataSource.patch(resourceId, params);
const theFormatted = responseBodySerializerPair.serialize(newObject);
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': responseMediaType,
newObject = await resource.dataSource.patch(resourceId, params);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.end(theFormatted);
res.statusMessage = errorMessageCollection.statusMessages.unableToPatchResource(resource);
res.end();
return {
handled: true,
};
}

let serialized;
try {
serialized = responseBodySerializerPair.serialize(newObject);
} catch {
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR;
res.statusMessage = `Could Not Return ${resource.itemName}`;
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let theFormatted;
try {
theFormatted = encoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
});
res.statusMessage = responseBodyMessageCollection.statusMessages.resourcePatched(resource);
res.end(theFormatted);
return {
handled: true
};

// TODO finish the rest of the handlers!!!
};

export const handleCreateItem: Middleware = ({
appState,
serverParams,
responseMediaType,
responseBodySerializerPair,
backendState,
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair],
responseBodyLanguage: [languageCode, responseBodyMessageCollection],
responseBodyEncoding: [encodingKey, encoding],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
errorResponseBodyMediaType: [errorMediaType, errorSerializerPair],
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding],
resource,
}) => async (req: IncomingMessage, res: ServerResponse) => {
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req);
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') {
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE;
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToDeserializeRequest();
res.end();
return {
handled: true
@@ -287,43 +541,87 @@ export const handleCreateItem: Middleware = ({

let bodyDeserialized: unknown;
try {
bodyDeserialized = await getBody(req, requestBodyDeserializerPair, requestBodyEncodingPair, resource.schema);
bodyDeserialized = await getBody(
req,
requestBodyDeserializerPair,
requestBodyEncodingPair,
resource.schema
);
} catch (errRaw) {
const err = errRaw as v.ValiError;
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
res.statusMessage = `Invalid ${resource.itemName}`;

if (Array.isArray(err.issues)) {
// TODO better error reporting, localizable messages
const theFormatted = responseBodySerializerPair.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
))
);
res.end(theFormatted);
} else {
const headers: Record<string, string> = {
'Content-Language': errorLanguageCode,
};
if (!Array.isArray(err.issues)) {
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers)
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource);
res.end();
return {
handled: true,
};
}
// TODO better error reporting, localizable messages
// TODO handle error handlers' errors
const serialized = errorSerializerPair.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
);
const theFormatted = errorEncoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {
...headers,
'Content-Type': errorMediaType,
'Content-Encoding': errorEncodingKey,
})
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource);
res.end(theFormatted);
return {
handled: true
handled: true,
};
}

try {
await resource.dataSource.initialize();
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource);
res.end();
return {
handled: true
};
}

try {
// TODO error handling for each process
const newId = await resource.newId(resource.dataSource);
const params = bodyDeserialized as Record<string, unknown>;
params[resource.idAttr] = newId;
params[resource.state.idAttr] = newId;
const newObject = await resource.dataSource.create(params);
const theFormatted = responseBodySerializerPair.serialize(newObject);
res.writeHead(constants.HTTP_STATUS_CREATED, {
'Content-Type': responseMediaType,
'Location': `${serverParams.baseUrl}/${resource.routeName}/${newId}`
});
let totalItemCount: number | undefined;
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
'Location': `${serverParams.baseUrl}/${resource.state.routeName}/${newId}`
};
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
const serialized = responseBodySerializerPair.serialize(newObject);
const theFormatted = encoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_CREATED, headers);
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCreated(resource);
res.end(theFormatted);
} catch {
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR;
res.statusMessage = `Could Not Return ${resource.itemName}`;
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
})
res.statusMessage = `Could Not Return ${resource.state.itemName}`;
res.end();
}
return {
@@ -334,14 +632,22 @@ export const handleCreateItem: Middleware = ({
export const handleEmplaceItem: Middleware = ({
appState,
serverParams,
responseBodySerializerPair,
responseMediaType,
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair],
responseBodyLanguage: [languageCode, responseBodyMessageCollection],
responseBodyEncoding: [encodingKey, encoding],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
errorResponseBodyMediaType: [errorMediaType, errorSerializerPair],
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding],
resource,
resourceId,
backendState,
}) => async (req: IncomingMessage, res: ServerResponse) => {
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req);
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') {
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE;
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToDeserializeRequest();
res.end();
return {
handled: true
@@ -351,7 +657,6 @@ export const handleEmplaceItem: Middleware = ({
let bodyDeserialized: unknown;
try {
const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema<any> : resource.schema
//console.log(schema);
bodyDeserialized = await getBody(
req,
requestBodyDeserializerPair,
@@ -360,9 +665,9 @@ export const handleEmplaceItem: Middleware = ({
? v.merge([
schema as v.ObjectSchema<any>,
v.object({
[resource.idAttr]: v.transform(
[resource.state.idAttr]: v.transform(
v.any(),
input => resource.idSerializer(input),
input => resource.state.idSerializer(input),
v.literal(resourceId)
)
})
@@ -371,44 +676,81 @@ export const handleEmplaceItem: Middleware = ({
);
} catch (errRaw) {
const err = errRaw as v.ValiError;
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
res.statusMessage = `Invalid ${resource.itemName}`;

if (Array.isArray(err.issues)) {
// TODO better error reporting, localizable messages
const theFormatted = responseBodySerializerPair.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
))
);
res.end(theFormatted);
} else {
const headers: Record<string, string> = {
'Content-Language': errorLanguageCode,
};
if (!Array.isArray(err.issues)) {
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers)
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource);
res.end();
return {
handled: true,
};
}
// TODO better error reporting, localizable messages
// TODO handle error handlers' errors
const serialized = errorSerializerPair.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
);
const theFormatted = errorEncoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {
...headers,
'Content-Type': errorMediaType,
'Content-Encoding': errorEncodingKey,
})
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource);
res.end(theFormatted);
return {
handled: true
handled: true,
};
}

try {
await resource.dataSource.initialize();
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource);
res.end();
return {
handled: true
};
}

try {
// TODO error handling for each process
const params = bodyDeserialized as Record<string, unknown>;
const [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params);
const theFormatted = responseBodySerializerPair.serialize(newObject);
const serialized = responseBodySerializerPair.serialize(newObject);
const theFormatted = encoding.encode(serialized);
const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
};
let totalItemCount: number | undefined;
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
if (isCreated) {
res.writeHead(constants.HTTP_STATUS_CREATED, {
'Content-Type': responseMediaType,
'Location': `${serverParams.baseUrl}/${resource.routeName}/${resourceId}`
});
} else {
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': responseMediaType,
});
headers['Location'] = `${serverParams.baseUrl}/${resource.state.routeName}/${resourceId}`;
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
}
res.writeHead(isCreated ? constants.HTTP_STATUS_CREATED : constants.HTTP_STATUS_OK, headers);
res.statusMessage = (
isCreated
? responseBodyMessageCollection.statusMessages.resourceCreated(resource)
: responseBodyMessageCollection.statusMessages.resourceReplaced(resource)
);
res.end(theFormatted);
} catch {
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR;
res.statusMessage = `Could Not Return ${resource.itemName}`;
res.statusMessage = `Could Not Return ${resource.state.itemName}`;
res.end();
}
return {


+ 97
- 0
src/languages/en/index.ts View File

@@ -0,0 +1,97 @@
import {MessageCollection, Resource} from '../../core';

export const messages: MessageCollection = {
statusMessages: {
unableToSerializeResponse(): string {
return 'Unable To Serialize Response';
},
unableToEncodeResponse(): string {
return 'Unable To Encode Response';
},
unableToInitializeResourceDataSource(resource: Resource): string {
return `Unable To Initialize ${resource.state.itemName} Data Source`;
},
unableToFetchResourceCollection(resource: Resource): string {
return `Unable To Fetch ${resource.state.itemName} Collection`;
},
unableToFetchResource(resource: Resource): string {
return `Unable To Fetch ${resource.state.itemName}`;
},
unableToDeleteResource(resource: Resource): string {
return `Unable To Delete ${resource.state.itemName}`;
},
languageNotAcceptable(): string {
return 'Language Not Acceptable';
},
encodingNotAcceptable(): string {
return 'Encoding Not Acceptable';
},
mediaTypeNotAcceptable(): string {
return 'Media Type Not Acceptable';
},
methodNotAllowed(): string {
return 'Method Not Allowed';
},
urlNotFound(): string {
return 'URL Not Found';
},
badRequest(): string {
return 'Bad Request';
},
ok(): string {
return 'OK';
},
resourceCollectionFetched(resource: Resource): string {
return `${resource.state.itemName} Collection Fetched`;
},
resourceFetched(resource: Resource): string {
return `${resource.state.itemName} Fetched`;
},
resourceNotFound(resource: Resource): string {
return `${resource.state.itemName} Not Found`;
},
deleteNonExistingResource(resource: Resource): string {
return `Delete Non-Existing ${resource.state.itemName}`;
},
resourceDeleted(resource: Resource): string {
return `${resource.state.itemName} Deleted`;
},
unableToDeserializeRequest(): string {
return 'Unable To Deserialize Request';
},
patchNonExistingResource(resource: Resource): string {
return `Patch Non-Existing ${resource.state.itemName}`;
},
unableToPatchResource(resource: Resource): string {
return `Unable To Patch ${resource.state.itemName}`;
},
invalidResourcePatch(resource: Resource): string {
return `Invalid ${resource.state.itemName} Patch`;
},
invalidResource(resource: Resource): string {
return `Invalid ${resource.state.itemName}`;
},
resourcePatched(resource: Resource): string {
return `${resource.state.itemName} Patched`;
},
resourceCreated(resource: Resource): string {
return `${resource.state.itemName} Created`;
},
resourceReplaced(resource: Resource): string {
return `${resource.state.itemName} Replaced`;
}
},
bodies: {
languageNotAcceptable() {
return [];
},
encodingNotAcceptable() {
return [];
},
mediaTypeNotAcceptable() {
return []
}
}
};

export const code = 'en';

+ 2
- 0
src/serializers/application/json.ts View File

@@ -1,3 +1,5 @@
export const serialize = (obj: unknown) => JSON.stringify(obj);

export const deserialize = (str: string) => JSON.parse(str);

export const name = 'application/json';

+ 59
- 56
test/e2e/default.test.ts View File

@@ -96,7 +96,11 @@ describe('yasumi', () => {
.encoding(ACCEPT_ENCODING, encodings.utf8)
.resource(Piano);

server = app.createServer({
const backend = app
.createBackend()
.throws404OnDeletingNotFound();

server = backend.createServer({
baseUrl: '/api'
});

@@ -126,10 +130,16 @@ describe('yasumi', () => {
}));

describe('serving collections', () => {
beforeEach(() => {
Piano.canFetchCollection();
});

afterEach(() => {
Piano.canFetchCollection(false);
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowFetchCollection();

const req = request(
{
host: HOST,
@@ -143,7 +153,6 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokeFetchCollection();
reject(err);
});

@@ -159,14 +168,12 @@ describe('yasumi', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual([]);
Piano.revokeFetchCollection();
resolve();
});
},
);

req.on('error', (err) => {
Piano.revokeFetchCollection();
reject(err);
});

@@ -186,10 +193,16 @@ describe('yasumi', () => {
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canFetchItem();
});

afterEach(() => {
Piano.canFetchItem(false);
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowFetchItem();

const req = request(
{
host: HOST,
@@ -203,7 +216,6 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokeFetchItem();
reject(err);
});

@@ -219,14 +231,12 @@ describe('yasumi', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(data);
Piano.revokeFetchItem();
resolve();
});
},
);

req.on('error', (err) => {
Piano.revokeFetchItem();
reject(err);
});

@@ -236,8 +246,6 @@ describe('yasumi', () => {

it('throws on item not found', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowFetchItem();

const req = request(
{
host: HOST,
@@ -251,19 +259,16 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokeFetchItem();
Piano.canFetchItem(false);
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);

Piano.revokeFetchItem();
resolve();
},
);

req.on('error', (err) => {
Piano.revokeFetchItem();
reject(err);
});

@@ -287,11 +292,17 @@ describe('yasumi', () => {
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canCreate();
});

afterEach(() => {
Piano.canCreate(false);
});

// FIXME ID de/serialization problems
it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowCreate();

const req = request(
{
host: HOST,
@@ -306,7 +317,6 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokeCreate();
reject(err);
});

@@ -323,16 +333,15 @@ describe('yasumi', () => {
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...newData,
id: '2'
id: 2
});
Piano.revokeCreate();
resolve();
});
},
);

req.on('error', (err) => {
Piano.revokeCreate();
reject(err);
});

@@ -357,15 +366,21 @@ describe('yasumi', () => {
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canPatch();
});

afterEach(() => {
Piano.canPatch(false);
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowPatch();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/1',
path: `/api/pianos/${data.id}`,
method: 'PATCH',
headers: {
'Accept': ACCEPT,
@@ -375,7 +390,6 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokePatch();
reject(err);
});

@@ -394,14 +408,12 @@ describe('yasumi', () => {
...data,
...newData,
});
Piano.revokePatch();
resolve();
});
},
);

req.on('error', (err) => {
Piano.revokePatch();
reject(err);
});

@@ -412,8 +424,6 @@ describe('yasumi', () => {

it('throws on item to patch not found', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowPatch();

const req = request(
{
host: HOST,
@@ -428,19 +438,15 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokePatch();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);

Piano.revokePatch();
resolve();
},
);

req.on('error', (err) => {
Piano.revokePatch();
reject(err);
});

@@ -466,16 +472,22 @@ describe('yasumi', () => {
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canEmplace();
});

afterEach(() => {
Piano.canEmplace(false);
});

// FIXME IDs not properly being de/serialized
it('returns data for replacement', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowEmplace();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/1',
path: `/api/pianos/${newData.id}`,
method: 'PUT',
headers: {
'Accept': ACCEPT,
@@ -485,7 +497,6 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokeEmplace();
reject(err);
});

@@ -501,14 +512,12 @@ describe('yasumi', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(newData);
Piano.revokeEmplace();
resolve();
});
},
);

req.on('error', (err) => {
Piano.revokeEmplace();
reject(err);
});

@@ -520,7 +529,6 @@ describe('yasumi', () => {
it('returns data for creation', () => {
return new Promise<void>((resolve, reject) => {
const id = 2;
Piano.allowEmplace();

const req = request(
{
@@ -536,7 +544,6 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokeEmplace();
reject(err);
});

@@ -555,14 +562,12 @@ describe('yasumi', () => {
...newData,
id,
});
Piano.revokeEmplace();
resolve();
});
},
);

req.on('error', (err) => {
Piano.revokeEmplace();
reject(err);
});

@@ -586,15 +591,21 @@ describe('yasumi', () => {
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canDelete();
});

afterEach(() => {
Piano.canDelete(false);
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowDelete();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/1',
path: `/api/pianos/${data.id}`,
method: 'DELETE',
headers: {
'Accept': ACCEPT,
@@ -603,18 +614,15 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokeDelete();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
Piano.revokeDelete();
resolve();
},
);

req.on('error', (err) => {
Piano.revokeDelete();
reject(err);
});

@@ -624,8 +632,6 @@ describe('yasumi', () => {

it('throws on item not found', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowDelete();

const req = request(
{
host: HOST,
@@ -639,18 +645,15 @@ describe('yasumi', () => {
},
(res) => {
res.on('error', (err) => {
Piano.revokeDelete();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
Piano.revokeDelete();
resolve();
},
);

req.on('error', (err) => {
Piano.revokeDelete();
reject(err);
});



Loading…
Cancel
Save