Browse Source

Add server state, request decorator

Include request decorator to features.
master
TheoryOfNekomata 7 months ago
parent
commit
c9caf5a473
10 changed files with 122 additions and 72 deletions
  1. +4
    -0
      README.md
  2. +3
    -2
      examples/basic/server.ts
  3. +1
    -1
      pridepack.json
  4. +2
    -4
      src/backend/core.ts
  5. +2
    -9
      src/backend/data-sources/file-jsonl.ts
  6. +96
    -48
      src/backend/servers/http/core.ts
  7. +1
    -0
      src/backend/servers/http/index.ts
  8. +0
    -1
      src/index.ts
  9. +10
    -4
      test/e2e/features.test.ts
  10. +3
    -3
      test/e2e/http.test.ts

+ 4
- 0
README.md View File

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

## Links

- Representational State Transfer

https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm

- Roy Fielding (creator of REST)'s post about criticisms of REST APIs
https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven


+ 3
- 2
examples/basic/server.ts View File

@@ -1,8 +1,9 @@
import {
application, dataSources,
application,
resource,
validation as v,
} from '../../src';
} from '../../src/common';
import { dataSources } from '../../src/backend';
import {TEXT_SERIALIZER_PAIR} from './serializers';
import {autoIncrement} from './data-source';



+ 1
- 1
pridepack.json View File

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


+ 2
- 4
src/backend/core.ts View File

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

@@ -10,7 +8,7 @@ export interface BackendBuilder<T extends DataSource = DataSource> {
showTotalItemCountOnCreateItem(b?: boolean): this;
checksSerializersOnDelete(b?: boolean): this;
throwsErrorOnDeletingNotFound(b?: boolean): this;
createHttpServer(serverParams?: CreateServerParams): http.Server | https.Server;
createHttpServer(serverParams?: CreateServerParams): Server;
dataSource?: (resource: Resource) => T;
}



+ 2
- 9
src/backend/data-sources/file-jsonl.ts View File

@@ -1,17 +1,10 @@
import {readFile, writeFile} from 'fs/promises';
import {join} from 'path';
import {DataSource as DataSourceInterface, ResourceIdConfig} from '../data-source';
import {Resource} from '../..';
import {Resource} from '../../common';
import * as v from 'valibot';

declare module '../..' {


interface BaseResourceState {
idAttr: string;
idConfig: ResourceIdConfig<v.BaseSchema>
}

declare module '../../common' {
interface Resource<
Schema extends v.BaseSchema = v.BaseSchema,
CurrentName extends string = string,


+ 96
- 48
src/backend/servers/http/core.ts View File

@@ -6,8 +6,8 @@ import {
AllowedMiddlewareSpecification,
BackendState,
Middleware,
RequestContext,
Response
RequestContext, RequestDecorator,
Response,
} from '../../common';
import {Resource} from '../../../common';
import {
@@ -142,7 +142,24 @@ class CqrsEventEmitter extends EventEmitter {

}

interface ServerState {
requestDecorators: Set<RequestDecorator>;
}

export interface Server {
readonly listening: boolean;
on(event: string, cb: (...args: unknown[]) => unknown): this;
close(callback?: (err?: Error) => void): this;
listen(...args: Parameters<http.Server['listen']>): this;
requestDecorator(requestDecorator: RequestDecorator): this;
defaultErrorHandler(): this;
}

export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => {
const state: ServerState = {
requestDecorators: new Set<RequestDecorator>(),
};

const isHttps = 'key' in serverParams && 'cert' in serverParams;
const theRes = new CqrsEventEmitter();

@@ -156,12 +173,6 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
requestTimeout: serverParams.requestTimeout,
});

const defaultRequestDecorators = [
decorateRequestWithMethod,
decorateRequestWithUrl(serverParams),
decorateRequestWithBackend(backendState),
];

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;
@@ -305,9 +316,18 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
return middlewareResponse as Awaited<ReturnType<Middleware>>
};

const defaultRequestDecorators = [
decorateRequestWithMethod,
decorateRequestWithUrl(serverParams),
decorateRequestWithBackend(backendState),
];

const decorateRequest = async (reqRaw: http.IncomingMessage) => {
// TODO custom decorators
const effectiveRequestDecorators = defaultRequestDecorators;
const effectiveRequestDecorators = [
...defaultRequestDecorators,
...Array.from(state.requestDecorators),
];

return await effectiveRequestDecorators.reduce(
async (resultRequestPromise, decorator) => {
const resultRequest = await resultRequestPromise;
@@ -319,10 +339,51 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
);
};

const handleMiddlewareError = (processRequestErrRaw: Error) => (resourceReq: ResourceRequestContext, res: http.ServerResponse<RequestContext>) => {
const finalErr = processRequestErrRaw as ErrorPlainResponse;
const headers = finalErr.headers ?? {};
let encoded: Buffer | undefined;
let serialized;
try {
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,
resourceReq.resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
return;
}

try {
encoded = typeof serialized !== 'undefined' ? resourceReq.backend.cn.charset.encode(serialized) : undefined;
} catch (cause) {
res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g,
resourceReq.resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
}

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

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.statusCode, headers);
if (typeof encoded !== 'undefined') {
res.end(encoded);
return;
}
res.end();
};

const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse<RequestContext>) => {
const plainReq = await decorateRequest(reqRaw); // TODO add type safety here, put handleGetRoot as its own middleware as it does not concern over any resource
if (typeof plainReq.resource !== 'undefined') {
const resourceReq = plainReq as ResourceRequestContext;
// TODO custom middlewares
const effectiveMiddlewares = (
typeof resourceReq.resourceId === 'string'
? defaultItemMiddlewares
@@ -335,43 +396,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
try {
middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this
} catch (processRequestErrRaw) {
const finalErr = processRequestErrRaw as ErrorPlainResponse;
const headers = finalErr.headers ?? {};
let encoded: Buffer | undefined;
let serialized;
try {
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,
resourceReq.resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
return;
}

try {
encoded = typeof serialized !== 'undefined' ? resourceReq.backend.cn.charset.encode(serialized) : undefined;
} catch (cause) {
res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g,
resourceReq.resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
}

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

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.statusCode, headers);
if (typeof encoded !== 'undefined') {
res.end(encoded);
return;
}
res.end();
handleMiddlewareError(processRequestErrRaw as Error)(resourceReq, res);
return;
}

@@ -476,5 +501,28 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr

server.on('request', handleRequest);

return server;
return {
get listening() { return server.listening },
listen(...args: Parameters<Server['listen']>) {
server.listen(...args);
return this;
},
close(callback?: (err?: Error) => void) {
server.close(callback);
return this;
},
on(...args: Parameters<Server['on']>) {
server.on(args[0], args[1]);
return this;
},
requestDecorator(requestDecorator: RequestDecorator) {
state.requestDecorators.add(requestDecorator);
return this;
},
defaultErrorHandler() {
return this;
}
} satisfies Server;

// return server;
}

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

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

+ 0
- 1
src/index.ts View File

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

+ 10
- 4
test/e2e/features.test.ts View File

@@ -2,10 +2,10 @@ 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 {application, resource, Resource, validation as v} from '../../src/common';
import {BackendBuilder, dataSources} from '../../src/backend';
import {autoIncrement} from '../fixtures';
import {RequestContext} from '../../src/backend/common';

const PORT = 3001;
const HOST = '127.0.0.1';
@@ -52,7 +52,7 @@ describe('decorators', () => {
});
});

let server: Server;
let server: ReturnType<BackendBuilder['createHttpServer']>;
beforeEach(() => {
const app = application({
name: 'piano-service',
@@ -95,6 +95,12 @@ describe('decorators', () => {
}));

it('decorates requests', () => {
server.requestDecorator((req) => {
const reqMut = req as unknown as Record<string, unknown>;
reqMut['foo'] = 'bar';
return reqMut as unknown as RequestContext;
});

// TODO how to make assertions here
});
});

+ 3
- 3
test/e2e/http.test.ts View File

@@ -18,10 +18,10 @@ import {
import {
join
} from 'path';
import {request, Server} from 'http';
import {request} from 'http';
import {constants} from 'http2';
import {BackendBuilder, dataSources} from '../../src/backend';
import { application, resource, validation as v, Resource } from '../../src';
import { application, resource, validation as v, Resource } from '../../src/common';
import { autoIncrement } from '../fixtures';

const PORT = 3000;
@@ -70,7 +70,7 @@ describe('yasumi HTTP', () => {
});

let backend: BackendBuilder;
let server: Server;
let server: ReturnType<BackendBuilder['createHttpServer']>;
beforeEach(() => {
const app = application({
name: 'piano-service',


Loading…
Cancel
Save