UNPKG

@expressive-analytics/deep-thought-service

Version:

Typescript conversion of Deep Thought Services (formerly providers)

613 lines (571 loc) 18.7 kB
import {DTVerifier} from "./DTVerifier" import {DTResponse} from './DTResponse' import {DTSession} from './DTSession' import {DTBasicVerifier} from './DTBasicVerifier' import {DTModel,DTParams,DTStore,DTStorage, DTQueryBuilder} from '@expressive-analytics/deep-thought-js' export type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" export type DTAction = "get" | "list" | "count" | "create" | "create_many" | "update" | "update_many" | "upsert" | "remove" | "remove_all" export type DTRequest = { method:HTTPMethod, params:Record<string,any>, headers:Record<string,any> } export class DTHTTPError extends Error { code: number header: string message: string constructor(code, header, message?:string){ super(message) this.code = code this.header = header } } function empty(val?){ return val===undefined || val===null || val==="" } export class DTService { request:DTRequest protected $meta_model?:string protected $model?:typeof DTModel // the default model to use for automatic actions (or the name of a meta model) protected $entity_model:typeof DTModel // default model to use for entity actions protected $public?:string[] /** * determines which automatic actions are available, defaults to "read-only" * valid values include: get, list, count, create, create_many, * update, update_many, upsert, remove, remove_all */ protected automatic_actions:DTAction[] = [] protected archive_column?:string // flag this column instead of deleting rows protected lookup_column = "id" // unique "key" column for automatic actions, defaults to model's primary key protected creation_column // defines a column to store a creation timestamp protected modified_column // defines a column to store a modified timestamp params:DTParams session?:Record<string,any> response?:DTResponse verifier?:DTVerifier db?:DTStore constructor(verifier?:DTVerifier){ this.verifier = (verifier===undefined)?new DTBasicVerifier():verifier this.session = DTSession.shared() if(this.$meta_model !== undefined){ // try to instantiate the model via meta /// @TODO implement the meta // me.model = ... } } actionSessionDestroy(){ DTSession.destroy() } setParams(params:Record<string,any>){ this.params = new DTParams(params,this.db) } get model():typeof DTModel{ return this.$model } set model(val:typeof DTModel){ this.$model = val // make sure we have populated the lookup column if(this.lookup_column === undefined) this.lookup_column = this.$model.primaryKeyColumn() } //================== //! Request Handling //================== /** @name Request Handling * Session setup and action delivery */ ///@{ /** * A convenience method for handling a standard request and sending a response */ handleRequest(req:DTRequest,rsp:DTResponse){ if(this.model === undefined) throw new Error(`'${this.constructor.name} is missing model definition.`) this.response = rsp this.request = req this.setParams(req.params) const act = this.params.stringParam("act"); this.params.unset("act") const action = act===undefined||act==""?'':"action"+act.replace( /\w\S*/g, (txt)=>{ // title case return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); } ) try{ if(this.verifyRequest(action)) this.performAction(action); this.response.respond(this.params.allParams()); }catch(e){ if(e instanceof DTHTTPError){ this.response.sendHeader("HTTP/1.0 442 DTR Application Error"); this.setResponseCode(-1); this.response.respond("Request could not be verified."); } } } verifyRequest(action){ const token = this.params.stringParam("dttok") this.params.unset("dttok") const status = this.verifier.verify(action,token); this.db = this.verifier.db(); return status; } /** * performs an action by name * @param action - the action to perform (uses 'act' param key, if null) * @NOTE if action is the name of a method, the appropriate method is called */ protected performAction(action?){ action = (action!==undefined?action:this.params.stringParam("act")); if(this.actionExists(action)){ this.setResponse(this[action]()); this.recordRequest(action); }else console.warn("Action not found ({action})."); } setResponse(response){ this.response.setResponse(response); } responseCode(){ return this.response.error(); } /** @return returns null so that it may be chained with an action's return statement */ setResponseCode(code){ this.response.error(code); return null; } ///@} protected recordRequest(action){ // override in subclasses to customize logs } /** @internal */ protected actionExists(action){ try { if(typeof this[action] === 'function') return true }catch(e) {} return false; } //==================== //! Automatic Actions //==================== /** @name Automatic Actions * Default behavior for providers with a defined model */ ///@{ /** * get a MODEL * @http_method get * @param object dtf a querybuilder filter * @return ENTITY_MODEL returns an object */ actionGet(){ if(!this.isAutomatic("get")){ this.response.error(403); return "unauthorized action (get)"; } const me = (this.constructor as typeof DTService) const model:typeof DTModel = this.$entity_model!==undefined?this.$entity_model:this.model; let filter = this.enforce(this.advancedFilterBy(this.db.filter()),"get",model); const order = this.params.stringParam("dt_order"); if(order!==undefined){ const desc = this.params.boolParam("dt_desc",false); filter = this.orderBy(filter,[{order:order,desc:desc}]); }else{ const dto = this.params.arrayParam("dto"); filter = this.orderBy(filter,dto); } try{ return model.query(filter); }catch(e){ this.response.error(404); //404 Not Found } return null; } /** * get a random set of MODEL objects * @http_method GET * @param integer draws the number of objects to return * @param string wt_col the column or value to use for weighing picks * @param integer seed a seed for the random generator * @param object dtf a querybuilder filter * @return array[MODEL] returns a list of random objects */ actionPick(){ if(!this.isAutomatic("pick")){ this.response.error(403); return "unauthorized action (pick)"; } const model = this.model; const filter = this.enforce(this.advancedFilterBy(this.db.filter()),"pick",model); return model.pick(this.params.intParam("draws",1),filter,this.params.intParam('wt_col',1)); } /** * get a list of MODEL objects * @http_method get * @param object dtf a querybuilder filter * @param integer dt_ct number of records per page, triggers pagination mode * @param integer dt_pg page number for paginated results, triggers pagination mode * @param string dt_order the column to order by * @param boolean dt_desc determines whether records are in descending order * @param boolean dt_proxy determines whether has-many properties are expanded * @param string dt_q a basic search string * @param string dt_col the column to apply the basic search string dt_q * @return array[MODEL] returns a list of objects, possibly paginated */ actionList(){ if(!this.isAutomatic("list")){ this.response.error(403); return "unauthorized action (list)"; } this.response.as_proxy = typeof this.params["dt_proxy"]!=='undefined'?this.params.boolParam("dt_proxy"):true; const model = this.model; let filter = this.db.filter(); const like=this.params.stringParam("dt_q"); if(!empty(like)) filter = this.filterBy(filter,this.params.stringParam("dt_col","name"),like); filter = this.advancedFilterBy(filter); const order = this.params.stringParam("dt_order"); if(!empty(order)){ const desc = this.params.boolParam("dt_desc",false); filter = this.orderBy(filter,[{order,desc}]); }else if(typeof this.params["dto"] !== 'undefined'){ const dto = this.params.arrayParam("dto"); filter = this.orderBy(filter,dto); }else // MSSQL requires order-by for limit filter = this.orderBy(filter,[{order:model.primaryKeyColumn(),desc:false}]); if(!empty(this.archive_column)){ let p = {} p[`${model.dtType()}.${this.archive_column}`] = 0 filter.filter(p); } this.enforce(filter,"list"); const ct = this.params.intParam("dt_ct") if(ct > 0){ // dt_pg is set, we are paginating const page = this.params.intParam("dt_pg",1)-1; return { "total":model.count(filter), "items":model.select(filter.limit(ct,page*ct)) } }else return model.select(filter,model.dtType()+".*"); } /** override to customize simple filter behavior */ protected filterBy(qb,col,str){ const model = this.model; const matches = "{this.db.ilike} '%{str}%'"; let alias = model.aliasForOwner(col); if(empty(alias)) alias = model.dtType(); return qb.where(`${alias}.${col} ${matches}`); } /** override to customize advanced filter behavior */ protected advancedFilterBy(qb){ const dtf = this.params.objectParam("dtf"); const m = this.model; return m.filter(qb,dtf); } protected enforce(qb,action,model?){ if(!empty(model)) model = this.model; switch(action){ case "get": case "create": case "update": case "upsert": const id1 = this.params.stringParam(this.lookup_column); if(!empty(id1)){ let p = {} p[`${model.dtType()}.${this.lookup_column}`] = id1 qb.filter(p); } break; case "remove": const id = this.params.stringParam(this.lookup_column); if(!empty(id)){ let p = {} p[`${this.lookup_column}`] = id qb.filter(p) } break; case "remove_many": const ids = this.params.arrayParam(this.lookup_column); if(ids.length>0){ let p = {} p[`${model.dtType()}.${this.lookup_column}`] = ["IN",ids] qb.filter(p); } break; } return qb; } /** * @param order an array of order/desc pairs * @param desc string determines whether to sort in descending order * override to customize sorting behavior * @return returns the query builder with a new orderBy setting */ protected orderBy(qb:DTQueryBuilder,order):DTQueryBuilder{ if(order===undefined) return qb Object.entries(order).forEach(([i,o],index)=>{ qb.appendOrderBy(this.orderCol(qb,o["order"],i),typeof o["desc"] !== 'undefined'?o["desc"]:"ASC"); }) return qb; } protected orderCol(qb:DTQueryBuilder,order,uniq):string{ const path=order.split("."); const model = this.model let m:typeof DTModel = model let alias = m.aliasForOwner(order); if(!alias) alias = m.dtType() let col = path.pop() path.forEach((p,i)=>{ alias = m.join(qb,p,alias,"ob{uniq}") let x = m.modelFor(p) if(x instanceof DTModel) m = x as typeof DTModel else m = x[0] }) // we can pass an equation if we use the = prefix // this is not a good plan, cause injection //if(substr(col,0,1)=="=") // return substr(col,1); const esc = (qb.db.constructor as typeof DTStore).col_esc(); return `${alias}.${esc}${col}${esc}`; } /** * get a count of MODEL objects * @http_method GET * @param object dtf a querybuilder filter * @return integer returns the total count of matching objects */ actionCount(){ if(!this.isAutomatic("count")){ this.response.error(403); return "unauthorized action (count)"; } const model = this.model; let filter = this.enforce(this.db.filter(),"count"); if(!empty(this.archive_column)){ let p = {} p[`${model.dtType()}.${this.archive_column}`] = 0 filter.filter(p) } filter = this.advancedFilterBy(filter); return model.count(filter); } /** * create a new MODEL * @http_method POST * @param ENTITY_MODEL any valid model properties * @return ENTITY_MODEL the newly created object */ actionCreate(){ if(!this.isAutomatic("create")){ this.response.error(403); return "unauthorized action (create)"; } const model = !empty(this.$entity_model)?this.$entity_model:this.model; const qb = this.enforce(this.db.qb().fail(),"create",model); const params = this.params.allParams(); let creation_params = {}; if(!empty(this.creation_column)) // use the creation timestamp creation_params[this.creation_column] = (this.db.constructor as typeof DTStore).now(); if(!empty(this.modified_column)) // use the modified timestamp params[this.modified_column] = (this.db.constructor as typeof DTStore).now(); return model.upsert(qb,params,creation_params); } /** * create multiple MODEL objects * @http_method PUT * @param array a list of objects to create */ actionCreateMany(){ if(!this.isAutomatic("create_many")){ this.response.error(403); return "unauthorized action (create_many)"; } const params = this.params.params as Array<any>; //get raw, dirty params for(let p of params){ this.setParams(p); this.actionCreate(); } this.setParams(params); //restore the original params } /** * update a MODEL * @http_method POST * @param ENTITY_MODEL a subset of properties of the model to update * @return ENTITY_MODEL returns the updated object, or 404 if not found */ actionUpdate(){ if(!this.isAutomatic("update")){ this.response.error(403); return "unauthorized action (update)"; } const model = !empty(this.$entity_model)?this.$entity_model:this.model; try{ let filter = this.enforce(this.db.filter(),"update",model); const params = this.params.allParams(); if(!empty(this.modified_column)) // use the modified timestamp params[this.modified_column] = (this.db.constructor as typeof DTStore).now(); // this should fail if we don't exist const obj = model.query(filter); obj.clean(); obj.merge(params); obj.update(); const proxy = typeof this.params["dt_proxy"]!=='undefined'?this.params.boolParam("dt_proxy"):true; return model.byID(this.db,obj[model.primaryKeyColumn()]); }catch(e){ this.response.error(404); console.warn(e.message); return "record does not exist"; } } /** * update many MODEL objects * @http_method POST * @return array[ENTITY_MODEL] returns the updated objects */ actionUpdateMany(){ if(!this.isAutomatic("update_many")){ this.response.error(403); return "unauthorized action (update_many)"; } const params = this.params.params as Array<any>; //get raw, dirty params let objs = []; for(let p of params){ this.setParams(p); objs.push(this.actionUpdate()) } this.setParams(params); //restore the original params return objs; } /** * update or create a MODEL, as necessary * @http_method PATCH * @return MODEL returns the existing or newly created object */ actionUpsert(){ if(!this.isAutomatic("upsert")){ this.response.error(403); return "unauthorized action (upsert)"; } const model = this.model; const f = typeof this.params["dtf"] !== 'undefined' ?this.advancedFilterBy(this.db.filter()) :this.db.filter(this.params.allParams()); const filter = this.enforce(f,"upsert",model); let params = this.params.allParams(); let creation_params = {} if(!empty(this.creation_column)) // use the creation timestamp creation_params[this.creation_column] = (this.db.constructor as typeof DTStore).now(); if(!empty(this.modified_column)) // use the modified timestamp params[this.modified_column] = (this.db.constructor as typeof DTStore).now(); return model.upsert(filter,params,creation_params); } /** * loop through the param +dt_items+ and upsert * @http_method PATCH * @return array[MODEL] returns the upserted list */ actionUpsertMany(){ if(!this.isAutomatic("upsert_many")){ this.response.error(403); return "unauthorized action (upsert_many)"; } const model = this.model; let creation_params = {} const now = (this.db.constructor as typeof DTStore).now(); if(!empty(this.creation_column)) // use the creation timestamp creation_params[this.creation_column] = now; const arr = this.params.arrayParam("dt_items"); let out = [] for(let params of arr){ const f = model.filter(this.db.filter(),params); let filter = this.enforce(f,"upsert",model); if(!empty(this.modified_column)) // use the modified timestamp params[this.modified_column] = now; out.push(model.upsert(filter,params,creation_params)) } return out; } /** * remove a MODEL * @http_method DELETE * @param integer id the record to remove */ actionRemove(){ if(!this.isAutomatic("remove")){ this.response.error(403); return "unauthorized action (remove)"; } const model = this.model; const filter = this.enforce(this.advancedFilterBy(this.db.filter()),"remove",model); if(!empty(this.archive_column)){ let p = {} p[`${this.archive_column}`] = 1 model.updateRows(filter,p); } else model.deleteRows(filter); } /** * remove multiple MODEL * @http_method DELETE * @param array the list of records to delete */ actionRemoveMany(){ if(!this.isAutomatic("remove_many")){ this.response.error(403); return "unauthorized action (remove_many)"; } const model = this.model; const filter = this.enforce(this.advancedFilterBy(this.db.filter()),"remove_many",model); if(!empty(this.archive_column)){ let p = {} p[`${this.archive_column}`] = 1 model.updateRows(filter,p); } else model.deleteRows(filter); } ///@} static factory(p,db?:DTStore,verifier?:DTVerifier){ const cls:typeof DTService = (typeof p["class"]==='undefined'?this:p["class"]) if(db===undefined) db = (DTStorage.defaultStore() as DTStore) let svc if (typeof p["verifier"] === 'undefined') svc = new cls(verifier) else svc = new cls(new p["verifier"](db)) if(typeof p["model"] !== 'undefined') svc.model = p["model"] let actions = [] if(typeof p["access"] !== 'undefined'){ if(p["access"].indexOf("r")>-1) actions = [...actions,...["get","list","count","pick"]]; if(p["access"].indexOf("w")>-1) actions = [...actions,...["create","update","upsert","remove"]]; } if(svc.automatic_actions.length==0) // if we haven't specified... svc.automatic_actions = actions; if(!empty(p["creation_column"]) && empty(svc.creation_column)) svc.creation_column = p["creation_column"]; if(!empty(p["modified_column"]) && empty(svc.modified_column)) svc.modified_column = p["modified_column"]; if(!empty(p["archive_column"]) && empty(svc.archive_column)) svc.archive_column = p["archive_column"]; if(!empty(p["lookup_column"]) && empty(svc.lookup_column)) svc.lookup_column = p["lookup_column"]; svc.$public = p["type"] return svc; } isAutomatic(action){ return this.automatic_actions.indexOf(action)>-1; } }