A pre hook is a function of yours, in any of the languages of API Maker, which runs before an API. It sees the request, can change it, can answer instead of the API, and can refuse it. Attach it to a whole instance, a database, a table, one API, a custom API or a system API, and change it any time without a restart.
the hooks above, only for API users of the groups named on the hook
groupNames on the hook
Pre hooks run from the widest level to the narrowest : instance, database, table, API, each level in the order of its list. Post hooks run the other way round. See Post hooks.
In API Testing, the Pre Hooks tab of an API of a table lists every pre hook which runs before it, in that order : the hooks of its instance, its database and its table (marked Instance, Database, Table), then its own. The ones of the other levels are changed in their categories of API Testing (Instance Hooks, Database Hooks, Collection Hooks).
A hook with groupNames runs only when the API user of the call belongs to one of those groups. That is how one table gets a row scoping hook for the application users and none for the back office.
A hook is written in the language of its API. The code of an API and all its hooks are one language on one runtime : the request is handed to one sandbox, which runs the hooks and the API one after the other. The form of a hook shows that language with a lock. For an API of a table, the hooks of its instance, its database, its table and the API itself are one language.
import*asTfrom'types';import*asdbfrom'db-interfaces';asyncfunctionmain(g:T.IAMGlobal){if(!g.req.isApiRequestFromUser)return;// calls made by your own code : leave them alone// g.req.query, g.req.body, g.req.params, g.req.headers : the request, changeable// g.req.auth.authAMUser, g.req.auth.authAMDB… : who calls// g.res.shared : values for the API and the post hooks of the same request}module.exports=main;
fromamimportGasyncdefmain(g:G):ifnotg.req.is_api_request_from_user:return# calls made by your own code : leave them alone# g.req.query, g.req.body, g.req.params, g.req.headers : the request, changeable# g.req.auth.authAMUser, g.req.auth.authAMDB… : who calls# g.res.shared : values for the API and the post hooks of the same request
<?phpfunctionmain(AmG$g){if(!$g->req->isApiRequestFromUser){returnnull;// calls made by your own code : leave them alone}// $g->req->query, $g->req->body, $g->req->params, $g->req->headers : the request, changeable// $g->req->auth->authAMUser, $g->req->auth->authAMDB… : who calls// $g->res->shared : values for the API and the post hooks of the same requestreturnnull;}
filestaticclassApi{publicstaticasyncTask<object?>Main(Gg){if(!g.Req.IsApiRequestFromUser)returnnull;// calls made by your own code : leave them alone// g.Req.Query, g.Req.Body, g.Req.Params, g.Req.Headers : the request, changeable// g.Req.Auth["authAMUser"], g.Req.Auth["authAMDB"]… : who calls// g.Res.Shared : values for the API and the post hooks of the same requestreturnnull;}}
packagecodeimport"apimaker/am"funcMain(g*am.G)(any,error){if!g.Req.IsApiRequestFromUser(){returnnil,nil// calls made by your own code : leave them alone}// g.Req.Query(), g.Req.Body(), g.Req.Params(), g.Req.Headers() : the request, changeable// g.Req.Auth().Map("authAMUser"), g.Req.Auth().Map("authAMDB")… : who calls// g.Res.Shared() : values for the API and the post hooks of the same requestreturnnil,nil}
useam::{Errors,G,Value};pubfnmain(g:&mutG)->Result<Value,Errors>{if!g.req.is_api_request_from_user(){returnOk(Value::Null);// calls made by your own code : leave them alone}// g.req.query_mut(), g.req.body_mut(), g.req.params_mut(), g.req.headers_mut() : the request, changeable// g.req.auth()["authAMUser"], g.req.auth()["authAMDB"]… : who calls// g.res.shared_mut() : values for the API and the post hooks of the same requestOk(Value::Null)}
importam.G;classApi{staticObjectmain(Gg){if(!g.req.isApiRequestFromUser())returnnull;// calls made by your own code : leave them alone// g.req.getQuery(), g.req.getBody(), g.req.getParams(), g.req.getHeaders() : the request, changeable// g.req.getAuth().getObject("authAMUser"), g.req.getAuth().getObject("authAMDB")… : who calls// g.res.getShared() : values for the API and the post hooks of the same requestreturnnull;}}
importam.Gfunmain(g:G):Any?{if(!g.req.isApiRequestFromUser)returnnull// calls made by your own code : leave them alone// g.req.query, g.req.body, g.req.params, g.req.headers : the request, changeable// g.req.auth.getObject("authAMUser"), g.req.auth.getObject("authAMDB")… : who calls// g.res.shared : values for the API and the post hooks of the same requestreturnnull}
What the return value does :
The hook
Effect
returns nothing
The next hook, then the API, run.
returns a value
That value is the answer of the request : the remaining hooks and the API do not run. The response is 200 with the value in data.
sets g.res.output and returns nothing
The API runs ; its output replaces g.res.output unless a post hook changes it again.
throws
The request stops with the error : an exception gives 500 and its message ; the errors of API Maker give their own status (throw [{ code: 400, message: '…' }] in TypeScript, raise Errors('…', code=400) in Python, AmErrors in PHP, C#, Java and Kotlin, am.Fail(400, '…') in Go, am::fail(400, '…') in Rust).
A hook in any language other than TypeScript runs in the sandbox of its language. A hook in TypeScript runs inside API Maker when it needs nothing of a sandbox, else in its sandbox of Node.js or Bun ; the answer is the same. (runOnNativeProcess runs a hook on the machine of the server.) Every hook runs within the sandbox timeout of the call.
When the answer of an API comes from the cache, neither pre nor post hooks run.
Calls from your own code (g.sys.db.getAll in a custom API) skip the hooks by default. Pass skipHookRunning: false to run them.
A person must reach only their own rows. This is the hook the APIs Security Report installs with Add a row scoping pre-hook, here on orders, whose owner is in customer_id :
every read, update and delete gets the owner added to its filter, with $and, so a caller can not widen it ;
every write gets the owner stamped into its rows, so a caller can not hand a row to someone else ;
aggregate and distinct can not be scoped this way, and any API the hook does not know is refused : it fails closed.
import*asTfrom'types';/** * Row scoping pre-hook for orders, as the API Security Report writes it. * Every request is limited to the rows whose "customer_id" equals the id of the person behind it * (g.req.auth.authAMDB.customer_id). Edit freely : the report reads the code again on every scan. */constOWNER_COLUMN='customer_id';constIDENTITY_FIELD='customer_id';// Where each generated API keeps its filter. API Maker ANDs query.find onto the primary key for the *-by-id APIs.constQUERY_FIND_APIS=newSet<string>(['SCHEMA_GET_ALL','SCHEMA_GET_ALL_STREAM','SCHEMA_GET_BY_ID','SCHEMA_PUT_UPDATE_BY_ID','SCHEMA_PUT_REPLACE_BY_ID','SCHEMA_DEL_DELETE_BY_ID','GEN_GET_ALL','GEN_GET_ALL_STREAM','GEN_GET_BY_ID','GEN_PUT_UPDATE_BY_ID','GEN_PUT_REPLACE_BY_ID','GEN_DEL_DELETE_BY_ID',]);constBODY_FIND_APIS=newSet<string>(['SCHEMA_POST_QUERY','SCHEMA_POST_QUERY_STREAM','SCHEMA_POST_QUERY_DELETE','SCHEMA_UPDATE_MANY','SCHEMA_POST_DISTINCT_QUERY','SCHEMA_ARRAY_OPERATIONS','SCHEMA_POST_COUNT','GEN_POST_QUERY','GEN_POST_QUERY_STREAM','GEN_POST_QUERY_DELETE','GEN_UPDATE_MANY','GEN_POST_DISTINCT_QUERY','GEN_ARRAY_OPERATIONS','GEN_POST_COUNT',]);constSTAMP_OWNER_APIS=newSet<string>(['SCHEMA_POST_BULK_INSERT','SCHEMA_MASTER_SAVE','GEN_POST_BULK_INSERT','GEN_MASTER_SAVE',]);asyncfunctionmain(g:T.IAMGlobal){// Custom APIs, schedulers and events calling g.sys.db run under their own rules, only real callers are scoped.if(!g.req.isApiRequestFromUser)return;constperson=g.req.auth?.authAMDB;constme=person?person[IDENTITY_FIELD]:undefined;if(me===undefined||me===null||me===''){g.res.statusCode=T.EStatusCode.UNAUTHORIZED;thrownewError('A person token is required for this API.');}constscope={[OWNER_COLUMN]:me};// MERGE, never replace : $and keeps what the caller asked for and adds the owner on top, so a caller// can never widen the rows by sending its own owner filter.constmerge=(existing:any)=>(existing&&typeofexisting==='object'&&Object.keys(existing).length?{$and:[existing,scope]}:{...scope});conststamp=(row:any)=>{if(row&&typeofrow==='object')row[OWNER_COLUMN]=me;};constapiId=g.req.reqInfo?.apiInfo?.id;if(QUERY_FIND_APIS.has(apiId)){g.req.query.find=merge(g.req.query.find);stamp(g.req.body);// update-by-id and replace-by-id : the owner of a row can not be handed overreturn;}if(BODY_FIND_APIS.has(apiId)){g.req.body=g.req.body||{};g.req.body.find=merge(g.req.body.find);if(g.req.body.updateData)stamp(g.req.body.updateData);// update-manyreturn;}if(STAMP_OWNER_APIS.has(apiId)){constrows=Array.isArray(g.req.body)?g.req.body:[g.req.body];for(constrowofrows)stamp(row);// never trust the owner sent by the callerreturn;}// Fail closed : aggregate and distinct can not be scoped generically, so they are refused for row scoped callers.g.res.statusCode=T.EStatusCode.FORBIDDEN;thrownewError(`API ${apiId} is not available for row scoped users.`);}module.exports=main;
fromamimportErrors,G,Obj# Row scoping pre-hook for orders, as the API Security Report writes it.# Every request is limited to the rows whose "customer_id" equals the id of the person behind it# (g.req.auth.authAMDB.customer_id). Edit freely : the report reads the code again on every scan.OWNER_COLUMN='customer_id'IDENTITY_FIELD='customer_id'# Where each generated API keeps its filter. API Maker ANDs query.find onto the primary key for the *-by-id APIs.QUERY_FIND_APIS={'SCHEMA_GET_ALL','SCHEMA_GET_ALL_STREAM','SCHEMA_GET_BY_ID','SCHEMA_PUT_UPDATE_BY_ID','SCHEMA_PUT_REPLACE_BY_ID','SCHEMA_DEL_DELETE_BY_ID','GEN_GET_ALL','GEN_GET_ALL_STREAM','GEN_GET_BY_ID','GEN_PUT_UPDATE_BY_ID','GEN_PUT_REPLACE_BY_ID','GEN_DEL_DELETE_BY_ID',}BODY_FIND_APIS={'SCHEMA_POST_QUERY','SCHEMA_POST_QUERY_STREAM','SCHEMA_POST_QUERY_DELETE','SCHEMA_UPDATE_MANY','SCHEMA_POST_DISTINCT_QUERY','SCHEMA_ARRAY_OPERATIONS','SCHEMA_POST_COUNT','GEN_POST_QUERY','GEN_POST_QUERY_STREAM','GEN_POST_QUERY_DELETE','GEN_UPDATE_MANY','GEN_POST_DISTINCT_QUERY','GEN_ARRAY_OPERATIONS','GEN_POST_COUNT',}STAMP_OWNER_APIS={'SCHEMA_POST_BULK_INSERT','SCHEMA_MASTER_SAVE','GEN_POST_BULK_INSERT','GEN_MASTER_SAVE',}asyncdefmain(g:G):# Custom APIs, schedulers and events calling g.sys.db run under their own rules, only real callers are scoped.ifnotg.req.is_api_request_from_user:returnperson=g.req.auth.authAMDBme=person.get(IDENTITY_FIELD)ifisinstance(person,dict)elseNoneifmeisNoneorme=='':raiseErrors('A person token is required for this API.',code=401)scope={OWNER_COLUMN:me}# MERGE, never replace : $and keeps what the caller asked for and adds the owner on top, so a caller# can never widen the rows by sending its own owner filter.defmerge(existing):return{'$and':[existing,scope]}ifisinstance(existing,dict)andexistingelsedict(scope)api_id=g.req.req_info.apiInfo.idifapi_idinQUERY_FIND_APIS:g.req.query['find']=merge(g.req.query.get('find'))ifisinstance(g.req.body,dict):g.req.body[OWNER_COLUMN]=me# update-by-id and replace-by-id : the owner of a row can not be handed overreturnifapi_idinBODY_FIND_APIS:ifnotisinstance(g.req.body,dict):g.req.body=Obj()g.req.body['find']=merge(g.req.body.get('find'))ifisinstance(g.req.body.get('updateData'),dict):g.req.body['updateData'][OWNER_COLUMN]=me# update-manyreturnifapi_idinSTAMP_OWNER_APIS:rows=g.req.bodyifisinstance(g.req.body,list)else[g.req.body]forrowinrows:ifisinstance(row,dict):row[OWNER_COLUMN]=me# never trust the owner sent by the callerreturn# Fail closed : aggregate and distinct can not be scoped generically, so they are refused for row scoped callers.raiseErrors(f'API {api_id} is not available for row scoped users.',code=403)
<?php// Row scoping pre-hook for orders, as the API Security Report writes it.// Every request is limited to the rows whose "customer_id" equals the id of the person behind it// (g.req.auth.authAMDB.customer_id). Edit freely : the report reads the code again on every scan.constOWNER_COLUMN='customer_id';constIDENTITY_FIELD='customer_id';// Where each generated API keeps its filter. API Maker ANDs query.find onto the primary key for the *-by-id APIs.constQUERY_FIND_APIS=['SCHEMA_GET_ALL','SCHEMA_GET_ALL_STREAM','SCHEMA_GET_BY_ID','SCHEMA_PUT_UPDATE_BY_ID','SCHEMA_PUT_REPLACE_BY_ID','SCHEMA_DEL_DELETE_BY_ID','GEN_GET_ALL','GEN_GET_ALL_STREAM','GEN_GET_BY_ID','GEN_PUT_UPDATE_BY_ID','GEN_PUT_REPLACE_BY_ID','GEN_DEL_DELETE_BY_ID',];constBODY_FIND_APIS=['SCHEMA_POST_QUERY','SCHEMA_POST_QUERY_STREAM','SCHEMA_POST_QUERY_DELETE','SCHEMA_UPDATE_MANY','SCHEMA_POST_DISTINCT_QUERY','SCHEMA_ARRAY_OPERATIONS','SCHEMA_POST_COUNT','GEN_POST_QUERY','GEN_POST_QUERY_STREAM','GEN_POST_QUERY_DELETE','GEN_UPDATE_MANY','GEN_POST_DISTINCT_QUERY','GEN_ARRAY_OPERATIONS','GEN_POST_COUNT',];constSTAMP_OWNER_APIS=['SCHEMA_POST_BULK_INSERT','SCHEMA_MASTER_SAVE','GEN_POST_BULK_INSERT','GEN_MASTER_SAVE',];functionmain(AmG$g){// Custom APIs, schedulers and events calling g.sys.db run under their own rules, only real callers are scoped.if(!$g->req->isApiRequestFromUser){returnnull;}$person=$g->req->auth->authAMDB;$me=$personinstanceofAmObject?$person->get(IDENTITY_FIELD):null;if($me===null||$me===''){thrownewAmErrors('A person token is required for this API.',401);}$scope=[OWNER_COLUMN=>$me];// MERGE, never replace : $and keeps what the caller asked for and adds the owner on top, so a caller// can never widen the rows by sending its own owner filter.$merge=fn($existing)=>($existinginstanceofAmObject||is_array($existing))&&count($existing)>0?['$and'=>[$existing,$scope]]:$scope;$apiId=$g->req->reqInfo->apiInfo->id;if(in_array($apiId,QUERY_FIND_APIS,true)){$g->req->query->find=$merge($g->req->query->find);if($g->req->bodyinstanceofAmObject){$g->req->body[OWNER_COLUMN]=$me;// update-by-id and replace-by-id : the owner of a row can not be handed over}returnnull;}if(in_array($apiId,BODY_FIND_APIS,true)){if(!($g->req->bodyinstanceofAmObject)){$g->req->body=newAmObject([]);}$g->req->body->find=$merge($g->req->body->find);if($g->req->body->updateDatainstanceofAmObject){$g->req->body->updateData[OWNER_COLUMN]=$me;// update-many}returnnull;}if(in_array($apiId,STAMP_OWNER_APIS,true)){$rows=is_array($g->req->body)?$g->req->body:[$g->req->body];foreach($rowsas$row){if($rowinstanceofAmObject){$row[OWNER_COLUMN]=$me;// never trust the owner sent by the caller}}returnnull;}// Fail closed : aggregate and distinct can not be scoped generically, so they are refused for row scoped callers.thrownewAmErrors("API $apiId is not available for row scoped users.",403);}
// Row scoping pre-hook for orders, as the API Security Report writes it.// Every request is limited to the rows whose "customer_id" equals the id of the person behind it// (g.req.auth.authAMDB.customer_id). Edit freely : the report reads the code again on every scan.filestaticclassApi{conststringOwnerColumn="customer_id";conststringIdentityField="customer_id";// Where each generated API keeps its filter. API Maker ANDs query.find onto the primary key for the *-by-id APIs.staticreadonlyHashSet<string>QueryFindApis=[ "SCHEMA_GET_ALL", "SCHEMA_GET_ALL_STREAM", "SCHEMA_GET_BY_ID", "SCHEMA_PUT_UPDATE_BY_ID", "SCHEMA_PUT_REPLACE_BY_ID", "SCHEMA_DEL_DELETE_BY_ID", "GEN_GET_ALL", "GEN_GET_ALL_STREAM", "GEN_GET_BY_ID", "GEN_PUT_UPDATE_BY_ID", "GEN_PUT_REPLACE_BY_ID", "GEN_DEL_DELETE_BY_ID", ];staticreadonlyHashSet<string>BodyFindApis=[ "SCHEMA_POST_QUERY", "SCHEMA_POST_QUERY_STREAM", "SCHEMA_POST_QUERY_DELETE", "SCHEMA_UPDATE_MANY", "SCHEMA_POST_DISTINCT_QUERY", "SCHEMA_ARRAY_OPERATIONS", "SCHEMA_POST_COUNT", "GEN_POST_QUERY", "GEN_POST_QUERY_STREAM", "GEN_POST_QUERY_DELETE", "GEN_UPDATE_MANY", "GEN_POST_DISTINCT_QUERY", "GEN_ARRAY_OPERATIONS", "GEN_POST_COUNT", ];staticreadonlyHashSet<string>StampOwnerApis=[ "SCHEMA_POST_BULK_INSERT", "SCHEMA_MASTER_SAVE", "GEN_POST_BULK_INSERT", "GEN_MASTER_SAVE", ];publicstaticasyncTask<object?>Main(Gg){// Custom APIs, schedulers and events calling g.sys.db run under their own rules, only real callers are scoped.if(!g.Req.IsApiRequestFromUser)returnnull;varme=g.Req.Auth.GetObject("authAMDB")[IdentityField];if(meisnull||me.ToString()=="")thrownewAmErrors("A person token is required for this API.",401);JsonObjectScope()=>new(){[OwnerColumn]=me.DeepClone()};// MERGE, never replace : $and keeps what the caller asked for and adds the owner on top, so a caller// can never widen the rows by sending its own owner filter.JsonNodeMerge(JsonNode?existing)=>existingisJsonObject{Count:>0}?newJsonObject{["$and"]=newJsonArray(existing.DeepClone(),Scope())}:Scope();varapiId=g.Req.ReqInfo.GetObject("apiInfo").GetString("id")??"";if(QueryFindApis.Contains(apiId)){g.Req.Query["find"]=Merge(g.Req.Query["find"]);varbody=g.Req["body"];if(bodyisJsonObject)body[OwnerColumn]=me.DeepClone();// update-by-id and replace-by-id : the owner of a row can not be handed overreturnnull;}if(BodyFindApis.Contains(apiId)){g.Req.Body["find"]=Merge(g.Req.Body["find"]);varupdateData=g.Req.Body["updateData"];if(updateDataisJsonObject)updateData[OwnerColumn]=me.DeepClone();// update-manyreturnnull;}if(StampOwnerApis.Contains(apiId)){varbody=g.Req["body"];if(bodyisJsonArrayrows){foreach(varrowinrows){if(rowisJsonObject)row[OwnerColumn]=me.DeepClone();// never trust the owner sent by the caller}}elseif(bodyisJsonObject)body[OwnerColumn]=me.DeepClone();returnnull;}// Fail closed : aggregate and distinct can not be scoped generically, so they are refused for row scoped callers.thrownewAmErrors($"API {apiId} is not available for row scoped users.",403);}}
packagecodeimport("fmt""apimaker/am")// Row scoping pre-hook for orders, as the API Security Report writes it.// Every request is limited to the rows whose "customer_id" equals the id of the person behind it// (g.req.auth.authAMDB.customer_id). Edit freely : the report reads the code again on every scan.constownerColumn="customer_id"constidentityField="customer_id"// Where each generated API keeps its filter. API Maker ANDs query.find onto the primary key for the *-by-id APIs.varqueryFindApis=map[string]bool{"SCHEMA_GET_ALL":true,"SCHEMA_GET_ALL_STREAM":true,"SCHEMA_GET_BY_ID":true,"SCHEMA_PUT_UPDATE_BY_ID":true,"SCHEMA_PUT_REPLACE_BY_ID":true,"SCHEMA_DEL_DELETE_BY_ID":true,"GEN_GET_ALL":true,"GEN_GET_ALL_STREAM":true,"GEN_GET_BY_ID":true,"GEN_PUT_UPDATE_BY_ID":true,"GEN_PUT_REPLACE_BY_ID":true,"GEN_DEL_DELETE_BY_ID":true,}varbodyFindApis=map[string]bool{"SCHEMA_POST_QUERY":true,"SCHEMA_POST_QUERY_STREAM":true,"SCHEMA_POST_QUERY_DELETE":true,"SCHEMA_UPDATE_MANY":true,"SCHEMA_POST_DISTINCT_QUERY":true,"SCHEMA_ARRAY_OPERATIONS":true,"SCHEMA_POST_COUNT":true,"GEN_POST_QUERY":true,"GEN_POST_QUERY_STREAM":true,"GEN_POST_QUERY_DELETE":true,"GEN_UPDATE_MANY":true,"GEN_POST_DISTINCT_QUERY":true,"GEN_ARRAY_OPERATIONS":true,"GEN_POST_COUNT":true,}varstampOwnerApis=map[string]bool{"SCHEMA_POST_BULK_INSERT":true,"SCHEMA_MASTER_SAVE":true,"GEN_POST_BULK_INSERT":true,"GEN_MASTER_SAVE":true,}funcMain(g*am.G)(any,error){// Custom APIs, schedulers and events calling g.sys.db run under their own rules, only real callers are scoped.if!g.Req.IsApiRequestFromUser(){returnnil,nil}me:=g.Req.Auth().Map("authAMDB").Get(identityField)ifme==nil||me==""{returnnil,am.Fail(401,"A person token is required for this API.")}// MERGE, never replace : $and keeps what the caller asked for and adds the owner on top, so a caller// can never widen the rows by sending its own owner filter.merge:=func(existingam.M)am.M{iflen(existing)>0{returnam.M{"$and":[]any{existing,am.M{ownerColumn:me}}}}returnam.M{ownerColumn:me}}apiID:=g.Req.ReqInfo().Map("apiInfo").String("id")switch{casequeryFindApis[apiID]:query:=g.Req.Query()query["find"]=merge(query.Map("find"))ifbody:=g.Req.Map("body");body!=nil{body[ownerColumn]=me// update-by-id and replace-by-id : the owner of a row can not be handed over}returnnil,nilcasebodyFindApis[apiID]:body:=g.Req.Body()body["find"]=merge(body.Map("find"))ifupdateData:=body.Map("updateData");updateData!=nil{updateData[ownerColumn]=me// update-many}returnnil,nilcasestampOwnerApis[apiID]:rows:=g.Req.List("body")ifrows==nil{rows=[]any{g.Req.Get("body")}}for_,row:=rangerows{iffields:=am.AsM(row);row!=nil{fields[ownerColumn]=me// never trust the owner sent by the caller}}returnnil,nil}// Fail closed : aggregate and distinct can not be scoped generically, so they are refused for row scoped callers.returnnil,am.Fail(403,fmt.Sprintf("API %s is not available for row scoped users.",apiID))}
// Row scoping pre-hook for orders, as the API Security Report writes it.// Every request is limited to the rows whose "customer_id" equals the id of the person behind it// (g.req.auth.authAMDB.customer_id). Edit freely : the report reads the code again on every scan.useam::{Errors,G,Value,json};constOWNER_COLUMN:&str="customer_id";constIDENTITY_FIELD:&str="customer_id";// Where each generated API keeps its filter. API Maker ANDs query.find onto the primary key for the *-by-id APIs.constQUERY_FIND_APIS:[&str;12]=["SCHEMA_GET_ALL","SCHEMA_GET_ALL_STREAM","SCHEMA_GET_BY_ID","SCHEMA_PUT_UPDATE_BY_ID","SCHEMA_PUT_REPLACE_BY_ID","SCHEMA_DEL_DELETE_BY_ID","GEN_GET_ALL","GEN_GET_ALL_STREAM","GEN_GET_BY_ID","GEN_PUT_UPDATE_BY_ID","GEN_PUT_REPLACE_BY_ID","GEN_DEL_DELETE_BY_ID",];constBODY_FIND_APIS:[&str;14]=["SCHEMA_POST_QUERY","SCHEMA_POST_QUERY_STREAM","SCHEMA_POST_QUERY_DELETE","SCHEMA_UPDATE_MANY","SCHEMA_POST_DISTINCT_QUERY","SCHEMA_ARRAY_OPERATIONS","SCHEMA_POST_COUNT","GEN_POST_QUERY","GEN_POST_QUERY_STREAM","GEN_POST_QUERY_DELETE","GEN_UPDATE_MANY","GEN_POST_DISTINCT_QUERY","GEN_ARRAY_OPERATIONS","GEN_POST_COUNT",];constSTAMP_OWNER_APIS:[&str;4]=["SCHEMA_POST_BULK_INSERT","SCHEMA_MASTER_SAVE","GEN_POST_BULK_INSERT","GEN_MASTER_SAVE",];pubfnmain(g:&mutG)->Result<Value,Errors>{// Custom APIs, schedulers and events calling g.sys.db run under their own rules, only real callers are scoped.if!g.req.is_api_request_from_user(){returnOk(Value::Null);}letme=g.req.auth()["authAMDB"][IDENTITY_FIELD].clone();ifme.is_null()||me==""{returnErr(am::fail(401,"A person token is required for this API."));}letscope=json!({OWNER_COLUMN:me.clone()});// MERGE, never replace : $and keeps what the caller asked for and adds the owner on top, so a caller// can never widen the rows by sending its own owner filter.letmerge=|existing:&Value|matchexisting{Value::Object(find)if!find.is_empty()=>json!({"$and":[existing,scope]}),_=>scope.clone(),};letapi_id=g.req.req_info()["apiInfo"]["id"].as_str().unwrap_or_default().to_owned();ifQUERY_FIND_APIS.contains(&api_id.as_str()){letfind=merge(&g.req.query()["find"]);g.req.query_mut()["find"]=find;letbody=g.req.body_mut();ifbody.is_object(){body[OWNER_COLUMN]=me.clone();// update-by-id and replace-by-id : the owner of a row can not be handed over}returnOk(Value::Null);}ifBODY_FIND_APIS.contains(&api_id.as_str()){letfind=merge(&g.req.body()["find"]);g.req.body_mut()["find"]=find;ifletSome(update_data)=g.req.body_mut().get_mut("updateData").filter(|update_data|update_data.is_object()){update_data[OWNER_COLUMN]=me.clone();// update-many}returnOk(Value::Null);}ifSTAMP_OWNER_APIS.contains(&api_id.as_str()){letrows:Vec<&mutValue>=matchg.req.body_mut(){Value::Array(rows)=>rows.iter_mut().collect(),row=>vec![row],};forrowinrows{ifrow.is_object(){row[OWNER_COLUMN]=me.clone();// never trust the owner sent by the caller}}returnOk(Value::Null);}// Fail closed : aggregate and distinct can not be scoped generically, so they are refused for row scoped callers.Err(am::fail(403,format!("API {api_id} is not available for row scoped users.")))}
// Row scoping pre-hook for orders, as the API Security Report writes it.// Every request is limited to the rows whose "customer_id" equals the id of the person behind it// (g.req.auth.authAMDB.customer_id). Edit freely : the report reads the code again on every scan.importam.AmErrors;importam.G;importam.JsonObject;importjava.util.List;importjava.util.Map;importjava.util.Set;classApi{staticfinalStringOWNER_COLUMN="customer_id";staticfinalStringIDENTITY_FIELD="customer_id";// Where each generated API keeps its filter. API Maker ANDs query.find onto the primary key for the *-by-id APIs.staticfinalSet<String>QUERY_FIND_APIS=Set.of("SCHEMA_GET_ALL","SCHEMA_GET_ALL_STREAM","SCHEMA_GET_BY_ID","SCHEMA_PUT_UPDATE_BY_ID","SCHEMA_PUT_REPLACE_BY_ID","SCHEMA_DEL_DELETE_BY_ID","GEN_GET_ALL","GEN_GET_ALL_STREAM","GEN_GET_BY_ID","GEN_PUT_UPDATE_BY_ID","GEN_PUT_REPLACE_BY_ID","GEN_DEL_DELETE_BY_ID");staticfinalSet<String>BODY_FIND_APIS=Set.of("SCHEMA_POST_QUERY","SCHEMA_POST_QUERY_STREAM","SCHEMA_POST_QUERY_DELETE","SCHEMA_UPDATE_MANY","SCHEMA_POST_DISTINCT_QUERY","SCHEMA_ARRAY_OPERATIONS","SCHEMA_POST_COUNT","GEN_POST_QUERY","GEN_POST_QUERY_STREAM","GEN_POST_QUERY_DELETE","GEN_UPDATE_MANY","GEN_POST_DISTINCT_QUERY","GEN_ARRAY_OPERATIONS","GEN_POST_COUNT");staticfinalSet<String>STAMP_OWNER_APIS=Set.of("SCHEMA_POST_BULK_INSERT","SCHEMA_MASTER_SAVE","GEN_POST_BULK_INSERT","GEN_MASTER_SAVE");// MERGE, never replace : $and keeps what the caller asked for and adds the owner on top, so a caller// can never widen the rows by sending its own owner filter.staticObjectmerge(Objectexisting,Map<String,Object>scope){returnexistinginstanceofMap<?,?>find&&!find.isEmpty()?JsonObject.of("$and",List.of(existing,scope)):newJsonObject(scope);}@SuppressWarnings("unchecked")staticObjectmain(Gg){// Custom APIs, schedulers and events calling g.sys.db run under their own rules, only real callers are scoped.if(!g.req.isApiRequestFromUser())returnnull;Objectme=g.req.getAuth().getObject("authAMDB").get(IDENTITY_FIELD);if(me==null||"".equals(me))thrownewAmErrors("A person token is required for this API.",401);JsonObjectscope=JsonObject.of(OWNER_COLUMN,me);StringapiId=g.req.getReqInfo().getObject("apiInfo").getString("id","");if(QUERY_FIND_APIS.contains(apiId)){JsonObjectquery=g.req.objectAt("query");query.put("find",merge(query.get("find"),scope));Objectbody=g.req.get("body");if(bodyinstanceofMap<?,?>)((Map<String,Object>)body).put(OWNER_COLUMN,me);// update-by-id and replace-by-id : the owner of a row can not be handed overreturnnull;}if(BODY_FIND_APIS.contains(apiId)){JsonObjectbody=g.req.objectAt("body");body.put("find",merge(body.get("find"),scope));ObjectupdateData=body.get("updateData");if(updateDatainstanceofMap<?,?>)((Map<String,Object>)updateData).put(OWNER_COLUMN,me);// update-manyreturnnull;}if(STAMP_OWNER_APIS.contains(apiId)){List<?>rows=g.req.get("body")instanceofList<?>list?list:List.of(g.req.objectAt("body"));for(Objectrow:rows){if(rowinstanceofMap<?,?>)((Map<String,Object>)row).put(OWNER_COLUMN,me);// never trust the owner sent by the caller}returnnull;}// Fail closed : aggregate and distinct can not be scoped generically, so they are refused for row scoped callers.thrownewAmErrors("API "+apiId+" is not available for row scoped users.",403);}}
// Row scoping pre-hook for orders, as the API Security Report writes it.// Every request is limited to the rows whose "customer_id" equals the id of the person behind it// (g.req.auth.authAMDB.customer_id). Edit freely : the report reads the code again on every scan.importam.AmErrorsimportam.GconstvalOWNER_COLUMN="customer_id"constvalIDENTITY_FIELD="customer_id"// Where each generated API keeps its filter. API Maker ANDs query.find onto the primary key for the *-by-id APIs.valQUERY_FIND_APIS=setOf("SCHEMA_GET_ALL","SCHEMA_GET_ALL_STREAM","SCHEMA_GET_BY_ID","SCHEMA_PUT_UPDATE_BY_ID","SCHEMA_PUT_REPLACE_BY_ID","SCHEMA_DEL_DELETE_BY_ID","GEN_GET_ALL","GEN_GET_ALL_STREAM","GEN_GET_BY_ID","GEN_PUT_UPDATE_BY_ID","GEN_PUT_REPLACE_BY_ID","GEN_DEL_DELETE_BY_ID",)valBODY_FIND_APIS=setOf("SCHEMA_POST_QUERY","SCHEMA_POST_QUERY_STREAM","SCHEMA_POST_QUERY_DELETE","SCHEMA_UPDATE_MANY","SCHEMA_POST_DISTINCT_QUERY","SCHEMA_ARRAY_OPERATIONS","SCHEMA_POST_COUNT","GEN_POST_QUERY","GEN_POST_QUERY_STREAM","GEN_POST_QUERY_DELETE","GEN_UPDATE_MANY","GEN_POST_DISTINCT_QUERY","GEN_ARRAY_OPERATIONS","GEN_POST_COUNT",)valSTAMP_OWNER_APIS=setOf("SCHEMA_POST_BULK_INSERT","SCHEMA_MASTER_SAVE","GEN_POST_BULK_INSERT","GEN_MASTER_SAVE",)@Suppress("UNCHECKED_CAST")funmain(g:G):Any?{// Custom APIs, schedulers and events calling g.sys.db run under their own rules, only real callers are scoped.if(!g.req.isApiRequestFromUser)returnnullvalme=g.req.auth.getObject("authAMDB")[IDENTITY_FIELD]if(me==null||me=="")throwAmErrors("A person token is required for this API.",401)valscope=mapOf(OWNER_COLUMNtome)// MERGE, never replace : $and keeps what the caller asked for and adds the owner on top, so a caller// can never widen the rows by sending its own owner filter.funmerge(existing:Any?):Any=if(existingisMap<*,*>&&existing.isNotEmpty())mapOf("\$and"tolistOf(existing,scope))elsescope.toMutableMap()valapiId=g.req.reqInfo.getObject("apiInfo").getString("id","")if(apiIdinQUERY_FIND_APIS){valquery=g.req.objectAt("query")query["find"]=merge(query["find"])valbody=g.req["body"]if(bodyisMutableMap<*,*>)(bodyasMutableMap<String,Any?>)[OWNER_COLUMN]=me// update-by-id and replace-by-id : the owner of a row can not be handed overreturnnull}if(apiIdinBODY_FIND_APIS){valbody=g.req.objectAt("body")body["find"]=merge(body["find"])valupdateData=body["updateData"]if(updateDataisMutableMap<*,*>)(updateDataasMutableMap<String,Any?>)[OWNER_COLUMN]=me// update-manyreturnnull}if(apiIdinSTAMP_OWNER_APIS){valrows=g.req["body"]as?List<*>?:listOf(g.req.objectAt("body"))for(rowinrows){if(rowisMutableMap<*,*>)(rowasMutableMap<String,Any?>)[OWNER_COLUMN]=me// never trust the owner sent by the caller}returnnull}// Fail closed : aggregate and distinct can not be scoped generically, so they are refused for row scoped callers.throwAmErrors("API $apiId is not available for row scoped users.",403)}
The report writes this hook for you, for the tables which need it, and reads it again on every scan. Handle role based permissions builds the same pattern for several tables at once, with a group which reads every row.
import*asTfrom'types';asyncfunctionmain(g:T.IAMGlobal){constcached=awaitg.sys.cache.getKey('countries');if(cached)returnJSON.parse(cached);// the API does not run}module.exports=main;
filestaticclassApi{publicstaticasyncTask<object?>Main(Gg){varcached=awaitg.Sys.Cache.GetKey("countries");if(cachedisnotnull)returnJsonNode.Parse(cached);// the API does not runreturnnull;}}
packagecodeimport("encoding/json""apimaker/am")funcMain(g*am.G)(any,error){cached,err:=g.Sys.Cache.GetKey("countries")iferr!=nil||cached==""{returnnil,err}varcountriesanyerr=json.Unmarshal([]byte(cached),&countries)returncountries,err// the API does not run}
useam::{Errors,G,Value};pubfnmain(g:&mutG)->Result<Value,Errors>{matchg.sys.cache.get_key("countries")?{Some(cached)=>Ok(serde_json::from_str(&cached)?),// the API does not runNone=>Ok(Value::Null),}}
importam.G;importam.Json;classApi{staticObjectmain(Gg){Stringcached=g.sys.cache.getKey("countries");if(cached!=null)returnJson.parse(cached);// the API does not runreturnnull;}}
import*asTfrom'types';asyncfunctionmain(g:T.IAMGlobal){if(!g.req.isApiRequestFromUser)return;if(g.req.body?.price_cents<0)throw[{code:T.EStatusCode.BAD_REQUEST,message:'A price can not be negative.'}];g.req.body.updated_by=g.req.auth.authAMDB?.email;g.req.headers['x-am-response-case']='camelCase';// headers can change toog.res.shared.startedAt=Date.now();// for the post hook of this request}module.exports=main;
importtimefromamimportErrors,Gasyncdefmain(g:G):ifnotg.req.is_api_request_from_user:returnif(g.req.body.price_centsor0)<0:raiseErrors('A price can not be negative.',code=400)g.req.body.updated_by=(g.req.auth.authAMDBor{}).get('email')g.req.headers['x-am-response-case']='camelCase'# headers can change toog.res.shared.startedAt=int(time.time()*1000)# for the post hook of this request
<?phpfunctionmain(AmG$g){if(!$g->req->isApiRequestFromUser){returnnull;}if(($g->req->body->price_cents??0)<0){thrownewAmErrors('A price can not be negative.',400);}$g->req->body->updated_by=$g->req->auth->authAMDB?->email;$g->req->headers['x-am-response-case']='camelCase';// headers can change too$g->res->shared->startedAt=(int)(microtime(true)*1000);// for the post hook of this requestreturnnull;}
filestaticclassApi{publicstaticasyncTask<object?>Main(Gg){if(!g.Req.IsApiRequestFromUser)returnnull;if(g.Req.Body.GetDouble("price_cents")<0)thrownewAmErrors("A price can not be negative.",400);g.Req.Body["updated_by"]=g.Req.Auth.GetObject("authAMDB").GetString("email");g.Req.Headers["x-am-response-case"]="camelCase";// headers can change toog.Res.Shared["startedAt"]=DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();// for the post hook of this requestreturnnull;}}
packagecodeimport("time""apimaker/am")funcMain(g*am.G)(any,error){if!g.Req.IsApiRequestFromUser(){returnnil,nil}ifg.Req.Body().Float("price_cents")<0{returnnil,am.Fail(400,"A price can not be negative.")}g.Req.Body()["updated_by"]=g.Req.Auth().Map("authAMDB").Get("email")g.Req.Headers()["x-am-response-case"]="camelCase"// headers can change toog.Res.Shared()["startedAt"]=time.Now().UnixMilli()// for the post hook of this requestreturnnil,nil}
usestd::time::{SystemTime,UNIX_EPOCH};useam::{Errors,G,Value,json};pubfnmain(g:&mutG)->Result<Value,Errors>{if!g.req.is_api_request_from_user(){returnOk(Value::Null);}ifg.req.body()["price_cents"].as_f64().unwrap_or(0.0)<0.0{returnErr(am::fail(400,"A price can not be negative."));}letemail=g.req.auth()["authAMDB"]["email"].clone();g.req.body_mut()["updated_by"]=email;g.req.headers_mut()["x-am-response-case"]=json!("camelCase");// headers can change tooletnow=SystemTime::now().duration_since(UNIX_EPOCH).map(|since|since.as_millis()).unwrap_or(0);g.res.shared_mut()["startedAt"]=json!(nowasu64);// for the post hook of this requestOk(Value::Null)}
importam.AmErrors;importam.G;classApi{staticObjectmain(Gg){if(!g.req.isApiRequestFromUser())returnnull;if(g.req.getBody().getDouble("price_cents")<0)thrownewAmErrors("A price can not be negative.",400);g.req.getBody().put("updated_by",g.req.getAuth().getObject("authAMDB").get("email"));g.req.getHeaders().put("x-am-response-case","camelCase");// headers can change toog.res.getShared().put("startedAt",System.currentTimeMillis());// for the post hook of this requestreturnnull;}}
importam.AmErrorsimportam.Gfunmain(g:G):Any?{if(!g.req.isApiRequestFromUser)returnnullif(g.req.body.getDouble("price_cents")<0)throwAmErrors("A price can not be negative.",400)g.req.body["updated_by"]=g.req.auth.getObject("authAMDB")["email"]g.req.headers["x-am-response-case"]="camelCase"// headers can change toog.res.shared["startedAt"]=System.currentTimeMillis()// for the post hook of this requestreturnnull}
A hook calls your utility classes like any other code of its language : a check shared by many hooks lives in one.
utils/Guards : a utility class
import*asTfrom'types';// The person of the request has the role, or the request is refused.exportfunctionrequireRole(g:T.IAMGlobal,role:string){constroles:string[]=g.req.auth.authAMDB?.roles||[];if(!roles.includes(role)){throw[{code:T.EStatusCode.FORBIDDEN,message:`The role ${role} is needed.`}];}}
fromamimportErrors# The person of the request has the role, or the request is refused.defrequire_role(g,role):roles=(g.req.auth.authAMDBor{}).get('roles')or[]ifrolenotinroles:raiseErrors(f'The role {role} is needed.',code=403)
<?php// The person of the request has the role, or the request is refused.classGuards{publicstaticfunctionrequireRole(AmG$g,string$role):void{$roles=$g->req->auth->authAMDB?->roles??[];if(!in_array($role,(array)$roles,true)){thrownewAmErrors("The role {$role} is needed.",403);}}}
// The person of the request has the role, or the request is refused.publicstaticclassGuards{publicstaticvoidRequireRole(Gg,stringrole){if(!g.Req.Auth.GetObject("authAMDB").GetStrings("roles").Contains(role))thrownewAmErrors($"The role {role} is needed.",403);}}
packageguardsimport"apimaker/am"// RequireRole : the person of the request has the role, or the request is refused.funcRequireRole(g*am.G,rolestring)error{for_,held:=rangeg.Req.Auth().Map("authAMDB").List("roles"){ifheld==role{returnnil}}returnam.Fail(403,"The role "+role+" is needed.")}
useam::{Errors,G};/// The person of the request has the role, or the request is refused.pubfnrequire_role(g:&G,role:&str)->Result<(),Errors>{letroles=g.req.auth()["authAMDB"]["roles"].as_array().cloned().unwrap_or_default();ifroles.iter().any(|held|held==role){Ok(())}else{Err(am::fail(403,&format!("The role {role} is needed.")))}}
importam.AmErrors;importam.G;/** The person of the request has the role, or the request is refused. */publicclassGuards{publicstaticvoidrequireRole(Gg,Stringrole){if(!g.req.getAuth().getObject("authAMDB").getStrings("roles").contains(role)){thrownewAmErrors("The role "+role+" is needed.",403);}}}
importam.AmErrorsimportam.G/** The person of the request has the role, or the request is refused. */objectGuards{funrequireRole(g:G,role:String){if(role!ing.req.auth.getObject("authAMDB").getStrings("roles"))throwAmErrors("The role $role is needed.",403)}}
The API testing page runs an API with its hooks. While you edit a hook there, the Test button of the hook runs only that hook, so you see its effect alone.
Every hook has versions : keep the old one active until the new one is ready.
The log profile records each hook run with the request ; the log explorer shows them next to the API call.