@expressive-analytics/deep-thought-service
Version:
Typescript conversion of Deep Thought Services (formerly providers)
613 lines (571 loc) • 18.7 kB
text/typescript
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;
}
}