Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: setup test for koa application #1

Merged
merged 5 commits into from
Dec 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,171 changes: 1,170 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@
},
"devDependencies": {
"@eslint/js": "^9.12.0",
"@koa/router": "^13.1.0",
"@types/debug": "^4.1.12",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/koa": "^2.15.0",
"@types/koa-static": "^4.0.4",
"@types/koa__router": "^12.0.4",
"@types/node": "^22.7.4",
"@typescript-eslint/eslint-plugin": "^8.8.0",
"@typescript-eslint/parser": "^8.8.0",
Expand All @@ -65,6 +69,9 @@
"globals": "^15.10.0",
"husky": "^9.1.6",
"jest": "^29.7.0",
"koa": "^2.15.3",
"koa-body": "^6.0.1",
"koa-static": "^5.0.0",
"prettier": "^3.3.3",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
Expand Down
48 changes: 40 additions & 8 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Context, Event } from './types';
import ServerlessRequest from './serverlessRequest';
import url from 'node:url';
import ServerlessResponse from './serverlessResponse';
import { debug } from './common';

// const requestRemoteAddress = (event) => {
Expand All @@ -11,23 +10,56 @@
// return event.requestContext.identity.sourceIp;
// };

const requestBody = (event: Event) => {
if (event.body === undefined || event.body === null) {
return undefined;
}
const type = typeof event.body;

if (Buffer.isBuffer(event.body)) {
return event.body;

Check warning on line 20 in src/context.ts

View check run for this annotation

Codecov / codecov/patch

src/context.ts#L20

Added line #L20 was not covered by tests
} else if (type === 'string') {
return Buffer.from(event.body as string, event.isBase64Encoded ? 'base64' : 'utf8');
} else if (type === 'object') {
return Buffer.from(JSON.stringify(event.body));

Check warning on line 24 in src/context.ts

View check run for this annotation

Codecov / codecov/patch

src/context.ts#L24

Added line #L24 was not covered by tests
}

throw new Error(`Unexpected event.body type: ${typeof event.body}`);

Check warning on line 27 in src/context.ts

View check run for this annotation

Codecov / codecov/patch

src/context.ts#L27

Added line #L27 was not covered by tests
};

const requestHeaders = (event: Event) => {
const initialHeader = {} as Record<string, string>;

// if (event.multiValueHeaders) {
// Object.keys(event.multiValueHeaders).reduce((headers, key) => {
// headers[key.toLowerCase()] = event.multiValueHeaders[key].join(', ');
// return headers;
// }, initialHeader);
// }

return Object.keys(event.headers).reduce((headers, key) => {
headers[key.toLowerCase()] = event.headers[key];
return headers;
}, initialHeader);
};

export const constructFrameworkContext = (event: Event, context: Context) => {
debug(`constructFrameworkContext: ${JSON.stringify({ event, context })}`);
const body = requestBody(event);
const headers = requestHeaders(event);

const request = new ServerlessRequest({
method: event.httpMethod,
headers: event.headers,
body:
event.body !== undefined && event.body !== null
? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8')
: undefined,
path: event.path,
headers,
body,
remoteAddress: '',
url: url.format({
pathname: event.path,
query: event.queryParameters,
}),
isBase64Encoded: event.isBase64Encoded,
});
const response = new ServerlessResponse(request);

return { request, response };
return { request };
};
25 changes: 25 additions & 0 deletions src/framework.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Express } from 'express';
import Application from 'koa';
import ServerlessResponse from './serverlessResponse';
import ServerlessRequest from './serverlessRequest';

// eslint-disable-next-line
const callableFn = (callback: (req: any, res: any) => Promise<void>) => {
return async (request: ServerlessRequest) => {
const response = new ServerlessResponse(request);

callback(request, response);

return response;
};
};

export const constructFramework = (app: Express | Application) => {
if (app instanceof Application) {
return callableFn(app.callback());
} else if (typeof app === 'function') {
return callableFn(app);
} else {
throw new Error(`Unsupported framework ${app}`);

Check warning on line 23 in src/framework.ts

View check run for this annotation

Codecov / codecov/patch

src/framework.ts#L23

Added line #L23 was not covered by tests
}
};
12 changes: 6 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { Express } from 'express';
import Application from 'koa';
import { ServerlessAdapter } from './types';
import sendRequest from './sendRequest';
import { IncomingHttpHeaders } from 'http';
import { constructFrameworkContext } from './context';
import { buildResponse, waitForStreamComplete } from './transport';
import { constructFramework } from './framework';

const serverlessAdapter: ServerlessAdapter = (app: Express) => {
const serverlessAdapter: ServerlessAdapter = (app: Express | Application) => {
const serverlessFramework = constructFramework(app);
return async (event, context) => {
const { request, response } = constructFrameworkContext(event, context);
const { request } = constructFrameworkContext(event, context);

try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
await sendRequest(app, request, response);
const response = await serverlessFramework(request);
await waitForStreamComplete(response);
return buildResponse({ request, response });
} catch (err) {
Expand Down
8 changes: 0 additions & 8 deletions src/sendRequest.ts

This file was deleted.

29 changes: 12 additions & 17 deletions src/serverlessRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const HTTPS_PORT = 443;
interface ServerlessRequestOptions {
method: string;
url: string;
path: string;
headers: { [key: string]: string | number };
body: Buffer | string | undefined;
remoteAddress: string;
Expand All @@ -21,46 +22,40 @@ export default class ServerlessRequest extends IncomingMessage {

isBase64Encoded: boolean;

constructor({
method,
url,
headers,
body,
remoteAddress,
isBase64Encoded,
}: ServerlessRequestOptions) {
constructor(request: ServerlessRequestOptions) {
super({
encrypted: true,
readable: false,
remoteAddress,
remoteAddress: request.remoteAddress,
address: () => ({ port: HTTPS_PORT }),
end: NO_OP,
destroy: NO_OP,
path: request.path,
headers: request.headers,
} as unknown as Socket);

const combinedHeaders = Object.fromEntries(
Object.entries({
...headers,
'content-length': Buffer.byteLength(body ?? '').toString(),
...request.headers,
'content-length': Buffer.byteLength(request.body ?? '').toString(),
}).map(([key, value]) => [key.toLowerCase(), value]),
);

Object.assign(this, {
...request,
complete: true,
httpVersion: '1.1',
httpVersionMajor: '1',
httpVersionMinor: '1',
method,
url,
headers: combinedHeaders,
});

this.body = body;
this.ip = remoteAddress;
this.isBase64Encoded = isBase64Encoded;
this.body = request.body;
this.ip = request.remoteAddress;
this.isBase64Encoded = request.isBase64Encoded;

this._read = () => {
this.push(body);
this.push(request.body);
this.push(null);
};
}
Expand Down
9 changes: 2 additions & 7 deletions src/serverlessResponse.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { IncomingHttpHeaders, ServerResponse } from 'http';
import { IncomingMessage } from 'node:http';
import ServerlessRequest from './serverlessRequest';
import { Socket } from 'node:net';
import { debug } from './common';
import ServerlessRequest from './serverlessRequest';

const headerEnd = '\r\n\r\n';

Expand Down Expand Up @@ -37,12 +36,8 @@ export default class ServerlessResponse extends ServerResponse {
[BODY]: Buffer[];
[HEADERS]: IncomingHttpHeaders;

static from(res: IncomingMessage): ServerlessResponse {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
static from(res: ServerlessRequest): ServerlessResponse {
const response = new ServerlessResponse(res);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const { statusCode = 0, headers, body } = res;
response.statusCode = statusCode;
response[HEADERS] = headers;
Expand Down
57 changes: 51 additions & 6 deletions src/transport.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Writable } from 'stream';
import { IncomingHttpHeaders } from 'http';
import ServerlessRequest from './serverlessRequest';
import ServerlessResponse from './serverlessResponse';

type MultiValueHeaders = {
[key: string]: string[];
};
export const waitForStreamComplete = (stream: Writable): Promise<Writable> => {
if (stream.writableFinished || stream.writableEnded) {
return Promise.resolve(stream);
}

return new Promise((resolve, reject) => {
stream.once('error', complete);
stream.once('end', complete);
stream.once('finish', complete);

let isComplete = false;

function complete(err?: Error) {
Expand All @@ -31,20 +31,65 @@
resolve(stream);
}
}

stream.once('error', complete);
stream.once('end', complete);
stream.once('finish', complete);
});
};

const sanitizeHeaders = (headers: IncomingHttpHeaders) => {
return Object.keys(headers).reduce(
(memo, key) => {
const value = headers[key];

if (Array.isArray(value)) {
memo.multiValueHeaders[key] = value;

Check warning on line 47 in src/transport.ts

View check run for this annotation

Codecov / codecov/patch

src/transport.ts#L47

Added line #L47 was not covered by tests
if (key.toLowerCase() !== 'set-cookie') {
memo.headers[key] = value.join(', ');

Check warning on line 49 in src/transport.ts

View check run for this annotation

Codecov / codecov/patch

src/transport.ts#L49

Added line #L49 was not covered by tests
}
} else {
memo.headers[key] = value == null ? '' : value.toString();
}

return memo;
},
{
headers: {} as IncomingHttpHeaders,
multiValueHeaders: {} as MultiValueHeaders,
},
);
};

export const buildResponse = ({
request,
response,
}: {
request: ServerlessRequest;
response: ServerlessResponse;
}) => {
const { headers, multiValueHeaders } = sanitizeHeaders(ServerlessResponse.headers(response));

let body = ServerlessResponse.body(response).toString(
request.isBase64Encoded ? 'base64' : 'utf8',
);
if (headers['transfer-encoding'] === 'chunked' || response.chunkedEncoding) {
const raw = ServerlessResponse.body(response).toString().split('\r\n');
const parsed = [];
for (let i = 0; i < raw.length; i += 2) {
const size = parseInt(raw[i], 16);
const value = raw[i + 1];

Check warning on line 81 in src/transport.ts

View check run for this annotation

Codecov / codecov/patch

src/transport.ts#L77-L81

Added lines #L77 - L81 were not covered by tests
if (value) {
parsed.push(value.substring(0, size));

Check warning on line 83 in src/transport.ts

View check run for this annotation

Codecov / codecov/patch

src/transport.ts#L83

Added line #L83 was not covered by tests
}
}
body = parsed.join('');

Check warning on line 86 in src/transport.ts

View check run for this annotation

Codecov / codecov/patch

src/transport.ts#L86

Added line #L86 was not covered by tests
}
return {
statusCode: response.statusCode,
body: ServerlessResponse.body(response).toString(request.isBase64Encoded ? 'base64' : 'utf8'),
headers: response.headers,
body,
headers,
multiValueHeaders,
isBase64Encoded: request.isBase64Encoded,
};
};
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Express } from 'express';
import Application from 'koa';
import { IncomingHttpHeaders } from 'http';

type AliyunApiGatewayEvent = {
Expand Down Expand Up @@ -52,7 +53,7 @@ type AliyunApiGatewayContext = {
export type Event = AliyunApiGatewayEvent;
export type Context = AliyunApiGatewayContext;

export type ServerlessAdapter = (app: Express) => (
export type ServerlessAdapter = (app: Express | Application) => (
event: Event,
context: Context,
) => Promise<{
Expand Down
File renamed without changes.
Loading
Loading