1use 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#[derive(Clone)]
21pub struct AppState {
22 pub engine: Arc<AnalyzerEngine>,
23}
24
25#[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
62pub 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
73pub async fn analyze(
75 State(state): State<AppState>,
76 Json(request): Json<AnalyzeRequest>,
77) -> Result<Json<AnalyzeResponse>, ApiError> {
78 if request.text.is_empty() {
80 return Err(ApiError::bad_request("Text cannot be empty"));
81 }
82
83 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 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 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 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
128pub async fn anonymize(
130 State(state): State<AppState>,
131 Json(request): Json<AnonymizeRequest>,
132) -> Result<Json<AnonymizeResponse>, ApiError> {
133 if request.text.is_empty() {
135 return Err(ApiError::bad_request("Text cannot be empty"));
136 }
137
138 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 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 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 let result = if let Some(entities) = entity_types.as_ref() {
170 let analysis = state
172 .engine
173 .analyze_with_entities(&request.text, entities, Some(&request.language))
174 .map_err(ApiError::from)?;
175
176 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 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}