Skip to main content

redact_api/
handlers.rs

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