mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Restructure to monorepo (#6907)
* wip: restructure to monorepo Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * refactor create-release-pr to a package Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * build fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * github workflow fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix typo Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * add webpack-env types to core Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix github workflows Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * refactor/fix integration tests Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * lint fix Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * yarn run dev Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * eslint settings for vscode Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * move templates to right package Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * open-lens build fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * integration test fix Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix nx task dependencies Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * use bash shell for unit tests in test workflow Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix test:unit for windows Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix win-ca webpack error in open-lens Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix win-ca webpack error in open-lens Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix build:app on windows Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * remove ELECTRON_BUILDER_EXTRA_ARGS Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * sync src/ from master Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * remove Makefile from core Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
1 parent
0066ca4ea2
commit
2657df2293
3321 files changed
+28699
-11447
No files matched your search
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import type { ServerResponse } from "http";
|
||||
import loggerInjectable from "../../common/logger.injectable";
|
||||
import { object } from "../../common/utils";
|
||||
import type { LensApiRequest, Route } from "./route";
|
||||
import { contentTypes } from "./router-content-types";
|
||||
|
||||
export type RouteHandler = (request: LensApiRequest<string>, response: ServerResponse) => Promise<void>;
|
||||
export type CreateHandlerForRoute = (route: Route<unknown, string>) => RouteHandler;
|
||||
|
||||
interface LensServerResponse {
|
||||
statusCode: number;
|
||||
content: any;
|
||||
headers: {
|
||||
[name: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
const writeServerResponseFor = (serverResponse: ServerResponse) => ({
|
||||
statusCode,
|
||||
content,
|
||||
headers,
|
||||
}: LensServerResponse) => {
|
||||
serverResponse.statusCode = statusCode;
|
||||
|
||||
for (const [name, value] of object.entries(headers)) {
|
||||
serverResponse.setHeader(name, value);
|
||||
}
|
||||
|
||||
if (content instanceof Buffer) {
|
||||
serverResponse.write(content);
|
||||
serverResponse.end();
|
||||
} else if (content) {
|
||||
serverResponse.end(content);
|
||||
} else {
|
||||
serverResponse.end();
|
||||
}
|
||||
};
|
||||
|
||||
const createHandlerForRouteInjectable = getInjectable({
|
||||
id: "create-handler-for-route",
|
||||
instantiate: (di): CreateHandlerForRoute => {
|
||||
const logger = di.inject(loggerInjectable);
|
||||
|
||||
return (route) => async (request, response) => {
|
||||
const writeServerResponse = writeServerResponseFor(response);
|
||||
|
||||
try {
|
||||
const result = await route.handler(request);
|
||||
|
||||
if (!result) {
|
||||
const mappedResult = contentTypes.txt.resultMapper({
|
||||
statusCode: 204,
|
||||
response: undefined,
|
||||
});
|
||||
|
||||
writeServerResponse(mappedResult);
|
||||
} else if (!result.proxy) {
|
||||
const contentType = result.contentType || contentTypes.json;
|
||||
|
||||
writeServerResponse(contentType.resultMapper(result));
|
||||
}
|
||||
} catch(error) {
|
||||
const mappedResult = contentTypes.txt.resultMapper({
|
||||
statusCode: 500,
|
||||
error: error ? String(error) : "unknown error",
|
||||
});
|
||||
|
||||
logger.error(`[ROUTER]: route ${route.path}, called with ${request.path}, threw an error`, error);
|
||||
writeServerResponse(mappedResult);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default createHandlerForRouteInjectable;
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import Subtext from "@hapi/subtext";
|
||||
|
||||
export type ParseRequest = typeof Subtext.parse;
|
||||
|
||||
const parseRequestInjectable = getInjectable({
|
||||
id: "parse-http-request",
|
||||
instantiate: () => Subtext.parse,
|
||||
});
|
||||
|
||||
export default parseRequestInjectable;
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { Cluster } from "../../common/cluster/cluster";
|
||||
import type http from "http";
|
||||
import type httpProxy from "http-proxy";
|
||||
import type { LensApiResultContentType } from "./router-content-types";
|
||||
import type { URLSearchParams } from "url";
|
||||
import type Joi from "joi";
|
||||
|
||||
export type InferParam<
|
||||
T extends string,
|
||||
PathParams extends Record<string, string>,
|
||||
> =
|
||||
T extends `{${infer P}?}`
|
||||
? PathParams & Partial<Record<P, string>>
|
||||
: T extends `{${infer P}}`
|
||||
? PathParams & Record<P, string>
|
||||
: PathParams;
|
||||
|
||||
export type InferParamFromPath<P extends string> =
|
||||
P extends `${string}/{${infer B}*}${infer Tail}`
|
||||
? Tail extends ""
|
||||
? Record<B, string>
|
||||
: never
|
||||
: P extends `${infer A}/${infer B}`
|
||||
? InferParam<A, InferParamFromPath<B>>
|
||||
: InferParam<P, {}>;
|
||||
|
||||
export interface LensApiRequest<Path extends string> {
|
||||
path: Path;
|
||||
payload: unknown;
|
||||
params: InferParamFromPath<Path>;
|
||||
cluster: Cluster | undefined;
|
||||
query: URLSearchParams;
|
||||
raw: {
|
||||
req: http.IncomingMessage;
|
||||
res: http.ServerResponse;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClusterLensApiRequest<Path extends string> extends LensApiRequest<Path> {
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
export interface LensApiResult<Response> {
|
||||
statusCode?: number;
|
||||
response?: Response;
|
||||
error?: any;
|
||||
contentType?: LensApiResultContentType;
|
||||
headers?: Partial<Record<string, string>>;
|
||||
proxy?: httpProxy;
|
||||
}
|
||||
|
||||
export type RouteResponse<Response> =
|
||||
| LensApiResult<Response>
|
||||
| void;
|
||||
|
||||
export interface RouteHandler<TResponse, Path extends string>{
|
||||
(request: LensApiRequest<Path>): RouteResponse<TResponse> | Promise<RouteResponse<TResponse>>;
|
||||
}
|
||||
|
||||
export interface BaseRoutePaths<Path extends string> {
|
||||
path: Path;
|
||||
method: "get" | "post" | "put" | "patch" | "delete";
|
||||
}
|
||||
|
||||
export interface PayloadValidator<Payload> {
|
||||
validate(payload: unknown): Joi.ValidationResult<Payload>;
|
||||
}
|
||||
|
||||
export interface ValidatorBaseRoutePaths<Path extends string, Payload> extends BaseRoutePaths<Path> {
|
||||
payloadValidator: PayloadValidator<Payload>;
|
||||
}
|
||||
|
||||
export interface Route<TResponse, Path extends string> extends BaseRoutePaths<Path> {
|
||||
handler: RouteHandler<TResponse, Path>;
|
||||
}
|
||||
|
||||
export interface BindHandler<Path extends string> {
|
||||
<TResponse>(handler: RouteHandler<TResponse, Path>): Route<TResponse, Path>;
|
||||
}
|
||||
|
||||
export function route<Path extends string>(parts: BaseRoutePaths<Path>): BindHandler<Path> {
|
||||
return (handler) => ({
|
||||
...parts,
|
||||
handler,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ClusterRouteHandler<Response, Path extends string>{
|
||||
(request: ClusterLensApiRequest<Path>): RouteResponse<Response> | Promise<RouteResponse<Response>>;
|
||||
}
|
||||
|
||||
export interface BindClusterHandler<Path extends string> {
|
||||
<TResponse>(handler: ClusterRouteHandler<TResponse, Path>): Route<TResponse, Path>;
|
||||
}
|
||||
|
||||
export function clusterRoute<Path extends string>(parts: BaseRoutePaths<Path>): BindClusterHandler<Path> {
|
||||
return (handler) => ({
|
||||
...parts,
|
||||
handler: ({ cluster, ...rest }) => {
|
||||
if (!cluster) {
|
||||
return {
|
||||
error: "Cluster missing",
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
return handler({ cluster, ...rest });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface ValidatedClusterLensApiRequest<Path extends string, Payload> extends ClusterLensApiRequest<Path> {
|
||||
payload: Payload;
|
||||
}
|
||||
|
||||
export interface ValidatedClusterRouteHandler<Payload, Response, Path extends string> {
|
||||
(request: ValidatedClusterLensApiRequest<Path, Payload>): RouteResponse<Response> | Promise<RouteResponse<Response>>;
|
||||
}
|
||||
|
||||
export interface BindValidatedClusterHandler<Path extends string, Payload> {
|
||||
<Response>(handler: ValidatedClusterRouteHandler<Payload, Response, Path>): Route<Response, Path>;
|
||||
}
|
||||
|
||||
export function payloadValidatedClusterRoute<Path extends string, Payload>({ payloadValidator, ...parts }: ValidatorBaseRoutePaths<Path, Payload>): BindValidatedClusterHandler<Path, Payload> {
|
||||
const boundClusterRoute = clusterRoute(parts);
|
||||
|
||||
return (handler) => boundClusterRoute(({ payload, ...rest }) => {
|
||||
const validationResult = payloadValidator.validate(payload);
|
||||
|
||||
if (validationResult.error) {
|
||||
return {
|
||||
error: validationResult.error,
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
return handler({
|
||||
payload: validationResult.value,
|
||||
...rest,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import type { LensApiResult } from "./route";
|
||||
|
||||
export interface LensApiResultContentType {
|
||||
resultMapper: (result: LensApiResult<any>) => ({
|
||||
statusCode: number;
|
||||
content: any;
|
||||
headers: Record<string, string>;
|
||||
});
|
||||
}
|
||||
|
||||
const resultMapperFor =
|
||||
(contentType: string): LensApiResultContentType["resultMapper"] =>
|
||||
({ response, error, statusCode= error ? 400 : 200, headers = {}}) => ({
|
||||
statusCode,
|
||||
content: error || response,
|
||||
headers: { ...headers, "Content-Type": contentType },
|
||||
});
|
||||
|
||||
export type SupportedFileExtension = "json" | "txt" | "html" | "css" | "gif" | "jpg" | "png" | "svg" | "js" | "woff2" | "ttf";
|
||||
|
||||
export const contentTypes: Record<SupportedFileExtension, LensApiResultContentType> = {
|
||||
json: {
|
||||
resultMapper: (result) => {
|
||||
const resultMapper = resultMapperFor("application/json");
|
||||
|
||||
const mappedResult = resultMapper(result);
|
||||
|
||||
const contentIsObject = typeof mappedResult.content === "object";
|
||||
const contentIsBuffer = mappedResult.content instanceof Buffer;
|
||||
const contentShouldBeStringified = contentIsObject && !contentIsBuffer;
|
||||
|
||||
const content = contentShouldBeStringified
|
||||
? JSON.stringify(mappedResult.content)
|
||||
: mappedResult.content;
|
||||
|
||||
return {
|
||||
...mappedResult,
|
||||
content,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
txt: {
|
||||
resultMapper: resultMapperFor("text/plain"),
|
||||
},
|
||||
|
||||
html: {
|
||||
resultMapper: resultMapperFor("text/html"),
|
||||
},
|
||||
|
||||
css: {
|
||||
resultMapper: resultMapperFor("text/css"),
|
||||
},
|
||||
|
||||
gif: {
|
||||
resultMapper: resultMapperFor("image/gif"),
|
||||
},
|
||||
|
||||
jpg: {
|
||||
resultMapper: resultMapperFor("image/jpeg"),
|
||||
},
|
||||
|
||||
png: {
|
||||
resultMapper: resultMapperFor("image/png"),
|
||||
},
|
||||
|
||||
svg: {
|
||||
resultMapper: resultMapperFor("image/svg+xml"),
|
||||
},
|
||||
|
||||
js: {
|
||||
resultMapper: resultMapperFor("application/javascript"),
|
||||
},
|
||||
|
||||
woff2: {
|
||||
resultMapper: resultMapperFor("font/woff2"),
|
||||
},
|
||||
|
||||
ttf: {
|
||||
resultMapper: resultMapperFor("font/ttf"),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import type { Injectable, InjectionToken } from "@ogre-tools/injectable";
|
||||
import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
|
||||
import { Router } from "./router";
|
||||
import parseRequestInjectable from "./parse-request.injectable";
|
||||
import type { Route } from "./route";
|
||||
import createHandlerForRouteInjectable from "./create-handler-for-route.injectable";
|
||||
|
||||
export const routeInjectionToken = getInjectionToken<Route<unknown, string>>({
|
||||
id: "route-injection-token",
|
||||
});
|
||||
|
||||
export function getRouteInjectable<T, Path extends string>(
|
||||
opts: Omit<Injectable<Route<T, Path>, Route<T, Path>, void>, "lifecycle" | "injectionToken">,
|
||||
) {
|
||||
return getInjectable({
|
||||
...opts,
|
||||
injectionToken: routeInjectionToken as unknown as InjectionToken<Route<T, Path>, void>,
|
||||
});
|
||||
}
|
||||
|
||||
const routerInjectable = getInjectable({
|
||||
id: "router",
|
||||
|
||||
instantiate: (di) => new Router({
|
||||
parseRequest: di.inject(parseRequestInjectable),
|
||||
routes: di.injectMany(routeInjectionToken),
|
||||
createHandlerForRoute: di.inject(createHandlerForRouteInjectable),
|
||||
}),
|
||||
});
|
||||
|
||||
export default routerInjectable;
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import routerInjectable, { routeInjectionToken } from "./router.injectable";
|
||||
import { getDiForUnitTesting } from "../getDiForUnitTesting";
|
||||
import type { Router } from "./router";
|
||||
import type { Cluster } from "../../common/cluster/cluster";
|
||||
import { Request } from "mock-http";
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import type { AsyncFnMock } from "@async-fn/jest";
|
||||
import asyncFn from "@async-fn/jest";
|
||||
import parseRequestInjectable from "./parse-request.injectable";
|
||||
import { contentTypes } from "./router-content-types";
|
||||
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
|
||||
import type { Route } from "./route";
|
||||
import type { SetRequired } from "type-fest";
|
||||
import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable";
|
||||
import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable";
|
||||
import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
describe("router", () => {
|
||||
let router: Router;
|
||||
let routeHandlerMock: AsyncFnMock<() => any>;
|
||||
|
||||
beforeEach(async () => {
|
||||
routeHandlerMock = asyncFn();
|
||||
|
||||
const di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||
|
||||
di.override(parseRequestInjectable, () => () => Promise.resolve({
|
||||
payload: "some-payload",
|
||||
mime: "some-mime",
|
||||
}));
|
||||
di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
|
||||
di.override(kubectlBinaryNameInjectable, () => "kubectl");
|
||||
di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64");
|
||||
di.override(normalizedPlatformInjectable, () => "darwin");
|
||||
|
||||
const injectable = getInjectable({
|
||||
id: "some-route",
|
||||
|
||||
instantiate: () => ({
|
||||
method: "get",
|
||||
path: "/some-path",
|
||||
handler: routeHandlerMock,
|
||||
} as Route<any, string>),
|
||||
|
||||
injectionToken: routeInjectionToken,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
di.register(injectable);
|
||||
});
|
||||
|
||||
router = di.inject(routerInjectable);
|
||||
});
|
||||
|
||||
describe("when navigating to the route", () => {
|
||||
let actualPromise: Promise<boolean>;
|
||||
let clusterStub: Cluster;
|
||||
let requestStub: SetRequired<Request, "url" | "method">;
|
||||
let responseStub: any;
|
||||
|
||||
beforeEach(() => {
|
||||
requestStub = new Request({
|
||||
url: "/some-path",
|
||||
method: "get",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
}) as SetRequired<Request, "url" | "method">;
|
||||
|
||||
responseStub = { end: jest.fn(), setHeader: jest.fn(), write: jest.fn(), statusCode: undefined };
|
||||
|
||||
clusterStub = {} as Cluster;
|
||||
|
||||
actualPromise = router.route(clusterStub, requestStub, responseStub);
|
||||
});
|
||||
|
||||
it("calls handler with the request", () => {
|
||||
expect(routeHandlerMock).toHaveBeenCalledWith({
|
||||
cluster: clusterStub,
|
||||
params: {},
|
||||
path: "/some-path",
|
||||
payload: "some-payload",
|
||||
query: expect.any(URLSearchParams),
|
||||
raw: { req: requestStub, res: responseStub },
|
||||
});
|
||||
});
|
||||
|
||||
it("given no content-type is specified, when handler resolves, resolves with JSON", async () => {
|
||||
await routeHandlerMock.resolve({ response: "some-response-from-route-handler" });
|
||||
|
||||
await actualPromise;
|
||||
|
||||
expect(responseStub.setHeader.mock.calls).toEqual([
|
||||
["Content-Type", "application/json"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("given JSON content-type is specified, when handler resolves with object, resolves with JSON", async () => {
|
||||
await routeHandlerMock.resolve({ response: { some: "object" }});
|
||||
|
||||
await actualPromise;
|
||||
|
||||
expect(responseStub.end).toHaveBeenCalledWith('{"some":"object"}');
|
||||
});
|
||||
|
||||
describe("when handler resolves without any result", () => {
|
||||
beforeEach(async () => {
|
||||
await routeHandlerMock.resolve(undefined);
|
||||
|
||||
await actualPromise;
|
||||
});
|
||||
|
||||
it("resolves as plain text", () => {
|
||||
expect(responseStub.setHeader.mock.calls).toEqual([["Content-Type", "text/plain"]]);
|
||||
});
|
||||
|
||||
it("resolves with status code for no content", async () => {
|
||||
expect(responseStub.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it("resolves without content", async () => {
|
||||
expect(responseStub.end.mock.calls).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when handler rejects", () => {
|
||||
beforeEach(async () => {
|
||||
await routeHandlerMock.reject(new Error("some-error"));
|
||||
|
||||
await actualPromise;
|
||||
});
|
||||
|
||||
it("resolves as plain text", () => {
|
||||
expect(responseStub.setHeader.mock.calls).toEqual([["Content-Type", "text/plain"]]);
|
||||
});
|
||||
|
||||
it('resolves with "500" status code', () => {
|
||||
expect(responseStub.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it("resolves with error", () => {
|
||||
expect(responseStub.end).toHaveBeenCalledWith("Error: some-error");
|
||||
});
|
||||
});
|
||||
|
||||
[
|
||||
{ contentType: "text/plain", contentTypeObject: contentTypes.txt },
|
||||
{ contentType: "application/json", contentTypeObject: contentTypes.json },
|
||||
{ contentType: "text/html", contentTypeObject: contentTypes.html },
|
||||
{ contentType: "text/css", contentTypeObject: contentTypes.css },
|
||||
{ contentType: "image/gif", contentTypeObject: contentTypes.gif },
|
||||
{ contentType: "image/jpeg", contentTypeObject: contentTypes.jpg },
|
||||
{ contentType: "image/png", contentTypeObject: contentTypes.png },
|
||||
{ contentType: "image/svg+xml", contentTypeObject: contentTypes.svg },
|
||||
{ contentType: "application/javascript", contentTypeObject: contentTypes.js },
|
||||
{ contentType: "font/woff2", contentTypeObject: contentTypes.woff2 },
|
||||
{ contentType: "font/ttf", contentTypeObject: contentTypes.ttf },
|
||||
].forEach(scenario => {
|
||||
describe(`given content type is "${scenario.contentType}", when handler resolves with response`, () => {
|
||||
beforeEach(async () => {
|
||||
await routeHandlerMock.resolve({ response: "some-response", contentType: scenario.contentTypeObject });
|
||||
|
||||
await actualPromise;
|
||||
});
|
||||
|
||||
it("has content type specific headers", () => {
|
||||
expect(responseStub.setHeader.mock.calls).toEqual([
|
||||
["Content-Type", scenario.contentType],
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults to successful status code", () => {
|
||||
expect(responseStub.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("has response as body", () => {
|
||||
expect(responseStub.end).toHaveBeenCalledWith("some-response");
|
||||
});
|
||||
});
|
||||
|
||||
it(`given content type is "${scenario.contentType}", when handler resolves with success and custom status code, defaults to "200" as status code`, async () => {
|
||||
await routeHandlerMock.resolve({
|
||||
response: "some-response",
|
||||
statusCode: 204,
|
||||
contentType: scenario.contentTypeObject,
|
||||
});
|
||||
|
||||
await actualPromise;
|
||||
|
||||
expect(responseStub.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it(`given content type is "${scenario.contentType}", when handler resolves with success but without status code, defaults to "200" as status code`, async () => {
|
||||
await routeHandlerMock.resolve({
|
||||
response: "some-response",
|
||||
contentType: scenario.contentTypeObject,
|
||||
});
|
||||
|
||||
await actualPromise;
|
||||
|
||||
expect(responseStub.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
|
||||
it(`given content type is "${scenario.contentType}", when handler resolves without response, has no body`, async () => {
|
||||
await routeHandlerMock.resolve({
|
||||
response: undefined,
|
||||
contentType: scenario.contentTypeObject,
|
||||
});
|
||||
|
||||
await actualPromise;
|
||||
|
||||
expect(responseStub.end.mock.calls).toEqual([[]]);
|
||||
});
|
||||
|
||||
it(`given content type is "${scenario.contentType}", when handler resolves with error, has error as body`, async () => {
|
||||
await routeHandlerMock.resolve({
|
||||
error: "some-error",
|
||||
contentType: scenario.contentTypeObject,
|
||||
});
|
||||
|
||||
await actualPromise;
|
||||
|
||||
expect(responseStub.end).toHaveBeenCalledWith("some-error");
|
||||
});
|
||||
|
||||
it(`given content type is "${scenario.contentType}", when handler resolves with error and status code, has custom status code`, async () => {
|
||||
await routeHandlerMock.resolve({
|
||||
error: "some-error",
|
||||
statusCode: 414,
|
||||
contentType: scenario.contentTypeObject,
|
||||
});
|
||||
|
||||
await actualPromise;
|
||||
|
||||
expect(responseStub.statusCode).toBe(414);
|
||||
});
|
||||
|
||||
it(`given content type is "${scenario.contentType}", when handler resolves with error but without status code, defaults to "400" as status code`, async () => {
|
||||
await routeHandlerMock.resolve({
|
||||
error: "some-error",
|
||||
contentType: scenario.contentTypeObject,
|
||||
});
|
||||
|
||||
await actualPromise;
|
||||
|
||||
expect(responseStub.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it(`given content type is "${scenario.contentType}", when handler resolves custom headers, resolves with content type specific headers and custom headers`, async () => {
|
||||
await routeHandlerMock.resolve({
|
||||
response: "irrelevant",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "some-content-type-to-be-overridden",
|
||||
"Some-Header": "some-header-value",
|
||||
},
|
||||
|
||||
contentType: scenario.contentTypeObject,
|
||||
});
|
||||
|
||||
await actualPromise;
|
||||
|
||||
expect(responseStub.setHeader.mock.calls).toEqual([
|
||||
["Content-Type", scenario.contentType],
|
||||
["Some-Header", "some-header-value"],
|
||||
]);
|
||||
|
||||
});
|
||||
|
||||
describe(`given content type is "${scenario.contentType}", when handler resolves with binary content`, () => {
|
||||
let responseBufferStub: Buffer;
|
||||
|
||||
beforeEach(async () => {
|
||||
responseBufferStub = Buffer.from("some-binary-content");
|
||||
|
||||
await routeHandlerMock.resolve({
|
||||
response: responseBufferStub,
|
||||
contentType: scenario.contentTypeObject,
|
||||
});
|
||||
|
||||
await actualPromise;
|
||||
});
|
||||
|
||||
it("writes binary content to response", () => {
|
||||
expect(responseStub.write).toHaveBeenCalledWith(responseBufferStub);
|
||||
});
|
||||
|
||||
it("does not end with the response", () => {
|
||||
expect(responseStub.end.mock.calls[0]).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import Call from "@hapi/call";
|
||||
import type http from "http";
|
||||
import type { Cluster } from "../../common/cluster/cluster";
|
||||
import type { LensApiRequest, Route } from "./route";
|
||||
import type { ServerIncomingMessage } from "../lens-proxy/lens-proxy";
|
||||
import type { ParseRequest } from "./parse-request.injectable";
|
||||
import type { CreateHandlerForRoute, RouteHandler } from "./create-handler-for-route.injectable";
|
||||
|
||||
export interface RouterRequestOpts {
|
||||
req: http.IncomingMessage;
|
||||
res: http.ServerResponse;
|
||||
cluster: Cluster | undefined;
|
||||
params: Partial<Record<string, string>>;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
parseRequest: ParseRequest;
|
||||
createHandlerForRoute: CreateHandlerForRoute;
|
||||
readonly routes: Route<unknown, string>[];
|
||||
}
|
||||
|
||||
export class Router {
|
||||
private readonly router = new Call.Router<RouteHandler>();
|
||||
|
||||
constructor(private readonly dependencies: Dependencies) {
|
||||
for (const route of this.dependencies.routes) {
|
||||
this.router.add({ method: route.method, path: route.path }, this.dependencies.createHandlerForRoute(route));
|
||||
}
|
||||
}
|
||||
|
||||
public async route(cluster: Cluster | undefined, req: ServerIncomingMessage, res: http.ServerResponse): Promise<boolean> {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const path = url.pathname;
|
||||
const method = req.method.toLowerCase();
|
||||
const matchingRoute = this.router.route(method, path);
|
||||
|
||||
if (matchingRoute instanceof Error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params });
|
||||
|
||||
await matchingRoute.route(request, res);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async getRequest(opts: RouterRequestOpts): Promise<LensApiRequest<string>> {
|
||||
const { req, res, url, cluster, params } = opts;
|
||||
const { payload } = await this.dependencies.parseRequest(req, null, {
|
||||
parse: true,
|
||||
output: "data",
|
||||
});
|
||||
|
||||
return {
|
||||
cluster,
|
||||
path: url.pathname,
|
||||
raw: {
|
||||
req, res,
|
||||
},
|
||||
query: url.searchParams,
|
||||
payload,
|
||||
params,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user