Browse Source

Add decorator system

Implement decorator system with requests.
master
TheoryOfNekomata 8 months ago
parent
commit
67b097a026
25 changed files with 442 additions and 330 deletions
  1. +1
    -1
      examples/basic/server.ts
  2. +0
    -67
      src/app.ts
  3. +11
    -6
      src/backend/common.ts
  4. +5
    -5
      src/backend/core.ts
  5. +29
    -0
      src/backend/decorators/backend/content-negotiation.ts
  6. +18
    -0
      src/backend/decorators/backend/index.ts
  7. +25
    -0
      src/backend/decorators/backend/resource.ts
  8. +7
    -0
      src/backend/decorators/method/index.ts
  9. +13
    -0
      src/backend/decorators/url/base-path.ts
  10. +13
    -0
      src/backend/decorators/url/host.ts
  11. +26
    -0
      src/backend/decorators/url/index.ts
  12. +13
    -0
      src/backend/decorators/url/scheme.ts
  13. +68
    -16
      src/backend/handlers.ts
  14. +34
    -131
      src/backend/server.ts
  15. +41
    -21
      src/backend/utils.ts
  16. +9
    -9
      src/client/index.ts
  17. +65
    -3
      src/common/app.ts
  18. +6
    -0
      src/common/charset.ts
  19. +5
    -52
      src/common/index.ts
  20. +41
    -0
      src/common/language.ts
  21. +6
    -0
      src/common/media-type.ts
  22. +4
    -0
      src/common/resource.ts
  23. +0
    -5
      src/common/validation.ts
  24. +0
    -2
      src/index.ts
  25. +2
    -12
      test/e2e/default.test.ts

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

@@ -58,7 +58,7 @@ const backend = app.createBackend({
dataSource,
});

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



+ 0
- 67
src/app.ts View File

@@ -1,67 +0,0 @@
import * as v from 'valibot';
import {
Resource,
Language,
MediaType,
Charset,
ApplicationParams,
ApplicationState,
FALLBACK_LANGUAGE,
FALLBACK_CHARSET, FALLBACK_MEDIA_TYPE,
} 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(FALLBACK_LANGUAGE);
appState.charsets.add(FALLBACK_CHARSET);
appState.mediaTypes.add(FALLBACK_MEDIA_TYPE);

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

+ 11
- 6
src/backend/common.ts View File

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

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

export interface RequestContext extends http.IncomingMessage {
body?: unknown;
}

export type RequestDecorator = (req: RequestContext) => RequestContext | Promise<RequestContext>;

export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator;

+ 5
- 5
src/backend/core.ts View File

@@ -23,8 +23,8 @@ 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;
throwsErrorOnDeletingNotFound(b?: boolean): this;
createHttpServer(serverParams?: CreateServerParams): http.Server | https.Server;
dataSource?: (resource: Resource) => T;
}

@@ -57,7 +57,7 @@ export const createBackend = (params: CreateBackendParams) => {
backendState.showTotalItemCountOnCreateItem = b;
return this;
},
throws404OnDeletingNotFound(b = true) {
throwsErrorOnDeletingNotFound(b = true) {
backendState.throws404OnDeletingNotFound = b;
return this;
},
@@ -65,8 +65,8 @@ export const createBackend = (params: CreateBackendParams) => {
backendState.checksSerializersOnDelete = b;
return this;
},
createServer(serverParams = {} as CreateServerParams) {
createHttpServer(serverParams = {} as CreateServerParams) {
return createServer(backendState, serverParams);
}
},
} satisfies BackendBuilder;
};

+ 29
- 0
src/backend/decorators/backend/content-negotiation.ts View File

@@ -0,0 +1,29 @@
import {ContentNegotiation} from '../../../common';
import {RequestDecorator} from '../../common';
import Negotiator from 'negotiator';

declare module '../../common' {
interface RequestContext {
cn: ContentNegotiation;
}
}

export const decorateRequestWithContentNegotiation: RequestDecorator = (req) => {
const negotiator = new Negotiator(req);

const availableLanguages = Array.from(req.backend.app.languages.values() ?? []);
const availableCharsets = Array.from(req.backend.app.charsets.values() ?? []);
const availableMediaTypes = Array.from(req.backend.app.mediaTypes.values() ?? []);

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

req.cn = {
language: req.backend.app.languages.get(languageCandidate) ?? req.backend.cn.language,
mediaType: req.backend.app.mediaTypes.get(mediaTypeCandidate) ?? req.backend.cn.mediaType,
charset: req.backend.app.charsets.get(charsetCandidate) ?? req.backend.cn.charset,
};

return req;
};

+ 18
- 0
src/backend/decorators/backend/index.ts View File

@@ -0,0 +1,18 @@
import {BackendState, ParamRequestDecorator} from '../../common';
import {decorateRequestWithContentNegotiation} from './content-negotiation';
import {decorateRequestWithResource} from './resource';

declare module '../../common' {
interface RequestContext {
backend: BackendState;
}
}

export const decorateRequestWithBackend: ParamRequestDecorator<[BackendState]> = (backend) => (req) => {
req.backend = backend;

decorateRequestWithContentNegotiation(req);
decorateRequestWithResource(req);

return req;
};

+ 25
- 0
src/backend/decorators/backend/resource.ts View File

@@ -0,0 +1,25 @@
import {RequestDecorator} from '../../common';
import {DataSource} from '../../data-source';
import {Resource} from '../../../common';
import {BackendResource} from '../../core';

declare module '../../common' {
interface RequestContext {
resource?: Resource;
resourceId?: string;
}
}

export const decorateRequestWithResource: RequestDecorator = (req) => {
const [, resourceRouteName, resourceId = ''] = req.url?.split('/') ?? [];
const resource = Array.from(req.backend.app.resources)
.find((r) => r.state.routeName === resourceRouteName) as BackendResource | undefined;

if (typeof resource !== 'undefined') {
req.resource = resource;
req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource;
req.resourceId = resourceId;
}

return req;
};

+ 7
- 0
src/backend/decorators/method/index.ts View File

@@ -0,0 +1,7 @@
import {RequestDecorator} from '../../common';

export const decorateRequestWithMethod: RequestDecorator = (req) => {
req.method = req.method?.trim().toUpperCase() ?? '';

return req;
};

+ 13
- 0
src/backend/decorators/url/base-path.ts View File

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

declare module '../../common' {
interface RequestContext {
basePath: string;
}
}

export const decorateRequestWithBasePath: ParamRequestDecorator<[string]> = (basePath) => (req) => {
req.basePath = basePath;

return req;
}

+ 13
- 0
src/backend/decorators/url/host.ts View File

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

declare module '../../common' {
interface RequestContext {
host: string;
}
}

export const decorateRequestWithHost: ParamRequestDecorator<[string]> = (host) => (req) => {
req.host = host;

return req;
};

+ 26
- 0
src/backend/decorators/url/index.ts View File

@@ -0,0 +1,26 @@
import {ParamRequestDecorator} from '../../common';
import {CreateServerParams} from '../../server';
import {decorateRequestWithScheme} from './scheme';
import {decorateRequestWithHost} from './host';
import {decorateRequestWithBasePath} from './base-path';

declare module '../../common' {
interface RequestContext {
rawUrl?: string;
query: URLSearchParams;
}
}

export const decorateRequestWithUrl: ParamRequestDecorator<[CreateServerParams]> = (serverParams) => (req) => {
const isHttps = 'key' in serverParams && 'cert' in serverParams;
decorateRequestWithScheme(isHttps ? 'https' : 'http')(req);
decorateRequestWithHost(serverParams.host ?? '127.0.0.1')(req);
decorateRequestWithBasePath(serverParams.basePath ?? '')(req);
const basePath = new URL(req.basePath, `${req.scheme}://${req.host}`);
const parsedUrl = new URL(`${basePath.pathname}/${req.url ?? ''}`, basePath.origin);
req.rawUrl = req.url;
req.url = req.url?.slice(basePath.pathname.length) ?? '';
req.query = parsedUrl.searchParams;

return req;
};

+ 13
- 0
src/backend/decorators/url/scheme.ts View File

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

declare module '../../common' {
interface RequestContext {
scheme: string;
}
}

export const decorateRequestWithScheme: ParamRequestDecorator<[string]> = (scheme) => (req) => {
req.scheme = scheme;

return req;
};

+ 68
- 16
src/backend/handlers.ts View File

@@ -1,15 +1,17 @@
import { constants } from 'http2';
import * as v from 'valibot';
import {HttpMiddlewareError, PlainResponse, Middleware} from './server';
import {LinkMap} from './utils';
import {BackendResource} from './core';

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

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

const registeredResources = Array.from(backend!.app.resources);
const registeredResources = Array.from(backend.app.resources);
const availableResources = registeredResources.filter((r) => (
r.state.canFetchCollection
|| r.state.canCreate
@@ -18,13 +20,16 @@ export const handleGetRoot: Middleware = (req) => {
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(', ');
headers['Link'] = new LinkMap(
availableResources.map((r) => ({
url: `${basePath}/${r.state.routeName}`,
params: {
rel: 'related',
name: r.state.routeName,
},
}))
)
.toString();
}

return new PlainResponse({
@@ -36,7 +41,12 @@ export const handleGetRoot: Middleware = (req) => {
};

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

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
}
const resource = resourceRaw as BackendResource;

let data: v.Output<typeof resource.schema>[];
let totalItemCount: number | undefined;
@@ -57,7 +67,6 @@ export const handleGetCollection: Middleware = async (req) => {
}

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

if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
@@ -71,7 +80,12 @@ export const handleGetCollection: Middleware = async (req) => {
};

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

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
}
const resource = resourceRaw as BackendResource;

if (typeof resourceId === 'undefined') {
throw new HttpMiddlewareError(
@@ -82,6 +96,15 @@ export const handleGetItem: Middleware = async (req) => {
);
}

if ((resourceId.trim().length ?? 0) < 1) {
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);
@@ -112,7 +135,12 @@ export const handleGetItem: Middleware = async (req) => {
};

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

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
}
const resource = resourceRaw as BackendResource;

if (typeof resourceId === 'undefined') {
throw new HttpMiddlewareError(
@@ -157,7 +185,21 @@ export const handleDeleteItem: Middleware = async (req) => {
};

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

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
}
const resource = resourceRaw as BackendResource;

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

let existing: unknown | null;
try {
@@ -195,7 +237,12 @@ export const handlePatchItem: Middleware = async (req) => {
};

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

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
}
const resource = resourceRaw as BackendResource;

let newId;
let params: v.Output<typeof resource.schema>;
@@ -249,7 +296,12 @@ export const handleCreateItem: Middleware = async (req) => {
}

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

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
}
const resource = resourceRaw as BackendResource;

let newObject: v.Output<typeof resource.schema>;
let isCreated: boolean;


+ 34
- 131
src/backend/server.ts View File

@@ -1,8 +1,7 @@
import http from 'http';
import {BackendState} from './common';
import {Language, Resource, Charset, MediaType, LanguageStatusMessageMap} from '../common';
import {BackendState, RequestContext} from './common';
import {Language, Resource, LanguageStatusMessageMap} from '../common';
import https from 'https';
import Negotiator from 'negotiator';
import {constants} from 'http2';
import {
handleCreateItem,
@@ -18,7 +17,9 @@ import {
} from './core';
import * as v from 'valibot';
import {getBody} from './utils';
import {DataSource} from './data-source';
import {decorateRequestWithBackend} from './decorators/backend';
import {decorateRequestWithMethod} from './decorators/method';
import {decorateRequestWithUrl} from './decorators/url';

export interface Response {
statusCode: number;
@@ -83,36 +84,6 @@ export interface CreateServerParams {
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>;
}
@@ -175,73 +146,6 @@ const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, m
return middlewares;
};

const adjustRequestForContentNegotiation = (req: RequestContext, res: http.ServerResponse<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)) ?? req.backend!.cn.language.name;
const charsetCandidate = negotiator.charset(availableCharsets.map((l) => l.name)) ?? req.backend!.cn.charset.name;
const mediaTypeCandidate = negotiator.mediaType(availableMediaTypes.map((l) => l.name)) ?? req.backend!.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;
};

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

@@ -255,31 +159,32 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
requestTimeout: serverParams.requestTimeout,
});

server.on('request', async (req: RequestContext, res: http.ServerResponse<RequestContext>) => {
req.backend = backendState;
req.basePath = serverParams.basePath ?? '';
req.host = serverParams.host ?? 'localhost';
req.scheme = isHttps ? 'https' : 'http';
req.cn = req.backend.cn;
req.method = req.method?.trim().toUpperCase() ?? '';

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;

adjustRequestForContentNegotiation(req, res);
const requestDecorators = [
decorateRequestWithMethod,
decorateRequestWithUrl(serverParams),
decorateRequestWithBackend(backendState),
];

const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse<RequestContext>) => {
let req: RequestContext;
// TODO custom decorators
const effectiveRequestDecorators = requestDecorators;
req = await effectiveRequestDecorators.reduce(
async (resultRequestPromise, decorator) => {
const resultRequest = await resultRequestPromise;

return await decorator(resultRequest);
},
Promise.resolve(reqRaw)
);

let middlewareState;
if (req.url === '/' || req.url === '') {
middlewareState = await handleGetRoot(req);
}

let resource = req.resource as BackendResource | undefined;
if (typeof middlewareState === 'undefined') {
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;
@@ -287,12 +192,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
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();
await resource.dataSource.initialize();
} catch (cause) {
throw new HttpMiddlewareError(
'unableToInitializeResourceDataSource',
@@ -321,8 +222,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
}

if (schema) {
const availableSerializers = Array.from(req.backend!.app.mediaTypes);
const availableCharsets = Array.from(req.backend!.app.charsets);
const availableSerializers = Array.from(req.backend!.app.mediaTypes.values());
const availableCharsets = Array.from(req.backend!.app.charsets.values());
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream';
const fragments = contentTypeHeader.split(';');
const mediaType = fragments[0];
@@ -424,7 +325,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
}

const statusMessageKey = middlewareState.statusMessage ? req.cn.language.statusMessages[middlewareState.statusMessage] : undefined;
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? '';
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resource!.state.itemName) ?? '';
res.writeHead(middlewareState.statusCode, headers);
if (typeof encoded !== 'undefined') {
res.end(encoded);
@@ -459,7 +360,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
].join('; ');

const statusMessageKey = finalErr.response.statusMessage ? req.backend.cn.language.statusMessages[finalErr.response.statusMessage] : undefined;
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? '';
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resource!.state.itemName) ?? '';
res.writeHead(finalErr.response.statusCode, headers);
if (typeof encoded !== 'undefined') {
res.end(encoded);
@@ -471,7 +372,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
}

if (middlewares.length > 0) {
res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? '';
res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, {
Allow: middlewares.map((m) => m[0]).join(', '),
'Content-Language': req.backend.cn.language.name,
@@ -481,13 +382,15 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
}

// TODO error handler in line with authentication
res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? '';
res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, {
'Content-Language': req.backend.cn.language.name,
});
res.end();
return;
});
};

server.on('request', handleRequest);

return server;
}

+ 41
- 21
src/backend/utils.ts View File

@@ -3,26 +3,46 @@ import {MediaType, Charset} from '../common';
import {BaseSchema, parseAsync} from 'valibot';

export const getBody = (
req: IncomingMessage,
schema: BaseSchema,
encodingPair?: Charset,
deserializer?: MediaType,
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);
}
});
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);
}
});
});

interface LinkMapEntry {
url: string;
params: Record<string, string>;
}

export class LinkMap extends Set<LinkMapEntry> {
toString() {
const entries = Array.from(this.values());

return entries.map((e) => {
const params = Object.entries(e.params);

return [
`<${encodeURIComponent(e.url)}>`,
...params.map(([key, value]) => `${encodeURIComponent(key)}="${encodeURIComponent(value)}"`)
].join(';')
}).join(',');
}
}

+ 9
- 9
src/client/index.ts View File

@@ -16,9 +16,9 @@ export interface ClientState {
}

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

export interface CreateClientParams {
@@ -34,18 +34,18 @@ export const createClient = (params: CreateClientParams) => {
};

return {
setMediaType(mediaTypeName) {
const mediaType = Array.from(clientState.app.mediaTypes).find((l) => l.name === mediaTypeName);
mediaTyoe(mediaTypeName) {
const mediaType = clientState.app.mediaTypes.get(mediaTypeName);
clientState.mediaType = mediaType ?? FALLBACK_MEDIA_TYPE;
return this;
},
setCharset(charsetName) {
const charset = Array.from(clientState.app.charsets).find((l) => l.name === charsetName);
charset(charsetName) {
const charset = clientState.app.charsets.get(charsetName);
clientState.charset = charset ?? FALLBACK_CHARSET;
return this;
},
setLanguage(languageCode) {
const language = Array.from(clientState.app.languages).find((l) => l.name === languageCode);
language(languageCode) {
const language = clientState.app.languages.get(languageCode);
clientState.language = language ?? FALLBACK_LANGUAGE;
return this;
}


+ 65
- 3
src/common/app.ts View File

@@ -2,15 +2,77 @@ import {Resource} from './resource';
import {Language} from './language';
import {MediaType} from './media-type';
import {Charset} from './charset';
import * as v from 'valibot';
import {BackendBuilder, createBackend, CreateBackendParams} from '../backend';
import {ClientBuilder, createClient, CreateClientParams} from '../client';
import {FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE} from './index';

type ApplicationMap<T extends { name: string }> = Map<T['name'], T>;

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

export interface ApplicationParams {
name: string;
}

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 Map<Language["name"], Language>([
[FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE],
]),
mediaTypes: new Map<MediaType["name"], MediaType>([
[FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE],
]),
charsets: new Map<Charset["name"], Charset>([
[FALLBACK_CHARSET.name, FALLBACK_CHARSET],
]),
};

return {
mediaType(mediaType: MediaType) {
appState.mediaTypes.set(mediaType.name, mediaType);
return this;
},
charset(charset: Charset) {
appState.charsets.set(charset.name, charset);
return this;
},
language(language: Language) {
appState.languages.set(language.name, 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
});
},
};
};

+ 6
- 0
src/common/charset.ts View File

@@ -3,3 +3,9 @@ export interface Charset {
encode: (str: string) => Buffer;
decode: (buf: Buffer) => string;
}

export const FALLBACK_CHARSET = {
encode: (str: string) => Buffer.from(str, 'utf-8'),
decode: (buf: Buffer) => buf.toString('utf-8'),
name: 'utf-8' as const,
} satisfies Charset;

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

@@ -8,55 +8,8 @@ export * from './media-type';
export * from './resource';
export * from './language';

export const FALLBACK_LANGUAGE = {
name: 'en' as const,
statusMessages: {
unableToSerializeResponse: 'Unable To Serialize Response',
unableToEncodeResponse: 'Unable To Encode Response',
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source',
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection',
unableToFetchResource: 'Unable To Fetch $RESOURCE',
unableToDeleteResource: 'Unable To Delete $RESOURCE',
languageNotAcceptable: 'Language Not Acceptable',
encodingNotAcceptable: 'Encoding Not Acceptable',
mediaTypeNotAcceptable: 'Media Type Not Acceptable',
methodNotAllowed: 'Method Not Allowed',
urlNotFound: 'URL Not Found',
badRequest: 'Bad Request',
ok: 'OK',
resourceCollectionFetched: '$RESOURCE Collection Fetched',
resourceFetched: '$RESOURCE Fetched',
resourceNotFound: '$RESOURCE Not Found',
deleteNonExistingResource: 'Delete Non-Existing $RESOURCE',
resourceDeleted: '$RESOURCE Deleted',
unableToDeserializeRequest: 'Unable To Deserialize Request',
patchNonExistingResource: 'Patch Non-Existing $RESOURCE',
unableToPatchResource: 'Unable To Patch $RESOURCE',
invalidResourcePatch: 'Invalid $RESOURCE Patch',
invalidResource: 'Invalid $RESOURCE',
resourcePatched: '$RESOURCE Patched',
resourceCreated: '$RESOURCE Created',
resourceReplaced: '$RESOURCE Replaced',
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source',
unableToEmplaceResource: 'Unable To Emplace $RESOURCE',
resourceIdNotGiven: '$RESOURCE ID Not Given',
unableToCreateResource: 'Unable To Create $RESOURCE',
},
bodies: {
languageNotAcceptable: [],
encodingNotAcceptable: [],
mediaTypeNotAcceptable: []
},
} satisfies Language;

export const FALLBACK_CHARSET = {
encode: (str: string) => Buffer.from(str, 'utf-8'),
decode: (buf: Buffer) => buf.toString('utf-8'),
name: 'utf-8' as const,
} satisfies Charset;

export const FALLBACK_MEDIA_TYPE = {
serialize: (obj: unknown) => JSON.stringify(obj),
deserialize: (str: string) => JSON.parse(str),
name: 'application/json' as const,
} satisfies MediaType;
export interface ContentNegotiation {
language: Language;
mediaType: MediaType;
charset: Charset;
}

+ 41
- 0
src/common/language.ts View File

@@ -52,3 +52,44 @@ export interface Language {
statusMessages: LanguageStatusMessageMap,
bodies: LanguageBodyMap
}

export const FALLBACK_LANGUAGE = {
name: 'en' as const,
statusMessages: {
unableToSerializeResponse: 'Unable To Serialize Response',
unableToEncodeResponse: 'Unable To Encode Response',
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source',
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection',
unableToFetchResource: 'Unable To Fetch $RESOURCE',
unableToDeleteResource: 'Unable To Delete $RESOURCE',
languageNotAcceptable: 'Language Not Acceptable',
encodingNotAcceptable: 'Encoding Not Acceptable',
mediaTypeNotAcceptable: 'Media Type Not Acceptable',
methodNotAllowed: 'Method Not Allowed',
urlNotFound: 'URL Not Found',
badRequest: 'Bad Request',
ok: 'OK',
resourceCollectionFetched: '$RESOURCE Collection Fetched',
resourceFetched: '$RESOURCE Fetched',
resourceNotFound: '$RESOURCE Not Found',
deleteNonExistingResource: 'Delete Non-Existing $RESOURCE',
resourceDeleted: '$RESOURCE Deleted',
unableToDeserializeRequest: 'Unable To Deserialize Request',
patchNonExistingResource: 'Patch Non-Existing $RESOURCE',
unableToPatchResource: 'Unable To Patch $RESOURCE',
invalidResourcePatch: 'Invalid $RESOURCE Patch',
invalidResource: 'Invalid $RESOURCE',
resourcePatched: '$RESOURCE Patched',
resourceCreated: '$RESOURCE Created',
resourceReplaced: '$RESOURCE Replaced',
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source',
unableToEmplaceResource: 'Unable To Emplace $RESOURCE',
resourceIdNotGiven: '$RESOURCE ID Not Given',
unableToCreateResource: 'Unable To Create $RESOURCE',
},
bodies: {
languageNotAcceptable: [],
encodingNotAcceptable: [],
mediaTypeNotAcceptable: []
},
} satisfies Language;

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

@@ -3,3 +3,9 @@ export interface MediaType {
serialize: <T>(object: T) => string;
deserialize: <T>(s: string) => T;
}

export const FALLBACK_MEDIA_TYPE = {
serialize: (obj: unknown) => JSON.stringify(obj),
deserialize: (str: string) => JSON.parse(str),
name: 'application/json' as const,
} satisfies MediaType;

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

@@ -147,3 +147,7 @@ export const resource = <
},
} as Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>;
};

export type ResourceType<R extends Resource> = v.Output<R['schema']>;

export type ResourceTypeWithId<R extends Resource> = ResourceType<R> & Record<R['state']['idAttr'], v.Output<R['state']['idConfig']['schema']>>;

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

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

export const datelike = () => v.transform(
v.union([
@@ -12,7 +11,3 @@ export const datelike = () => v.transform(
(value) => new Date(value).toISOString(),
v.string([v.isoTimestamp()])
);

export type ResourceType<R extends Resource> = v.Output<R['schema']>;

export type ResourceTypeWithId<R extends Resource> = ResourceType<R> & Record<R['state']['idAttr'], v.Output<R['state']['idConfig']['schema']>>;

+ 0
- 2
src/index.ts View File

@@ -2,5 +2,3 @@ export * from './common';
export * as validation from './common/validation';

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

export * from './app';

+ 2
- 12
test/e2e/default.test.ts View File

@@ -6,7 +6,6 @@ import {
describe,
expect,
it,
test,
} from 'vitest';
import {
tmpdir
@@ -93,9 +92,9 @@ describe('yasumi', () => {
.createBackend({
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir),
})
.throws404OnDeletingNotFound();
.throwsErrorOnDeletingNotFound();

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

@@ -750,13 +749,4 @@ describe('yasumi', () => {
});
});
});

// https://github.com/mayajs/maya/blob/main/test/index.test.ts
//
// peak unit test
describe("Contribute to see a unit test", () => {
test("should have a unit test", () => {
expect("Is this a unit test?").not.toEqual("Yes this is a unit test.");
});
});
});

Loading…
Cancel
Save