UNPKG

@webda/core

Version:

Expose API with Lambda

884 lines (881 loc) 35.4 kB
import { Core, JSONUtils, Service, ServiceParameters, WebdaError, WebdaQL } from "../index.js"; import { TransformCase } from "../utils/case.js"; /** * @WebdaModda */ export class ModelsOperationsService extends Service { /** * Add operations for all exposed models * @returns */ initOperations() { super.initOperations(); const app = this.getWebda().getApplication(); // Add default schemas if (!app.hasSchema("uuidRequest")) { app.registerSchema("uuidRequest", { type: "object", properties: { uuid: { type: "string" } }, required: ["uuid"] }); } if (!app.hasSchema("searchRequest")) { app.registerSchema("searchRequest", { type: "object", properties: { query: { type: "string" } } }); } const getSchema = (id, input, params) => { const schema = { type: "object", properties: {}, required: [] }; if (input && app.hasSchema(input)) { schema.properties["input"] = app.getSchema(input); schema.required.push("input"); } if (params) { schema.properties["params"] = app.getSchema(params); schema.required.push("params"); } app.registerSchema(id, schema); return id; }; const models = app.getModels(); for (let modelKey in models) { const model = models[modelKey]; const expose = model.Expose; if (!expose) { continue; } const prefix = modelKey.split("/").pop(); const modelSchema = modelKey; ["create", "update"] .filter(k => !expose.restrict[k]) .forEach(k => { k = k.substring(0, 1).toUpperCase() + k.substring(1); const id = `${prefix}.${k}`; this.getWebda().registerOperation(id, { service: this.getName(), method: k === "Create" ? "httpCreate" : "operationUpdate", id, input: getSchema(id, modelSchema, k === "Update" ? "uuidRequest" : undefined), output: modelSchema }); }); ["delete", "get"] .filter(k => !expose.restrict[k]) .forEach(k => { k = k.substring(0, 1).toUpperCase() + k.substring(1); const id = `${prefix}.${k}`; const info = { service: this.getName(), method: `http${k}`, id, input: getSchema(id, undefined, "uuidRequest") }; if (k === "Get") { info.output = modelSchema; } this.getWebda().registerOperation(id, info); }); if (!expose.restrict.query) { const id = `${prefix}.Query`; this.getWebda().registerOperation(id, { service: this.getName(), method: "httpQuery", id, input: getSchema(id, undefined, "searchRequest") }); } // Add patch if (!expose.restrict.update) { const id = `${prefix}.Patch`; this.getWebda().registerOperation(id, { service: this.getName(), method: "operationPatch", id, input: getSchema(id, modelSchema + "?", "uuidRequest") }); } // Add all operations for Actions let actions = model.getActions(); Object.keys(actions).forEach(name => { const id = `${prefix}.${name.substring(0, 1).toUpperCase() + name.substring(1)}`; const info = { service: this.getName(), method: `action${name}`, id }; info.input = getSchema(id, `${modelKey}.${name}.input`, "uuidRequest"); info.output = `${modelKey}.${name}.output`; this.getWebda().registerOperation(id, info); }); } } } export class DomainServiceParameters extends ServiceParameters { constructor(params) { super(params); // Init default here this.operations ?? (this.operations = true); this.nameTransfomer ?? (this.nameTransfomer = "camelCase"); this.queryMethod ?? (this.queryMethod = "PUT"); this.models ?? (this.models = ["*"]); this.excludedModels = this.models.filter(i => i.startsWith("!")).map(i => i.substring(1)); // Only contains excluded models so a wildcard is implied if (this.models.length === this.excludedModels.length) { this.models = ["*"]; } } /** * Is a model is included in the service * @param model * @returns */ isIncluded(model) { return !this.isExcluded(model) && (this.models.includes("*") || this.models.includes(model)); } /** * Is a model excluded from the service * @param model * @returns */ isExcluded(model) { return this.excludedModels.includes(model); } } /** * Swagger static html */ const swagger = ` <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.19.5/swagger-ui.css" > <style> .topbar { display: none; } </style> </head> <body> <div id="swagger-ui"></div> <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.19.5/swagger-ui-bundle.js"> </script> <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.19.5/swagger-ui-standalone-preset.js"> </script> <script> const spec = {{OPENAPI}}; window.onload = function() { const ui = SwaggerUIBundle({ spec: spec, dom_id: '#swagger-ui', deepLinking: true, presets: [ SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset ], plugins: [ SwaggerUIBundle.plugins.DownloadUrl ], layout: "StandaloneLayout" }) window.ui = ui } </script> </body> </html> `; /** * Domain Service expose all the models as Route and Operations * * Model are exposed if they have a Expose decorator * * Children models Exposed should be under the first ModelRelated targetting them or the segment endpoint of Expose * * Other relations (ModelLinks, ModelParent) should only display their information but not be exposed * ModelRelated should be ignored */ export class DomainService extends Service { /** * Return the model name for this service * @param name * @returns * * @see https://blog.boot.dev/clean-code/casings-in-coding/#:~:text=%F0%9F%94%97%20Camel%20Case,Go */ transformName(name) { return TransformCase(name, this.parameters.nameTransfomer); } /** * Explore the models * @param model * @param name * @param depth * @param modelContext * @returns */ walkModel(model, name, depth = 0, modelContext = {}) { // If not expose or not in the list of models if (!model.Expose || (model.getIdentifier && !this.parameters.isIncluded(model.getIdentifier()))) { return; } const context = JSONUtils.duplicate(modelContext); context.depth = depth; if (!this.handleModel(model, name, context)) { return; } const queries = this.app.getRelations(model).queries || []; // Get the children now (this.app.getRelations(model).children || []).forEach(name => { const childModel = this.app.getModel(name); const parentAttribute = this.app.getRelations(childModel)?.parent?.attribute; const segment = queries.find(q => q.model === name && q.targetAttribute === parentAttribute)?.attribute || this.app.getModelPlural(name); this.walkModel(childModel, segment, depth + 1, context); }); } /** * Your service is now created as all the other services */ resolve() { super.resolve(); this.app = this.getWebda().getApplication(); // Add all routes per model this.app.getRootExposedModels().forEach(name => { const model = this.app.getModel(name); this.walkModel(model, this.app.getModelPlural(name)); }); return this; } } /** * */ export class RESTDomainServiceParameters extends DomainServiceParameters { /** * Set default url to / * @param params */ constructor(params) { super(params); this.url ?? (this.url = "/"); } } /** * Expose all models via a REST API * @WebdaModda */ export class RESTDomainService extends DomainService { /** * Override to fallback on isDebug for exposeOpenAPI * @returns */ resolve() { var _a; (_a = this.parameters).exposeOpenAPI ?? (_a.exposeOpenAPI = this.getWebda().isDebug()); super.resolve(); if (this.parameters.exposeOpenAPI) { this.addRoute(".", ["GET"], this.openapi, { hidden: true }); } return this; } /** * @override */ loadParameters(params) { return new RESTDomainServiceParameters(params); } /** * Handle one model and expose it based on the service * @param model * @param name * @param context * @returns */ handleModel(model, name, context) { const depth = context.depth || 0; const relations = model.getRelations(); const injectAttribute = relations?.parent?.attribute; const app = this.getWebda().getApplication(); const injector = (service, method, type, ...args) => { return async (context) => { let parentId = `pid.${depth - 1}`; if (type === "SET" && injectAttribute && depth > 0) { (await context.getInput())[injectAttribute] = context.getPathParameters()[parentId]; } else if (type === "QUERY") { let input = await context.getInput({ defaultValue: { q: "" } }); let q; if (context.getHttpContext().getMethod() === "GET") { q = context.getParameters().q; } else { q = input.q; } let query; try { query = new WebdaQL.QueryValidator(q); } catch (err) { throw new WebdaError.BadRequest("Invalid query " + q); } if (injectAttribute) { query.merge(`${injectAttribute} = "${context.getPathParameters()[parentId]}"`); } if (args[0] !== 0) { query.merge(`__types CONTAINS "${model.getIdentifier()}"`); } if (context.getHttpContext().getMethod() === "GET") { context.getParameters().q = query.toString(); } else { input.q = query.toString(); } this.log("TRACE", `Query modified to '${query.toString()}' from '${q}' ${args[0]}`); } // Complete the uuid if needed if (context.getParameters().uuid) { context.getParameters().uuid = model.completeUid(context.getParameters().uuid); } await service[method](context, ...args); }; }; // Update prefix const prefix = (context.prefix || (this.parameters.url.endsWith("/") ? this.parameters.url : this.parameters.url + "/")) + this.transformName(name); context.prefix = prefix + `/{pid.${depth}}/`; // Register the model url this.getWebda().getRouter().registerModelUrl(app.getModelName(model), prefix); let openapi = { [this.parameters.queryMethod.toLowerCase()]: { tags: [name], summary: `Query ${name}`, operationId: `query${name}`, requestBody: this.parameters.queryMethod === "GET" ? undefined : { content: { "application/json": { schema: { properties: { q: { type: "string" } } } } } }, parameters: this.parameters.queryMethod === "GET" ? [ { name: "q", in: "query", description: "Query to execute", schema: { type: "string" } } ] : [], responses: { "200": { description: "Operation success", content: { "application/json": { schema: { properties: { continuationToken: { type: "string" }, results: { type: "array", items: { $ref: `#/components/schemas/${model.getIdentifier()}` } } } } } } }, "400": { description: "Query is invalid" }, "403": { description: "You don't have permissions" } } } }; model.Expose.restrict.query || this.addRoute(`${prefix}${this.parameters.queryMethod === "GET" ? "{?q?}" : ""}`, [this.parameters.queryMethod], injector(model.store(), "httpQuery", "QUERY", model.store().handleModel(model)), openapi); openapi = { post: { tags: [name], summary: `Create ${name}`, operationId: `create${name}`, requestBody: { content: { "application/json": { schema: { $ref: `#/components/schemas/${model.getIdentifier()}` } } } }, responses: { "200": { description: "Operation success", content: { "application/json": { schema: { $ref: `#/components/schemas/${model.getIdentifier()}` } } } }, "400": { description: "Invalid input" }, "403": { description: "You don't have permissions" }, "409": { description: "Object already exists" } } } }; model.Expose.restrict.create || this.addRoute(`${prefix}`, ["POST"], injector(model.store(), "operationCreate", "SET", model.getIdentifier()), openapi); openapi = { delete: { tags: [name], operationId: `delete${name}`, description: `Delete ${name} if the permissions allow`, summary: `Delete a ${name}`, responses: { "204": { description: "Operation success" }, "403": { description: "You don't have permissions" }, "404": { description: "Unknown object" } } } }; model.Expose.restrict.delete || this.addRoute(`${prefix}/{uuid}`, ["DELETE"], injector(model.store(), "httpDelete", "DELETE"), openapi); let openapiInfo = { tags: [name], operationId: `update${name}`, description: `Update ${name} if the permissions allow`, summary: `Update a ${name}`, requestBody: { content: { "application/json": { schema: { $ref: `#/components/schemas/${model.getIdentifier()}` } } } }, responses: { "204": { description: "Operation success" }, "400": { description: "Invalid input" }, "403": { description: "You don't have permissions" }, "404": { description: "Unknown object" } } }; openapi = { put: openapiInfo, patch: openapiInfo }; model.Expose.restrict.update || this.addRoute(`${prefix}/{uuid}`, ["PUT", "PATCH"], injector(model.store(), "httpUpdate", "SET"), openapi); openapi = { get: { tags: [name], description: `Retrieve ${name} model if permissions allow`, summary: `Retrieve a ${name}`, operationId: `get${name}`, responses: { "200": { content: { "application/json": { schema: { $ref: `#/components/schemas/${model.getIdentifier()}` } } } }, "400": { description: "Object is invalid" }, "403": { description: "You don't have permissions" }, "404": { description: "Unknown object" } } } }; model.Expose.restrict.get || this.addRoute(`${prefix}/{uuid}`, ["GET"], injector(model.store(), "httpGet", "GET"), openapi); // Add all actions // Actions cannot be restricted as its purpose is to be exposed let actions = model.getActions(); Object.keys(actions).forEach(actionName => { let action = actions[actionName]; openapi = { ...action.openapi }; (action.methods || ["PUT"]).forEach(method => { openapi[method.toLowerCase()] = { tags: [name], ...(action.openapi?.[method.toLowerCase()] ?? {}) }; }); if (app.hasSchema(`${model.getIdentifier(false)}.${actionName}.input`)) { Object.keys(openapi) .filter(k => ["get", "post", "put", "patch", "delete"].includes(k)) .forEach(k => { openapi[k].requestBody = { content: { "application/json": { schema: { $ref: `#/components/schemas/${model.getIdentifier(false)}.${actionName}.input` } } } }; }); } if (app.hasSchema(`${model.getIdentifier(false)}.${actionName}.output`)) { Object.keys(openapi) .filter(k => ["get", "post", "put", "patch", "delete"].includes(k)) .forEach(k => { var _a, _b; (_a = openapi[k]).responses ?? (_a.responses = {}); (_b = openapi[k].responses)["200"] ?? (_b["200"] = {}); openapi[k].responses["200"].content = { "application/json": { schema: { $ref: `#/components/schemas/${model.getIdentifier(false)}.${actionName}.output` } } }; }); } this.addRoute(action.global ? `${prefix}/${actionName}` : `${prefix}/{uuid}/${actionName}`, action.methods || ["PUT"], async (ctx) => { if (action.global) { return model.store().httpGlobalAction(ctx, model); } else { ctx.getParameters().uuid = model.completeUid(ctx.getParameters().uuid); return model.store().httpAction(ctx, action.method); } }, openapi); }); /* Binaries should expose several methods If cardinality is ONE GET to download the binary POST to upload a binary directly PUT to upload a binary with challenge DELETE /{hash} to delete a binary PUT /{hash} to update metadata GET /url to get a signed url If cardinality is MANY GET /{index} to download the binary GET /{index}/url to get a signed url POST to upload a binary directly PUT to upload a binary with challenge DELETE /{index}/{hash} to delete a binary PUT /{index}/{hash} to update metadata */ (relations.binaries || []).forEach(binary => { const store = Core.get().getBinaryStore(model, binary.attribute); const modelInjector = async (ctx) => { ctx.getParameters().model = model.getIdentifier(); ctx.getParameters().property = binary.attribute; await store.httpRoute(ctx); }; const modelInjectorChallenge = async (ctx) => { ctx.getParameters().model = model.getIdentifier(); ctx.getParameters().property = binary.attribute; await store.httpChallenge(ctx); }; const modelInjectorGet = async (ctx) => { ctx.getParameters().model = model.getIdentifier(); ctx.getParameters().property = binary.attribute; await store.httpGet(ctx); }; openapi = { put: { tags: [name], summary: `Upload ${binary.attribute} of ${name} after challenge`, description: `You will need to call the challenge method first to get a signed url to upload the content\nIf the data is already known then done is returned`, operationId: `upload${name}${binary.attribute}`, requestBody: { content: { "application/octet-stream": { schema: { type: "object", required: ["hash", "challenge"], properties: { hash: { type: "string", description: "md5(data)" }, challenge: { type: "string", description: "md5('Webda' + data)" }, size: { type: "number", description: "Size of the data" }, name: { type: "string", description: "Name of the file" }, mimetype: { type: "string", description: "Mimetype of the file" }, metadata: { type: "object", description: "Metadata to add to the binary" } } } } } }, responses: { "200": { description: "Operation success", content: { "application/octet-stream": { schema: { type: "object", properties: { done: { type: "boolean" }, url: { type: "string" }, method: { type: "string" }, md5: { type: "string", description: "base64 md5 of the data\nThe next request requires Content-MD5 to be added with this one along with Content-Type: 'application/octet-stream'" } } } } } }, "403": { description: "You don't have permissions" }, "404": { description: "Unknown object" } } } }; this.addRoute(`${prefix}/{uuid}/${binary.attribute}`, ["PUT"], modelInjectorChallenge, openapi); openapi = { post: { tags: [name], summary: `Upload ${binary.attribute} of ${name} directly`, description: `You can upload the content directly to the server\nThis is not the recommended way as it wont be able to optimize network traffic`, operationId: `upload${name}${binary.attribute}`, requestBody: { content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } } }, responses: { "204": { description: "" }, "403": { description: "You don't have permissions" }, "404": { description: "Object does not exist or attachment does not exist" } } } }; this.addRoute(`${prefix}/{uuid}/${binary.attribute}`, ["POST"], modelInjector, openapi); let rootUrl = `${prefix}/{uuid}/${binary.attribute}`; if (binary.cardinality === "MANY") { rootUrl += "/{index}"; } openapi = { get: { tags: [name], summary: `Download ${binary.attribute} of ${name}`, operationId: `download${name}${binary.attribute}`, responses: { "200": { description: "Operation success", content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } } }, "302": { description: "Redirect to the binary" }, "403": { description: "You don't have permissions" }, "404": { description: "Unknown object" } } } }; this.addRoute(`${rootUrl}`, ["GET"], modelInjectorGet, openapi); openapi = { delete: { tags: [name], summary: `Delete ${binary.attribute} of ${name}`, responses: { "204": { description: "" }, "403": { description: "You don't have permissions" }, "404": { description: "Object does not exist or attachment does not exist" }, "412": { description: "Provided hash does not match" } } } }; this.addRoute(`${rootUrl}/{hash}`, ["DELETE"], modelInjector, openapi); openapi = { put: { tags: [name], summary: `Update metadata of ${binary.attribute} of ${name}`, operationId: `update${name}${binary.attribute}`, requestBody: { content: { "application/json": { schema: { type: "object", description: "Metadata to add to the binary", additionalProperties: true } } } }, responses: { "204": { description: "" }, "400": { description: "Invalid input: metadata are limited to 4kb" }, "403": { description: "You don't have permissions" }, "404": { description: "Object does not exist or attachment does not exist" }, "412": { description: "Provided hash does not match" } } } }; this.addRoute(`${rootUrl}/{hash}`, ["PUT"], modelInjector, openapi); openapi = { get: { tags: [name], summary: `Get a signed url to download ${binary.attribute} of ${name} with a limited lifetime`, responses: { "200": { description: "Operation success", content: { "application/json": { schema: { type: "object", properties: { Location: { type: "string", description: "Signed url to download the binary" }, Map: { $ref: "#/components/schemas/BinaryMap" } } } } } }, "403": { description: "You don't have permissions" }, "404": { description: "Object does not exist or attachment does not exist" } } } }; this.addRoute(`${rootUrl}/url`, ["GET"], modelInjectorGet, openapi); }); return true; } /** * Serve the openapi with the swagger-ui * @param ctx */ async openapi(ctx) { this.openapiContent ?? (this.openapiContent = swagger.replace("{{OPENAPI}}", JSON.stringify(this.getWebda().exportOpenAPI(true)))); ctx.write(this.openapiContent); } } //# sourceMappingURL=domainservice.js.map