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