Skip to main content

modkit/api/
select.rs

1//! Field projection support for `$select` `OData` queries.
2//!
3//! This module provides utilities for projecting DTOs based on selected fields.
4//! It allows handlers to filter response objects to only include requested fields.
5
6use serde_json::{Map, Value, json};
7use std::collections::HashSet;
8
9/// Project a JSON value to only include selected fields.
10///
11/// Supports dot notation for nested field selection (e.g., `access_control.read`).
12/// For objects, recursively includes only the specified fields.
13/// For arrays, projects each element.
14/// For other types, returns the value unchanged.
15///
16/// # Arguments
17///
18/// * `value` - The JSON value to project
19/// * `selected_fields` - Set of field names to include (case-insensitive, supports dot notation)
20///
21/// # Returns
22///
23/// A new JSON value containing only the selected fields
24///
25/// # Examples
26///
27/// ```ignore
28/// // Select top-level field
29/// $select=id,name
30///
31/// // Select nested field (includes entire nested object)
32/// $select=access_control
33///
34/// // Select specific nested field
35/// $select=access_control.read,access_control.write
36/// ```
37#[allow(clippy::implicit_hasher)] // we don't care for now about the hasher of the hashset
38#[must_use]
39pub fn project_json(value: &Value, selected_fields: &HashSet<String>) -> Value {
40    match value {
41        Value::Object(map) => {
42            let mut projected = Map::new();
43            for (key, val) in map {
44                let key_lower = key.to_lowercase();
45
46                // Check if this exact field is selected
47                if selected_fields.contains(&key_lower) {
48                    // Include entire field (no further filtering)
49                    projected.insert(key.clone(), val.clone());
50                } else {
51                    // Check if any nested fields are selected (dot notation)
52                    let nested_fields = extract_nested_fields(&key_lower, selected_fields);
53                    if !nested_fields.is_empty() {
54                        // Recursively project nested fields
55                        projected.insert(key.clone(), project_json(val, &nested_fields));
56                    }
57                }
58            }
59            Value::Object(projected)
60        }
61        Value::Array(arr) => Value::Array(
62            arr.iter()
63                .map(|v| project_json(v, selected_fields))
64                .collect(),
65        ),
66        other => other.clone(),
67    }
68}
69
70/// Extract nested field selectors for a given parent field.
71///
72/// For example, if `selected_fields` contains `access_control.read` and `access_control.write`,
73/// this function returns a set containing `read` and `write` when called with `access_control`.
74fn extract_nested_fields(parent_key: &str, selected_fields: &HashSet<String>) -> HashSet<String> {
75    let prefix = format!("{parent_key}.");
76    selected_fields
77        .iter()
78        .filter(|field| field.starts_with(&prefix))
79        .map(|field| field[prefix.len()..].to_string())
80        .collect()
81}
82
83/// Helper function to apply field projection to a serializable value.
84///
85/// # Arguments
86///
87/// * `value` - The value to project
88/// * `selected_fields` - Optional slice of field names to include
89///
90/// # Returns
91///
92/// The projected JSON value, or the original value if no fields are selected
93pub fn apply_select<T: serde::Serialize>(value: T, selected_fields: Option<&[String]>) -> Value {
94    match selected_fields {
95        Some(fields) if !fields.is_empty() => {
96            let fields_set: HashSet<String> = fields.iter().map(|f| f.to_lowercase()).collect();
97            match serde_json::to_value(value) {
98                Ok(v) => project_json(&v, &fields_set),
99                Err(e) => {
100                    tracing::warn!(error = %e, "DTO serialization failed in apply_select; returning empty object");
101                    json!({})
102                }
103            }
104        }
105        _ => serde_json::to_value(value).unwrap_or_else(|e| {
106            tracing::warn!(error = %e, "DTO serialization failed in apply_select; returning empty object");
107            json!({})
108        }),
109    }
110}
111
112/// Convert a page of items to a page of projected JSON values.
113///
114/// This is a convenience function that combines serialization and projection
115/// for paginated responses. It automatically applies `$select` projection if specified.
116///
117/// # Arguments
118///
119/// * `page` - The page containing items to project
120/// * `selected_fields` - Optional slice of field names to include
121///
122/// # Returns
123///
124/// A `modkit_odata::Page<Value>` with projected items
125#[must_use]
126pub fn page_to_projected_json<T: serde::Serialize>(
127    page: &modkit_odata::Page<T>,
128    selected_fields: Option<&[String]>,
129) -> modkit_odata::Page<Value> {
130    let projected_items: Vec<Value> = page
131        .items
132        .iter()
133        .map(|item| apply_select(item, selected_fields))
134        .collect();
135
136    modkit_odata::Page {
137        items: projected_items,
138        page_info: page.page_info.clone(),
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_project_json_object() {
148        let value = json!({
149            "id": "123",
150            "name": "John",
151            "email": "john@example.com",
152            "age": 30
153        });
154
155        let selected = ["id".to_owned(), "name".to_owned()];
156        let fields_set: HashSet<String> = selected.iter().map(|f| f.to_lowercase()).collect();
157
158        let projected = project_json(&value, &fields_set);
159
160        assert_eq!(projected.get("id").and_then(|v| v.as_str()), Some("123"));
161        assert_eq!(projected.get("name").and_then(|v| v.as_str()), Some("John"));
162        assert!(projected.get("email").is_none());
163        assert!(projected.get("age").is_none());
164    }
165
166    #[test]
167    fn test_project_json_case_insensitive() {
168        let value = json!({
169            "Id": "123",
170            "Name": "John"
171        });
172
173        let selected = ["id".to_owned(), "name".to_owned()];
174        let fields_set: HashSet<String> = selected.iter().map(|f| f.to_lowercase()).collect();
175
176        let projected = project_json(&value, &fields_set);
177
178        assert_eq!(projected.get("Id").and_then(|v| v.as_str()), Some("123"));
179        assert_eq!(projected.get("Name").and_then(|v| v.as_str()), Some("John"));
180    }
181
182    #[test]
183    fn test_project_json_array() {
184        let value = json!([
185            {"id": "1", "name": "John"},
186            {"id": "2", "name": "Jane"}
187        ]);
188
189        let selected = ["id".to_owned()];
190        let fields_set: HashSet<String> = selected.iter().map(|f| f.to_lowercase()).collect();
191
192        let projected = project_json(&value, &fields_set);
193
194        let arr = projected.as_array().unwrap();
195        assert_eq!(arr.len(), 2);
196        assert_eq!(arr[0].get("id").and_then(|v| v.as_str()), Some("1"));
197        assert!(arr[0].get("name").is_none());
198    }
199
200    #[test]
201    fn test_project_json_nested() {
202        let value = json!({
203            "id": "123",
204            "user": {
205                "name": "John",
206                "email": "john@example.com"
207            }
208        });
209
210        let selected = ["id".to_owned(), "user".to_owned()];
211        let fields_set: HashSet<String> = selected.iter().map(|f| f.to_lowercase()).collect();
212
213        let projected = project_json(&value, &fields_set);
214
215        assert_eq!(projected.get("id").and_then(|v| v.as_str()), Some("123"));
216        assert!(projected.get("user").is_some());
217    }
218
219    #[test]
220    fn test_apply_select_with_fields() {
221        #[derive(serde::Serialize)]
222        struct User {
223            id: String,
224            name: String,
225            email: String,
226        }
227
228        let user = User {
229            id: "123".to_owned(),
230            name: "John".to_owned(),
231            email: "john@example.com".to_owned(),
232        };
233
234        let selected = vec!["id".to_owned(), "name".to_owned()];
235        let result = apply_select(&user, Some(&selected));
236
237        assert_eq!(result.get("id").and_then(|v| v.as_str()), Some("123"));
238        assert_eq!(result.get("name").and_then(|v| v.as_str()), Some("John"));
239        assert!(result.get("email").is_none());
240    }
241
242    #[test]
243    fn test_apply_select_without_fields() {
244        #[derive(serde::Serialize)]
245        struct User {
246            id: String,
247            name: String,
248        }
249
250        let user = User {
251            id: "123".to_owned(),
252            name: "John".to_owned(),
253        };
254
255        let result = apply_select(&user, None);
256
257        assert_eq!(result.get("id").and_then(|v| v.as_str()), Some("123"));
258        assert_eq!(result.get("name").and_then(|v| v.as_str()), Some("John"));
259    }
260}