apiplant_server/
builtins.rs1use apiplant_abi::{FunctionManifest, HostApi, HttpMethod, Visibility};
19use apiplant_core::App;
20use serde_json::{json, Map, Value};
21
22use crate::functions::{FunctionRegistry, HostBridge};
23
24pub fn register_all(registry: &mut FunctionRegistry, app: &App) {
27 registry.register_builtin(
28 manifest(
29 ORGANIZATION_JOIN,
30 "Resolve the user being added to an organisation, by id or identity.",
31 ),
32 organization_join,
33 organization_join_config(app),
34 );
35}
36
37pub const PREFIX: &str = "apiplant_";
41
42pub const ORGANIZATION_JOIN: &str = "apiplant_organization_join";
45
46fn manifest(name: &str, description: &str) -> FunctionManifest {
48 FunctionManifest {
49 name: name.into(),
50 version: env!("CARGO_PKG_VERSION").into(),
51 description: description.into(),
52 visibility: Visibility::Private,
53 role: "".into(),
54 method: HttpMethod::Post,
55 permission: "private".into(),
56 admin: "".into(),
57 config_schema: "".into(),
58 input_schema: "".into(),
59 output_schema: "".into(),
60 }
61}
62
63fn organization_join_config(app: &App) -> String {
70 let table = |name: &str| {
71 app.resources
72 .get(name)
73 .map(|r| format!("\"{}\"", r.table_name()))
74 };
75 let identity_field = app
76 .resources
77 .get("user")
78 .and_then(|r| r.auth.as_ref())
79 .map(|auth| auth.identity_field.clone())
80 .unwrap_or_else(|| "email".to_string());
81 json!({
82 "user_table": table("user"),
83 "membership_table": table("membership"),
84 "identity_field": identity_field,
85 })
86 .to_string()
87}
88
89pub fn organization_join(bridge: &HostBridge, input: &str) -> Result<String, String> {
111 let mut data: Map<String, Value> = match serde_json::from_str(input) {
112 Ok(Value::Object(map)) => map,
113 _ => return Ok(reject(400, "expected a JSON object")),
114 };
115 let config: Value = serde_json::from_str(&bridge.config()).unwrap_or(Value::Null);
116 let identity_field = config["identity_field"].as_str().unwrap_or("email");
117
118 let identity = data
120 .remove(identity_field)
121 .and_then(|v| v.as_str().map(str::to_string))
122 .map(|s| s.trim().to_string())
123 .filter(|s| !s.is_empty());
124
125 let user_id = match nonempty(data.get("user_id")) {
126 Some(id) => id,
127 None => {
128 let Some(identity) = identity else {
129 return Ok(reject(
130 422,
131 &format!("provide the member's `user_id` or their {identity_field}"),
132 ));
133 };
134 let Some(user_table) = config["user_table"].as_str() else {
135 return Err("the `user` resource is missing".to_string());
136 };
137 let sql = format!(
138 "SELECT id::text AS id FROM {user_table} WHERE lower({identity_field}) = lower($1) LIMIT 1"
139 );
140 match first_column(bridge, &sql, vec![Value::String(identity.clone())], "id")? {
141 Some(id) => id,
142 None => {
145 return Ok(reject(
146 404,
147 &format!("nobody is registered with that {identity_field}"),
148 ))
149 }
150 }
151 }
152 };
153
154 if let Some(membership_table) = config["membership_table"].as_str() {
157 let hook: Value = serde_json::from_str(&bridge.hook()).unwrap_or(Value::Null);
158 if let Some(org) = hook["organization_id"].as_str() {
159 let sql = format!(
160 "SELECT id::text AS id FROM {membership_table} \
161 WHERE organization_id = $1::uuid AND user_id = $2::uuid LIMIT 1"
162 );
163 let params = vec![
164 Value::String(org.to_string()),
165 Value::String(user_id.clone()),
166 ];
167 if first_column(bridge, &sql, params, "id")?.is_some() {
168 return Ok(reject(
169 409,
170 "they are already a member of this organization",
171 ));
172 }
173 }
174 }
175
176 data.insert("user_id".to_string(), Value::String(user_id));
177 Ok(json!({ "data": data }).to_string())
178}
179
180fn reject(status: u16, message: &str) -> String {
182 json!({ "error": { "status": status, "message": message } }).to_string()
183}
184
185fn nonempty(value: Option<&Value>) -> Option<String> {
186 value
187 .and_then(Value::as_str)
188 .map(str::trim)
189 .filter(|s| !s.is_empty())
190 .map(str::to_string)
191}
192
193fn first_column(
195 bridge: &HostBridge,
196 sql: &str,
197 params: Vec<Value>,
198 column: &str,
199) -> Result<Option<String>, String> {
200 let request = json!({ "sql": sql, "params": params }).to_string();
201 let raw = match bridge.query(request.as_str().into()) {
202 abi_stable::std_types::RResult::ROk(v) => v.into_string(),
203 abi_stable::std_types::RResult::RErr(e) => return Err(e.into_string()),
204 };
205 let rows: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
206 Ok(rows
207 .get(0)
208 .and_then(|row| row.get(column))
209 .and_then(Value::as_str)
210 .map(str::to_string))
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use apiplant_core::defaults;
217
218 fn empty_app() -> App {
220 let dir = std::env::temp_dir().join(format!(
221 "apiplant-builtins-{}-{:?}",
222 std::process::id(),
223 std::time::SystemTime::now()
224 ));
225 std::fs::create_dir_all(&dir).unwrap();
226 let app = App::load(&dir).unwrap();
227 std::fs::remove_dir_all(&dir).ok();
228 app
229 }
230
231 #[test]
234 fn every_builtin_lives_in_the_reserved_namespace() {
235 let app = empty_app();
236 let mut registry = FunctionRegistry::default();
237 register_all(&mut registry, &app);
238
239 let names: Vec<String> = registry
240 .iter()
241 .map(|f| f.manifest.name.to_string())
242 .collect();
243 assert!(!names.is_empty());
244 for name in &names {
245 assert!(
246 name.starts_with(PREFIX),
247 "`{name}` is missing the `{PREFIX}` prefix"
248 );
249 }
250 }
251
252 #[test]
255 fn the_membership_hook_resolves_to_a_registered_builtin() {
256 let membership = defaults::parse_builtin(defaults::MEMBERSHIP_TOML);
257 let hook = membership
258 .hook(apiplant_core::HookEvent::BeforeCreate)
259 .expect("membership declares a before_create hook");
260 assert_eq!(hook, ORGANIZATION_JOIN);
261
262 let mut registry = FunctionRegistry::default();
263 register_all(&mut registry, &empty_app());
264 assert!(registry.get(hook).is_some());
265 }
266
267 #[test]
268 fn builtins_are_not_exposed_over_http() {
269 let mut registry = FunctionRegistry::default();
270 register_all(&mut registry, &empty_app());
271 for f in registry.iter() {
272 assert_eq!(f.manifest.visibility, Visibility::Private);
273 }
274 }
275}