UNPKG

@intuitionrobotics/thunderstorm

Version:
322 lines 12.7 kB
import { _keys, BadImplementationException, dispatch_onServerError, isErrorOfType, Logger, LogLevel, merge, MUSTNeverHappenException, ServerErrorSeverity, validate, ValidationException } from "@intuitionrobotics/ts-common"; import { Stream } from "stream"; import { parse } from "url"; import {} from "./HttpServer.js"; import {} from "http"; // noinspection TypeScriptPreferShortImport import { HttpMethod } from "../../../shared/types.js"; import { assertProperty } from "../../utils/to-be-removed.js"; import { ApiException } from "../../exceptions.js"; import {} from "../../utils/types.js"; import { RemoteProxy } from "../proxy/RemoteProxy.js"; import { Storm } from "../../core/Storm.js"; export class ServerApi extends Logger { static isDebug; printResponse = true; headersToLog = []; method; // The api's own URL fragment, used by dynamic-endpoint-array sites // (e.g. DB API generator output) that iterate a list and mount each // at its own path. For static endpoints declared in the routes file, // leave this empty — the routes file owns the path. relativePath; middlewares; bodyValidator; queryValidator; scopes = []; constructor(method, relativePath = "", tag) { super(tag || relativePath || method); this.setMinLevel(ServerApi.isDebug ? LogLevel.Verbose : LogLevel.Info); this.method = method; this.relativePath = `${relativePath}`; } // The Express RequestHandler form of this endpoint. Pass it straight to // your routes file: `v1.post("/register", register.handler)`. Stored on // the instance after first access so repeated reads are O(1). _handler; get handler() { if (!this._handler) this._handler = (req, res) => this.call(req, res); return this._handler; } shouldPrintResponse() { return this.printResponse; } ; setMiddlewares(...middlewares) { this.middlewares = middlewares; return this; } addMiddlewares(...middlewares) { this.middlewares = [...(this.middlewares || []), ...middlewares]; return this; } setScopes(...scopes) { this.scopes = [...scopes]; return this; } getScopes() { return this.scopes; } addHeaderToLog(...headersToLog) { this.headersToLog.push(...headersToLog); } setBodyValidator(bodyValidator) { this.bodyValidator = bodyValidator; } setQueryValidator(queryValidator) { this.queryValidator = queryValidator; } asProxy() { return new ServerApi_Proxy(this); } dontPrintResponse() { this.printResponse = false; } setMaxResponsePrintSize(printResponseMaxSizeBytes) { this.printResponse = printResponseMaxSizeBytes > -1; } assertProperty = assertProperty; async call(req, res) { const response = new ApiResponse(this, res); this.logInfo(`Intercepted Url: ${req.path}`); if (this.headersToLog.length > 0) { const headers = {}; for (const headerName of this.headersToLog) { headers[headerName] = req.header(headerName); } this.logDebug(`-- Headers: `, headers); } const reqQuery = parse(req.url, true).query; if (reqQuery && typeof reqQuery === "object" && Object.keys(reqQuery).length) this.logVerbose(`-- Url Params: `, reqQuery); else this.logVerbose(`-- No Params`); const body = req.body; if (body && ((typeof body === "object"))) this.logVerbose(`-- Body (Object): `, body); else if (body && body.length) this.logVerbose(`-- Body (String): `, body); else this.logVerbose(`-- No Body`); const requestData = { method: this.method, originalUrl: req.path, headers: req.headers, url: req.url, query: reqQuery, body: body }; try { if (this.bodyValidator) validate(body, this.bodyValidator); if (this.queryValidator) validate(reqQuery, this.queryValidator); const context = await this.applyMiddlewares(req, requestData, response, this.scopes); const toReturn = await this.process(req, response, reqQuery, body, context); if (response.isConsumed()) return; if (!toReturn) return await response.end(200); // TODO need to handle stream and buffers // if (Buffer.isBuffer(toReturn)) // return response.stream(200, toReturn as Buffer); const responseType = typeof toReturn; if (responseType === "object") return await response.json(200, toReturn); if (responseType === "string" && toReturn.toLowerCase().startsWith("<html")) return await response.html(200, toReturn); return await response.text(200, toReturn); } catch (err) { let dispatchError = true; let e = err; let severity = ServerErrorSeverity.Warning; if (typeof e === "string") e = new BadImplementationException(`String was thrown: ${e}`); if (!(e instanceof Error) && typeof e === "object") e = new BadImplementationException(`Object instance was thrown: ${JSON.stringify(e)}`); try { this.logErrorBold(e); } catch (e2) { this.logErrorBold("Error while handling error on request...", e2); this.logErrorBold(`Original error thrown: ${JSON.stringify(e)}`); this.logErrorBold(`-- Someone was stupid... you MUST only throw an Error and not objects or strings!! --`); } if (isErrorOfType(e, ValidationException)) e = new ApiException(400, "Validator exception", e); if (!isErrorOfType(e, ApiException)) e = new ApiException(500, "Unexpected server error", e); const apiException = isErrorOfType(e, ApiException); if (!apiException) throw new MUSTNeverHappenException("MUST NEVER REACH HERE!!!"); dispatchError = apiException.getDispatchError(); if (apiException.responseCode >= 500) severity = ServerErrorSeverity.Error; else if (apiException.responseCode >= 400) severity = ServerErrorSeverity.Warning; switch (apiException.responseCode) { case 401: severity = ServerErrorSeverity.Debug; break; case 404: severity = ServerErrorSeverity.Info; break; case 403: severity = ServerErrorSeverity.Warning; break; case 500: severity = ServerErrorSeverity.Critical; break; } if (dispatchError) { try { const storm = Storm.getInstance(); if (storm) { const message = await storm.errorMessageComposer(requestData, apiException); await dispatch_onServerError.dispatchModuleAsync(severity, message); } } catch (e) { this.logError("Error while handing server error", e); } } if (apiException.responseCode === 500) return response.serverError(apiException); return response.exception(apiException); } } async applyMiddlewares(req, requestData, response, scopes) { if (!this.middlewares) return {}; const contextList = await Promise.all(this.middlewares.map(async (m) => m(req, requestData, response, scopes))); return contextList.reduce((acc, c) => merge(acc, c || {}), {}); } } // ServerApi_Get / ServerApi_Post were dropped — every endpoint now extends // ServerApi<Binder> directly and passes its HttpMethod to super(). export class ServerApi_Proxy extends ServerApi { api; constructor(api) { super(api.method, `${api.relativePath}/proxy`); this.api = api; this.setMiddlewares(RemoteProxy.Middleware); } async process(request, response, queryParams, body, context) { // @ts-expect-error TS struggles with this dynamic typing return this.api.process(request, response, queryParams, body, context); } } export class ServerApi_Redirect extends ServerApi { responseCode; redirectUrl; constructor(apiName, responseCode, redirectUrl) { super(HttpMethod.ALL, apiName); this.responseCode = responseCode; this.redirectUrl = redirectUrl; } async process(request, response, queryParams, _body) { const query = queryParams ? _keys(queryParams).reduce((c, k) => c + '&' + k + '=' + queryParams[k], '?') : ''; // v2.0: `redirectUrl` must be either absolute or Express-relative. // Pre-v2 the framework prefixed it with HttpServer.config.baseUrl — // callers that relied on that should now pass the full URL themselves. response.redirect(this.responseCode, `${this.redirectUrl}${query}`); } } export class ApiResponse { api; res; consumed = false; constructor(api, res) { this.api = api; this.res = res; } isConsumed() { return this.consumed; } consume() { if (this.consumed) { this.api.logError("This API was already satisfied!!", new Error()); return; } this.consumed = true; } stream(responseCode, stream, headers) { this.consume(); this.printHeaders(headers); this.res.set(headers); this.res.writeHead(responseCode); stream.pipe(this.res, { end: false }); stream.on('end', () => { this.res.end(); }); } printHeaders(headers) { if (!headers) return this.api.logVerbose(` -- No response headers`); this.api.logVerbose(` -- Response with headers: `, headers); } printResponse(response) { if (!response) return this.api.logVerbose(` -- No response body`); if (!this.api.shouldPrintResponse()) return this.api.logVerbose(` -- Response: -- Not Printing --`); this.api.logVerbose(` -- Response:`, response); } code(responseCode, headers) { this.printHeaders(headers); this.end(responseCode, "", headers); } text(responseCode, response, _headers) { const headers = (_headers || {}); headers["content-type"] = "text/plain"; this.end(responseCode, response, headers); } html(responseCode, response, _headers) { const headers = (_headers || {}); headers["content-type"] = "text/html"; this.end(responseCode, response, headers); } json(responseCode, response, _headers) { this._json(responseCode, response, _headers); } _json(responseCode, response, _headers) { const headers = (_headers || {}); headers["content-type"] = "application/json"; this.end(responseCode, response, headers); } end(responseCode, response, headers) { this.consume(); this.printHeaders(headers); this.printResponse(response); this.res.set(headers); this.res.writeHead(responseCode); this.res.end(typeof response !== "string" ? JSON.stringify(response, null, 2) : response); } setHeaders(headers) { this.res.header(headers); } setHeader(headerKey, value) { this.res.header(headerKey, value); } getHeader(headerKey) { return this.res.get(headerKey); } redirect(responseCode, url) { this.consume(); this.res.redirect(responseCode, url); } exception(exception, headers) { const responseBody = exception.responseBody; if (!ServerApi.isDebug) delete responseBody.debugMessage; this._json(exception.responseCode, responseBody, headers); } serverError(error, headers) { const stack = error.cause ? error.cause.stack : error.stack; const message = (error.cause ? error.cause.message : error.message) || ""; this.text(500, ServerApi.isDebug && stack ? stack : message, headers); } } //# sourceMappingURL=server-api.js.map