UNPKG

@webda/core

Version:

Expose API with Lambda

419 lines 16.2 kB
import uriTemplates from "uri-templates"; /** * Manage Route resolution * @category CoreFeatures */ export class Router { constructor(webda) { this.routes = new Map(); this.initiated = false; this.models = new Map(); this.webda = webda; } /** * Registration of a model * @param model * @param url */ registerModelUrl(model, url) { this.models.set(model, url); } /** * Return the route for model * @param model * @returns */ getModelUrl(model) { if (typeof model !== "string") { model = this.webda.getApplication().getModelName(model); } return this.models.get(model); } /** * Include prefix to the url if not present * @param url * @returns */ getFinalUrl(url) { // We have to replace all @ by %40 as it is allowed in url rfc (https://www.rfc-editor.org/rfc/rfc3986#page-22) // But disallowed in https://www.rfc-editor.org/rfc/rfc6570#section-3.2.1 // Similar for / in query string url = url.replace(/@/g, "%40"); if (url.includes("?")) { url = url.substring(0, url.indexOf("?")) + "?" + url.substring(url.indexOf("?") + 1).replace(/\//g, "%2F"); } const prefix = this.webda.getGlobalParams().routePrefix || ""; if (prefix && url.startsWith(prefix)) { return url; } // Absolute url if (url.startsWith("//")) { return url.substring(1); } return `${prefix}${url}`; } /** * Return routes * @returns */ getRoutes() { return this.routes; } /** * Add a route dynamicaly * * @param {String} url of the route can contains dynamic part like {uuid} * @param {Object} info the type of executor */ addRoute(url, info) { const finalUrl = this.getFinalUrl(url); this.webda.log("TRACE", `Add route ${info.methods.join(",")} ${finalUrl}`); info.openapi ?? (info.openapi = {}); if (this.routes[finalUrl]) { // If route is already added do not do anything if (this.routes[finalUrl].includes(info)) { return; } // Check and add warning if same method is used let methods = this.routes[finalUrl].map((r) => r.methods).flat(); info.methods.forEach(m => { if (methods.indexOf(m) >= 0) { if (!info.override) { this.webda.log("WARN", `${m} ${finalUrl} overlap with another defined route`); } } }); // Last added need to be overriding this.routes[finalUrl].unshift(info); } else { this.routes[finalUrl] = [info]; } if (this.initiated) { this.remapRoutes(); } } /** * Remove a route dynamicly * * @param {String} url to remove */ removeRoute(url, info = undefined) { const finalUrl = this.getFinalUrl(url); if (!info) { delete this.routes[finalUrl]; } else if (this.routes[finalUrl] && this.routes[finalUrl].includes(info)) { this.routes[finalUrl].splice(this.routes[finalUrl].indexOf(info), 1); } this.remapRoutes(); } /** * Reinit all routes * * It will readd the URITemplates if needed * Sort all routes again */ remapRoutes() { // Might need to ensure each routes is prefixed const prefix = this.webda.getGlobalParams().routePrefix || ""; if (prefix) { Object.keys(this.routes) .filter(k => !k.startsWith(prefix)) .forEach(k => { this.routes[this.getFinalUrl(k)] = this.routes[k]; delete this.routes[k]; }); } this.initURITemplates(this.routes); // Order path desc this.pathMap = []; for (let i in this.routes) { // Might need to trail the query string this.routes[i].forEach((config) => { this.pathMap.push({ url: i, config }); }); } this.pathMap.sort(this.comparePath); this.initiated = true; } comparePath(a, b) { // Normal node works with localeCompare but not Lambda... // Local compare { to a return: 26 on Lambda let bs = b.url.replace(/\{[^{}]+}/, "{}").split("/"); let as = a.url.replace(/\{[^{}]+}/, "{}").split("/"); for (let i in as) { if (bs[i] === undefined) return -1; if (as[i] === bs[i]) continue; if (as[i][0] === "{" && bs[i][0] !== "{") return 1; if (as[i][0] !== "{" && bs[i][0] === "{") return -1; return bs[i] < as[i] ? -1 : 1; } return 1; } /** * @hidden */ initURITemplates(config) { // Prepare tbe URI parser for (let map in config) { if (map.indexOf("{") !== -1) { config[map].forEach((e) => { let idx = map.indexOf("{?"); let queryOptional = true; if (idx >= 0) { let query = map.substring(idx + 2, map.length - 1); e._queryParams = []; query.split(",").forEach(q => { if (q.endsWith("*")) { e._queryCatchAll = q.substring(0, q.length - 1); return; } else if (q.endsWith("+")) { e._queryCatchAll = q.substring(0, q.length - 1); queryOptional = false; return; } else if (q.endsWith("?")) { e._queryParams.push({ name: q.substring(0, q.length - 1), required: false }); } else { queryOptional = false; e._queryParams.push({ name: q, required: true }); } }); // We do not use uri-templates for query parsing //map = map.substring(0, idx) + "?{+URITemplateQuery}"; const templates = [uriTemplates(map.substring(0, idx) + "?{+URITemplateQuery}")]; let pathTemplate = uriTemplates(map.substring(0, idx)); if (queryOptional) { templates.push(pathTemplate); } e._uriTemplateParse = { fromUri: (url) => { return templates.reduce((v, t) => (v ? v : t.fromUri(url)), undefined); }, varNames: [...pathTemplate.varNames, ...e._queryParams.map(q => q.name)] }; } else { e._uriTemplateParse = uriTemplates(map); } }); } } } /** * Get all method for a specific url * @param config * @param method * @param url */ getRouteMethodsFromUrl(url) { const finalUrl = this.getFinalUrl(url); let methods = new Set(); for (let i in this.pathMap) { const routeUrl = this.pathMap[i].url; const map = this.pathMap[i].config; if (routeUrl !== finalUrl && (map._uriTemplateParse === undefined || map._uriTemplateParse.fromUri(finalUrl, { strict: true }) === undefined)) { continue; } map.methods.forEach(m => methods.add(m)); } return Array.from(methods); } /** * Get the route from a method / url */ getRouteFromUrl(ctx, method, url) { const finalUrl = this.getFinalUrl(url); let parameters = this.webda.getConfiguration().parameters; for (let i in this.pathMap) { const routeUrl = this.pathMap[i].url; const map = this.pathMap[i].config; // Check method if (map.methods.indexOf(method) === -1) { continue; } if (routeUrl === finalUrl) { ctx.setServiceParameters(parameters); return map; } if (map._uriTemplateParse === undefined) { continue; } const parse_result = map._uriTemplateParse.fromUri(finalUrl, { strict: true }); if (parse_result !== undefined) { let parseUrl = new URL(`http://localhost${finalUrl}`); if (map._queryCatchAll) { parse_result[map._queryCatchAll] = {}; parseUrl.searchParams.forEach((v, k) => { if (!map._queryParams?.find(q => q.name === k)) { parse_result[map._queryCatchAll][k] = v; } }); } // Check for each params let mandatoryParams = true; map._queryParams?.forEach(q => { if (!parseUrl.searchParams.has(q.name)) { mandatoryParams && (mandatoryParams = !q.required); return; } parse_result[q.name] = parseUrl.searchParams.get(q.name); }); // Skip if we miss mandatory params if (!mandatoryParams) { continue; } if (parse_result.URITemplateQuery) { delete parse_result.URITemplateQuery; } ctx.setServiceParameters(parameters); ctx.setPathParameters(parse_result); return map; } } } getOpenAPISchema(schema) { if (!schema) { return { $ref: "#/components/schemas/Object" }; } else if (typeof schema === "string") { return { $ref: "#/components/schemas/" + schema }; } return schema; } /** * Add all known routes to paths * * @param openapi to complete * @param skipHidden add hidden routes or not */ completeOpenAPI(openapi, skipHidden = true) { let hasTag = tag => openapi.tags.find(t => t.name === tag) !== undefined; for (let i in this.routes) { this.routes[i].forEach((route) => { route.openapi = this.webda .getApplication() .replaceVariables(route.openapi || {}, this.webda.getService(route.executor).getOpenApiReplacements()); if (route.openapi.hidden && skipHidden) { return; } route.openapi.hidden = false; let path = i; if (i.indexOf("{?") >= 0) { path = i.substring(0, i.indexOf("{?")); } openapi.paths[path] = openapi.paths[path] || {}; if (route._uriTemplateParse) { openapi.paths[path].parameters = []; route._uriTemplateParse.varNames.forEach(varName => { const queryParam = route._queryParams?.find(i => i.name === varName); if (queryParam) { let name = varName; if (name.startsWith("*")) { name = name.substr(1); } openapi.paths[path].parameters.push({ name, in: "query", required: queryParam.required, schema: { type: "string" } }); return; } openapi.paths[path].parameters.push({ // ^[a-zA-Z0-9._$-]+$] is the official regex of AWS name: varName.replace(/[^a-zA-Z0-9._$-]/g, ""), in: "path", required: true, schema: { type: "string" } }); }); } route.methods.forEach(method => { var _a; let responses; let schema; let description; let summary; let operationId; let requestBody; let tags = route.openapi.tags ?? []; // Refactor here if (route.openapi[method.toLowerCase()]) { responses = route.openapi[method.toLowerCase()].responses; schema = this.getOpenAPISchema(route.openapi[method.toLowerCase()].schemas?.output); description = route.openapi[method.toLowerCase()].description; summary = route.openapi[method.toLowerCase()].summary; operationId = route.openapi[method.toLowerCase()].operationId; tags.push(...(route.openapi[method.toLowerCase()].tags || [])); requestBody = route.openapi[method.toLowerCase()].requestBody; } responses = responses || { 200: { description: "Operation success" } }; for (let j in responses) { // Add default answer let code = parseInt(j); if (code < 300 && code >= 200 && !responses[j].description) { responses[j].description = "Operation success"; (_a = responses[j]).content ?? (_a.content = {}); responses[j].content["application/json"] = { schema }; } } // Add the service name if no tags are defined if (tags.length === 0) { tags.push(route.executor); } let desc = { tags, responses: responses, description, summary, operationId, requestBody }; if (method.toLowerCase().startsWith("p") && route.openapi[method.toLowerCase()]?.schemas?.input) { // Add request schema if exist desc.requestBody ?? (desc.requestBody = { content: { "application/json": { schema: this.getOpenAPISchema(route.openapi[method.toLowerCase()]?.schemas?.input) } } }); } openapi.paths[path][method.toLowerCase()] = desc; tags .filter(tag => !hasTag(tag)) .forEach(tag => openapi.tags.push({ name: tag })); }); }); } } } //# sourceMappingURL=router.js.map