Skip to main content

redact_api/
handlers.rs

1// Copyright (c) 2026 Censgate LLC.
2// Licensed under the Business Source License 1.1 (BUSL-1.1).
3// See the LICENSE file in the project root for license details,
4// including the Additional Use Grant, Change Date, and Change License.
5
6use crate::models::{
7    AnalyzeRequest, AnalyzeResponse, AnonymizeRequest, AnonymizeResponse, EntityResult,
8    ErrorResponse, HealthResponse, TokenInfo,
9};
10use axum::{
11    extract::State,
12    http::StatusCode,
13    response::{IntoResponse, Response},
14    Json,
15};
16use redact_core::{AnalyzerEngine, AnonymizerConfig, EntityType};
17use std::sync::Arc;
18
19/// Application state
20#[derive(Clone)]
21pub struct AppState {
22    pub engine: Arc<AnalyzerEngine>,
23}
24
25/// Custom error type
26#[derive(Debug)]
27pub struct ApiError {
28    status: StatusCode,
29    message: String,
30}
31
32impl ApiError {
33    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
34        Self {
35            status,
36            message: message.into(),
37        }
38    }
39
40    pub fn bad_request(message: impl Into<String>) -> Self {
41        Self::new(StatusCode::BAD_REQUEST, message)
42    }
43
44    pub fn internal_error(message: impl Into<String>) -> Self {
45        Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
46    }
47}
48
49impl IntoResponse for ApiError {
50    fn into_response(self) -> Response {
51        let body = Json(ErrorResponse::new("error", self.message));
52        (self.status, body).into_response()
53    }
54}
55
56impl From<anyhow::Error> for ApiError {
57    fn from(err: anyhow::Error) -> Self {
58        ApiError::internal_error(err.to_string())
59    }
60}
61
62/// Health check endpoint
63pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
64    let stats = state.engine.recognizer_registry().stats();
65    Json(HealthResponse {
66        status: "healthy".to_string(),
67        version: redact_core::VERSION.to_string(),
68        recognizers: stats.recognizer_count,
69        entity_types: stats.entity_coverage.len(),
70    })
71}
72
73/// Analyze endpoint - detect PII entities
74pub async fn analyze(
75    State(state): State<AppState>,
76    Json(request): Json<AnalyzeRequest>,
77) -> Result<Json<AnalyzeResponse>, ApiError> {
78    // Validate input
79    if request.text.is_empty() {
80        return Err(ApiError::bad_request("Text cannot be empty"));
81    }
82
83    // Parse entity types if provided
84    let entity_types: Option<Vec<EntityType>> = request.entities.as_ref().map(|entities| {
85        entities
86            .iter()
87            .map(|e| EntityType::from(e.clone()))
88            .collect()
89    });
90
91    // Analyze text
92    let result = if let Some(entities) = entity_types.as_ref() {
93        state
94            .engine
95            .analyze_with_entities(&request.text, entities, Some(&request.language))
96            .map_err(ApiError::from)?
97    } else {
98        state
99            .engine
100            .analyze(&request.text, Some(&request.language))
101            .map_err(ApiError::from)?
102    };
103
104    // Filter by min_score if provided
105    let mut results: Vec<EntityResult> = result
106        .detected_entities
107        .into_iter()
108        .filter(|e| {
109            if let Some(min_score) = request.min_score {
110                e.score >= min_score
111            } else {
112                true
113            }
114        })
115        .map(EntityResult::from)
116        .collect();
117
118    // Sort by start position
119    results.sort_by_key(|r| r.start);
120
121    Ok(Json(AnalyzeResponse {
122        original_text: None,
123        results,
124        metadata: result.metadata.into(),
125    }))
126}
127
128/// Anonymize endpoint - detect and anonymize PII
129pub async fn anonymize(
130    State(state): State<AppState>,
131    Json(request): Json<AnonymizeRequest>,
132) -> Result<Json<AnonymizeResponse>, ApiError> {
133    // Validate input
134    if request.text.is_empty() {
135        return Err(ApiError::bad_request("Text cannot be empty"));
136    }
137
138    // Validate encryption key if needed
139    if request.config.strategy == redact_core::AnonymizationStrategy::Encrypt
140        && request.config.encryption_key.is_none()
141    {
142        return Err(ApiError::bad_request(
143            "Encryption key required for encrypt strategy",
144        ));
145    }
146
147    // Convert API config to core config
148    let mask_char = request.config.mask_char.chars().next().unwrap_or('*');
149
150    let core_config = AnonymizerConfig {
151        strategy: request.config.strategy,
152        mask_char,
153        mask_start_chars: request.config.mask_start_chars,
154        mask_end_chars: request.config.mask_end_chars,
155        preserve_format: request.config.preserve_format,
156        encryption_key: request.config.encryption_key,
157        hash_salt: request.config.hash_salt,
158    };
159
160    // Parse entity types if provided
161    let entity_types: Option<Vec<EntityType>> = request.entities.as_ref().map(|entities| {
162        entities
163            .iter()
164            .map(|e| EntityType::from(e.clone()))
165            .collect()
166    });
167
168    // Analyze and anonymize
169    let result = if let Some(entities) = entity_types.as_ref() {
170        // First analyze with specific entities
171        let analysis = state
172            .engine
173            .analyze_with_entities(&request.text, entities, Some(&request.language))
174            .map_err(ApiError::from)?;
175
176        // Then anonymize
177        let anonymized = state
178            .engine
179            .anonymizer_registry()
180            .anonymize(
181                &request.text,
182                analysis.detected_entities.clone(),
183                &core_config,
184            )
185            .map_err(ApiError::from)?;
186
187        (analysis.detected_entities, anonymized, analysis.metadata)
188    } else {
189        let analysis = state
190            .engine
191            .analyze_and_anonymize(&request.text, Some(&request.language), &core_config)
192            .map_err(ApiError::from)?;
193
194        let anonymized = analysis
195            .anonymized
196            .ok_or_else(|| ApiError::internal_error("Anonymization failed"))?;
197
198        (analysis.detected_entities, anonymized, analysis.metadata)
199    };
200
201    let (detected_entities, anonymized, metadata) = result;
202
203    // Convert results
204    let results: Vec<EntityResult> = detected_entities
205        .into_iter()
206        .map(EntityResult::from)
207        .collect();
208
209    let tokens: Option<Vec<TokenInfo>> = anonymized
210        .tokens
211        .map(|tokens| tokens.into_iter().map(TokenInfo::from).collect());
212
213    Ok(Json(AnonymizeResponse {
214        text: anonymized.text,
215        results,
216        tokens,
217        metadata: metadata.into(),
218    }))
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::models::AnonymizationConfig;
225
226    fn create_test_state() -> AppState {
227        AppState {
228            engine: Arc::new(AnalyzerEngine::new()),
229        }
230    }
231
232    #[tokio::test]
233    async fn test_health() {
234        let state = create_test_state();
235        let response = health(State(state)).await;
236
237        assert_eq!(response.status, "healthy");
238        assert!(!response.version.is_empty());
239    }
240
241    #[tokio::test]
242    async fn test_analyze() {
243        let state = create_test_state();
244        let request = AnalyzeRequest {
245            text: "Email: john@example.com".to_string(),
246            language: "en".to_string(),
247            entities: None,
248            min_score: None,
249        };
250
251        let response = analyze(State(state), Json(request)).await.unwrap();
252
253        assert!(!response.results.is_empty());
254        assert_eq!(response.results[0].entity_type, "EMAIL_ADDRESS");
255    }
256
257    #[tokio::test]
258    async fn test_anonymize() {
259        let state = create_test_state();
260        let request = AnonymizeRequest {
261            text: "Email: john@example.com".to_string(),
262            language: "en".to_string(),
263            config: AnonymizationConfig::default(),
264            entities: None,
265        };
266
267        let response = anonymize(State(state), Json(request)).await.unwrap();
268
269        assert!(response.text.contains("[EMAIL_ADDRESS]"));
270        assert!(!response.results.is_empty());
271    }
272}