Skip to main content

feagi_api/endpoints/
region.rs

1// Copyright 2025 Neuraville Inc.
2// Licensed under the Apache License, Version 2.0
3
4//! Region API Endpoints - Exact port from Python `/v1/region/*`
5
6// Removed - using crate::common::State instead
7use crate::common::ApiState;
8use crate::common::{ApiError, ApiResult, Json, Path, State};
9use feagi_services::types::CreateBrainRegionParams;
10use feagi_structures::genomic::brain_regions::RegionID;
11use std::collections::HashMap;
12
13/// GET /v1/region/regions_members
14///
15/// Returns all brain regions with their member cortical areas
16///
17/// Example response:
18/// ```json
19/// {
20///   "root": {
21///     "title": "Root Brain Region",
22///     "description": "",
23///     "parent_region_id": null,
24///     "coordinate_2d": [0, 0],
25///     "coordinate_3d": [0, 0, 0],
26///     "areas": ["area1", "area2"],
27///     "regions": [],
28///     "inputs": [],
29///     "outputs": []
30///   }
31/// }
32/// ```
33#[utoipa::path(
34    get,
35    path = "/v1/region/regions_members",
36    tag = "region",
37    responses(
38        (status = 200, description = "Brain regions with member areas", body = HashMap<String, serde_json::Value>),
39        (status = 500, description = "Internal server error")
40    )
41)]
42pub async fn get_regions_members(
43    State(state): State<ApiState>,
44) -> ApiResult<Json<HashMap<String, serde_json::Value>>> {
45    use tracing::trace;
46    let connectome_service = state.connectome_service.as_ref();
47    match connectome_service.list_brain_regions().await {
48        Ok(regions) => {
49            trace!(target: "feagi-api", "Found {} brain regions to return", regions.len());
50            let mut result = HashMap::new();
51            for region in regions {
52                trace!(
53                    target: "feagi-api",
54                    "Region: {} ({}) with {} areas",
55                    region.region_id,
56                    region.name,
57                    region.cortical_areas.len()
58                );
59
60                // Extract inputs/outputs from region properties if they exist
61                let inputs = region
62                    .properties
63                    .get("inputs")
64                    .and_then(|v| v.as_array())
65                    .map(|arr| {
66                        arr.iter()
67                            .filter_map(|v| v.as_str().map(String::from))
68                            .collect::<Vec<String>>()
69                    })
70                    .unwrap_or_default();
71
72                let outputs = region
73                    .properties
74                    .get("outputs")
75                    .and_then(|v| v.as_array())
76                    .map(|arr| {
77                        arr.iter()
78                            .filter_map(|v| v.as_str().map(String::from))
79                            .collect::<Vec<String>>()
80                    })
81                    .unwrap_or_default();
82
83                trace!(
84                    target: "feagi-api",
85                    "Inputs: {} areas, Outputs: {} areas",
86                    inputs.len(),
87                    outputs.len()
88                );
89
90                // Extract coordinate_3d from properties (set by smart positioning in neuroembryogenesis)
91                let coordinate_3d = region
92                    .properties
93                    .get("coordinate_3d")
94                    .and_then(|v| v.as_array())
95                    .and_then(|arr| {
96                        if arr.len() >= 3 {
97                            Some(serde_json::json!([arr[0], arr[1], arr[2]]))
98                        } else {
99                            None
100                        }
101                    })
102                    .unwrap_or_else(|| serde_json::json!([0, 0, 0]));
103
104                // Extract coordinate_2d from properties
105                let coordinate_2d = region
106                    .properties
107                    .get("coordinate_2d")
108                    .and_then(|v| v.as_array())
109                    .and_then(|arr| {
110                        if arr.len() >= 2 {
111                            Some(serde_json::json!([arr[0], arr[1]]))
112                        } else {
113                            None
114                        }
115                    })
116                    .unwrap_or_else(|| serde_json::json!([0, 0]));
117
118                result.insert(
119                    region.region_id.clone(),
120                    serde_json::json!({
121                        "title": region.name,
122                        "description": "",  // TODO: Add description field to BrainRegionInfo
123                        "parent_region_id": region.parent_id,
124                        "coordinate_2d": coordinate_2d,
125                        "coordinate_3d": coordinate_3d,
126                        "areas": region.cortical_areas,
127                        "regions": region.child_regions,
128                        "inputs": inputs,
129                        "outputs": outputs
130                    }),
131                );
132            }
133            trace!(target: "feagi-api", "Returning {} regions in response", result.len());
134            Ok(Json(result))
135        }
136        Err(e) => Err(ApiError::internal(format!("Failed to get regions: {}", e))),
137    }
138}
139
140/// POST /v1/region/region
141#[utoipa::path(post, path = "/v1/region/region", tag = "region")]
142pub async fn post_region(
143    State(state): State<ApiState>,
144    Json(mut req): Json<HashMap<String, serde_json::Value>>,
145) -> ApiResult<Json<HashMap<String, serde_json::Value>>> {
146    let connectome_service = state.connectome_service.as_ref();
147
148    let title = req
149        .get("title")
150        .or_else(|| req.get("name"))
151        .and_then(|v| v.as_str())
152        .map(str::trim)
153        .filter(|value| !value.is_empty())
154        .ok_or_else(|| ApiError::invalid_input("title required"))?
155        .to_string();
156    req.remove("title");
157    req.remove("name");
158
159    let region_id = match req.get("region_id").and_then(|v| v.as_str()) {
160        Some(value) if !value.trim().is_empty() => RegionID::from_string(value)
161            .map_err(|e| ApiError::invalid_input(format!("Invalid region_id: {}", e)))?
162            .to_string(),
163        _ => RegionID::new().to_string(),
164    };
165    req.remove("region_id");
166
167    let parent_region_id = req
168        .get("parent_region_id")
169        .and_then(|v| v.as_str())
170        .map(str::trim)
171        .filter(|value| !value.is_empty())
172        .map(str::to_string);
173    req.remove("parent_region_id");
174
175    let region_type = req
176        .get("region_type")
177        .and_then(|v| v.as_str())
178        .map(str::trim)
179        .filter(|value| !value.is_empty())
180        .map(str::to_string)
181        .unwrap_or_else(|| "Undefined".to_string());
182    req.remove("region_type");
183
184    let coordinate_2d_value = req
185        .get("coordinate_2d")
186        .or_else(|| req.get("coordinates_2d"))
187        .and_then(|v| v.as_array().cloned())
188        .ok_or_else(|| ApiError::invalid_input("coordinates_2d required"))?;
189    if coordinate_2d_value.len() != 2 {
190        return Err(ApiError::invalid_input(
191            "coordinates_2d must contain exactly 2 values",
192        ));
193    }
194    req.remove("coordinate_2d");
195    req.remove("coordinates_2d");
196
197    let coordinate_3d_value = req
198        .get("coordinate_3d")
199        .or_else(|| req.get("coordinates_3d"))
200        .and_then(|v| v.as_array().cloned())
201        .ok_or_else(|| ApiError::invalid_input("coordinates_3d required"))?;
202    if coordinate_3d_value.len() != 3 {
203        return Err(ApiError::invalid_input(
204            "coordinates_3d must contain exactly 3 values",
205        ));
206    }
207    req.remove("coordinate_3d");
208    req.remove("coordinates_3d");
209
210    let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
211    properties.insert(
212        "coordinate_2d".to_string(),
213        serde_json::Value::Array(coordinate_2d_value),
214    );
215    properties.insert(
216        "coordinate_3d".to_string(),
217        serde_json::Value::Array(coordinate_3d_value),
218    );
219    if let Some(parent_region_id) = &parent_region_id {
220        properties.insert(
221            "parent_region_id".to_string(),
222            serde_json::json!(parent_region_id),
223        );
224    }
225    if let Some(areas) = req.remove("areas") {
226        properties.insert("areas".to_string(), areas);
227    }
228    if let Some(regions) = req.remove("regions") {
229        properties.insert("regions".to_string(), regions);
230    }
231    for (key, value) in req {
232        properties.insert(key, value);
233    }
234
235    let params = CreateBrainRegionParams {
236        region_id: region_id.clone(),
237        name: title.clone(),
238        region_type,
239        parent_id: parent_region_id.clone(),
240        properties: Some(properties),
241    };
242
243    let info = connectome_service
244        .create_brain_region(params)
245        .await
246        .map_err(ApiError::from)?;
247
248    let coordinate_2d = info
249        .properties
250        .get("coordinate_2d")
251        .cloned()
252        .ok_or_else(|| ApiError::internal("Missing coordinate_2d on created region"))?;
253    let coordinate_3d = info
254        .properties
255        .get("coordinate_3d")
256        .cloned()
257        .ok_or_else(|| ApiError::internal("Missing coordinate_3d on created region"))?;
258
259    let mut response = HashMap::from([
260        ("region_id".to_string(), serde_json::json!(info.region_id)),
261        ("title".to_string(), serde_json::json!(info.name)),
262        (
263            "parent_region_id".to_string(),
264            serde_json::json!(info.parent_id),
265        ),
266        ("coordinate_2d".to_string(), coordinate_2d),
267        ("coordinate_3d".to_string(), coordinate_3d),
268        ("areas".to_string(), serde_json::json!(info.cortical_areas)),
269        ("regions".to_string(), serde_json::json!(info.child_regions)),
270    ]);
271
272    if let Some(inputs) = info.properties.get("inputs") {
273        response.insert("inputs".to_string(), inputs.clone());
274    }
275    if let Some(outputs) = info.properties.get("outputs") {
276        response.insert("outputs".to_string(), outputs.clone());
277    }
278
279    Ok(Json(response))
280}
281
282/// PUT /v1/region/region
283#[utoipa::path(put, path = "/v1/region/region", tag = "region")]
284pub async fn put_region(
285    State(state): State<ApiState>,
286    Json(mut request): Json<HashMap<String, serde_json::Value>>,
287) -> ApiResult<Json<HashMap<String, String>>> {
288    let connectome_service = state.connectome_service.as_ref();
289
290    // Extract region_id
291    let region_id = request
292        .get("region_id")
293        .and_then(|v| v.as_str())
294        .ok_or_else(|| ApiError::invalid_input("region_id required"))?
295        .to_string();
296
297    // Remove region_id from properties (it's not a property to update)
298    request.remove("region_id");
299
300    // Update the brain region
301    match connectome_service
302        .update_brain_region(&region_id, request)
303        .await
304    {
305        Ok(_) => Ok(Json(HashMap::from([
306            ("message".to_string(), "Brain region updated".to_string()),
307            ("region_id".to_string(), region_id),
308        ]))),
309        Err(e) => Err(ApiError::internal(format!(
310            "Failed to update brain region: {}",
311            e
312        ))),
313    }
314}
315
316/// DELETE /v1/region/region
317#[utoipa::path(delete, path = "/v1/region/region", tag = "region")]
318pub async fn delete_region(
319    State(state): State<ApiState>,
320    Json(req): Json<HashMap<String, String>>,
321) -> ApiResult<Json<HashMap<String, String>>> {
322    let connectome_service = state.connectome_service.as_ref();
323    let region_id = req
324        .get("region_id")
325        .or_else(|| req.get("id"))
326        .map(String::as_str)
327        .map(str::trim)
328        .filter(|value| !value.is_empty())
329        .ok_or_else(|| ApiError::invalid_input("region_id required"))?
330        .to_string();
331
332    connectome_service
333        .delete_brain_region(&region_id)
334        .await
335        .map_err(ApiError::from)?;
336
337    Ok(Json(HashMap::from([
338        ("message".to_string(), "Brain region deleted".to_string()),
339        ("region_id".to_string(), region_id),
340    ])))
341}
342
343/// POST /v1/region/clone
344#[utoipa::path(post, path = "/v1/region/clone", tag = "region")]
345pub async fn post_clone(
346    State(_state): State<ApiState>,
347    Json(_req): Json<HashMap<String, serde_json::Value>>,
348) -> ApiResult<Json<HashMap<String, String>>> {
349    Err(ApiError::internal("Not yet implemented"))
350}
351
352/// PUT /v1/region/relocate_members
353#[utoipa::path(put, path = "/v1/region/relocate_members", tag = "region")]
354pub async fn put_relocate_members(
355    State(state): State<ApiState>,
356    Json(request): Json<HashMap<String, serde_json::Value>>,
357) -> ApiResult<Json<HashMap<String, String>>> {
358    let connectome_service = state.connectome_service.as_ref();
359
360    if request.is_empty() {
361        return Err(ApiError::invalid_input("Request cannot be empty"));
362    }
363
364    let mut updated_regions: Vec<String> = Vec::new();
365
366    for (region_id, payload) in request {
367        let payload_obj = payload.as_object().ok_or_else(|| {
368            ApiError::invalid_input(format!("Region '{}' entry must be an object", region_id))
369        })?;
370
371        if payload_obj.contains_key("parent_region_id") {
372            return Err(ApiError::invalid_input(
373                "parent_region_id relocation is not implemented via relocate_members",
374            ));
375        }
376
377        let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
378        if let Some(value) = payload_obj
379            .get("coordinate_2d")
380            .or_else(|| payload_obj.get("coordinates_2d"))
381        {
382            properties.insert("coordinate_2d".to_string(), value.clone());
383        }
384        if let Some(value) = payload_obj
385            .get("coordinate_3d")
386            .or_else(|| payload_obj.get("coordinates_3d"))
387        {
388            properties.insert("coordinate_3d".to_string(), value.clone());
389        }
390
391        if properties.is_empty() {
392            return Err(ApiError::invalid_input(format!(
393                "Region '{}' has no supported properties to update",
394                region_id
395            )));
396        }
397
398        connectome_service
399            .update_brain_region(&region_id, properties)
400            .await
401            .map_err(|e| {
402                ApiError::internal(format!("Failed to update region {}: {}", region_id, e))
403            })?;
404
405        updated_regions.push(region_id);
406    }
407
408    Ok(Json(HashMap::from([
409        (
410            "message".to_string(),
411            format!("Updated {} brain regions", updated_regions.len()),
412        ),
413        ("region_ids".to_string(), updated_regions.join(", ")),
414    ])))
415}
416
417/// DELETE /v1/region/region_and_members
418#[utoipa::path(delete, path = "/v1/region/region_and_members", tag = "region")]
419pub async fn delete_region_and_members(
420    State(_state): State<ApiState>,
421    Json(_req): Json<HashMap<String, String>>,
422) -> ApiResult<Json<HashMap<String, String>>> {
423    Err(ApiError::internal("Not yet implemented"))
424}
425
426/// GET /v1/region/regions
427/// Get list of all brain region IDs
428#[utoipa::path(
429    get,
430    path = "/v1/region/regions",
431    tag = "region",
432    responses(
433        (status = 200, description = "List of region IDs", body = Vec<String>)
434    )
435)]
436pub async fn get_regions(State(state): State<ApiState>) -> ApiResult<Json<Vec<String>>> {
437    let connectome_service = state.connectome_service.as_ref();
438
439    let regions = connectome_service
440        .list_brain_regions()
441        .await
442        .map_err(|e| ApiError::internal(format!("Failed to list regions: {}", e)))?;
443
444    let region_ids: Vec<String> = regions.iter().map(|r| r.region_id.clone()).collect();
445    Ok(Json(region_ids))
446}
447
448/// GET /v1/region/region_titles
449/// Get mapping of region IDs to titles
450#[utoipa::path(
451    get,
452    path = "/v1/region/region_titles",
453    tag = "region",
454    responses(
455        (status = 200, description = "Region ID to title mapping", body = HashMap<String, String>)
456    )
457)]
458pub async fn get_region_titles(
459    State(state): State<ApiState>,
460) -> ApiResult<Json<HashMap<String, String>>> {
461    let connectome_service = state.connectome_service.as_ref();
462
463    let regions = connectome_service
464        .list_brain_regions()
465        .await
466        .map_err(|e| ApiError::internal(format!("Failed to list regions: {}", e)))?;
467
468    let mut titles = HashMap::new();
469    for region in regions {
470        titles.insert(region.region_id.clone(), region.name.clone());
471    }
472
473    Ok(Json(titles))
474}
475
476/// GET /v1/region/region/{region_id}
477/// Get detailed properties for a specific brain region
478#[utoipa::path(
479    get,
480    path = "/v1/region/region/{region_id}",
481    tag = "region",
482    params(
483        ("region_id" = String, Path, description = "Brain region ID")
484    ),
485    responses(
486        (status = 200, description = "Region properties", body = HashMap<String, serde_json::Value>),
487        (status = 404, description = "Region not found")
488    )
489)]
490pub async fn get_region_detail(
491    State(state): State<ApiState>,
492    Path(region_id): Path<String>,
493) -> ApiResult<Json<HashMap<String, serde_json::Value>>> {
494    let connectome_service = state.connectome_service.as_ref();
495
496    let region = connectome_service
497        .get_brain_region(&region_id)
498        .await
499        .map_err(|e| ApiError::not_found("region", &e.to_string()))?;
500
501    // Extract coordinate_3d from properties (set by smart positioning in neuroembryogenesis)
502    let coordinate_3d = region
503        .properties
504        .get("coordinate_3d")
505        .and_then(|v| v.as_array())
506        .and_then(|arr| {
507            if arr.len() >= 3 {
508                Some(serde_json::json!([arr[0], arr[1], arr[2]]))
509            } else {
510                None
511            }
512        })
513        .unwrap_or_else(|| serde_json::json!([0, 0, 0]));
514
515    // Extract coordinate_2d from properties
516    let coordinate_2d = region
517        .properties
518        .get("coordinate_2d")
519        .and_then(|v| v.as_array())
520        .and_then(|arr| {
521            if arr.len() >= 2 {
522                Some(serde_json::json!([arr[0], arr[1]]))
523            } else {
524                None
525            }
526        })
527        .unwrap_or_else(|| serde_json::json!([0, 0]));
528
529    let mut response = HashMap::new();
530    response.insert("region_id".to_string(), serde_json::json!(region.region_id));
531    response.insert("title".to_string(), serde_json::json!(region.name));
532    response.insert("description".to_string(), serde_json::json!(""));
533    response.insert("coordinate_2d".to_string(), coordinate_2d);
534    response.insert("coordinate_3d".to_string(), coordinate_3d);
535    response.insert(
536        "areas".to_string(),
537        serde_json::json!(region.cortical_areas),
538    );
539    response.insert(
540        "regions".to_string(),
541        serde_json::json!(region.child_regions),
542    );
543    response.insert(
544        "parent_region_id".to_string(),
545        serde_json::json!(region.parent_id),
546    );
547
548    Ok(Json(response))
549}
550
551/// PUT /v1/region/change_region_parent
552/// Change the parent of a brain region
553#[utoipa::path(
554    put,
555    path = "/v1/region/change_region_parent",
556    tag = "region",
557    responses(
558        (status = 200, description = "Parent changed", body = HashMap<String, String>)
559    )
560)]
561pub async fn put_change_region_parent(
562    State(_state): State<ApiState>,
563    Json(_request): Json<HashMap<String, String>>,
564) -> ApiResult<Json<HashMap<String, String>>> {
565    Ok(Json(HashMap::from([(
566        "message".to_string(),
567        "Region parent change not yet implemented".to_string(),
568    )])))
569}
570
571/// PUT /v1/region/change_cortical_area_region
572/// Change the region association of a cortical area
573#[utoipa::path(
574    put,
575    path = "/v1/region/change_cortical_area_region",
576    tag = "region",
577    responses(
578        (status = 200, description = "Association changed", body = HashMap<String, String>)
579    )
580)]
581pub async fn put_change_cortical_area_region(
582    State(_state): State<ApiState>,
583    Json(_request): Json<HashMap<String, String>>,
584) -> ApiResult<Json<HashMap<String, String>>> {
585    Ok(Json(HashMap::from([(
586        "message".to_string(),
587        "Cortical area region association change not yet implemented".to_string(),
588    )])))
589}