Browse Source

Major refactor of codebase

Organize code even further to improve extensibility.
master
TheoryOfNekomata 8 months ago
parent
commit
d94f7e4c44
36 changed files with 1492 additions and 1576 deletions
  1. +6
    -2
      README.md
  2. +22
    -20
      examples/basic/server.ts
  3. +61
    -0
      src/app.ts
  4. +21
    -0
      src/backend/common.ts
  5. +162
    -0
      src/backend/core.ts
  6. +13
    -0
      src/backend/data-source.ts
  7. +3
    -2
      src/backend/data-sources/file-jsonl.ts
  8. +0
    -0
      src/backend/data-sources/index.ts
  9. +20
    -0
      src/backend/extenders/method.ts
  10. +27
    -0
      src/backend/extenders/url.ts
  11. +290
    -0
      src/backend/handlers.ts
  12. +2
    -0
      src/backend/index.ts
  13. +533
    -0
      src/backend/server.ts
  14. +28
    -0
      src/backend/utils.ts
  15. +48
    -0
      src/client/index.ts
  16. +16
    -0
      src/common/app.ts
  17. +1
    -3
      src/common/charset.ts
  18. +1
    -0
      src/common/charsets/index.ts
  19. +0
    -0
      src/common/charsets/utf-8.ts
  20. +5
    -0
      src/common/data-source.ts
  21. +5
    -0
      src/common/index.ts
  22. +10
    -6
      src/common/language.ts
  23. +8
    -2
      src/common/languages/en/index.ts
  24. +1
    -0
      src/common/languages/index.ts
  25. +5
    -0
      src/common/media-type.ts
  26. +0
    -0
      src/common/media-types/application/json.ts
  27. +2
    -0
      src/common/media-types/index.ts
  28. +149
    -0
      src/common/resource.ts
  29. +1
    -1
      src/common/validation.ts
  30. +0
    -585
      src/core.ts
  31. +0
    -835
      src/handlers.ts
  32. +10
    -5
      src/index.ts
  33. +0
    -8
      src/serializers/index.ts
  34. +0
    -54
      src/utils.ts
  35. +38
    -52
      test/e2e/default.test.ts
  36. +4
    -1
      tsconfig.json

+ 6
- 2
README.md View File

@@ -36,6 +36,10 @@ See [docs folder](./docs) for more details.


https://www.rfc-editor.org/rfc/rfc9457.html https://www.rfc-editor.org/rfc/rfc9457.html


- RFC 5988 - Web Linking
- ~~RFC 5988 - Web Linking~~


https://datatracker.ietf.org/doc/html/rfc5988
~~https://datatracker.ietf.org/doc/html/rfc5988~~

- RFC 9288 - Web Linking

https://httpwg.org/specs/rfc8288.html

+ 22
- 20
examples/basic/server.ts View File

@@ -2,8 +2,8 @@ import {
application, application,
resource, resource,
validation as v, validation as v,
serializers,
encodings,
mediaTypes,
charsets,
} from '../../src'; } from '../../src';
import {TEXT_SERIALIZER_PAIR} from './serializers'; import {TEXT_SERIALIZER_PAIR} from './serializers';
import {autoIncrement, dataSource} from './data-source'; import {autoIncrement, dataSource} from './data-source';
@@ -49,28 +49,30 @@ const User = resource(v.object(


const app = application({ const app = application({
name: 'piano-service', name: 'piano-service',
dataSource,
}) })
.contentType(serializers.applicationJson)
.contentType(serializers.textJson)
.contentType(TEXT_SERIALIZER_PAIR)
.encoding(encodings.utf8)
.mediaType(mediaTypes.applicationJson)
.mediaType(mediaTypes.textJson)
.mediaType(TEXT_SERIALIZER_PAIR)
.charset(charsets.utf8)
.resource(Piano) .resource(Piano)
.resource(User); .resource(User);


const backend = app.createBackend();
app.create({
dataSource,
}).then((backend) => {
const server = backend.createServer({
basePath: '/api'
});

server.listen(3000);


const server = backend.createServer({
baseUrl: '/api'
setTimeout(() => {
// Allow user operations after 5 seconds from startup
User
.canFetchItem()
.canFetchCollection()
.canCreate()
.canPatch();
}, 5000);
}); });


server.listen(3000);

setTimeout(() => {
// Allow user operations after 5 seconds from startup
User
.canFetchItem()
.canFetchCollection()
.canCreate()
.canPatch();
}, 5000);

+ 61
- 0
src/app.ts View File

@@ -0,0 +1,61 @@
import * as v from 'valibot';
import * as en from './common/languages/en';
import * as utf8 from './common/charsets/utf-8';
import * as applicationJson from './common/media-types/application/json';
import {Resource, Language, MediaType, Charset, ApplicationParams, ApplicationState} from './common';
import {BackendBuilder, createBackend, CreateBackendParams} from './backend';
import {ClientBuilder, createClient, CreateClientParams} from './client';

export interface ApplicationBuilder {
mediaType(mediaType: MediaType): this;
language(language: Language): this;
charset(charset: Charset): this;
resource<T extends v.BaseSchema>(resRaw: Resource<T>): this;
createBackend(params: Omit<CreateBackendParams, 'app'>): BackendBuilder;
createClient(params: Omit<CreateClientParams, 'app'>): ClientBuilder;
}

export const application = (appParams: ApplicationParams): ApplicationBuilder => {
const appState: ApplicationState = {
name: appParams.name,
resources: new Set<Resource<any>>(),
languages: new Set<Language>(),
mediaTypes: new Set<MediaType>(),
charsets: new Set<Charset>(),
};

appState.languages.add(en);
appState.charsets.add(utf8);
appState.mediaTypes.add(applicationJson);

return {
mediaType(serializerPair: MediaType) {
appState.mediaTypes.add(serializerPair);
return this;
},
charset(encodingPair: Charset) {
appState.charsets.add(encodingPair);
return this;
},
language(language: Language) {
appState.languages.add(language);
return this;
},
resource<T extends v.BaseSchema>(resRaw: Resource<T>) {
appState.resources.add(resRaw);
return this;
},
createBackend(params: Omit<CreateBackendParams, 'app'>) {
return createBackend({
...params,
app: appState
});
},
createClient(params: Omit<CreateClientParams, 'app'>) {
return createClient({
...params,
app: appState
});
},
};
};

+ 21
- 0
src/backend/common.ts View File

@@ -0,0 +1,21 @@
import {ApplicationState, Charset, Language, MediaType, Resource} from '../common';
import {BaseDataSource} from '../common/data-source';

export interface BackendState {
app: ApplicationState;
dataSource: (resource: Resource) => BaseDataSource;
cn: {
language: Language;
charset: Charset;
mediaType: MediaType;
}
errorHeaders: {
language?: string;
charset?: string;
serializer?: string;
}
showTotalItemCountOnGetCollection: boolean;
throws404OnDeletingNotFound: boolean;
checksSerializersOnDelete: boolean;
showTotalItemCountOnCreateItem: boolean;
}

+ 162
- 0
src/backend/core.ts View File

@@ -0,0 +1,162 @@
import * as v from 'valibot';
import {ApplicationState, Language, LanguageStatusMessageMap, Resource} from '../common';
import http from 'http';
import {createServer, CreateServerParams} from './server';
import https from 'https';
import {BackendState} from './common';
import {BaseDataSource} from '../common/data-source';
import * as en from '../common/languages/en';
import * as utf8 from '../common/charsets/utf-8';
import * as applicationJson from '../common/media-types/application/json';
import {DataSource} from './data-source';

export interface BackendResource<
DataSourceType extends BaseDataSource = DataSource,
ResourceSchema extends v.BaseSchema = v.BaseSchema,
ResourceName extends string = string,
ResourceRouteName extends string = string,
IdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
> extends Resource<ResourceSchema, ResourceName, ResourceRouteName, IdAttr, IdSchema> {
newId(dataSource: DataSourceType): string | number | unknown;
dataSource: DataSourceType;
}

export interface RequestContext extends http.IncomingMessage {}

export interface BackendBuilder<T extends BaseDataSource = BaseDataSource> {
showTotalItemCountOnGetCollection(b?: boolean): this;
showTotalItemCountOnCreateItem(b?: boolean): this;
checksSerializersOnDelete(b?: boolean): this;
throws404OnDeletingNotFound(b?: boolean): this;
createServer(serverParams?: CreateServerParams): http.Server | https.Server;
dataSource?: (resource: Resource) => T;
}

export class MiddlewareError extends Error {}

interface ResponseParams {
statusCode: Response['statusCode'];
statusMessage?: Response['statusMessage'];
headers?: Response['headers'];
}


interface PlainResponseParams<T = unknown> extends ResponseParams {
body?: T;
}

interface StreamResponseParams extends ResponseParams {
stream: NodeJS.ReadableStream;
}


interface HttpMiddlewareErrorParams<T = unknown> extends Omit<PlainResponseParams<T>, 'statusMessage'> {
cause?: unknown
}

export interface Response {
statusCode: number;

statusMessage?: keyof LanguageStatusMessageMap;

headers?: Record<string, string>;
}

export class PlainResponse<T = unknown> implements Response {
readonly statusCode: Response['statusCode'];

readonly statusMessage?: keyof LanguageStatusMessageMap;

readonly headers: Response['headers'];

readonly body?: T;

constructor(args: PlainResponseParams<T>) {
this.statusCode = args.statusCode;
this.statusMessage = args.statusMessage;
this.headers = args.headers;
this.body = args.body;
}
}

export class StreamResponse implements Response {
readonly statusCode: Response['statusCode'];

readonly statusMessage?: keyof LanguageStatusMessageMap;

readonly headers: Response['headers'];

readonly stream: NodeJS.ReadableStream;

constructor(args: StreamResponseParams) {
this.statusCode = args.statusCode;
this.statusMessage = args.statusMessage;
this.headers = args.headers;
this.stream = args.stream;
}
}

export class HttpMiddlewareError extends MiddlewareError {
readonly response: PlainResponse;

constructor(statusMessage: keyof Language['statusMessages'], params: HttpMiddlewareErrorParams) {
super(statusMessage, { cause: params.cause });
this.response = new PlainResponse({
...params,
statusMessage,
});
}
}

export interface ResponseContext<T extends http.IncomingMessage> extends http.ServerResponse<T> {}

export interface CreateBackendParams {
app: ApplicationState;
dataSource: (resource: Resource) => BaseDataSource;
}

export const createBackend = (params: CreateBackendParams) => {
const backendState: BackendState = {
app: params.app,
dataSource: params.dataSource,
cn: {
language: en,
charset: utf8,
mediaType: applicationJson
},
errorHeaders: {
// undefined follows user accept headers strictly
//
language: undefined,
charset: 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) {
return createServer(backendState, serverParams);
}
} satisfies BackendBuilder;
};

+ 13
- 0
src/backend/data-source.ts View File

@@ -0,0 +1,13 @@
import {BaseDataSource} from '../common/data-source';

export interface DataSource<T = object, Q = object> extends BaseDataSource {
initialize(): Promise<unknown>;
getTotalCount?(query?: Q): Promise<number>;
getMultiple(query?: Q): Promise<T[]>;
getById(id: string): Promise<T | null>;
getSingle?(query?: Q): Promise<T | null>;
create(data: T): Promise<T>;
delete(id: string): Promise<unknown>;
emplace(id: string, data: T): Promise<[T, boolean]>;
patch(id: string, data: Partial<T>): Promise<T | null>;
}

src/data-sources/file-jsonl.ts → src/backend/data-sources/file-jsonl.ts View File

@@ -1,6 +1,7 @@
import {readFile, writeFile} from 'fs/promises'; import {readFile, writeFile} from 'fs/promises';
import {join} from 'path'; import {join} from 'path';
import {DataSource as DataSourceInterface, Resource} from '../core';
import {DataSource as DataSourceInterface} from '../data-source';
import {Resource} from '../..';


export class DataSource<T extends Record<string, string>> implements DataSourceInterface<T> { export class DataSource<T extends Record<string, string>> implements DataSourceInterface<T> {
private readonly path: string; private readonly path: string;
@@ -8,7 +9,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
data: T[] = []; data: T[] = [];


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


async initialize() { async initialize() {

src/data-sources/index.ts → src/backend/data-sources/index.ts View File


+ 20
- 0
src/backend/extenders/method.ts View File

@@ -0,0 +1,20 @@
import {constants} from 'http2';
import http from 'http';
import {HttpMiddlewareError} from '../index';

interface RequestContext extends http.IncomingMessage {
method: string;
}

export const adjustMethod = (req: RequestContext) => {
if (!req.method) {
throw new HttpMiddlewareError('methodNotAllowed', {
statusCode: constants.HTTP_STATUS_METHOD_NOT_ALLOWED,
headers: {
'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE',
},
});
}

req.method = req.method.trim().toUpperCase();
};

+ 27
- 0
src/backend/extenders/url.ts View File

@@ -0,0 +1,27 @@
import {constants} from 'http2';
import http from 'http';
import {HttpMiddlewareError} from '..';

interface RequestContext extends http.IncomingMessage {
basePath?: string;

query?: URLSearchParams;

rawUrl: string;
}

export const adjustUrl = (req: RequestContext) => {
if (!req.url) {
throw new HttpMiddlewareError('badRequest', {
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
});
}

const theBasePathUrl = req.basePath ?? '';
const basePath = new URL(theBasePathUrl, 'http://localhost');
const parsedUrl = new URL(`${theBasePathUrl}/${req.url}`, 'http://localhost');
req.rawUrl = req.url;
req.url = req.url.slice(basePath.pathname.length);
req.query = parsedUrl.searchParams;
return;
};

+ 290
- 0
src/backend/handlers.ts View File

@@ -0,0 +1,290 @@
import { constants } from 'http2';
import * as v from 'valibot';
import {HttpMiddlewareError, PlainResponse} from './core';
import {Middleware} from './server';

export const handleGetRoot: Middleware = (req) => {
const { backend, basePath } = req;

const data = {
name: backend.app.name
};

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

const headers: Record<string, string> = {};
if (availableResources.length > 0) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
headers['Link'] = availableResources
.map((r) => [
`<${basePath}/${r.state.routeName}>`,
'rel="related"',
`name="${encodeURIComponent(r.state.routeName)}"`
].join('; '))
.join(', ');
}

return new PlainResponse({
headers,
statusMessage: 'ok',
statusCode: constants.HTTP_STATUS_OK,
body: data
});
};

export const handleGetCollection: Middleware = async (req) => {
const { query, resource, backend } = req;

let data: v.Output<typeof resource.schema>[];
let totalItemCount: number | undefined;
try {
// TODO querying mechanism
data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource
if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount(query);
}
} catch (cause) {
throw new HttpMiddlewareError(
'unableToFetchResourceCollection',
{
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
}
);
}

const headers: Record<string, string> = {};

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

return new PlainResponse({
headers,
statusCode: constants.HTTP_STATUS_OK,
statusMessage: 'resourceCollectionFetched',
body: data,
});
};

export const handleGetItem: Middleware = async (req) => {
const { resource, resourceId } = req;

if (typeof resourceId === 'undefined') {
throw new HttpMiddlewareError(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
}
);
}

let data: v.Output<typeof resource.schema> | null = null;
try {
data = await resource.dataSource.getById(resourceId);
} catch (cause) {
throw new HttpMiddlewareError(
'unableToFetchResource',
{
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
}
);
}

if (typeof data !== 'undefined' && data !== null) {
return new PlainResponse({
statusCode: constants.HTTP_STATUS_OK,
statusMessage: 'resourceFetched',
body: data
});
}

throw new HttpMiddlewareError(
'resourceNotFound',
{
statusCode: constants.HTTP_STATUS_NOT_FOUND,
}
);
};

export const handleDeleteItem: Middleware = async (req) => {
const { resource, resourceId, backend } = req;

if (typeof resourceId === 'undefined') {
throw new HttpMiddlewareError(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
}
);
}

let existing: unknown | null;
try {
existing = await resource.dataSource.getById(resourceId);
} catch (cause) {
throw new HttpMiddlewareError('unableToFetchResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR
});
}

if (!existing && backend.throws404OnDeletingNotFound) {
throw new HttpMiddlewareError('deleteNonExistingResource', {
statusCode: constants.HTTP_STATUS_NOT_FOUND
});
}

try {
if (existing) {
await resource.dataSource.delete(resourceId);
}
} catch (cause) {
throw new HttpMiddlewareError('unableToDeleteResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR
})
}

return new PlainResponse({
statusCode: constants.HTTP_STATUS_NO_CONTENT,
statusMessage: 'resourceDeleted',
});
};

export const handlePatchItem: Middleware = async (req) => {
const { resource, resourceId, body } = req;

let existing: unknown | null;
try {
existing = await resource.dataSource.getById(resourceId!);
} catch (cause) {
throw new HttpMiddlewareError('unableToFetchResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

if (!existing) {
throw new HttpMiddlewareError('patchNonExistingResource', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

let newObject: v.Output<typeof resource.schema> | null;
try {
newObject = await resource.dataSource.patch(resourceId!, body as object);
} catch (cause) {
throw new HttpMiddlewareError('unableToPatchResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR
});
}

return new PlainResponse({
statusCode: constants.HTTP_STATUS_OK,
statusMessage: 'resourcePatched',
body: newObject,
});

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

export const handleCreateItem: Middleware = async (req) => {
const { resource, body, backend, basePath } = req;

let newId;
let params: v.Output<typeof resource.schema>;
try {
newId = await resource.newId(resource.dataSource);
params = { ...body as Record<string, unknown> };
params[resource.state.idAttr] = newId;
} catch (cause) {
throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

let newObject;
let totalItemCount: number | undefined;
try {
newObject = await resource.dataSource.create(params);
if (backend.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
} catch (cause) {
throw new HttpMiddlewareError('unableToCreateResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

const location = `${basePath}/${resource.state.routeName}/${newId}`;

if (typeof totalItemCount !== 'undefined') {
return new PlainResponse({
statusCode: constants.HTTP_STATUS_CREATED,
headers: {
'Location': location,
'X-Resource-Total-Item-Count': totalItemCount.toString()
},
body: newObject,
statusMessage: 'resourceCreated'
});
}

return new PlainResponse({
statusCode: constants.HTTP_STATUS_CREATED,
body: newObject,
headers: {
'Location': location,
},
statusMessage: 'resourceCreated'
});
}

export const handleEmplaceItem: Middleware = async (req) => {
const { resource, resourceId, basePath, body, backend } = req;

let newObject: v.Output<typeof resource.schema>;
let isCreated: boolean;
try {
const params = { ...body as Record<string, unknown> };
params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string);
[newObject, isCreated] = await resource.dataSource.emplace(resourceId!, params);
} catch (cause) {
throw new HttpMiddlewareError('unableToEmplaceResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

const headers: Record<string, string> = {};
let totalItemCount: number | undefined;
if (backend.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
if (isCreated) {
headers['Location'] = `${basePath}/${resource.state.routeName}/${resourceId}`;
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
}

return new PlainResponse({
statusCode: isCreated ? constants.HTTP_STATUS_CREATED : constants.HTTP_STATUS_OK,
headers,
statusMessage: (
isCreated
? 'resourceCreated'
: 'resourceReplaced'
),
body: newObject
});
}

+ 2
- 0
src/backend/index.ts View File

@@ -0,0 +1,2 @@
export * from './core';
export * as dataSources from './data-sources';

+ 533
- 0
src/backend/server.ts View File

@@ -0,0 +1,533 @@
import http from 'http';
import {BackendState} from './common';
import {Language, Resource, Charset, MediaType} from '../common';
import * as applicationJson from '../common/media-types/application/json';
import * as utf8 from '../common/charsets/utf-8';
import * as en from '../common/languages/en';
import https from 'https';
import Negotiator from 'negotiator';
import {constants} from 'http2';
import {adjustMethod} from './extenders/method';
import {adjustUrl} from './extenders/url';
import {
handleCreateItem,
handleDeleteItem,
handleEmplaceItem,
handleGetCollection,
handleGetItem,
handleGetRoot,
handlePatchItem,
} from './handlers';
import {
HttpMiddlewareError,
PlainResponse,
ResponseContext,
StreamResponse,
Response,
BackendResource,
} from './core';
import * as v from 'valibot';
import {getBody} from './utils';
import {DataSource} from './data-source';

export interface CreateServerParams {
basePath?: string;
host?: string;
cert?: string;
key?: string;
requestTimeout?: number;
// CQRS
streamResponses?: boolean;
}

export interface RequestContext extends http.IncomingMessage {
backend: BackendState;

host: string;

scheme: string;

basePath: string;

method: string;

url: string;

rawUrl: string;

cn: {
language: Language;
mediaType: MediaType;
charset: Charset;
};

query: URLSearchParams;

resource: BackendResource;

resourceId?: string;

body?: unknown;
}

export interface Middleware<Req extends RequestContext = RequestContext> {
(req: Req): undefined | Response | Promise<undefined | Response>;
}

const getAllowedMiddlewares = <T extends v.BaseSchema>(resource: Resource<T>, mainResourceId: string) => {
const middlewares = [] as [string, Middleware, v.BaseSchema?][];
if (mainResourceId === '') {
if (resource.state.canFetchCollection) {
middlewares.push(['GET', handleGetCollection]);
}
if (resource.state.canCreate) {
middlewares.push(['POST', handleCreateItem, resource.schema]);
}
return middlewares;
}

if (resource.state.canFetchItem) {
middlewares.push(['GET', handleGetItem]);
}
if (resource.state.canEmplace) {
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema;
const putSchema = (
schema.type === 'object'
? v.merge([
schema as v.ObjectSchema<any>,
v.object({
[resource.state.idAttr]: v.transform(
v.any(),
input => resource.state.idConfig.serialize(input),
v.literal(mainResourceId)
)
})
])
: schema
);
middlewares.push(['PUT', handleEmplaceItem, putSchema]);
}
if (resource.state.canPatch) {
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema;
const patchSchema = (
schema.type === 'object'
? v.partial(
schema as v.ObjectSchema<any>,
(schema as v.ObjectSchema<any>).rest,
(schema as v.ObjectSchema<any>).pipe
)
: schema
);
middlewares.push(['PATCH', handlePatchItem, patchSchema]);
}
if (resource.state.canDelete) {
middlewares.push(['DELETE', handleDeleteItem]);
}

return middlewares;
};

export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => {
const isHttps = 'key' in serverParams && 'cert' in serverParams;

class ServerYasumiRequest extends http.IncomingMessage implements RequestContext {
readonly host = serverParams.host ?? 'localhost';

readonly scheme = isHttps ? 'https' : 'http';

readonly basePath = serverParams.basePath ?? '';

readonly backend = backendState;

resource = undefined as unknown as BackendResource;

resourceId?: string;

query = new URLSearchParams();

body?: unknown;

method = '';

url = '';

rawUrl = '';

readonly cn: {
language: Language;
mediaType: MediaType;
charset: Charset;
} = {
language: en,
mediaType: applicationJson,
charset: utf8,
};
}

class ServerYasumiResponse<T extends http.IncomingMessage> extends http.ServerResponse<T> {

}

const server = isHttps
? https.createServer({
key: serverParams.key,
cert: serverParams.cert,
requestTimeout: serverParams.requestTimeout,
IncomingMessage: ServerYasumiRequest,
ServerResponse: ServerYasumiResponse,
})
: http.createServer({
requestTimeout: serverParams.requestTimeout,
IncomingMessage: ServerYasumiRequest,
ServerResponse: ServerYasumiResponse,
});

const adjustRequestForContentNegotiation = (req: RequestContext, res: ResponseContext<RequestContext>) => {

const negotiator = new Negotiator(req);
const availableLanguages = Array.from(req.backend.app.languages);
const availableCharsets = Array.from(req.backend.app.charsets);
const availableMediaTypes = Array.from(req.backend.app.mediaTypes);

const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? backendState.cn.language.name;
const charsetCandidate = negotiator.charset(availableCharsets.map((l) => l.name)) ?? backendState.cn.charset.name;
const mediaTypeCandidate = negotiator.mediaType(availableMediaTypes.map((l) => l.name)) ?? backendState.cn.mediaType.name;

// TODO refactor
const currentLanguage = availableLanguages.find((l) => l.name === languageCandidate);
if (typeof currentLanguage === 'undefined') {
const data = req.backend?.cn.language.bodies.languageNotAcceptable();
const responseRaw = req.backend?.cn.mediaType.serialize(data);
const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined;
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, {
'Content-Language': req.backend?.cn.language.name,
'Content-Type': [
req.backend?.cn.mediaType.name,
`charset="${req.backend?.cn.charset.name}"`
].join('; '),
});
res.statusMessage = req.backend?.cn.language.statusMessages.languageNotAcceptable() ?? '';
res.end(response);
return;
}

const currentMediaType = availableMediaTypes.find((l) => l.name === mediaTypeCandidate);
if (typeof currentMediaType === 'undefined') {
const data = req.backend?.cn.language.bodies.mediaTypeNotAcceptable();
const responseRaw = req.backend?.cn.mediaType.serialize(data);
const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined;
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, {
'Content-Language': req.backend?.cn.language.name,
'Content-Type': [
req.backend?.cn.mediaType.name,
`charset="${req.backend?.cn.charset.name}"`
].join('; '),
});
res.statusMessage = req.backend?.cn.language.statusMessages.mediaTypeNotAcceptable() ?? '';
res.end(response);
return;
}

const responseBodyCharset = availableCharsets.find((l) => l.name === charsetCandidate);
if (typeof responseBodyCharset === 'undefined') {
const data = req.backend?.cn.language.bodies.encodingNotAcceptable();
const responseRaw = req.backend?.cn.mediaType.serialize(data);
const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined;
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, {
'Content-Language': req.backend?.cn.language.name,
'Content-Type': [
req.backend?.cn.mediaType.name,
`charset="${req.backend?.cn.charset.name}"`
].join('; '),
});
res.statusMessage = req.backend?.cn.language.statusMessages.encodingNotAcceptable() ?? '';
res.end(response);
return;
}

req.cn.language = currentLanguage;
req.cn.mediaType = currentMediaType;
req.cn.charset = responseBodyCharset;
};

server.on('request', async (req: RequestContext, res) => {
adjustRequestForContentNegotiation(req, res);

try {
adjustMethod(req);
} catch (errRaw) {
if (typeof errRaw !== 'undefined') {
const err= errRaw as HttpMiddlewareError;
const errBody = err.response.body;
if (typeof errBody !== 'undefined') {
res.writeHead(err.response.statusCode, {
...(err.response.headers ?? {}),
'Content-Language': req.backend.cn.language.name,
'Content-Type': [
req.backend.cn.mediaType.name,
`charset="${req.backend.cn.charset.name}"`
].join('; '),
});
res.statusMessage = err.response.statusMessage ?? '';
const errBodySerialized = req.backend.cn.mediaType.serialize(errBody);
const errBodyEncoded = typeof errBodySerialized !== 'undefined' ? req.backend.cn.charset.encode(errBodySerialized) : undefined;
res.end(errBodyEncoded);
return;
}
res.writeHead(err.response.statusCode, {
...(err.response.headers ?? {}),
'Content-Language': req.backend.cn.language.name,
});
res.statusMessage = err.response.statusMessage ?? '';
res.end();
return;
}
}

try {
adjustUrl(req);
} catch (errRaw) {
if (typeof errRaw !== 'undefined') {
const err= errRaw as HttpMiddlewareError;
const errBody = err.response.body;
if (typeof errBody !== 'undefined') {
res.writeHead(err.response.statusCode, {
...(err.response.headers ?? {}),
'Content-Language': req.backend.cn.language.name,
'Content-Type': [
req.backend.cn.mediaType.name,
`charset="${req.backend.cn.charset.name}"`
].join('; '),
});
res.statusMessage = err.response.statusMessage ?? '';
const errBodySerialized = req.backend.cn.mediaType.serialize(errBody);
const errBodyEncoded = typeof errBodySerialized !== 'undefined' ? req.backend.cn.charset.encode(errBodySerialized) : undefined;
res.end(errBodyEncoded);
return;
}
res.writeHead(err.response.statusCode, {
...(err.response.headers ?? {}),
'Content-Language': req.backend.cn.language.name,
});
res.statusMessage = err.response.statusMessage ?? '';
res.end();
return;
}
}

if (req.url === '/') {
const middlewareState = await handleGetRoot(req);
if (typeof middlewareState !== 'undefined') {
return;
}

res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, {
Allow: 'HEAD, GET'
});
res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed();
res.end();
return;
}

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

req.resource = resource as BackendResource;
req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource;
req.resourceId = resourceId;

try {
await req.resource.dataSource.initialize();
} catch (cause) {
throw new HttpMiddlewareError(
'unableToInitializeResourceDataSource',
{
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
}
);
}

const middlewares = getAllowedMiddlewares(resource, resourceId);
const middlewareState = await middlewares.reduce<unknown>(
async (currentHandlerStatePromise, currentValue) => {
const [middlewareMethod, middleware, schema] = currentValue;
try {
const currentHandlerState = await currentHandlerStatePromise;

if (req.method !== middlewareMethod) {
return currentHandlerState;
}

if (typeof currentHandlerState !== 'undefined') {
return currentHandlerState;
}

if (schema) {
const availableSerializers = Array.from(req.backend.app.mediaTypes);
const availableCharsets = Array.from(req.backend.app.charsets);
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream';
const fragments = contentTypeHeader.split(';');
const mediaType = fragments[0];
const charsetParam = fragments.map((s) => s.trim())
.find((f) => f.startsWith('charset=')) ?? (mediaType.startsWith('text/') ? 'charset=utf-8' : 'charset=binary');
const [_charsetKey, charsetRaw] = charsetParam.split('=').map((s) => s.trim());
const charset = (
(
(charsetRaw.startsWith('"') && charsetRaw.endsWith('"'))
|| (charsetRaw.startsWith("'") && charsetRaw.endsWith("'"))
)
? charsetRaw.slice(1, -1).trim()
: charsetRaw.trim()
)
const deserializerPair = availableSerializers.find((l) => l.name === mediaType);
const encodingPair = availableCharsets.find((l) => l.name === charset);
(req as unknown as Record<string, unknown>).body = await getBody(req, schema, encodingPair, deserializerPair);
}

const result = await middleware(req);

return Promise.resolve(result);
} catch (errRaw) {
// todo use error message key for each method
// TODO better error reporting, localizable messages
// TODO handle error handlers' errors
if (errRaw instanceof v.ValiError && Array.isArray(errRaw.issues)) {
return new HttpMiddlewareError('invalidResource', {
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
body: errRaw.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
});
}

return errRaw;
}
},
Promise.resolve<ReturnType<Middleware>>(undefined)
) as Awaited<ReturnType<Middleware>>;

if (typeof middlewareState !== 'undefined') {
try {
if (middlewareState instanceof Error) {
throw middlewareState;
}

const headers: Record<string, string> = {
...(
middlewareState.headers ?? {}
),
'Content-Language': req.cn.language.name
};

if (middlewareState instanceof StreamResponse) {
res.writeHead(constants.HTTP_STATUS_ACCEPTED, headers);
middlewareState.stream.pipe(res);
middlewareState.stream.on('end', () => {
res.end();
});
return;
}

if (middlewareState instanceof PlainResponse) {
let encoded: Buffer | undefined;
if (typeof middlewareState.body !== 'undefined') {
let serialized;
try {
serialized = req.cn.mediaType.serialize(middlewareState.body);
} catch (cause) {
throw new HttpMiddlewareError('unableToSerializeResponse', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
headers: {
'Content-Language': req.backend.cn.language.name,
},
})
}

try {
encoded = req.cn.charset.encode(serialized);
} catch (cause) {
throw new HttpMiddlewareError('unableToEncodeResponse', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
headers: {
'Content-Language': req.backend.cn.language.name,
},
})
}

headers['Content-Type'] = [
req.cn.mediaType.name,
`charset=${req.cn.charset.name}`
].join('; ');
}

res.writeHead(middlewareState.statusCode, headers);
res.statusMessage = middlewareState.statusMessage ?? '';
if (typeof encoded !== 'undefined') {
res.end(encoded);
return;
}
res.end();
}
return;
} catch (finalErrRaw) {
const finalErr = finalErrRaw as HttpMiddlewareError;
const headers = finalErr.response.headers ?? {};
let encoded: Buffer | undefined;
let serialized;
try {
serialized = typeof finalErr.response.body !== 'undefined' ? req.backend.cn.mediaType.serialize(finalErr.response.body) : undefined;
} catch (cause) {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
return;
}

try {
encoded = typeof serialized !== 'undefined' ? req.backend.cn.charset.encode(serialized) : undefined;
} catch (cause) {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
}

headers['Content-Type'] = [
req.backend.cn.mediaType.name,
`charset=${req.backend.cn.charset.name}`
].join('; ');

res.writeHead(finalErr.response.statusCode, headers);
res.statusMessage = finalErr.response.statusMessage ?? '';
if (typeof encoded !== 'undefined') {
res.end(encoded);
return;
}
res.end();
return;
}
}

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

res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound();
res.end();
return;
});

return server;
}

+ 28
- 0
src/backend/utils.ts View File

@@ -0,0 +1,28 @@
import {IncomingMessage} from 'http';
import {MediaType, Charset} from '../common';
import {BaseSchema, parseAsync} from 'valibot';

export const getBody = (
req: IncomingMessage,
schema: BaseSchema,
encodingPair?: Charset,
deserializer?: MediaType,
) => new Promise((resolve, reject) => {
let body = Buffer.from('');
req.on('data', (chunk) => {
body = Buffer.concat([body, chunk]);
});
req.on('end', async () => {
const bodyStr = encodingPair?.decode(body) ?? body.toString();
try {
const bodyDeserialized = await parseAsync(
schema,
deserializer?.deserialize(bodyStr) ?? body,
{abortEarly: false},
);
resolve(bodyDeserialized);
} catch (err) {
reject(err);
}
});
});

+ 48
- 0
src/client/index.ts View File

@@ -0,0 +1,48 @@
import * as applicationJson from '../common/media-types/application/json';
import * as utf8 from '../common/charsets/utf-8';
import * as en from '../common/languages/en';
import {ApplicationState, Charset, Language, MediaType} from '../common';

export interface ClientState {
app: ApplicationState;
mediaType: MediaType;
charset: Charset;
language: Language;
}

export interface ClientBuilder {
setLanguage(languageCode: ClientState['language']['name']): this;
setCharset(charset: ClientState['charset']['name']): this;
setMediaType(mediaType: ClientState['mediaType']['name']): this;
}

export interface CreateClientParams {
app: ApplicationState;
}

export const createClient = (params: CreateClientParams) => {
const clientState: ClientState = {
app: params.app,
mediaType: applicationJson,
charset: utf8,
language: en
};

return {
setMediaType(mediaTypeName) {
const mediaType = Array.from(clientState.app.mediaTypes).find((l) => l.name === mediaTypeName);
clientState.mediaType = mediaType ?? applicationJson;
return this;
},
setCharset(charsetName) {
const charset = Array.from(clientState.app.charsets).find((l) => l.name === charsetName);
clientState.charset = charset ?? utf8;
return this;
},
setLanguage(languageCode) {
const language = Array.from(clientState.app.languages).find((l) => l.name === languageCode);
clientState.language = language ?? en;
return this;
}
} satisfies ClientBuilder;
};

+ 16
- 0
src/common/app.ts View File

@@ -0,0 +1,16 @@
import {Resource} from './resource';
import {Language} from './language';
import {MediaType} from './media-type';
import {Charset} from './charset';

export interface ApplicationState {
name: string;
resources: Set<Resource<any>>;
languages: Set<Language>;
mediaTypes: Set<MediaType>;
charsets: Set<Charset>;
}

export interface ApplicationParams {
name: string;
}

src/encodings/index.ts → src/common/charset.ts View File

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

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

+ 1
- 0
src/common/charsets/index.ts View File

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

src/encodings/utf-8.ts → src/common/charsets/utf-8.ts View File


+ 5
- 0
src/common/data-source.ts View File

@@ -0,0 +1,5 @@
export interface BaseDataSource {}

export interface GenerationStrategy<D extends BaseDataSource = BaseDataSource> {
(dataSource: D, ...args: unknown[]): Promise<string | number | unknown>;
}

+ 5
- 0
src/common/index.ts View File

@@ -0,0 +1,5 @@
export * from './app';
export * from './charset';
export * from './media-type';
export * from './resource';
export * from './language';

src/common.ts → src/common/language.ts View File

@@ -1,4 +1,4 @@
import {Resource} from './core';
import {Resource} from './resource';


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


@@ -6,6 +6,7 @@ export interface LanguageStatusMessageMap {
unableToInitializeResourceDataSource(resource: Resource): string; unableToInitializeResourceDataSource(resource: Resource): string;
unableToFetchResourceCollection(resource: Resource): string; unableToFetchResourceCollection(resource: Resource): string;
unableToFetchResource(resource: Resource): string; unableToFetchResource(resource: Resource): string;
resourceIdNotGiven(resource: Resource): string;
languageNotAcceptable(): string; languageNotAcceptable(): string;
encodingNotAcceptable(): string; encodingNotAcceptable(): string;
mediaTypeNotAcceptable(): string; mediaTypeNotAcceptable(): string;
@@ -17,6 +18,7 @@ export interface LanguageStatusMessageMap {
resourceFetched(resource: Resource): string; resourceFetched(resource: Resource): string;
resourceNotFound(resource: Resource): string; resourceNotFound(resource: Resource): string;
deleteNonExistingResource(resource: Resource): string; deleteNonExistingResource(resource: Resource): string;
unableToCreateResource(resource: Resource): string;
unableToGenerateIdFromResourceDataSource(resource: Resource): string; unableToGenerateIdFromResourceDataSource(resource: Resource): string;
unableToEmplaceResource(resource: Resource): string; unableToEmplaceResource(resource: Resource): string;
unableToSerializeResponse(): string; unableToSerializeResponse(): string;
@@ -33,12 +35,14 @@ export interface LanguageStatusMessageMap {
resourceReplaced(resource: Resource): string; resourceReplaced(resource: Resource): string;
} }


export interface LanguageBodyMap {
languageNotAcceptable(): MessageBody;
encodingNotAcceptable(): MessageBody;
mediaTypeNotAcceptable(): MessageBody;
}

export interface Language { export interface Language {
name: string, name: string,
statusMessages: LanguageStatusMessageMap, statusMessages: LanguageStatusMessageMap,
bodies: {
languageNotAcceptable(): MessageBody;
encodingNotAcceptable(): MessageBody;
mediaTypeNotAcceptable(): MessageBody;
}
bodies: LanguageBodyMap
} }

src/languages/en/index.ts → src/common/languages/en/index.ts View File

@@ -1,5 +1,5 @@
import {Resource} from '../../core';
import {Language} from '../../common';
import {Resource} from '../../resource';
import {Language} from '../../language';


export const statusMessages = { export const statusMessages = {
unableToSerializeResponse(): string { unableToSerializeResponse(): string {
@@ -85,6 +85,12 @@ export const statusMessages = {
}, },
unableToEmplaceResource(resource: Resource): string { unableToEmplaceResource(resource: Resource): string {
return `Unable To Emplace ${resource.state.itemName}`; return `Unable To Emplace ${resource.state.itemName}`;
},
resourceIdNotGiven(resource: Resource): string {
return `${resource.state.itemName} ID Not Given`;
},
unableToCreateResource(resource: Resource): string {
return `Unable To Create ${resource.state.itemName}`;
} }
} satisfies Language['statusMessages']; } satisfies Language['statusMessages'];



+ 1
- 0
src/common/languages/index.ts View File

@@ -0,0 +1 @@
export * as en from './en';

+ 5
- 0
src/common/media-type.ts View File

@@ -0,0 +1,5 @@
export interface MediaType {
name: string;
serialize: <T>(object: T) => string;
deserialize: <T>(s: string) => T;
}

src/serializers/application/json.ts → src/common/media-types/application/json.ts View File


+ 2
- 0
src/common/media-types/index.ts View File

@@ -0,0 +1,2 @@
export * as applicationJson from './application/json';
export * as textJson from './application/json';

+ 149
- 0
src/common/resource.ts View File

@@ -0,0 +1,149 @@
import * as v from 'valibot';
import {BaseDataSource, GenerationStrategy} from './data-source';

export interface ResourceIdConfig<IdSchema extends v.BaseSchema, DataSource extends BaseDataSource = BaseDataSource> {
generationStrategy: GenerationStrategy<DataSource>;
serialize: (id: unknown) => string;
deserialize: (id: string) => v.Output<IdSchema>;
schema: IdSchema;
}

export interface ResourceState<
ItemName extends string = string,
RouteName extends string = string,
IdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
> {
idAttr: IdAttr;
itemName: ItemName;
routeName: RouteName;
idConfig: ResourceIdConfig<IdSchema>;
fullTextAttrs: Set<string>;
canCreate: boolean;
canFetchCollection: boolean;
canFetchItem: boolean;
canPatch: boolean;
canEmplace: boolean;
canDelete: boolean;
}

export interface Resource<
Schema extends v.BaseSchema = v.BaseSchema,
CurrentName extends string = string,
CurrentRouteName extends string = string,
CurrentIdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
> {
dataSource?: unknown;
schema: Schema;
state: ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>;
id<NewIdAttr extends CurrentIdAttr, TheIdSchema extends v.BaseSchema>(
newIdAttr: NewIdAttr,
params: ResourceIdConfig<TheIdSchema>
): Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, TheIdSchema>;
fullText(fullTextAttr: string): this;
name<NewName extends CurrentName>(n: NewName): Resource<Schema, NewName, CurrentRouteName, CurrentIdAttr, IdSchema>;
route<NewRouteName extends CurrentRouteName>(n: NewRouteName): Resource<Schema, CurrentName, NewRouteName, CurrentIdAttr, IdSchema>;
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 const resource = <
Schema extends v.BaseSchema,
CurrentName extends string = string,
CurrentRouteName extends string = string,
CurrentIdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema> => {
const resourceState = {
fullTextAttrs: new Set<string>(),
canCreate: false,
canFetchCollection: false,
canFetchItem: false,
canPatch: false,
canEmplace: false,
canDelete: false,
} as ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>;

return {
get state(): ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema> {
return Object.freeze({
...resourceState
}) as unknown as ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>;
},
canFetchCollection(b = true) {
resourceState.canFetchCollection = b;
return this;
},
canFetchItem(b = true) {
resourceState.canFetchItem = b;
return this;
},
canCreate(b = true) {
resourceState.canCreate = b;
return this;
},
canPatch(b = true) {
resourceState.canPatch = b;
return this;
},
canEmplace(b = true) {
resourceState.canEmplace = b;
return this;
},
canDelete(b = true) {
resourceState.canDelete = b;
return this;
},
id<NewIdAttr extends CurrentIdAttr, NewIdSchema extends IdSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) {
resourceState.idAttr = newIdAttr;
resourceState.idConfig = params;
return this as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>;
},
newId(dataSource: BaseDataSource) {
return resourceState?.idConfig?.generationStrategy?.(dataSource);
},
fullText(fullTextAttr: string) {
if (
schema.type === 'object'
&& (
schema as unknown as v.ObjectSchema<
Record<string, v.BaseSchema>,
undefined,
Record<string, string>
>
)
.entries[fullTextAttr]?.type === 'string'
) {
resourceState.fullTextAttrs?.add(fullTextAttr);
return this;
}

throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`);
},
name<NewName extends CurrentName>(n: NewName) {
resourceState.itemName = n;
return this as Resource<Schema, NewName, CurrentRouteName, CurrentIdAttr, IdSchema>;
},
route<NewRouteName extends CurrentRouteName>(n: NewRouteName) {
resourceState.routeName = n;
return this as Resource<Schema, CurrentName, NewRouteName, CurrentIdAttr, IdSchema>;
},
get idAttr() {
return resourceState.idAttr;
},
get itemName() {
return resourceState.itemName;
},
get routeName() {
return resourceState.routeName;
},
get schema() {
return schema;
},
} as Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>;
};

src/validation.ts → src/common/validation.ts View File

@@ -1,6 +1,6 @@
import * as v from 'valibot'; import * as v from 'valibot';
export * from 'valibot'; export * from 'valibot';
import { Resource } from './core';
import { Resource } from './resource';


export const datelike = () => v.transform( export const datelike = () => v.transform(
v.union([ v.union([

+ 0
- 585
src/core.ts View File

@@ -1,585 +0,0 @@
import * as http from 'http';
import * as https from 'https';
import { constants } from 'http2';
import { pluralize } from 'inflection';
import * as v from 'valibot';
import { SerializerPair } from './serializers';
import {
handleCreateItem, handleDeleteItem, handleEmplaceItem,
handleGetCollection,
handleGetItem,
handleGetRoot,
handleHasMethodAndUrl, handlePatchItem,
} from './handlers';
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';
import {Language} from './common';

// TODO separate frontend and backend factory methods

export interface DataSource<T = object, Q = object> {
initialize(): Promise<unknown>;
getTotalCount?(query?: Q): Promise<number>;
getMultiple(query?: Q): Promise<T[]>;
getById(id: string): Promise<T | null>;
getSingle?(query?: Q): Promise<T | null>;
create(data: T): Promise<T>;
delete(id: string): Promise<unknown>;
emplace(id: string, data: T): Promise<[T, boolean]>;
patch(id: string, data: Partial<T>): Promise<T | null>;
}

export interface ApplicationParams {
name: string;
dataSource?: (resource: Resource) => DataSource;
}

interface ResourceState<IdAttr extends string = string, IdSchema extends v.BaseSchema = v.BaseSchema> {
idAttr: IdAttr;
itemName: string;
collectionName: string;
routeName: string;
idConfig: ResourceIdConfig<IdSchema>;
fullTextAttrs: Set<string>;
canCreate: boolean;
canFetchCollection: boolean;
canFetchItem: boolean;
canPatch: boolean;
canEmplace: boolean;
canDelete: boolean;
}

export interface Resource<
ResourceSchema extends v.BaseSchema = v.BaseSchema,
IdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
> {
newId(dataSource: DataSource): string | number | unknown;
schema: ResourceSchema;
state: ResourceState<IdAttr, IdSchema>;
id<NewIdAttr extends IdAttr, TheIdSchema extends v.BaseSchema>(
newIdAttr: NewIdAttr,
params: ResourceIdConfig<TheIdSchema>
): Resource<ResourceSchema, NewIdAttr, TheIdSchema>;
fullText(fullTextAttr: string): this;
name(n: string): this;
collection(n: string): this;
route(n: string): 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 ResourceWithDataSource<T extends v.BaseSchema> extends Resource<T> {
dataSource: DataSource;
}

interface GenerationStrategy {
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>;
}

interface ResourceIdConfig<IdSchema extends v.BaseSchema> {
generationStrategy: GenerationStrategy;
serialize: (id: unknown) => string;
deserialize: (id: string) => v.Output<IdSchema>;
schema: IdSchema;
}

const getAllowedMiddlewares = <T extends v.BaseSchema>(resource: Resource<T>, mainResourceId: string) => {
const middlewares = [] as [string, Middleware][];
if (mainResourceId === '') {
if (resource.state.canFetchCollection) {
middlewares.push(['GET', handleGetCollection]);
}
if (resource.state.canCreate) {
middlewares.push(['POST', handleCreateItem]);
}
return middlewares;
}

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

return middlewares;
};

export const resource = <
ResourceSchema extends v.BaseSchema,
IdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
>(schema: ResourceSchema): Resource<ResourceSchema, IdAttr, IdSchema> => {
const resourceState = {
fullTextAttrs: new Set<string>(),
canCreate: false,
canFetchCollection: false,
canFetchItem: false,
canPatch: false,
canEmplace: false,
canDelete: false,
} as ResourceState<IdAttr, IdSchema>;

return {
get state(): ResourceState<IdAttr, IdSchema> {
return Object.freeze({
...resourceState
}) as unknown as ResourceState<IdAttr, IdSchema>;
},
canFetchCollection(b = true) {
resourceState.canFetchCollection = b;
return this;
},
canFetchItem(b = true) {
resourceState.canFetchItem = b;
return this;
},
canCreate(b = true) {
resourceState.canCreate = b;
return this;
},
canPatch(b = true) {
resourceState.canPatch = b;
return this;
},
canEmplace(b = true) {
resourceState.canEmplace = b;
return this;
},
canDelete(b = true) {
resourceState.canDelete = b;
return this;
},
id<NewIdAttr extends IdAttr, NewIdSchema extends IdSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) {
resourceState.idAttr = newIdAttr;
resourceState.idConfig = params;
return this as Resource<ResourceSchema, NewIdAttr, NewIdSchema>;
},
newId(dataSource: DataSource) {
return resourceState?.idConfig?.generationStrategy?.(dataSource);
},
fullText(fullTextAttr: string) {
if (
schema.type === 'object'
&& (
schema as unknown as v.ObjectSchema<
Record<string, v.BaseSchema>,
undefined,
Record<string, string>
>
)
.entries[fullTextAttr]?.type === 'string'
) {
resourceState.fullTextAttrs?.add(fullTextAttr);
return this;
}

throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`);
},
name(n: string) {
resourceState.itemName = n;
resourceState.collectionName = resourceState.collectionName ?? pluralize(n).toLowerCase();
resourceState.routeName = resourceState.routeName ?? resourceState.collectionName;
return this;
},
collection(n: string) {
resourceState.collectionName = n;
resourceState.routeName = resourceState.routeName ?? n;
return this;
},
route(n: string) {
resourceState.routeName = n;
return this;
},
get idAttr() {
return resourceState.idAttr;
},
get collectionName() {
return resourceState.collectionName;
},
get itemName() {
return resourceState.itemName;
},
get routeName() {
return resourceState.routeName;
},
get schema() {
return schema;
},
} as Resource<ResourceSchema, IdAttr, IdSchema>;
};

type RequestListenerWithReturn<
P extends unknown = unknown,
Q extends typeof http.IncomingMessage = typeof http.IncomingMessage,
R extends typeof http.ServerResponse = typeof http.ServerResponse
> = (
...args: Parameters<http.RequestListener<Q, R>>
) => P;

interface HandlerState {
handled: boolean;
}

export interface ApplicationState {
resources: Set<ResourceWithDataSource<any>>;
languages: Set<Language>;
serializers: Set<SerializerPair>;
encodings: Set<EncodingPair>;
}

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<T extends v.BaseSchema> {
handlerState: HandlerState;
backendState: BackendState;
appState: ApplicationState;
appParams: ApplicationParams;
serverParams: CreateServerParams;
responseBodyLanguage: Language;
responseBodyEncoding: EncodingPair;
responseBodyMediaType: SerializerPair;
errorResponseBodyLanguage: Language;
errorResponseBodyEncoding: EncodingPair;
errorResponseBodyMediaType: SerializerPair;
resource: ResourceWithDataSource<T>;
resourceId: string;
query: URLSearchParams;
}

export interface Middleware {
<T extends v.BaseSchema = v.BaseSchema>(args: MiddlewareArgs<T>): RequestListenerWithReturn<HandlerState | Promise<HandlerState>>
}

interface CreateServerParams {
baseUrl?: string;
host?: string;
cert?: string;
key?: string;
requestTimeout?: number;
}

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(serializerPair: SerializerPair): this;
language(language: Language): this;
encoding(encodingPair: EncodingPair): this;
resource<T extends v.BaseSchema>(resRaw: Partial<Resource<T>>): this;
createBackend(): Backend;
createClient(): Client;
}

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

appState.languages.add(en);
appState.encodings.add(utf8);
appState.serializers.add(applicationJson);

return {
contentType(serializerPair: SerializerPair) {
appState.serializers.add(serializerPair);
return this;
},
encoding(encodingPair: EncodingPair) {
appState.encodings.add(encodingPair);
return this;
},
language(language: Language) {
appState.languages.add(language);
return this;
},
resource<T extends v.BaseSchema>(resRaw: Partial<Resource<T>>) {
const res = resRaw as Partial<ResourceWithDataSource<T>>;
res.dataSource = res.dataSource ?? appParams.dataSource?.(res as Resource);
if (typeof res.dataSource === 'undefined') {
throw new Error(`Resource ${res.state!.itemName} must have a data source.`);
}
appState.resources.add(res as ResourceWithDataSource<T>);
return this;
},
createClient(): Client {
const clientState = {
contentType: applicationJson.name,
encoding: utf8.name,
language: en.name as string
};

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.name,
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 availableLanguages = Array.from(appState.languages);
const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? backendState.fallback.language;
const availableEncodings = Array.from(appState.encodings);
const encodingCandidate = negotiator.encoding(availableEncodings.map((l) => l.name)) ?? backendState.fallback.encoding;
const availableContentTypes = Array.from(appState.serializers);
const contentTypeCandidate = negotiator.mediaType(availableContentTypes.map((l) => l.name)) ?? backendState.fallback.serializer;

const fallbackMessageCollection = en as Language;
const fallbackSerializerPair = applicationJson as SerializerPair;
const fallbackEncoding = utf8 as EncodingPair;

const errorLanguageCode = backendState.errorHeaders.language ?? backendState.fallback.language;
const errorMessageCollection = availableLanguages.find((l) => l.name === errorLanguageCode) ?? fallbackMessageCollection;

const errorContentType = backendState.errorHeaders.serializer ?? backendState.fallback.serializer;
const errorSerializerPair = availableContentTypes.find((l) => l.name === errorContentType) ?? fallbackSerializerPair;

const errorEncodingKey = backendState.errorHeaders.encoding ?? backendState.fallback.encoding;
const errorEncoding = availableEncodings.find((l) => l.name === errorEncodingKey) ?? fallbackEncoding;

// TODO refactor
const currentLanguageMessages = availableLanguages.find((l) => l.name === languageCandidate);
if (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 currentMediaType = availableContentTypes.find((l) => l.name === contentTypeCandidate);
if (typeof currentMediaType === '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 responseBodyEncodingEntry = availableEncodings.find((l) => l.name === encodingCandidate);
if (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<never>, 'resource' | 'resourceId'> = {
handlerState: {
handled: false
},
appState,
appParams,
backendState,
serverParams,
query,
responseBodyEncoding: responseBodyEncodingEntry,
responseBodyMediaType: currentMediaType,
responseBodyLanguage: currentLanguageMessages,
errorResponseBodyMediaType: errorSerializerPair,
errorResponseBodyEncoding: errorEncoding,
errorResponseBodyLanguage: errorMessageCollection,
};

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

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

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

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

+ 0
- 835
src/handlers.ts View File

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

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

});
res.statusMessage = errorResponseBodyLanguage.statusMessages.methodNotAllowed();
res.end();
return {
handled: true
};
}

if (!req.url) {
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
res.statusMessage = errorResponseBodyLanguage.statusMessages.badRequest();
res.end();
return {
handled: true
};
}

return {
handled: false
};
};

export const handleGetRoot: Middleware = ({
appState,
appParams,
serverParams,
responseBodyMediaType,
responseBodyLanguage,
responseBodyEncoding,
errorResponseBodyLanguage,
}) => (_req: IncomingMessage, res: ServerResponse) => {
const data = {
name: appParams.name
};

let serialized;
try {
serialized = responseBodyMediaType.serialize(data);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let encoded;
try {
encoded = responseBodyEncoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

const theHeaders: Record<string, string> = {
'Content-Type': responseBodyMediaType.name,
'Content-Language': responseBodyLanguage.name,
'Content-Encoding': responseBodyEncoding.name,
};

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
theHeaders['Link'] = availableResources
.map((r) =>
`<${serverParams.baseUrl}/${r.state.routeName}>; rel="related"; name="${r.state.collectionName}"`,
)
.join(', ');
}
res.writeHead(constants.HTTP_STATUS_OK, theHeaders);
res.statusMessage = responseBodyLanguage.statusMessages.ok();
res.end(encoded);
return {
handled: true
};
};

export const handleGetCollection: Middleware = ({
resource,
responseBodyMediaType,
responseBodyLanguage,
responseBodyEncoding,
errorResponseBodyLanguage,
backendState,
query,
}) => async (_req: IncomingMessage, res: ServerResponse) => {
try {
await resource.dataSource.initialize();
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource);
res.end();
return {
handled: true
};
}

let data: v.Output<typeof resource.schema>[];
let totalItemCount: number | undefined;
try {
// TODO querying mechanism
data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource
if (backendState.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount(query);
}
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResourceCollection(resource);
res.end();
return {
handled: true
};
}

let serialized;
try {
serialized = responseBodyMediaType.serialize(data);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let encoded;
try {
encoded = responseBodyEncoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType.name,
'Content-Language': responseBodyLanguage.name,
'Content-Encoding': responseBodyEncoding.name,
};

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

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

export const handleGetItem: Middleware = ({
resourceId,
resource,
responseBodyMediaType,
responseBodyLanguage,
responseBodyEncoding,
errorResponseBodyLanguage,
}) => async (_req: IncomingMessage, res: ServerResponse) => {
try {
await resource.dataSource.initialize();
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource);
res.end();
return {
handled: true
};
}

let data: v.Output<typeof resource.schema> | null = null;
try {
data = await resource.dataSource.getById(resourceId);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResource(resource);
res.end();
return {
handled: true
};
}

let serialized: string | null;
try {
serialized = data === null ? null : responseBodyMediaType.serialize(data);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let encoded;
try {
encoded = serialized === null ? null : responseBodyEncoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

if (encoded) {
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': responseBodyMediaType.name,
'Content-Language': responseBodyLanguage.name,
'Content-Encoding': responseBodyEncoding.name,
});
res.statusMessage = responseBodyLanguage.statusMessages.resourceFetched(resource)
res.end(encoded);
return {
handled: true
};
}

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


export const handleDeleteItem: Middleware = ({
resource,
resourceId,
responseBodyLanguage,
errorResponseBodyLanguage,
backendState,
}) => async (_req: IncomingMessage, res: ServerResponse) => {
try {
await resource.dataSource.initialize();
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource);
res.end();
return {
handled: true
};
}

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

if (!existing && backendState.throws404OnDeletingNotFound) {
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.deleteNonExistingResource(resource);
res.end();
return {
handled: true
};
}

try {
if (existing) {
await resource.dataSource.delete(resourceId);
}
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeleteResource(resource);
res.end();
return {
handled: true
};
}

res.writeHead(constants.HTTP_STATUS_NO_CONTENT, {
'Content-Language': responseBodyLanguage.name,
});
res.statusMessage = responseBodyLanguage.statusMessages.resourceDeleted(resource);
res.end();
return {
handled: true
};
};

export const handlePatchItem: Middleware = ({
appState,
resource,
resourceId,
responseBodyMediaType,
responseBodyLanguage,
responseBodyEncoding,
errorResponseBodyLanguage,
errorResponseBodyMediaType,
errorResponseBodyEncoding,
}) => async (req: IncomingMessage, res: ServerResponse) => {
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req);
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') {
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeserializeRequest();
res.end();
return {
handled: true
};
}

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

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

if (!existing) {
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.patchNonExistingResource(resource);
res.end();
return {
handled: true
};
}

let bodyDeserialized: unknown;
try {
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema
bodyDeserialized = await getBody(
req,
requestBodyDeserializerPair,
requestBodyEncodingPair,
schema.type === 'object'
? v.partial(
schema as v.ObjectSchema<any>,
(schema as v.ObjectSchema<any>).rest,
(schema as v.ObjectSchema<any>).pipe
)
: schema
);
} catch (errRaw) {
const err = errRaw as v.ValiError;
const headers: Record<string, string> = {
'Content-Language': responseBodyLanguage.name,
};
if (!Array.isArray(err.issues)) {
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers)
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResourcePatch(resource);
res.end();
return {
handled: true,
};
}
// TODO better error reporting, localizable messages
// TODO handle error handlers' errors
const serialized = errorResponseBodyMediaType.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
);
const encoded = errorResponseBodyEncoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {
...headers,
'Content-Type': errorResponseBodyMediaType.name,
'Content-Encoding': errorResponseBodyEncoding.name,
})
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResourcePatch(resource);
res.end(encoded);
return {
handled: true,
};
}

const params = bodyDeserialized as Record<string, unknown>;
let newObject: v.Output<typeof resource.schema> | null;
try {
newObject = await resource.dataSource.patch(resourceId, params);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToPatchResource(resource);
res.end();
return {
handled: true,
};
}

let serialized;
try {
serialized = responseBodyMediaType.serialize(newObject);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let encoded;
try {
encoded = responseBodyEncoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': responseBodyMediaType.name,
'Content-Language': responseBodyLanguage.name,
'Content-Encoding': responseBodyEncoding.name,
});
res.statusMessage = responseBodyLanguage.statusMessages.resourcePatched(resource);
res.end(encoded);
return {
handled: true
};

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

export const handleCreateItem: Middleware = ({
appState,
serverParams,
backendState,
responseBodyMediaType,
responseBodyLanguage,
responseBodyEncoding,
errorResponseBodyLanguage,
errorResponseBodyMediaType,
errorResponseBodyEncoding,
resource,
}) => async (req: IncomingMessage, res: ServerResponse) => {
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req);
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') {
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeserializeRequest();
res.end();
return {
handled: true
};
}

let bodyDeserialized: unknown;
try {
bodyDeserialized = await getBody(
req,
requestBodyDeserializerPair,
requestBodyEncodingPair,
resource.schema
);
} catch (errRaw) {
const err = errRaw as v.ValiError;
const headers: Record<string, string> = {
'Content-Language': errorResponseBodyLanguage.name,
};
if (!Array.isArray(err.issues)) {
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers)
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource);
res.end();
return {
handled: true,
};
}
// TODO better error reporting, localizable messages
// TODO handle error handlers' errors
const serialized = errorResponseBodyMediaType.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
);
const encoded = errorResponseBodyEncoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {
...headers,
'Content-Type': errorResponseBodyMediaType.name,
'Content-Encoding': errorResponseBodyEncoding.name,
})
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource);
res.end(encoded);
return {
handled: true,
};
}

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

//v.Output<typeof resource.schema>

let newId;
let params: v.Output<typeof resource.schema>;
try {
newId = await resource.newId(resource.dataSource);
params = bodyDeserialized as Record<string, unknown>;
params[resource.state.idAttr] = newId;
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToGenerateIdFromResourceDataSource(resource);
res.end();
return {
handled: true,
};
// noop
// TODO
}

let newObject;
let totalItemCount: number | undefined;
try {
newObject = await resource.dataSource.create(params);
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
} catch {
// noop
// TODO
}

let serialized;
try {
serialized = responseBodyMediaType.serialize(newObject);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let encoded;
try {
encoded = responseBodyEncoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType.name,
'Content-Language': responseBodyLanguage.name,
'Content-Encoding': responseBodyEncoding.name,
'Location': `${serverParams.baseUrl}/${resource.state.routeName}/${newId}`
};
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
res.writeHead(constants.HTTP_STATUS_CREATED, headers);
res.statusMessage = responseBodyLanguage.statusMessages.resourceCreated(resource);
res.end(encoded);
return {
handled: true
};
}

export const handleEmplaceItem: Middleware = ({
appState,
serverParams,
responseBodyMediaType,
responseBodyLanguage,
responseBodyEncoding,
errorResponseBodyLanguage,
errorResponseBodyMediaType,
errorResponseBodyEncoding,
resource,
resourceId,
backendState,
}) => async (req: IncomingMessage, res: ServerResponse) => {
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req);
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') {
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeserializeRequest();
res.end();
return {
handled: true
};
}

let bodyDeserialized: unknown;
try {
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema
bodyDeserialized = await getBody(
req,
requestBodyDeserializerPair,
requestBodyEncodingPair,
schema.type === 'object'
? v.merge([
schema as v.ObjectSchema<any>,
v.object({
[resource.state.idAttr]: v.transform(
v.any(),
input => resource.state.idConfig.serialize(input),
v.literal(resourceId)
)
})
])
: schema
);
} catch (errRaw) {
const err = errRaw as v.ValiError;
const headers: Record<string, string> = {
'Content-Language': errorResponseBodyLanguage.name,
};
if (!Array.isArray(err.issues)) {
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers)
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource);
res.end();
return {
handled: true,
};
}
// TODO better error reporting, localizable messages
// TODO handle error handlers' errors
const serialized = errorResponseBodyMediaType.serialize(
err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
);
const encoded = errorResponseBodyEncoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {
...headers,
'Content-Type': errorResponseBodyMediaType.name,
'Content-Encoding': errorResponseBodyEncoding.name,
})
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource);
res.end(encoded);
return {
handled: true,
};
}

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

let newObject: v.Output<typeof resource.schema>;
let isCreated: boolean;
try {
const params = bodyDeserialized as Record<string, unknown>;
params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string);
[newObject, isCreated] = await resource.dataSource.emplace(resourceId, params);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEmplaceResource(resource);
res.end();
return {
handled: true
};
}

let serialized;
try {
serialized = responseBodyMediaType.serialize(newObject);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let encoded;
try {
encoded = responseBodyEncoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorResponseBodyLanguage.name,
});
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType.name,
'Content-Language': responseBodyLanguage.name,
'Content-Encoding': responseBodyEncoding.name,
};
let totalItemCount: number | undefined;
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
if (isCreated) {
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
? responseBodyLanguage.statusMessages.resourceCreated(resource)
: responseBodyLanguage.statusMessages.resourceReplaced(resource)
);
res.end(encoded);
return {
handled: true
};
}

+ 10
- 5
src/index.ts View File

@@ -1,5 +1,10 @@
export * from './core';
export * as validation from './validation';
export * as dataSources from './data-sources';
export * as serializers from './serializers';
export * as encodings from './encodings';
export * from './common';
export * as validation from './common/validation';

export * as dataSources from './backend/data-sources';

export * as mediaTypes from './common/media-types';
export * as charsets from './common/charsets';
export * as languages from './common/languages';

export * from './app';

+ 0
- 8
src/serializers/index.ts View File

@@ -1,8 +0,0 @@
export * as applicationJson from './application/json';
export * as textJson from './application/json';

export interface SerializerPair {
name: string;
serialize: <T>(object: T) => string;
deserialize: <T>(s: string) => T;
}

+ 0
- 54
src/utils.ts View File

@@ -1,54 +0,0 @@
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 getDeserializerObjects = (appState: ApplicationState, req: IncomingMessage) => {
const availableSerializers = Array.from(appState.serializers);
const availableEncodings = Array.from(appState.encodings);
const deserializerPair = availableSerializers.find((l) => l.name === (req.headers['content-type'] ?? 'application/octet-stream'));
const encodingPair = availableEncodings.find((l) => l.name === (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');
const urlWithoutBaseRaw = urlObject.pathname.slice(baseUrl?.length ?? 0);

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

export const getBody = (
req: IncomingMessage,
deserializer: SerializerPair,
encodingPair: EncodingPair,
schema: BaseSchema
) => new Promise((resolve, reject) => {
let body = Buffer.from('');
req.on('data', (chunk) => {
body = Buffer.concat([body, chunk]);
});
req.on('end', async () => {
const bodyStr = encodingPair.decode(body);
try {
const bodyDeserialized = await parseAsync(
schema,
deserializer.deserialize(bodyStr),
{abortEarly: false},
);
resolve(bodyDeserialized);
} catch (err) {
reject(err);
}
});
});

+ 38
- 52
test/e2e/default.test.ts View File

@@ -19,23 +19,16 @@ import {
import { import {
join join
} from 'path'; } from 'path';
import {
application,
DataSource,
dataSources,
encodings,
Resource,
resource,
serializers,
validation as v,
} from '../../src';
import {request, Server} from 'http'; import {request, Server} from 'http';
import {constants} from 'http2'; import {constants} from 'http2';
import {DataSource} from '../../src/backend/data-source';
import { dataSources } from '../../src/backend';
import { application, resource, validation as v, Resource, charsets, mediaTypes } from '../../src';


const PORT = 3000; const PORT = 3000;
const HOST = 'localhost'; const HOST = 'localhost';
const ACCEPT_ENCODING = encodings.utf8.name;
const ACCEPT = serializers.applicationJson.name;
const ACCEPT_CHARSET = charsets.utf8.name;
const ACCEPT = mediaTypes.applicationJson.name;


const autoIncrement = async (dataSource: DataSource) => { const autoIncrement = async (dataSource: DataSource) => {
const data = await dataSource.getMultiple() as Record<string, string>[]; const data = await dataSource.getMultiple() as Record<string, string>[];
@@ -79,31 +72,33 @@ describe('yasumi', () => {
}, },
v.never() v.never()
)) ))
.name('Piano')
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, { .id('id' as const, {
generationStrategy: autoIncrement,
generationStrategy: autoIncrement as any,
serialize: (id) => id?.toString() ?? '0', serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(), schema: v.number(),
})
});
}); });


let server: Server; let server: Server;
beforeEach(() => { beforeEach(() => {
const app = application({ const app = application({
name: 'piano-service', name: 'piano-service',
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir),
}) })
.contentType(serializers.applicationJson)
.encoding(encodings.utf8)
.mediaType(mediaTypes.applicationJson)
.charset(charsets.utf8)
.resource(Piano); .resource(Piano);


const backend = app const backend = app
.createBackend()
.createBackend({
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir),
})
.throws404OnDeletingNotFound(); .throws404OnDeletingNotFound();


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


return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -149,8 +144,8 @@ describe('yasumi', () => {
path: '/api/pianos', path: '/api/pianos',
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Accept-Language': 'en',
}, },
}, },
(res) => { (res) => {
@@ -159,7 +154,7 @@ describe('yasumi', () => {
}); });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', ACCEPT);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));


let resBuffer = Buffer.from(''); let resBuffer = Buffer.from('');
res.on('data', (c) => { res.on('data', (c) => {
@@ -167,7 +162,7 @@ describe('yasumi', () => {
}); });


res.on('close', () => { res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resData = JSON.parse(resBufferJson); const resData = JSON.parse(resBufferJson);
expect(resData).toEqual([]); expect(resData).toEqual([]);
resolve(); resolve();
@@ -213,8 +208,7 @@ describe('yasumi', () => {
path: '/api/pianos/1', path: '/api/pianos/1',
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
}, },
}, },
(res) => { (res) => {
@@ -223,7 +217,7 @@ describe('yasumi', () => {
}); });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', ACCEPT);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));


let resBuffer = Buffer.from(''); let resBuffer = Buffer.from('');
res.on('data', (c) => { res.on('data', (c) => {
@@ -231,7 +225,7 @@ describe('yasumi', () => {
}); });


res.on('close', () => { res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resData = JSON.parse(resBufferJson); const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(data); expect(resData).toEqual(data);
resolve(); resolve();
@@ -256,8 +250,7 @@ describe('yasumi', () => {
path: '/api/pianos/2', path: '/api/pianos/2',
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
}, },
}, },
(res) => { (res) => {
@@ -312,8 +305,7 @@ describe('yasumi', () => {
path: '/api/pianos', path: '/api/pianos',
method: 'POST', method: 'POST',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT, 'Content-Type': ACCEPT,
}, },
}, },
@@ -323,7 +315,7 @@ describe('yasumi', () => {
}); });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res.headers).toHaveProperty('content-type', ACCEPT);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));


let resBuffer = Buffer.from(''); let resBuffer = Buffer.from('');
res.on('data', (c) => { res.on('data', (c) => {
@@ -331,7 +323,7 @@ describe('yasumi', () => {
}); });


res.on('close', () => { res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resData = JSON.parse(resBufferJson); const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({ expect(resData).toEqual({
...newData, ...newData,
@@ -385,8 +377,7 @@ describe('yasumi', () => {
path: `/api/pianos/${data.id}`, path: `/api/pianos/${data.id}`,
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT, 'Content-Type': ACCEPT,
}, },
}, },
@@ -396,7 +387,7 @@ describe('yasumi', () => {
}); });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', ACCEPT);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));


let resBuffer = Buffer.from(''); let resBuffer = Buffer.from('');
res.on('data', (c) => { res.on('data', (c) => {
@@ -404,7 +395,7 @@ describe('yasumi', () => {
}); });


res.on('close', () => { res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resData = JSON.parse(resBufferJson); const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({ expect(resData).toEqual({
...data, ...data,
@@ -433,8 +424,7 @@ describe('yasumi', () => {
path: '/api/pianos/2', path: '/api/pianos/2',
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT, 'Content-Type': ACCEPT,
}, },
}, },
@@ -491,8 +481,7 @@ describe('yasumi', () => {
path: `/api/pianos/${newData.id}`, path: `/api/pianos/${newData.id}`,
method: 'PUT', method: 'PUT',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT, 'Content-Type': ACCEPT,
}, },
}, },
@@ -502,7 +491,7 @@ describe('yasumi', () => {
}); });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', ACCEPT);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));


let resBuffer = Buffer.from(''); let resBuffer = Buffer.from('');
res.on('data', (c) => { res.on('data', (c) => {
@@ -510,7 +499,7 @@ describe('yasumi', () => {
}); });


res.on('close', () => { res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resData = JSON.parse(resBufferJson); const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(newData); expect(resData).toEqual(newData);
resolve(); resolve();
@@ -538,8 +527,7 @@ describe('yasumi', () => {
path: `/api/pianos/${id}`, path: `/api/pianos/${id}`,
method: 'PUT', method: 'PUT',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT, 'Content-Type': ACCEPT,
}, },
}, },
@@ -549,7 +537,7 @@ describe('yasumi', () => {
}); });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res.headers).toHaveProperty('content-type', ACCEPT);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));


let resBuffer = Buffer.from(''); let resBuffer = Buffer.from('');
res.on('data', (c) => { res.on('data', (c) => {
@@ -557,7 +545,7 @@ describe('yasumi', () => {
}); });


res.on('close', () => { res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resData = JSON.parse(resBufferJson); const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({ expect(resData).toEqual({
...newData, ...newData,
@@ -609,8 +597,7 @@ describe('yasumi', () => {
path: `/api/pianos/${data.id}`, path: `/api/pianos/${data.id}`,
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
}, },
}, },
(res) => { (res) => {
@@ -640,8 +627,7 @@ describe('yasumi', () => {
path: '/api/pianos/2', path: '/api/pianos/2',
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
}, },
}, },
(res) => { (res) => {


+ 4
- 1
tsconfig.json View File

@@ -3,7 +3,10 @@
"include": ["src", "types"], "include": ["src", "types"],
"compilerOptions": { "compilerOptions": {
"module": "ESNext", "module": "ESNext",
"lib": ["ESNext"],
"lib": [
"ESNext",
"dom"
],
"importHelpers": true, "importHelpers": true,
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,


Loading…
Cancel
Save