Browse Source

Refactor http server

Define http server exports in their own directory.
master
TheoryOfNekomata 8 months ago
parent
commit
8f05a07a67
23 changed files with 279 additions and 143 deletions
  1. +1
    -1
      package.json
  2. +6
    -1
      pridepack.json
  3. +35
    -3
      src/backend/common.ts
  4. +1
    -1
      src/backend/core.ts
  5. +41
    -90
      src/backend/servers/http/core.ts
  6. +3
    -3
      src/backend/servers/http/decorators/backend/content-negotiation.ts
  7. +2
    -2
      src/backend/servers/http/decorators/backend/index.ts
  8. +3
    -3
      src/backend/servers/http/decorators/backend/resource.ts
  9. +1
    -1
      src/backend/servers/http/decorators/method/index.ts
  10. +2
    -2
      src/backend/servers/http/decorators/url/base-path.ts
  11. +2
    -2
      src/backend/servers/http/decorators/url/host.ts
  12. +3
    -3
      src/backend/servers/http/decorators/url/index.ts
  13. +2
    -2
      src/backend/servers/http/decorators/url/scheme.ts
  14. +3
    -3
      src/backend/servers/http/handlers/default.ts
  15. +2
    -1
      src/backend/servers/http/handlers/resource.ts
  16. +1
    -0
      src/backend/servers/http/index.ts
  17. +36
    -0
      src/backend/servers/http/response.ts
  18. +8
    -0
      src/backend/servers/http/utils.ts
  19. +2
    -0
      src/common/language.ts
  20. +0
    -2
      src/index.ts
  21. +100
    -0
      test/e2e/features.test.ts
  22. +9
    -23
      test/e2e/http.test.ts
  23. +16
    -0
      test/fixtures.ts

+ 1
- 1
package.json View File

@@ -1,5 +1,5 @@
{
"name": "yasumi",
"name": "@modal-sh/yasumi",
"version": "0.0.0",
"files": [
"dist",


+ 6
- 1
pridepack.json View File

@@ -1,3 +1,8 @@
{
"target": "es2018"
"target": "es2018",
"entrypoints": {
".": "src/index.ts",
"./backend": "src/backend/index.ts",
"./client": "src/client/index.ts"
}
}

+ 35
- 3
src/backend/common.ts View File

@@ -1,7 +1,6 @@
import {ApplicationState, ContentNegotiation, Resource} from '../common';
import {DataSource} from './data-source';
import {BaseSchema} from 'valibot';
import {Middleware} from './http/server';
import {ApplicationState, ContentNegotiation, Language, LanguageStatusMessageMap, Resource} from '../common';
import {DataSource} from './data-source';

export interface BackendState {
app: ApplicationState;
@@ -15,6 +14,28 @@ export interface BackendState {

export interface RequestContext {}

export interface Middleware {}

export class MiddlewareError extends Error {}

export interface MiddlewareResponseErrorParams extends Omit<Response, 'statusMessage'> {
cause?: unknown;
}

export abstract class MiddlewareResponseError extends MiddlewareError implements Response {
readonly statusMessage: Response['statusMessage'];
readonly statusCode: Response['statusCode'];
readonly headers: Response['headers'];

constructor(statusMessage: keyof Language['statusMessages'], params: MiddlewareResponseErrorParams) {
super(statusMessage, { cause: params.cause });
this.statusCode = params.statusCode;
this.headers = params.headers;
this.statusMessage = statusMessage;
}
}


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

export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator;
@@ -27,3 +48,14 @@ export interface AllowedMiddlewareSpecification<Schema extends BaseSchema = Base
constructBodySchema?: (resource: Resource<Schema>, resourceId?: string) => BaseSchema;
allowed: (resource: Resource<Schema>) => boolean;
}

export interface Response {
// type of response
statusCode: number;

// description of response
statusMessage?: keyof LanguageStatusMessageMap;

// metadata of the response
headers?: Record<string, string>;
}

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

@@ -1,6 +1,6 @@
import {ApplicationState, FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE, Resource} from '../common';
import http from 'http';
import {createServer, CreateServerParams} from './http/server';
import {createServer, CreateServerParams} from './servers/http';
import https from 'https';
import {BackendState} from './common';
import {DataSource} from './data-source';


src/backend/http/server.ts → src/backend/servers/http/core.ts View File

@@ -1,9 +1,15 @@
import http from 'http';
import {AllowedMiddlewareSpecification, BackendState, RequestContext} from '../common';
import {Language, Resource, LanguageStatusMessageMap} from '../../common';
import https from 'https';
import {constants} from 'http2';
import * as v from 'valibot';
import {
AllowedMiddlewareSpecification,
BackendState,
Middleware,
RequestContext,
Response
} from '../../common';
import {Resource} from '../../../common';
import {
handleGetRoot, handleOptions,
} from './handlers/default';
@@ -15,79 +21,11 @@ import {
handleGetItem,
handlePatchItem,
} from './handlers/resource';
import {getBody} from './utils';
import {getBody, isTextMediaType} from './utils';
import {decorateRequestWithBackend} from './decorators/backend';
import {decorateRequestWithMethod} from './decorators/method';
import {decorateRequestWithUrl} from './decorators/url';

declare module '../common' {
interface RequestContext extends http.IncomingMessage {
body?: unknown;
}
}

export interface Response {
statusCode: number;

statusMessage?: keyof LanguageStatusMessageMap;

headers?: Record<string, string>;
}

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

export class MiddlewareError extends Error {}

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

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

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 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 CreateServerParams {
basePath?: string;
host?: string;
cert?: string;
key?: string;
requestTimeout?: number;
// CQRS
streamResponses?: boolean;
}
import {HttpMiddlewareError, PlainResponse} from './response';

type RequiredResource = Required<Pick<RequestContext, 'resource'>>['resource'];

@@ -99,8 +37,14 @@ interface ResourceRequestContext extends Omit<RequestContext, 'resource'> {
resource: ResourceWithDataSource;
}

export interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext> {
(req: Req): undefined | Response | Promise<undefined | Response>;
declare module '../../common' {
interface RequestContext extends http.IncomingMessage {
body?: unknown;
}

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

const constructPostSchema = <T extends v.BaseSchema>(resource: Resource<T>) => {
@@ -143,7 +87,6 @@ const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<T>) =>
: schema
);
};

// TODO add a way to define custom middlewares
const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [
{
@@ -184,6 +127,16 @@ const defaultItemMiddlewares: AllowedMiddlewareSpecification[] = [
},
];

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

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

@@ -203,14 +156,6 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
decorateRequestWithBackend(backendState),
];

const isTextMediaType = (mediaType: string) => (
mediaType.startsWith('text/')
|| [
'application/json',
'application/xml'
].includes(mediaType)
);

const handleMiddlewares = async (currentHandlerState: Awaited<ReturnType<Middleware>>, currentMiddleware: AllowedMiddlewareSpecification, req: ResourceRequestContext) => {
const { method: middlewareMethod, middleware, constructBodySchema} = currentMiddleware;
const effectiveMethod = req.method === 'HEAD' ? 'GET' : req.method;
@@ -350,8 +295,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
return await effectiveRequestDecorators.reduce(
async (resultRequestPromise, decorator) => {
const resultRequest = await resultRequestPromise;

return await decorator(resultRequest);
const decoratedRequest = await decorator(resultRequest);
// TODO log decorators
return decoratedRequest;
},
Promise.resolve(reqRaw as RequestContext)
);
@@ -373,11 +319,11 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this
} catch (processRequestErrRaw) {
const finalErr = processRequestErrRaw as HttpMiddlewareError;
const headers = finalErr.response.headers ?? {};
const headers = finalErr.headers ?? {};
let encoded: Buffer | undefined;
let serialized;
try {
serialized = typeof finalErr.response.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.response.body) : undefined;
serialized = typeof finalErr.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.body) : undefined;
} catch (cause) {
res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToSerializeResponse']?.replace(
/\$RESOURCE/g,
@@ -401,9 +347,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
`charset=${resourceReq.backend.cn.charset.name}`,
].join('; ');

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

throw new Error('Not implemented');
res.statusMessage = reqRaw.backend.cn.language.statusMessages.notImplemented.replace(/\$RESOURCE/g,
reqRaw.resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_NOT_IMPLEMENTED, {
'Content-Language': reqRaw.backend.cn.language.name,
});
res.end();
};

server.on('request', handleRequest);

src/backend/http/decorators/backend/content-negotiation.ts → src/backend/servers/http/decorators/backend/content-negotiation.ts View File

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

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

src/backend/http/decorators/backend/index.ts → src/backend/servers/http/decorators/backend/index.ts View File

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

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

src/backend/http/decorators/backend/resource.ts → src/backend/servers/http/decorators/backend/resource.ts View File

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

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

src/backend/http/decorators/method/index.ts → src/backend/servers/http/decorators/method/index.ts View File

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

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

src/backend/http/decorators/url/base-path.ts → src/backend/servers/http/decorators/url/base-path.ts View File

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

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

src/backend/http/decorators/url/host.ts → src/backend/servers/http/decorators/url/host.ts View File

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

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

src/backend/http/decorators/url/index.ts → src/backend/servers/http/decorators/url/index.ts View File

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

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

src/backend/http/decorators/url/scheme.ts → src/backend/servers/http/decorators/url/scheme.ts View File

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

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

src/backend/http/handlers/default.ts → src/backend/servers/http/handlers/default.ts View File

@@ -1,7 +1,7 @@
import {HttpMiddlewareError, Middleware, PlainResponse} from '../server';
import {LinkMap} from '../utils';
import {constants} from 'http2';
import {AllowedMiddlewareSpecification} from '../../common';
import {AllowedMiddlewareSpecification, Middleware} from '../../../common';
import {LinkMap} from '../utils';
import {PlainResponse, HttpMiddlewareError} from '../response';

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

src/backend/http/handlers/resource.ts → src/backend/servers/http/handlers/resource.ts View File

@@ -1,6 +1,7 @@
import { constants } from 'http2';
import * as v from 'valibot';
import {HttpMiddlewareError, PlainResponse, Middleware} from '../server';
import {Middleware} from '../../../common';
import {HttpMiddlewareError, PlainResponse} from '../response';

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

+ 1
- 0
src/backend/servers/http/index.ts View File

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

+ 36
- 0
src/backend/servers/http/response.ts View File

@@ -0,0 +1,36 @@
import {Language, LanguageStatusMessageMap} from '../../../common';
import {MiddlewareResponseError, Response} from '../../common';

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

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

export class HttpMiddlewareError<T = unknown> extends MiddlewareResponseError implements PlainResponseParams<T> {
body?: T;

constructor(statusMessage: keyof Language['statusMessages'], params: HttpMiddlewareErrorParams<T>) {
super(statusMessage, params);
this.body = params.body;
}
}

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

src/backend/http/utils.ts → src/backend/servers/http/utils.ts View File

@@ -1,5 +1,13 @@
import {IncomingMessage} from 'http';

export const isTextMediaType = (mediaType: string) => (
mediaType.startsWith('text/')
|| [
'application/json',
'application/xml'
].includes(mediaType)
);

export const getBody = (
req: IncomingMessage,
) => new Promise<Buffer>((resolve, reject) => {

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

@@ -33,6 +33,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [
'resourcePatched',
'resourceCreated',
'resourceReplaced',
'notImplemented',
] as const;

export type LanguageDefaultStatusMessageKey = typeof LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS[number];
@@ -90,6 +91,7 @@ export const FALLBACK_LANGUAGE = {
unableToEmplaceResource: 'Unable To Emplace $RESOURCE',
resourceIdNotGiven: '$RESOURCE ID Not Given',
unableToCreateResource: 'Unable To Create $RESOURCE',
notImplemented: 'Not Implemented'
},
bodies: {
languageNotAcceptable: [],


+ 0
- 2
src/index.ts View File

@@ -1,3 +1 @@
export * from './common';

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

+ 100
- 0
test/e2e/features.test.ts View File

@@ -0,0 +1,100 @@
import {describe, afterAll, afterEach, beforeAll, beforeEach, it} from 'vitest';
import {mkdtemp, rm} from 'fs/promises';
import {join} from 'path';
import {tmpdir} from 'os';
import {application, resource, Resource, validation as v} from '../../src';
import {dataSources} from '../../src/backend';
import {Server} from 'http';
import {autoIncrement} from '../fixtures';

const PORT = 3001;
const HOST = '127.0.0.1';
const BASE_PATH = '/api';
const ACCEPT = 'application/json';
const ACCEPT_LANGUAGE = 'en';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;

describe('decorators', () => {
let baseDir: string;
beforeAll(async () => {
try {
baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
} catch {
// noop
}
});
afterAll(async () => {
try {
await rm(baseDir, {
recursive: true,
});
} catch {
// noop
}
});

let Piano: Resource;
beforeEach(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});
});

let server: Server;
beforeEach(() => {
const app = application({
name: 'piano-service',
})
.resource(Piano);

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

server = backend.createHttpServer({
basePath: BASE_PATH
});

return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});

server.on('listening', () => {
resolve();
});

server.listen({
port: PORT
});
});
});

afterEach(() => new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}

resolve();
});
}));

it('decorates requests', () => {

});
});

+ 9
- 23
test/e2e/http.test.ts View File

@@ -20,32 +20,17 @@ import {
} from 'path';
import {request, Server} from 'http';
import {constants} from 'http2';
import {DataSource} from '../../src/backend/data-source';
import { dataSources } from '../../src/backend';
import {BackendBuilder, dataSources} from '../../src/backend';
import { application, resource, validation as v, Resource } from '../../src';
import { autoIncrement } from '../fixtures';

const PORT = 3000;
const HOST = '127.0.0.1';
const BASE_PATH = '/api';
const ACCEPT = 'application/json';
const ACCEPT_LANGUAGE = 'en';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;
const BASE_PATH = '/api';

const autoIncrement = async (dataSource: DataSource) => {
const data = await dataSource.getMultiple() as Record<string, string>[];

const highestId = data.reduce<number>(
(highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId),
-Infinity
);

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

return 1;
};

describe('yasumi HTTP', () => {
let baseDir: string;
@@ -84,6 +69,7 @@ describe('yasumi HTTP', () => {
});
});

let backend: BackendBuilder;
let server: Server;
beforeEach(() => {
const app = application({
@@ -91,11 +77,9 @@ describe('yasumi HTTP', () => {
})
.resource(Piano);

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

server = backend.createHttpServer({
basePath: BASE_PATH
@@ -1062,10 +1046,12 @@ describe('yasumi HTTP', () => {

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

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

it('throws on item not found', () => {


+ 16
- 0
test/fixtures.ts View File

@@ -0,0 +1,16 @@
import {DataSource} from '../src/backend/data-source';

export const autoIncrement = async (dataSource: DataSource) => {
const data = await dataSource.getMultiple() as Record<string, string>[];

const highestId = data.reduce<number>(
(highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId),
-Infinity
);

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

return 1;
};

Loading…
Cancel
Save