Skip to main content

fakecloud_comprehend/
service.rs

1//! Amazon Comprehend awsJson1_1 dispatch + operation handlers.
2//!
3//! Requests carry `X-Amz-Target: Comprehend_20171127.<Operation>`; dispatch
4//! keys off `req.action`. Every operation runs model-driven input validation
5//! first (required / length / range / enum / pattern), then real,
6//! account-partitioned, persisted CRUD. Dereferencing a resource that does not
7//! exist returns Comprehend's canonical `ResourceNotFoundException` (or
8//! `JobNotFoundException` for analysis jobs); a duplicate name returns
9//! `ResourceInUseException`.
10//!
11//! Honest NLP gap: fakecloud runs no natural-language inference. The
12//! synchronous detection operations return well-formed, structurally-correct
13//! result shapes with empty analysis lists; `DetectSentiment` returns the
14//! neutral default rather than an invented judgement. No entities, key phrases,
15//! syntax tokens, PII spans, or language probabilities are fabricated.
16
17use std::sync::Arc;
18
19use async_trait::async_trait;
20use http::StatusCode;
21use serde_json::{json, Map, Value};
22use tokio::sync::Mutex as AsyncMutex;
23
24use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
25use fakecloud_persistence::SnapshotStore;
26
27use crate::shared::{hex32, now_epoch, parse_resource_arn, resource_arn};
28use crate::state::{ComprehendData, SharedComprehendState};
29
30/// Every operation name in the Amazon Comprehend Smithy model (85 operations).
31pub const COMPREHEND_ACTIONS: &[&str] = &[
32    "BatchDetectDominantLanguage",
33    "BatchDetectEntities",
34    "BatchDetectKeyPhrases",
35    "BatchDetectSentiment",
36    "BatchDetectSyntax",
37    "BatchDetectTargetedSentiment",
38    "ClassifyDocument",
39    "ContainsPiiEntities",
40    "CreateDataset",
41    "CreateDocumentClassifier",
42    "CreateEndpoint",
43    "CreateEntityRecognizer",
44    "CreateFlywheel",
45    "DeleteDocumentClassifier",
46    "DeleteEndpoint",
47    "DeleteEntityRecognizer",
48    "DeleteFlywheel",
49    "DeleteResourcePolicy",
50    "DescribeDataset",
51    "DescribeDocumentClassificationJob",
52    "DescribeDocumentClassifier",
53    "DescribeDominantLanguageDetectionJob",
54    "DescribeEndpoint",
55    "DescribeEntitiesDetectionJob",
56    "DescribeEntityRecognizer",
57    "DescribeEventsDetectionJob",
58    "DescribeFlywheel",
59    "DescribeFlywheelIteration",
60    "DescribeKeyPhrasesDetectionJob",
61    "DescribePiiEntitiesDetectionJob",
62    "DescribeResourcePolicy",
63    "DescribeSentimentDetectionJob",
64    "DescribeTargetedSentimentDetectionJob",
65    "DescribeTopicsDetectionJob",
66    "DetectDominantLanguage",
67    "DetectEntities",
68    "DetectKeyPhrases",
69    "DetectPiiEntities",
70    "DetectSentiment",
71    "DetectSyntax",
72    "DetectTargetedSentiment",
73    "DetectToxicContent",
74    "ImportModel",
75    "ListDatasets",
76    "ListDocumentClassificationJobs",
77    "ListDocumentClassifierSummaries",
78    "ListDocumentClassifiers",
79    "ListDominantLanguageDetectionJobs",
80    "ListEndpoints",
81    "ListEntitiesDetectionJobs",
82    "ListEntityRecognizerSummaries",
83    "ListEntityRecognizers",
84    "ListEventsDetectionJobs",
85    "ListFlywheelIterationHistory",
86    "ListFlywheels",
87    "ListKeyPhrasesDetectionJobs",
88    "ListPiiEntitiesDetectionJobs",
89    "ListSentimentDetectionJobs",
90    "ListTagsForResource",
91    "ListTargetedSentimentDetectionJobs",
92    "ListTopicsDetectionJobs",
93    "PutResourcePolicy",
94    "StartDocumentClassificationJob",
95    "StartDominantLanguageDetectionJob",
96    "StartEntitiesDetectionJob",
97    "StartEventsDetectionJob",
98    "StartFlywheelIteration",
99    "StartKeyPhrasesDetectionJob",
100    "StartPiiEntitiesDetectionJob",
101    "StartSentimentDetectionJob",
102    "StartTargetedSentimentDetectionJob",
103    "StartTopicsDetectionJob",
104    "StopDominantLanguageDetectionJob",
105    "StopEntitiesDetectionJob",
106    "StopEventsDetectionJob",
107    "StopKeyPhrasesDetectionJob",
108    "StopPiiEntitiesDetectionJob",
109    "StopSentimentDetectionJob",
110    "StopTargetedSentimentDetectionJob",
111    "StopTrainingDocumentClassifier",
112    "StopTrainingEntityRecognizer",
113    "TagResource",
114    "UntagResource",
115    "UpdateEndpoint",
116    "UpdateFlywheel",
117];
118
119/// Read-only verbs; any other action mutates persisted state and triggers a
120/// snapshot after success. The inverse formulation guarantees no mutation is
121/// ever missed if a new op is added.
122fn is_mutating_action(action: &str) -> bool {
123    const READ_PREFIXES: &[&str] = &[
124        "Detect", "Batch", "Describe", "List", "Contains", "Classify",
125    ];
126    !READ_PREFIXES.iter().any(|p| action.starts_with(p))
127}
128
129/// A description of one asynchronous analysis-job family (there are nine): its
130/// internal key, the ARN resource-type segment, and its `*JobProperties` wire
131/// wrapper (the `Describe*` output member and the `List*` list member stem).
132struct JobFamily {
133    /// Internal family key stored in `job_families`.
134    key: &'static str,
135    /// ARN resource-type segment, e.g. `sentiment-detection-job`.
136    arn_type: &'static str,
137    /// The `*JobProperties` shape name (Describe output member).
138    props: &'static str,
139    /// The `*JobPropertiesList` list-output member.
140    props_list: &'static str,
141    /// Input members copied verbatim into the stored properties (a subset of the
142    /// family's `*JobProperties` members; `copy_members` only pulls those the
143    /// caller sent).
144    copy: &'static [&'static str],
145}
146
147const JOB_FAMILIES: &[JobFamily] = &[
148    JobFamily {
149        key: "sentiment",
150        arn_type: "sentiment-detection-job",
151        props: "SentimentDetectionJobProperties",
152        props_list: "SentimentDetectionJobPropertiesList",
153        copy: &[
154            "JobName",
155            "InputDataConfig",
156            "OutputDataConfig",
157            "LanguageCode",
158            "DataAccessRoleArn",
159            "VolumeKmsKeyId",
160            "VpcConfig",
161        ],
162    },
163    JobFamily {
164        key: "targeted-sentiment",
165        arn_type: "targeted-sentiment-detection-job",
166        props: "TargetedSentimentDetectionJobProperties",
167        props_list: "TargetedSentimentDetectionJobPropertiesList",
168        copy: &[
169            "JobName",
170            "InputDataConfig",
171            "OutputDataConfig",
172            "LanguageCode",
173            "DataAccessRoleArn",
174            "VolumeKmsKeyId",
175            "VpcConfig",
176        ],
177    },
178    JobFamily {
179        key: "key-phrases",
180        arn_type: "key-phrases-detection-job",
181        props: "KeyPhrasesDetectionJobProperties",
182        props_list: "KeyPhrasesDetectionJobPropertiesList",
183        copy: &[
184            "JobName",
185            "InputDataConfig",
186            "OutputDataConfig",
187            "LanguageCode",
188            "DataAccessRoleArn",
189            "VolumeKmsKeyId",
190            "VpcConfig",
191        ],
192    },
193    JobFamily {
194        key: "entities",
195        arn_type: "entities-detection-job",
196        props: "EntitiesDetectionJobProperties",
197        props_list: "EntitiesDetectionJobPropertiesList",
198        copy: &[
199            "JobName",
200            "EntityRecognizerArn",
201            "InputDataConfig",
202            "OutputDataConfig",
203            "LanguageCode",
204            "DataAccessRoleArn",
205            "VolumeKmsKeyId",
206            "VpcConfig",
207            "FlywheelArn",
208        ],
209    },
210    JobFamily {
211        key: "dominant-language",
212        arn_type: "dominant-language-detection-job",
213        props: "DominantLanguageDetectionJobProperties",
214        props_list: "DominantLanguageDetectionJobPropertiesList",
215        copy: &[
216            "JobName",
217            "InputDataConfig",
218            "OutputDataConfig",
219            "DataAccessRoleArn",
220            "VolumeKmsKeyId",
221            "VpcConfig",
222        ],
223    },
224    JobFamily {
225        key: "pii",
226        arn_type: "pii-entities-detection-job",
227        props: "PiiEntitiesDetectionJobProperties",
228        props_list: "PiiEntitiesDetectionJobPropertiesList",
229        copy: &[
230            "JobName",
231            "InputDataConfig",
232            "OutputDataConfig",
233            "RedactionConfig",
234            "LanguageCode",
235            "DataAccessRoleArn",
236            "Mode",
237        ],
238    },
239    JobFamily {
240        key: "events",
241        arn_type: "events-detection-job",
242        props: "EventsDetectionJobProperties",
243        props_list: "EventsDetectionJobPropertiesList",
244        copy: &[
245            "JobName",
246            "InputDataConfig",
247            "OutputDataConfig",
248            "LanguageCode",
249            "DataAccessRoleArn",
250            "TargetEventTypes",
251        ],
252    },
253    JobFamily {
254        key: "topics",
255        arn_type: "topics-detection-job",
256        props: "TopicsDetectionJobProperties",
257        props_list: "TopicsDetectionJobPropertiesList",
258        copy: &[
259            "JobName",
260            "InputDataConfig",
261            "OutputDataConfig",
262            "NumberOfTopics",
263            "DataAccessRoleArn",
264            "VolumeKmsKeyId",
265            "VpcConfig",
266        ],
267    },
268    JobFamily {
269        key: "document-classification",
270        arn_type: "document-classification-job",
271        props: "DocumentClassificationJobProperties",
272        props_list: "DocumentClassificationJobPropertiesList",
273        copy: &[
274            "JobName",
275            "DocumentClassifierArn",
276            "InputDataConfig",
277            "OutputDataConfig",
278            "DataAccessRoleArn",
279            "VolumeKmsKeyId",
280            "VpcConfig",
281            "FlywheelArn",
282        ],
283    },
284];
285
286fn family(key: &str) -> &'static JobFamily {
287    JOB_FAMILIES
288        .iter()
289        .find(|f| f.key == key)
290        .expect("known job family")
291}
292
293pub struct ComprehendService {
294    state: SharedComprehendState,
295    snapshot_store: Option<Arc<dyn SnapshotStore>>,
296    snapshot_lock: Arc<AsyncMutex<()>>,
297}
298
299impl ComprehendService {
300    pub fn new(state: SharedComprehendState) -> Self {
301        Self {
302            state,
303            snapshot_store: None,
304            snapshot_lock: Arc::new(AsyncMutex::new(())),
305        }
306    }
307
308    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
309        self.snapshot_store = Some(store);
310        self
311    }
312
313    async fn save(&self) {
314        crate::persistence::save_snapshot(
315            &self.state,
316            self.snapshot_store.clone(),
317            &self.snapshot_lock,
318        )
319        .await;
320    }
321
322    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
323    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
324        let store = self.snapshot_store.clone()?;
325        let state = self.state.clone();
326        let lock = self.snapshot_lock.clone();
327        Some(Arc::new(move || {
328            let state = state.clone();
329            let store = store.clone();
330            let lock = lock.clone();
331            Box::pin(async move {
332                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
333            })
334        }))
335    }
336
337    /// Run `f` against this account's mutable state.
338    fn with_account_mut<R>(&self, req: &AwsRequest, f: impl FnOnce(&mut ComprehendData) -> R) -> R {
339        let mut guard = self.state.write();
340        let acct = guard.get_or_create(&req.account_id);
341        f(acct)
342    }
343}
344
345#[async_trait]
346impl AwsService for ComprehendService {
347    fn service_name(&self) -> &str {
348        "comprehend"
349    }
350
351    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
352        let action = req.action.clone();
353        if let Err(msg) = crate::validate::validate_input(&action, &req.json_body()) {
354            return Err(invalid_request(msg));
355        }
356        let result = self.dispatch(&action, &req);
357        if is_mutating_action(&action)
358            && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
359        {
360            self.save().await;
361        }
362        result
363    }
364
365    fn supported_actions(&self) -> &[&str] {
366        COMPREHEND_ACTIONS
367    }
368}
369
370impl ComprehendService {
371    fn dispatch(&self, action: &str, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
372        let body = req.json_body();
373        match action {
374            // Synchronous detection
375            "DetectDominantLanguage" => self.detect_dominant_language(&body),
376            "DetectEntities" => self.detect_entities(&body),
377            "DetectKeyPhrases" => self.detect_key_phrases(&body),
378            "DetectSyntax" => self.detect_syntax(&body),
379            "DetectSentiment" => self.detect_sentiment(&body),
380            "DetectTargetedSentiment" => self.detect_targeted_sentiment(&body),
381            "DetectPiiEntities" => self.detect_pii_entities(&body),
382            "ContainsPiiEntities" => self.contains_pii_entities(&body),
383            "DetectToxicContent" => self.detect_toxic_content(&body),
384            "ClassifyDocument" => self.classify_document(&body),
385            // Batch detection
386            "BatchDetectDominantLanguage" => self.batch_detect(&body, "Languages"),
387            "BatchDetectEntities" => self.batch_detect(&body, "Entities"),
388            "BatchDetectKeyPhrases" => self.batch_detect(&body, "KeyPhrases"),
389            "BatchDetectSyntax" => self.batch_detect(&body, "SyntaxTokens"),
390            "BatchDetectSentiment" => self.batch_detect(&body, "Sentiment"),
391            "BatchDetectTargetedSentiment" => self.batch_detect(&body, "Entities"),
392            // Asynchronous analysis jobs
393            "StartDominantLanguageDetectionJob" => self.start_job(req, &body, "dominant-language"),
394            "StartEntitiesDetectionJob" => self.start_job(req, &body, "entities"),
395            "StartKeyPhrasesDetectionJob" => self.start_job(req, &body, "key-phrases"),
396            "StartSentimentDetectionJob" => self.start_job(req, &body, "sentiment"),
397            "StartTargetedSentimentDetectionJob" => {
398                self.start_job(req, &body, "targeted-sentiment")
399            }
400            "StartPiiEntitiesDetectionJob" => self.start_job(req, &body, "pii"),
401            "StartEventsDetectionJob" => self.start_job(req, &body, "events"),
402            "StartTopicsDetectionJob" => self.start_job(req, &body, "topics"),
403            "StartDocumentClassificationJob" => {
404                self.start_job(req, &body, "document-classification")
405            }
406            "DescribeDominantLanguageDetectionJob" => {
407                self.describe_job(req, &body, "dominant-language")
408            }
409            "DescribeEntitiesDetectionJob" => self.describe_job(req, &body, "entities"),
410            "DescribeKeyPhrasesDetectionJob" => self.describe_job(req, &body, "key-phrases"),
411            "DescribeSentimentDetectionJob" => self.describe_job(req, &body, "sentiment"),
412            "DescribeTargetedSentimentDetectionJob" => {
413                self.describe_job(req, &body, "targeted-sentiment")
414            }
415            "DescribePiiEntitiesDetectionJob" => self.describe_job(req, &body, "pii"),
416            "DescribeEventsDetectionJob" => self.describe_job(req, &body, "events"),
417            "DescribeTopicsDetectionJob" => self.describe_job(req, &body, "topics"),
418            "DescribeDocumentClassificationJob" => {
419                self.describe_job(req, &body, "document-classification")
420            }
421            "ListDominantLanguageDetectionJobs" => self.list_jobs(req, &body, "dominant-language"),
422            "ListEntitiesDetectionJobs" => self.list_jobs(req, &body, "entities"),
423            "ListKeyPhrasesDetectionJobs" => self.list_jobs(req, &body, "key-phrases"),
424            "ListSentimentDetectionJobs" => self.list_jobs(req, &body, "sentiment"),
425            "ListTargetedSentimentDetectionJobs" => {
426                self.list_jobs(req, &body, "targeted-sentiment")
427            }
428            "ListPiiEntitiesDetectionJobs" => self.list_jobs(req, &body, "pii"),
429            "ListEventsDetectionJobs" => self.list_jobs(req, &body, "events"),
430            "ListTopicsDetectionJobs" => self.list_jobs(req, &body, "topics"),
431            "ListDocumentClassificationJobs" => {
432                self.list_jobs(req, &body, "document-classification")
433            }
434            "StopDominantLanguageDetectionJob" => self.stop_job(req, &body, "dominant-language"),
435            "StopEntitiesDetectionJob" => self.stop_job(req, &body, "entities"),
436            "StopKeyPhrasesDetectionJob" => self.stop_job(req, &body, "key-phrases"),
437            "StopSentimentDetectionJob" => self.stop_job(req, &body, "sentiment"),
438            "StopTargetedSentimentDetectionJob" => self.stop_job(req, &body, "targeted-sentiment"),
439            "StopPiiEntitiesDetectionJob" => self.stop_job(req, &body, "pii"),
440            "StopEventsDetectionJob" => self.stop_job(req, &body, "events"),
441            // Document classifiers
442            "CreateDocumentClassifier" => self.create_document_classifier(req, &body),
443            "DescribeDocumentClassifier" => self.describe_document_classifier(req, &body),
444            "DeleteDocumentClassifier" => self.delete_document_classifier(req, &body),
445            "StopTrainingDocumentClassifier" => self.stop_training_document_classifier(req, &body),
446            "ListDocumentClassifiers" => self.list_document_classifiers(req, &body),
447            "ListDocumentClassifierSummaries" => {
448                self.list_document_classifier_summaries(req, &body)
449            }
450            // Entity recognizers
451            "CreateEntityRecognizer" => self.create_entity_recognizer(req, &body),
452            "DescribeEntityRecognizer" => self.describe_entity_recognizer(req, &body),
453            "DeleteEntityRecognizer" => self.delete_entity_recognizer(req, &body),
454            "StopTrainingEntityRecognizer" => self.stop_training_entity_recognizer(req, &body),
455            "ListEntityRecognizers" => self.list_entity_recognizers(req, &body),
456            "ListEntityRecognizerSummaries" => self.list_entity_recognizer_summaries(req, &body),
457            // Endpoints
458            "CreateEndpoint" => self.create_endpoint(req, &body),
459            "DescribeEndpoint" => self.describe_endpoint(req, &body),
460            "UpdateEndpoint" => self.update_endpoint(req, &body),
461            "DeleteEndpoint" => self.delete_endpoint(req, &body),
462            "ListEndpoints" => self.list_endpoints(req, &body),
463            // Flywheels + iterations
464            "CreateFlywheel" => self.create_flywheel(req, &body),
465            "DescribeFlywheel" => self.describe_flywheel(req, &body),
466            "UpdateFlywheel" => self.update_flywheel(req, &body),
467            "DeleteFlywheel" => self.delete_flywheel(req, &body),
468            "ListFlywheels" => self.list_flywheels(req, &body),
469            "StartFlywheelIteration" => self.start_flywheel_iteration(req, &body),
470            "DescribeFlywheelIteration" => self.describe_flywheel_iteration(req, &body),
471            "ListFlywheelIterationHistory" => self.list_flywheel_iteration_history(req, &body),
472            // Datasets
473            "CreateDataset" => self.create_dataset(req, &body),
474            "DescribeDataset" => self.describe_dataset(req, &body),
475            "ListDatasets" => self.list_datasets(req, &body),
476            // Model import + resource policies
477            "ImportModel" => self.import_model(req, &body),
478            "PutResourcePolicy" => self.put_resource_policy(req, &body),
479            "DescribeResourcePolicy" => self.describe_resource_policy(req, &body),
480            "DeleteResourcePolicy" => self.delete_resource_policy(req, &body),
481            // Tagging
482            "TagResource" => self.tag_resource(req, &body),
483            "UntagResource" => self.untag_resource(req, &body),
484            "ListTagsForResource" => self.list_tags_for_resource(req, &body),
485            _ => Err(AwsServiceError::action_not_implemented(
486                self.service_name(),
487                action,
488            )),
489        }
490    }
491}
492
493// ---- error / response helpers --------------------------------------------
494
495fn invalid_request(msg: impl Into<String>) -> AwsServiceError {
496    AwsServiceError::aws_error(
497        StatusCode::BAD_REQUEST,
498        "InvalidRequestException",
499        msg.into(),
500    )
501}
502
503fn resource_not_found(msg: impl Into<String>) -> AwsServiceError {
504    AwsServiceError::aws_error(
505        StatusCode::BAD_REQUEST,
506        "ResourceNotFoundException",
507        msg.into(),
508    )
509}
510
511fn job_not_found(msg: impl Into<String>) -> AwsServiceError {
512    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "JobNotFoundException", msg.into())
513}
514
515fn resource_in_use(msg: impl Into<String>) -> AwsServiceError {
516    AwsServiceError::aws_error(
517        StatusCode::BAD_REQUEST,
518        "ResourceInUseException",
519        msg.into(),
520    )
521}
522
523fn ok(value: Value) -> Result<AwsResponse, AwsServiceError> {
524    Ok(AwsResponse::ok_json(value))
525}
526
527// ---- small member helpers -------------------------------------------------
528
529/// Copy each named member from `body` into `obj` when present and non-null.
530fn copy_members(body: &Value, obj: &mut Map<String, Value>, names: &[&str]) {
531    for name in names {
532        if let Some(v) = body.get(*name).filter(|v| !v.is_null()) {
533            obj.insert((*name).to_string(), v.clone());
534        }
535    }
536}
537
538fn str_member<'a>(body: &'a Value, name: &str) -> Option<&'a str> {
539    body.get(name).and_then(Value::as_str)
540}
541
542/// The neutral-default sentiment score fakecloud returns in place of a real
543/// model judgement (see the crate-level NLP gap note).
544fn neutral_sentiment_score() -> Value {
545    json!({ "Positive": 0.0, "Negative": 0.0, "Neutral": 1.0, "Mixed": 0.0 })
546}
547
548/// Convert a `TagList` (`[{Key,Value}]`) into a sorted key/value map.
549fn taglist_to_map(tags: &Value) -> std::collections::BTreeMap<String, String> {
550    let mut out = std::collections::BTreeMap::new();
551    if let Some(arr) = tags.as_array() {
552        for t in arr {
553            if let (Some(k), Some(v)) = (str_member(t, "Key"), str_member(t, "Value")) {
554                out.insert(k.to_string(), v.to_string());
555            }
556        }
557    }
558    out
559}
560
561/// Render a key/value map back into a `TagList`.
562fn map_to_taglist(map: &std::collections::BTreeMap<String, String>) -> Value {
563    Value::Array(
564        map.iter()
565            .map(|(k, v)| json!({ "Key": k, "Value": v }))
566            .collect(),
567    )
568}
569
570impl ComprehendData {
571    /// Record `Tags` from a create/start request under `arn`, if any.
572    fn store_tags(&mut self, arn: &str, body: &Value) {
573        if let Some(tags) = body.get("Tags").filter(|v| !v.is_null()) {
574            let map = taglist_to_map(tags);
575            if !map.is_empty() {
576                self.tags.insert(arn.to_string(), map);
577            }
578        }
579    }
580
581    /// True if the resource named by an ARN's `(type, tail)` exists.
582    fn resource_exists(&self, arn: &str) -> bool {
583        self.document_classifiers.contains_key(arn)
584            || self.entity_recognizers.contains_key(arn)
585            || self.endpoints.contains_key(arn)
586            || self.flywheels.contains_key(arn)
587            || self.datasets.contains_key(arn)
588    }
589}
590
591// ---- synchronous detection ------------------------------------------------
592
593impl ComprehendService {
594    fn detect_dominant_language(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
595        // No language detection is run; an empty result set is honest.
596        ok(json!({ "Languages": [] }))
597    }
598
599    fn detect_entities(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
600        ok(json!({ "Entities": [] }))
601    }
602
603    fn detect_key_phrases(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
604        ok(json!({ "KeyPhrases": [] }))
605    }
606
607    fn detect_syntax(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
608        ok(json!({ "SyntaxTokens": [] }))
609    }
610
611    fn detect_sentiment(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
612        ok(json!({
613            "Sentiment": "NEUTRAL",
614            "SentimentScore": neutral_sentiment_score(),
615        }))
616    }
617
618    fn detect_targeted_sentiment(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
619        ok(json!({ "Entities": [] }))
620    }
621
622    fn detect_pii_entities(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
623        ok(json!({ "Entities": [] }))
624    }
625
626    fn contains_pii_entities(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
627        ok(json!({ "Labels": [] }))
628    }
629
630    fn detect_toxic_content(&self, body: &Value) -> Result<AwsResponse, AwsServiceError> {
631        // One well-formed (empty) result per input text segment.
632        let n = body
633            .get("TextSegments")
634            .and_then(Value::as_array)
635            .map(|a| a.len())
636            .unwrap_or(0);
637        let results: Vec<Value> = (0..n)
638            .map(|_| json!({ "Labels": [], "Toxicity": 0.0 }))
639            .collect();
640        ok(json!({ "ResultList": results }))
641    }
642
643    fn classify_document(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
644        // No custom-model inference is run; empty classes/labels are honest.
645        ok(json!({ "Classes": [], "Labels": [] }))
646    }
647
648    /// Shared body for the six `BatchDetect*` operations: emit one well-formed,
649    /// empty result per input string (indexed) and no per-document errors.
650    fn batch_detect(&self, body: &Value, field: &str) -> Result<AwsResponse, AwsServiceError> {
651        let n = body
652            .get("TextList")
653            .and_then(Value::as_array)
654            .map(|a| a.len())
655            .unwrap_or(0);
656        let results: Vec<Value> = (0..n)
657            .map(|i| {
658                let mut obj = Map::new();
659                obj.insert("Index".into(), json!(i as i64));
660                if field == "Sentiment" {
661                    obj.insert("Sentiment".into(), json!("NEUTRAL"));
662                    obj.insert("SentimentScore".into(), neutral_sentiment_score());
663                } else {
664                    obj.insert(field.to_string(), json!([]));
665                }
666                Value::Object(obj)
667            })
668            .collect();
669        ok(json!({ "ResultList": results, "ErrorList": [] }))
670    }
671}
672
673// ---- asynchronous analysis jobs -------------------------------------------
674
675impl ComprehendService {
676    fn start_job(
677        &self,
678        req: &AwsRequest,
679        body: &Value,
680        family_key: &str,
681    ) -> Result<AwsResponse, AwsServiceError> {
682        let fam = family(family_key);
683        let region = req.region.clone();
684        let account = req.account_id.clone();
685        let seed = str_member(body, "ClientRequestToken")
686            .map(|t| format!("{account}/{family_key}/{t}"))
687            .unwrap_or_default();
688        let job_id = hex32(&seed);
689        let job_arn = resource_arn(&region, &account, fam.arn_type, &job_id);
690        let job_name = str_member(body, "JobName")
691            .map(String::from)
692            .unwrap_or_else(|| job_id.clone());
693        self.with_account_mut(req, |d| {
694            let now = now_epoch();
695            let mut obj = Map::new();
696            obj.insert("JobId".into(), json!(job_id));
697            obj.insert("JobArn".into(), json!(job_arn));
698            obj.insert("JobName".into(), json!(job_name));
699            obj.insert("JobStatus".into(), json!("SUBMITTED"));
700            obj.insert("SubmitTime".into(), json!(now));
701            copy_members(body, &mut obj, fam.copy);
702            d.jobs.insert(job_id.clone(), Value::Object(obj));
703            d.job_families.insert(job_id.clone(), fam.key.to_string());
704            d.store_tags(&job_arn, body);
705            ok(json!({
706                "JobId": job_id,
707                "JobArn": job_arn,
708                "JobStatus": "SUBMITTED",
709            }))
710        })
711    }
712
713    fn describe_job(
714        &self,
715        req: &AwsRequest,
716        body: &Value,
717        family_key: &str,
718    ) -> Result<AwsResponse, AwsServiceError> {
719        let fam = family(family_key);
720        let job_id = str_member(body, "JobId").unwrap_or_default().to_string();
721        self.with_account_mut(req, |d| {
722            d.reconcile();
723            let belongs = d.job_families.get(&job_id).map(String::as_str) == Some(fam.key);
724            let Some(job) = d.jobs.get(&job_id).filter(|_| belongs).cloned() else {
725                return Err(job_not_found(
726                    "The specified job was not found. Check the job ID and try again.",
727                ));
728            };
729            ok(json!({ fam.props: job }))
730        })
731    }
732
733    fn list_jobs(
734        &self,
735        req: &AwsRequest,
736        body: &Value,
737        family_key: &str,
738    ) -> Result<AwsResponse, AwsServiceError> {
739        let fam = family(family_key);
740        let filter = body.get("Filter");
741        let name_eq = filter.and_then(|f| str_member(f, "JobName"));
742        let status_eq = filter.and_then(|f| str_member(f, "JobStatus"));
743        self.with_account_mut(req, |d| {
744            d.reconcile();
745            let list: Vec<Value> = d
746                .jobs
747                .iter()
748                .filter(|(id, _)| d.job_families.get(*id).map(String::as_str) == Some(fam.key))
749                .map(|(_, v)| v)
750                .filter(|v| {
751                    name_eq
752                        .map(|n| v.get("JobName").and_then(Value::as_str) == Some(n))
753                        .unwrap_or(true)
754                        && status_eq
755                            .map(|s| v.get("JobStatus").and_then(Value::as_str) == Some(s))
756                            .unwrap_or(true)
757                })
758                .cloned()
759                .collect();
760            ok(json!({ fam.props_list: list }))
761        })
762    }
763
764    fn stop_job(
765        &self,
766        req: &AwsRequest,
767        body: &Value,
768        family_key: &str,
769    ) -> Result<AwsResponse, AwsServiceError> {
770        let fam = family(family_key);
771        let job_id = str_member(body, "JobId").unwrap_or_default().to_string();
772        self.with_account_mut(req, |d| {
773            let belongs = d.job_families.get(&job_id).map(String::as_str) == Some(fam.key);
774            let Some(job) = d.jobs.get_mut(&job_id).filter(|_| belongs) else {
775                return Err(job_not_found(
776                    "The specified job was not found. Check the job ID and try again.",
777                ));
778            };
779            let obj = job.as_object_mut().expect("job is an object");
780            let status = obj
781                .get("JobStatus")
782                .and_then(Value::as_str)
783                .unwrap_or("")
784                .to_string();
785            let new_status = if matches!(status.as_str(), "SUBMITTED" | "IN_PROGRESS") {
786                obj.insert("JobStatus".into(), json!("STOP_REQUESTED"));
787                "STOP_REQUESTED"
788            } else {
789                status.as_str()
790            };
791            ok(json!({ "JobId": job_id, "JobStatus": new_status }))
792        })
793    }
794}
795
796// ---- document classifiers -------------------------------------------------
797
798impl ComprehendService {
799    fn create_document_classifier(
800        &self,
801        req: &AwsRequest,
802        body: &Value,
803    ) -> Result<AwsResponse, AwsServiceError> {
804        let name = str_member(body, "DocumentClassifierName")
805            .unwrap_or_default()
806            .to_string();
807        let version = str_member(body, "VersionName").map(String::from);
808        let region = req.region.clone();
809        let account = req.account_id.clone();
810        let tail = match &version {
811            Some(v) => format!("{name}/version/{v}"),
812            None => name.clone(),
813        };
814        let arn = resource_arn(&region, &account, "document-classifier", &tail);
815        self.with_account_mut(req, |d| {
816            if d.document_classifiers.contains_key(&arn) {
817                return Err(resource_in_use(
818                    "Concurrent modification of resources. A resource with the same name already exists.",
819                ));
820            }
821            let now = now_epoch();
822            let mut obj = Map::new();
823            obj.insert("DocumentClassifierArn".into(), json!(arn));
824            obj.insert("Status".into(), json!("SUBMITTED"));
825            obj.insert("SubmitTime".into(), json!(now));
826            copy_members(
827                body,
828                &mut obj,
829                &[
830                    "LanguageCode",
831                    "InputDataConfig",
832                    "OutputDataConfig",
833                    "DataAccessRoleArn",
834                    "VolumeKmsKeyId",
835                    "VpcConfig",
836                    "Mode",
837                    "ModelKmsKeyId",
838                    "VersionName",
839                ],
840            );
841            d.document_classifiers.insert(arn.clone(), Value::Object(obj));
842            d.store_tags(&arn, body);
843            ok(json!({ "DocumentClassifierArn": arn }))
844        })
845    }
846
847    fn describe_document_classifier(
848        &self,
849        req: &AwsRequest,
850        body: &Value,
851    ) -> Result<AwsResponse, AwsServiceError> {
852        let arn = str_member(body, "DocumentClassifierArn")
853            .unwrap_or_default()
854            .to_string();
855        self.with_account_mut(req, |d| {
856            d.reconcile();
857            let Some(v) = d.document_classifiers.get(&arn).cloned() else {
858                return Err(resource_not_found(
859                    "The specified resource ARN was not found. Check the ARN and try your request again.",
860                ));
861            };
862            ok(json!({ "DocumentClassifierProperties": v }))
863        })
864    }
865
866    fn delete_document_classifier(
867        &self,
868        req: &AwsRequest,
869        body: &Value,
870    ) -> Result<AwsResponse, AwsServiceError> {
871        let arn = str_member(body, "DocumentClassifierArn")
872            .unwrap_or_default()
873            .to_string();
874        self.with_account_mut(req, |d| {
875            if d.document_classifiers.remove(&arn).is_none() {
876                return Err(resource_not_found(
877                    "The specified resource ARN was not found. Check the ARN and try your request again.",
878                ));
879            }
880            d.tags.remove(&arn);
881            ok(json!({}))
882        })
883    }
884
885    fn stop_training_document_classifier(
886        &self,
887        req: &AwsRequest,
888        body: &Value,
889    ) -> Result<AwsResponse, AwsServiceError> {
890        let arn = str_member(body, "DocumentClassifierArn")
891            .unwrap_or_default()
892            .to_string();
893        self.with_account_mut(req, |d| {
894            let Some(v) = d.document_classifiers.get_mut(&arn) else {
895                return Err(resource_not_found(
896                    "The specified resource ARN was not found. Check the ARN and try your request again.",
897                ));
898            };
899            stop_training(v);
900            ok(json!({}))
901        })
902    }
903
904    fn list_document_classifiers(
905        &self,
906        req: &AwsRequest,
907        body: &Value,
908    ) -> Result<AwsResponse, AwsServiceError> {
909        let filter = body.get("Filter");
910        let status_eq = filter.and_then(|f| str_member(f, "Status"));
911        let name_eq = filter.and_then(|f| str_member(f, "DocumentClassifierName"));
912        self.with_account_mut(req, |d| {
913            d.reconcile();
914            let list: Vec<Value> = d
915                .document_classifiers
916                .values()
917                .filter(|v| {
918                    status_eq
919                        .map(|s| v.get("Status").and_then(Value::as_str) == Some(s))
920                        .unwrap_or(true)
921                        && name_eq
922                            .map(|n| classifier_name(v).as_deref() == Some(n))
923                            .unwrap_or(true)
924                })
925                .cloned()
926                .collect();
927            ok(json!({ "DocumentClassifierPropertiesList": list }))
928        })
929    }
930
931    fn list_document_classifier_summaries(
932        &self,
933        req: &AwsRequest,
934        _body: &Value,
935    ) -> Result<AwsResponse, AwsServiceError> {
936        self.with_account_mut(req, |d| {
937            d.reconcile();
938            let summaries = summarize_versions(
939                d.document_classifiers.values(),
940                classifier_name,
941                "DocumentClassifierName",
942            );
943            ok(json!({ "DocumentClassifierSummariesList": summaries }))
944        })
945    }
946}
947
948/// Extract the base classifier name from a `DocumentClassifierProperties` (the
949/// segment before any `/version/...`).
950fn classifier_name(v: &Value) -> Option<String> {
951    let arn = v.get("DocumentClassifierArn").and_then(Value::as_str)?;
952    let (_, tail) = parse_resource_arn(arn)?;
953    Some(tail.split("/version/").next().unwrap_or(&tail).to_string())
954}
955
956/// Extract the base recognizer name from an `EntityRecognizerProperties`.
957fn recognizer_name(v: &Value) -> Option<String> {
958    let arn = v.get("EntityRecognizerArn").and_then(Value::as_str)?;
959    let (_, tail) = parse_resource_arn(arn)?;
960    Some(tail.split("/version/").next().unwrap_or(&tail).to_string())
961}
962
963/// Move a trainable resource into `STOP_REQUESTED` when its training is still in
964/// flight (a no-op once terminal).
965fn stop_training(v: &mut Value) {
966    if let Some(obj) = v.as_object_mut() {
967        let status = obj.get("Status").and_then(Value::as_str).unwrap_or("");
968        if matches!(status, "SUBMITTED" | "TRAINING") {
969            obj.insert("Status".into(), json!("STOP_REQUESTED"));
970        }
971    }
972}
973
974/// Build `*Summary` entries (one per base name) collapsing all versions of a
975/// classifier / recognizer, mirroring `ListDocumentClassifierSummaries`.
976fn summarize_versions<'a>(
977    values: impl Iterator<Item = &'a Value>,
978    name_of: fn(&Value) -> Option<String>,
979    name_field: &str,
980) -> Vec<Value> {
981    use std::collections::BTreeMap;
982    // name -> (count, latest_submit, latest_version, latest_status)
983    let mut groups: BTreeMap<String, (i64, f64, Option<String>, Option<String>)> = BTreeMap::new();
984    for v in values {
985        let Some(name) = name_of(v) else { continue };
986        let submit = v.get("SubmitTime").and_then(Value::as_f64).unwrap_or(0.0);
987        let version = v
988            .get("VersionName")
989            .and_then(Value::as_str)
990            .map(String::from);
991        let status = v.get("Status").and_then(Value::as_str).map(String::from);
992        let entry = groups.entry(name).or_insert((0, f64::MIN, None, None));
993        entry.0 += 1;
994        if submit >= entry.1 {
995            entry.1 = submit;
996            entry.2 = version;
997            entry.3 = status;
998        }
999    }
1000    groups
1001        .into_iter()
1002        .map(|(name, (count, submit, version, status))| {
1003            let mut obj = Map::new();
1004            obj.insert(name_field.to_string(), json!(name));
1005            obj.insert("NumberOfVersions".into(), json!(count));
1006            if submit > f64::MIN {
1007                obj.insert("LatestVersionCreatedAt".into(), json!(submit));
1008            }
1009            if let Some(v) = version {
1010                obj.insert("LatestVersionName".into(), json!(v));
1011            }
1012            if let Some(s) = status {
1013                obj.insert("LatestVersionStatus".into(), json!(s));
1014            }
1015            Value::Object(obj)
1016        })
1017        .collect()
1018}
1019
1020// ---- entity recognizers ---------------------------------------------------
1021
1022impl ComprehendService {
1023    fn create_entity_recognizer(
1024        &self,
1025        req: &AwsRequest,
1026        body: &Value,
1027    ) -> Result<AwsResponse, AwsServiceError> {
1028        let name = str_member(body, "RecognizerName")
1029            .unwrap_or_default()
1030            .to_string();
1031        let version = str_member(body, "VersionName").map(String::from);
1032        let region = req.region.clone();
1033        let account = req.account_id.clone();
1034        let tail = match &version {
1035            Some(v) => format!("{name}/version/{v}"),
1036            None => name.clone(),
1037        };
1038        let arn = resource_arn(&region, &account, "entity-recognizer", &tail);
1039        self.with_account_mut(req, |d| {
1040            if d.entity_recognizers.contains_key(&arn) {
1041                return Err(resource_in_use(
1042                    "Concurrent modification of resources. A resource with the same name already exists.",
1043                ));
1044            }
1045            let now = now_epoch();
1046            let mut obj = Map::new();
1047            obj.insert("EntityRecognizerArn".into(), json!(arn));
1048            obj.insert("Status".into(), json!("SUBMITTED"));
1049            obj.insert("SubmitTime".into(), json!(now));
1050            copy_members(
1051                body,
1052                &mut obj,
1053                &[
1054                    "LanguageCode",
1055                    "InputDataConfig",
1056                    "DataAccessRoleArn",
1057                    "VolumeKmsKeyId",
1058                    "VpcConfig",
1059                    "ModelKmsKeyId",
1060                    "VersionName",
1061                ],
1062            );
1063            d.entity_recognizers.insert(arn.clone(), Value::Object(obj));
1064            d.store_tags(&arn, body);
1065            ok(json!({ "EntityRecognizerArn": arn }))
1066        })
1067    }
1068
1069    fn describe_entity_recognizer(
1070        &self,
1071        req: &AwsRequest,
1072        body: &Value,
1073    ) -> Result<AwsResponse, AwsServiceError> {
1074        let arn = str_member(body, "EntityRecognizerArn")
1075            .unwrap_or_default()
1076            .to_string();
1077        self.with_account_mut(req, |d| {
1078            d.reconcile();
1079            let Some(v) = d.entity_recognizers.get(&arn).cloned() else {
1080                return Err(resource_not_found(
1081                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1082                ));
1083            };
1084            ok(json!({ "EntityRecognizerProperties": v }))
1085        })
1086    }
1087
1088    fn delete_entity_recognizer(
1089        &self,
1090        req: &AwsRequest,
1091        body: &Value,
1092    ) -> Result<AwsResponse, AwsServiceError> {
1093        let arn = str_member(body, "EntityRecognizerArn")
1094            .unwrap_or_default()
1095            .to_string();
1096        self.with_account_mut(req, |d| {
1097            if d.entity_recognizers.remove(&arn).is_none() {
1098                return Err(resource_not_found(
1099                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1100                ));
1101            }
1102            d.tags.remove(&arn);
1103            ok(json!({}))
1104        })
1105    }
1106
1107    fn stop_training_entity_recognizer(
1108        &self,
1109        req: &AwsRequest,
1110        body: &Value,
1111    ) -> Result<AwsResponse, AwsServiceError> {
1112        let arn = str_member(body, "EntityRecognizerArn")
1113            .unwrap_or_default()
1114            .to_string();
1115        self.with_account_mut(req, |d| {
1116            let Some(v) = d.entity_recognizers.get_mut(&arn) else {
1117                return Err(resource_not_found(
1118                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1119                ));
1120            };
1121            stop_training(v);
1122            ok(json!({}))
1123        })
1124    }
1125
1126    fn list_entity_recognizers(
1127        &self,
1128        req: &AwsRequest,
1129        body: &Value,
1130    ) -> Result<AwsResponse, AwsServiceError> {
1131        let filter = body.get("Filter");
1132        let status_eq = filter.and_then(|f| str_member(f, "Status"));
1133        let name_eq = filter.and_then(|f| str_member(f, "RecognizerName"));
1134        self.with_account_mut(req, |d| {
1135            d.reconcile();
1136            let list: Vec<Value> = d
1137                .entity_recognizers
1138                .values()
1139                .filter(|v| {
1140                    status_eq
1141                        .map(|s| v.get("Status").and_then(Value::as_str) == Some(s))
1142                        .unwrap_or(true)
1143                        && name_eq
1144                            .map(|n| recognizer_name(v).as_deref() == Some(n))
1145                            .unwrap_or(true)
1146                })
1147                .cloned()
1148                .collect();
1149            ok(json!({ "EntityRecognizerPropertiesList": list }))
1150        })
1151    }
1152
1153    fn list_entity_recognizer_summaries(
1154        &self,
1155        req: &AwsRequest,
1156        _body: &Value,
1157    ) -> Result<AwsResponse, AwsServiceError> {
1158        self.with_account_mut(req, |d| {
1159            d.reconcile();
1160            let summaries = summarize_versions(
1161                d.entity_recognizers.values(),
1162                recognizer_name,
1163                "RecognizerName",
1164            );
1165            ok(json!({ "EntityRecognizerSummariesList": summaries }))
1166        })
1167    }
1168}
1169
1170// ---- endpoints ------------------------------------------------------------
1171
1172impl ComprehendService {
1173    fn create_endpoint(
1174        &self,
1175        req: &AwsRequest,
1176        body: &Value,
1177    ) -> Result<AwsResponse, AwsServiceError> {
1178        let name = str_member(body, "EndpointName")
1179            .unwrap_or_default()
1180            .to_string();
1181        let model_arn = str_member(body, "ModelArn").unwrap_or_default().to_string();
1182        let region = req.region.clone();
1183        let account = req.account_id.clone();
1184        // The endpoint ARN's resource type mirrors the backing model type.
1185        let ep_type = if model_arn.contains("entity-recognizer") {
1186            "entity-recognizer-endpoint"
1187        } else {
1188            "document-classifier-endpoint"
1189        };
1190        let arn = resource_arn(&region, &account, ep_type, &name);
1191        self.with_account_mut(req, |d| {
1192            if d.endpoints.contains_key(&arn) {
1193                return Err(resource_in_use(
1194                    "Concurrent modification of resources. An endpoint with the same name already exists.",
1195                ));
1196            }
1197            let now = now_epoch();
1198            let mut obj = Map::new();
1199            obj.insert("EndpointArn".into(), json!(arn));
1200            obj.insert("Status".into(), json!("CREATING"));
1201            obj.insert("CurrentInferenceUnits".into(), json!(0));
1202            obj.insert("CreationTime".into(), json!(now));
1203            obj.insert("LastModifiedTime".into(), json!(now));
1204            copy_members(
1205                body,
1206                &mut obj,
1207                &[
1208                    "ModelArn",
1209                    "DesiredInferenceUnits",
1210                    "DataAccessRoleArn",
1211                    "FlywheelArn",
1212                ],
1213            );
1214            d.endpoints.insert(arn.clone(), Value::Object(obj));
1215            d.store_tags(&arn, body);
1216            ok(json!({ "EndpointArn": arn, "ModelArn": model_arn }))
1217        })
1218    }
1219
1220    fn describe_endpoint(
1221        &self,
1222        req: &AwsRequest,
1223        body: &Value,
1224    ) -> Result<AwsResponse, AwsServiceError> {
1225        let arn = str_member(body, "EndpointArn")
1226            .unwrap_or_default()
1227            .to_string();
1228        self.with_account_mut(req, |d| {
1229            d.reconcile();
1230            let Some(v) = d.endpoints.get(&arn).cloned() else {
1231                return Err(resource_not_found(
1232                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1233                ));
1234            };
1235            ok(json!({ "EndpointProperties": v }))
1236        })
1237    }
1238
1239    fn update_endpoint(
1240        &self,
1241        req: &AwsRequest,
1242        body: &Value,
1243    ) -> Result<AwsResponse, AwsServiceError> {
1244        let arn = str_member(body, "EndpointArn")
1245            .unwrap_or_default()
1246            .to_string();
1247        self.with_account_mut(req, |d| {
1248            let Some(v) = d.endpoints.get_mut(&arn) else {
1249                return Err(resource_not_found(
1250                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1251                ));
1252            };
1253            let obj = v.as_object_mut().expect("endpoint is an object");
1254            let now = now_epoch();
1255            if let Some(m) = body.get("DesiredModelArn").filter(|v| !v.is_null()) {
1256                obj.insert("DesiredModelArn".into(), m.clone());
1257            }
1258            if let Some(u) = body.get("DesiredInferenceUnits").filter(|v| !v.is_null()) {
1259                obj.insert("DesiredInferenceUnits".into(), u.clone());
1260            }
1261            if let Some(r) = body.get("DesiredDataAccessRoleArn").filter(|v| !v.is_null()) {
1262                obj.insert("DesiredDataAccessRoleArn".into(), r.clone());
1263            }
1264            if let Some(f) = body.get("FlywheelArn").filter(|v| !v.is_null()) {
1265                obj.insert("FlywheelArn".into(), f.clone());
1266            }
1267            obj.insert("Status".into(), json!("UPDATING"));
1268            obj.insert("LastModifiedTime".into(), json!(now));
1269            let desired = obj.get("DesiredModelArn").cloned().unwrap_or(Value::Null);
1270            ok(json!({ "DesiredModelArn": desired }))
1271        })
1272    }
1273
1274    fn delete_endpoint(
1275        &self,
1276        req: &AwsRequest,
1277        body: &Value,
1278    ) -> Result<AwsResponse, AwsServiceError> {
1279        let arn = str_member(body, "EndpointArn")
1280            .unwrap_or_default()
1281            .to_string();
1282        self.with_account_mut(req, |d| {
1283            if d.endpoints.remove(&arn).is_none() {
1284                return Err(resource_not_found(
1285                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1286                ));
1287            }
1288            d.tags.remove(&arn);
1289            ok(json!({}))
1290        })
1291    }
1292
1293    fn list_endpoints(
1294        &self,
1295        req: &AwsRequest,
1296        body: &Value,
1297    ) -> Result<AwsResponse, AwsServiceError> {
1298        let filter = body.get("Filter");
1299        let status_eq = filter.and_then(|f| str_member(f, "Status"));
1300        let model_eq = filter.and_then(|f| str_member(f, "ModelArn"));
1301        self.with_account_mut(req, |d| {
1302            d.reconcile();
1303            let list: Vec<Value> = d
1304                .endpoints
1305                .values()
1306                .filter(|v| {
1307                    status_eq
1308                        .map(|s| v.get("Status").and_then(Value::as_str) == Some(s))
1309                        .unwrap_or(true)
1310                        && model_eq
1311                            .map(|m| v.get("ModelArn").and_then(Value::as_str) == Some(m))
1312                            .unwrap_or(true)
1313                })
1314                .cloned()
1315                .collect();
1316            ok(json!({ "EndpointPropertiesList": list }))
1317        })
1318    }
1319}
1320
1321// ---- flywheels + iterations -----------------------------------------------
1322
1323impl ComprehendService {
1324    fn create_flywheel(
1325        &self,
1326        req: &AwsRequest,
1327        body: &Value,
1328    ) -> Result<AwsResponse, AwsServiceError> {
1329        let name = str_member(body, "FlywheelName")
1330            .unwrap_or_default()
1331            .to_string();
1332        let region = req.region.clone();
1333        let account = req.account_id.clone();
1334        let arn = resource_arn(&region, &account, "flywheel", &name);
1335        let active_model = str_member(body, "ActiveModelArn").map(String::from);
1336        self.with_account_mut(req, |d| {
1337            if d.flywheels.contains_key(&arn) {
1338                return Err(resource_in_use(
1339                    "Concurrent modification of resources. A flywheel with the same name already exists.",
1340                ));
1341            }
1342            let now = now_epoch();
1343            let mut obj = Map::new();
1344            obj.insert("FlywheelArn".into(), json!(arn));
1345            obj.insert("Status".into(), json!("CREATING"));
1346            obj.insert("CreationTime".into(), json!(now));
1347            obj.insert("LastModifiedTime".into(), json!(now));
1348            copy_members(
1349                body,
1350                &mut obj,
1351                &[
1352                    "ActiveModelArn",
1353                    "DataAccessRoleArn",
1354                    "TaskConfig",
1355                    "ModelType",
1356                    "DataLakeS3Uri",
1357                    "DataSecurityConfig",
1358                ],
1359            );
1360            d.flywheels.insert(arn.clone(), Value::Object(obj));
1361            d.store_tags(&arn, body);
1362            ok(json!({ "FlywheelArn": arn, "ActiveModelArn": active_model }))
1363        })
1364    }
1365
1366    fn describe_flywheel(
1367        &self,
1368        req: &AwsRequest,
1369        body: &Value,
1370    ) -> Result<AwsResponse, AwsServiceError> {
1371        let arn = str_member(body, "FlywheelArn")
1372            .unwrap_or_default()
1373            .to_string();
1374        self.with_account_mut(req, |d| {
1375            d.reconcile();
1376            let Some(v) = d.flywheels.get(&arn).cloned() else {
1377                return Err(resource_not_found(
1378                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1379                ));
1380            };
1381            ok(json!({ "FlywheelProperties": v }))
1382        })
1383    }
1384
1385    fn update_flywheel(
1386        &self,
1387        req: &AwsRequest,
1388        body: &Value,
1389    ) -> Result<AwsResponse, AwsServiceError> {
1390        let arn = str_member(body, "FlywheelArn")
1391            .unwrap_or_default()
1392            .to_string();
1393        self.with_account_mut(req, |d| {
1394            let Some(v) = d.flywheels.get_mut(&arn) else {
1395                return Err(resource_not_found(
1396                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1397                ));
1398            };
1399            let obj = v.as_object_mut().expect("flywheel is an object");
1400            for m in ["ActiveModelArn", "DataAccessRoleArn", "DataSecurityConfig"] {
1401                if let Some(val) = body.get(m).filter(|v| !v.is_null()) {
1402                    obj.insert(m.to_string(), val.clone());
1403                }
1404            }
1405            obj.insert("LastModifiedTime".into(), json!(now_epoch()));
1406            ok(json!({ "FlywheelProperties": Value::Object(obj.clone()) }))
1407        })
1408    }
1409
1410    fn delete_flywheel(
1411        &self,
1412        req: &AwsRequest,
1413        body: &Value,
1414    ) -> Result<AwsResponse, AwsServiceError> {
1415        let arn = str_member(body, "FlywheelArn")
1416            .unwrap_or_default()
1417            .to_string();
1418        self.with_account_mut(req, |d| {
1419            if d.flywheels.remove(&arn).is_none() {
1420                return Err(resource_not_found(
1421                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1422                ));
1423            }
1424            d.tags.remove(&arn);
1425            // Drop the flywheel's iterations and datasets too.
1426            d.flywheel_iterations
1427                .retain(|k, _| !k.starts_with(&format!("{arn}#")));
1428            d.datasets
1429                .retain(|_, v| v.get("DatasetArn").and_then(Value::as_str)
1430                    .map(|a| !a.starts_with(&format!("{arn}/dataset/")))
1431                    .unwrap_or(true));
1432            ok(json!({}))
1433        })
1434    }
1435
1436    fn list_flywheels(
1437        &self,
1438        req: &AwsRequest,
1439        body: &Value,
1440    ) -> Result<AwsResponse, AwsServiceError> {
1441        let filter = body.get("Filter");
1442        let status_eq = filter.and_then(|f| str_member(f, "Status"));
1443        self.with_account_mut(req, |d| {
1444            d.reconcile();
1445            let list: Vec<Value> = d
1446                .flywheels
1447                .values()
1448                .filter(|v| {
1449                    status_eq
1450                        .map(|s| v.get("Status").and_then(Value::as_str) == Some(s))
1451                        .unwrap_or(true)
1452                })
1453                .map(|v| {
1454                    // FlywheelSummary is a subset of FlywheelProperties.
1455                    let mut s = Map::new();
1456                    if let Some(obj) = v.as_object() {
1457                        for f in [
1458                            "FlywheelArn",
1459                            "ActiveModelArn",
1460                            "DataLakeS3Uri",
1461                            "Status",
1462                            "ModelType",
1463                            "Message",
1464                            "CreationTime",
1465                            "LastModifiedTime",
1466                            "LatestFlywheelIteration",
1467                        ] {
1468                            if let Some(val) = obj.get(f) {
1469                                s.insert(f.to_string(), val.clone());
1470                            }
1471                        }
1472                    }
1473                    Value::Object(s)
1474                })
1475                .collect();
1476            ok(json!({ "FlywheelSummaryList": list }))
1477        })
1478    }
1479
1480    fn start_flywheel_iteration(
1481        &self,
1482        req: &AwsRequest,
1483        body: &Value,
1484    ) -> Result<AwsResponse, AwsServiceError> {
1485        let arn = str_member(body, "FlywheelArn")
1486            .unwrap_or_default()
1487            .to_string();
1488        let iter_id = hex32(&format!(
1489            "{}/{}",
1490            arn,
1491            str_member(body, "ClientRequestToken").unwrap_or_default()
1492        ));
1493        self.with_account_mut(req, |d| {
1494            if !d.flywheels.contains_key(&arn) {
1495                return Err(resource_not_found(
1496                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1497                ));
1498            }
1499            let now = now_epoch();
1500            let obj = json!({
1501                "FlywheelArn": arn,
1502                "FlywheelIterationId": iter_id,
1503                "CreationTime": now,
1504                "Status": "TRAINING",
1505            });
1506            d.flywheel_iterations
1507                .insert(format!("{arn}#{iter_id}"), obj);
1508            // Record the latest iteration on the flywheel.
1509            if let Some(fw) = d.flywheels.get_mut(&arn).and_then(Value::as_object_mut) {
1510                fw.insert("LatestFlywheelIteration".into(), json!(iter_id));
1511            }
1512            ok(json!({ "FlywheelArn": arn, "FlywheelIterationId": iter_id }))
1513        })
1514    }
1515
1516    fn describe_flywheel_iteration(
1517        &self,
1518        req: &AwsRequest,
1519        body: &Value,
1520    ) -> Result<AwsResponse, AwsServiceError> {
1521        let arn = str_member(body, "FlywheelArn")
1522            .unwrap_or_default()
1523            .to_string();
1524        let iter_id = str_member(body, "FlywheelIterationId")
1525            .unwrap_or_default()
1526            .to_string();
1527        self.with_account_mut(req, |d| {
1528            d.reconcile();
1529            let Some(v) = d.flywheel_iterations.get(&format!("{arn}#{iter_id}")).cloned() else {
1530                return Err(resource_not_found(
1531                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1532                ));
1533            };
1534            ok(json!({ "FlywheelIterationProperties": v }))
1535        })
1536    }
1537
1538    fn list_flywheel_iteration_history(
1539        &self,
1540        req: &AwsRequest,
1541        body: &Value,
1542    ) -> Result<AwsResponse, AwsServiceError> {
1543        let arn = str_member(body, "FlywheelArn")
1544            .unwrap_or_default()
1545            .to_string();
1546        self.with_account_mut(req, |d| {
1547            d.reconcile();
1548            if !d.flywheels.contains_key(&arn) {
1549                return Err(resource_not_found(
1550                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1551                ));
1552            }
1553            let prefix = format!("{arn}#");
1554            let list: Vec<Value> = d
1555                .flywheel_iterations
1556                .iter()
1557                .filter(|(k, _)| k.starts_with(&prefix))
1558                .map(|(_, v)| v.clone())
1559                .collect();
1560            ok(json!({ "FlywheelIterationPropertiesList": list }))
1561        })
1562    }
1563}
1564
1565// ---- datasets -------------------------------------------------------------
1566
1567impl ComprehendService {
1568    fn create_dataset(
1569        &self,
1570        req: &AwsRequest,
1571        body: &Value,
1572    ) -> Result<AwsResponse, AwsServiceError> {
1573        let fw_arn = str_member(body, "FlywheelArn")
1574            .unwrap_or_default()
1575            .to_string();
1576        let name = str_member(body, "DatasetName")
1577            .unwrap_or_default()
1578            .to_string();
1579        let arn = format!("{fw_arn}/dataset/{name}");
1580        self.with_account_mut(req, |d| {
1581            if !d.flywheels.contains_key(&fw_arn) {
1582                return Err(resource_not_found(
1583                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1584                ));
1585            }
1586            if d.datasets.contains_key(&arn) {
1587                return Err(resource_in_use(
1588                    "Concurrent modification of resources. A dataset with the same name already exists.",
1589                ));
1590            }
1591            let now = now_epoch();
1592            let mut obj = Map::new();
1593            obj.insert("DatasetArn".into(), json!(arn));
1594            obj.insert("DatasetName".into(), json!(name));
1595            obj.insert("Status".into(), json!("CREATING"));
1596            obj.insert("NumberOfDocuments".into(), json!(0));
1597            obj.insert("CreationTime".into(), json!(now));
1598            copy_members(body, &mut obj, &["DatasetType", "Description"]);
1599            d.datasets.insert(arn.clone(), Value::Object(obj));
1600            d.store_tags(&arn, body);
1601            ok(json!({ "DatasetArn": arn }))
1602        })
1603    }
1604
1605    fn describe_dataset(
1606        &self,
1607        req: &AwsRequest,
1608        body: &Value,
1609    ) -> Result<AwsResponse, AwsServiceError> {
1610        let arn = str_member(body, "DatasetArn")
1611            .unwrap_or_default()
1612            .to_string();
1613        self.with_account_mut(req, |d| {
1614            d.reconcile();
1615            let Some(v) = d.datasets.get(&arn).cloned() else {
1616                return Err(resource_not_found(
1617                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1618                ));
1619            };
1620            ok(json!({ "DatasetProperties": v }))
1621        })
1622    }
1623
1624    fn list_datasets(
1625        &self,
1626        req: &AwsRequest,
1627        body: &Value,
1628    ) -> Result<AwsResponse, AwsServiceError> {
1629        let fw_arn = str_member(body, "FlywheelArn").map(String::from);
1630        let filter = body.get("Filter");
1631        let status_eq = filter.and_then(|f| str_member(f, "Status"));
1632        self.with_account_mut(req, |d| {
1633            d.reconcile();
1634            let list: Vec<Value> = d
1635                .datasets
1636                .iter()
1637                .filter(|(arn, _)| {
1638                    fw_arn
1639                        .as_deref()
1640                        .map(|fw| arn.starts_with(&format!("{fw}/dataset/")))
1641                        .unwrap_or(true)
1642                })
1643                .map(|(_, v)| v)
1644                .filter(|v| {
1645                    status_eq
1646                        .map(|s| v.get("Status").and_then(Value::as_str) == Some(s))
1647                        .unwrap_or(true)
1648                })
1649                .cloned()
1650                .collect();
1651            ok(json!({ "DatasetPropertiesList": list }))
1652        })
1653    }
1654}
1655
1656// ---- model import + resource policies -------------------------------------
1657
1658impl ComprehendService {
1659    fn import_model(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
1660        let source = str_member(body, "SourceModelArn")
1661            .unwrap_or_default()
1662            .to_string();
1663        let region = req.region.clone();
1664        let account = req.account_id.clone();
1665        // Import produces a copy of the source model (classifier or recognizer);
1666        // the type is inferred from the source ARN.
1667        let is_recognizer = source.contains("entity-recognizer");
1668        let rtype = if is_recognizer {
1669            "entity-recognizer"
1670        } else {
1671            "document-classifier"
1672        };
1673        let name = str_member(body, "ModelName")
1674            .map(String::from)
1675            .or_else(|| {
1676                parse_resource_arn(&source)
1677                    .map(|(_, tail)| tail.split("/version/").next().unwrap_or(&tail).to_string())
1678            })
1679            .unwrap_or_else(|| "imported-model".to_string());
1680        let version = str_member(body, "VersionName").map(String::from);
1681        let tail = match &version {
1682            Some(v) => format!("{name}/version/{v}"),
1683            None => name.clone(),
1684        };
1685        let arn = resource_arn(&region, &account, rtype, &tail);
1686        self.with_account_mut(req, |d| {
1687            let now = now_epoch();
1688            let mut obj = Map::new();
1689            obj.insert("Status".into(), json!("TRAINED"));
1690            obj.insert("SubmitTime".into(), json!(now));
1691            obj.insert("TrainingStartTime".into(), json!(now));
1692            obj.insert("TrainingEndTime".into(), json!(now));
1693            obj.insert("EndTime".into(), json!(now));
1694            obj.insert("SourceModelArn".into(), json!(source));
1695            if let Some(v) = &version {
1696                obj.insert("VersionName".into(), json!(v));
1697            }
1698            copy_members(body, &mut obj, &["ModelKmsKeyId", "DataAccessRoleArn"]);
1699            if is_recognizer {
1700                obj.insert("EntityRecognizerArn".into(), json!(arn));
1701                d.entity_recognizers.insert(arn.clone(), Value::Object(obj));
1702            } else {
1703                obj.insert("DocumentClassifierArn".into(), json!(arn));
1704                d.document_classifiers
1705                    .insert(arn.clone(), Value::Object(obj));
1706            }
1707            d.store_tags(&arn, body);
1708            ok(json!({ "ModelArn": arn }))
1709        })
1710    }
1711
1712    fn put_resource_policy(
1713        &self,
1714        req: &AwsRequest,
1715        body: &Value,
1716    ) -> Result<AwsResponse, AwsServiceError> {
1717        let arn = str_member(body, "ResourceArn")
1718            .unwrap_or_default()
1719            .to_string();
1720        let policy = str_member(body, "ResourcePolicy")
1721            .unwrap_or_default()
1722            .to_string();
1723        self.with_account_mut(req, |d| {
1724            let now = now_epoch();
1725            let revision = hex32(&format!("{arn}/{}", now));
1726            let created = d
1727                .resource_policies
1728                .get(&arn)
1729                .and_then(|p| p.get("CreationTime").cloned())
1730                .unwrap_or(json!(now));
1731            d.resource_policies.insert(
1732                arn.clone(),
1733                json!({
1734                    "ResourcePolicy": policy,
1735                    "PolicyRevisionId": revision,
1736                    "CreationTime": created,
1737                    "LastModifiedTime": now,
1738                }),
1739            );
1740            ok(json!({ "PolicyRevisionId": revision }))
1741        })
1742    }
1743
1744    fn describe_resource_policy(
1745        &self,
1746        req: &AwsRequest,
1747        body: &Value,
1748    ) -> Result<AwsResponse, AwsServiceError> {
1749        let arn = str_member(body, "ResourceArn")
1750            .unwrap_or_default()
1751            .to_string();
1752        self.with_account_mut(req, |d| {
1753            let Some(p) = d.resource_policies.get(&arn).cloned() else {
1754                return Err(resource_not_found(
1755                    "The specified resource ARN was not found. Check the ARN and try your request again.",
1756                ));
1757            };
1758            ok(p)
1759        })
1760    }
1761
1762    fn delete_resource_policy(
1763        &self,
1764        req: &AwsRequest,
1765        body: &Value,
1766    ) -> Result<AwsResponse, AwsServiceError> {
1767        let arn = str_member(body, "ResourceArn")
1768            .unwrap_or_default()
1769            .to_string();
1770        self.with_account_mut(req, |d| {
1771            d.resource_policies.remove(&arn);
1772            ok(json!({}))
1773        })
1774    }
1775}
1776
1777// ---- tagging --------------------------------------------------------------
1778
1779impl ComprehendService {
1780    fn tag_resource(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
1781        let arn = str_member(body, "ResourceArn")
1782            .unwrap_or_default()
1783            .to_string();
1784        self.with_account_mut(req, |d| {
1785            if !d.resource_exists(&arn) {
1786                return Err(resource_not_found(format!(
1787                    "The specified resource ARN was not found: {arn}"
1788                )));
1789            }
1790            let entry = d.tags.entry(arn.clone()).or_default();
1791            for (k, v) in taglist_to_map(body.get("Tags").unwrap_or(&Value::Null)) {
1792                entry.insert(k, v);
1793            }
1794            ok(json!({}))
1795        })
1796    }
1797
1798    fn untag_resource(
1799        &self,
1800        req: &AwsRequest,
1801        body: &Value,
1802    ) -> Result<AwsResponse, AwsServiceError> {
1803        let arn = str_member(body, "ResourceArn")
1804            .unwrap_or_default()
1805            .to_string();
1806        self.with_account_mut(req, |d| {
1807            if !d.resource_exists(&arn) {
1808                return Err(resource_not_found(format!(
1809                    "The specified resource ARN was not found: {arn}"
1810                )));
1811            }
1812            if let Some(entry) = d.tags.get_mut(&arn) {
1813                if let Some(keys) = body.get("TagKeys").and_then(Value::as_array) {
1814                    for k in keys.iter().filter_map(Value::as_str) {
1815                        entry.remove(k);
1816                    }
1817                }
1818            }
1819            ok(json!({}))
1820        })
1821    }
1822
1823    fn list_tags_for_resource(
1824        &self,
1825        req: &AwsRequest,
1826        body: &Value,
1827    ) -> Result<AwsResponse, AwsServiceError> {
1828        let arn = str_member(body, "ResourceArn")
1829            .unwrap_or_default()
1830            .to_string();
1831        self.with_account_mut(req, |d| {
1832            if !d.resource_exists(&arn) {
1833                return Err(resource_not_found(format!(
1834                    "The specified resource ARN was not found: {arn}"
1835                )));
1836            }
1837            let tags = d.tags.get(&arn).map(map_to_taglist).unwrap_or(json!([]));
1838            ok(json!({ "ResourceArn": arn, "Tags": tags }))
1839        })
1840    }
1841}
1842
1843#[cfg(test)]
1844mod tests {
1845    use super::*;
1846    use fakecloud_core::multi_account::MultiAccountState;
1847    use parking_lot::RwLock;
1848
1849    fn service() -> ComprehendService {
1850        ComprehendService::new(Arc::new(RwLock::new(MultiAccountState::new(
1851            "000000000000",
1852            "us-east-1",
1853            "",
1854        ))))
1855    }
1856
1857    fn req(action: &str, body: Value) -> AwsRequest {
1858        AwsRequest {
1859            service: "comprehend".to_string(),
1860            action: action.to_string(),
1861            region: "us-east-1".to_string(),
1862            account_id: "000000000000".to_string(),
1863            request_id: "test".to_string(),
1864            headers: http::HeaderMap::new(),
1865            query_params: std::collections::HashMap::new(),
1866            body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap()),
1867            body_stream: parking_lot::Mutex::new(None),
1868            path_segments: Vec::new(),
1869            raw_path: String::new(),
1870            raw_query: String::new(),
1871            method: http::Method::POST,
1872            is_query_protocol: false,
1873            access_key_id: None,
1874            principal: None,
1875        }
1876    }
1877
1878    async fn body_of(resp: AwsResponse) -> Value {
1879        serde_json::from_slice(resp.body.expect_bytes()).unwrap()
1880    }
1881
1882    #[tokio::test]
1883    async fn detect_sentiment_is_neutral_default() {
1884        let svc = service();
1885        let resp = svc
1886            .handle(req(
1887                "DetectSentiment",
1888                json!({ "Text": "hello", "LanguageCode": "en" }),
1889            ))
1890            .await
1891            .unwrap();
1892        let v = body_of(resp).await;
1893        assert_eq!(v["Sentiment"], "NEUTRAL");
1894        assert_eq!(v["SentimentScore"]["Neutral"], 1.0);
1895    }
1896
1897    #[tokio::test]
1898    async fn missing_required_text_is_invalid_request() {
1899        let svc = service();
1900        let err = svc
1901            .handle(req("DetectSentiment", json!({ "LanguageCode": "en" })))
1902            .await
1903            .err()
1904            .unwrap();
1905        assert_eq!(err.code(), "InvalidRequestException");
1906    }
1907
1908    #[tokio::test]
1909    async fn sentiment_job_round_trip() {
1910        let svc = service();
1911        let start = svc
1912            .handle(req(
1913                "StartSentimentDetectionJob",
1914                json!({
1915                    "InputDataConfig": { "S3Uri": "s3://in/" },
1916                    "OutputDataConfig": { "S3Uri": "s3://out/" },
1917                    "DataAccessRoleArn": "arn:aws:iam::000000000000:role/r",
1918                    "LanguageCode": "en",
1919                    "JobName": "job1"
1920                }),
1921            ))
1922            .await
1923            .unwrap();
1924        let sv = body_of(start).await;
1925        let job_id = sv["JobId"].as_str().unwrap().to_string();
1926        assert_eq!(sv["JobStatus"], "SUBMITTED");
1927
1928        // Describe settles it to COMPLETED.
1929        let desc = svc
1930            .handle(req(
1931                "DescribeSentimentDetectionJob",
1932                json!({ "JobId": job_id }),
1933            ))
1934            .await
1935            .unwrap();
1936        let dv = body_of(desc).await;
1937        assert_eq!(
1938            dv["SentimentDetectionJobProperties"]["JobStatus"],
1939            "COMPLETED"
1940        );
1941
1942        // A job id belonging to another family is not found here.
1943        let cross = svc
1944            .handle(req(
1945                "DescribeEntitiesDetectionJob",
1946                json!({ "JobId": "00000000000000000000000000000000" }),
1947            ))
1948            .await
1949            .err()
1950            .unwrap();
1951        assert_eq!(cross.code(), "JobNotFoundException");
1952
1953        // List shows it.
1954        let listed = svc
1955            .handle(req("ListSentimentDetectionJobs", json!({})))
1956            .await
1957            .unwrap();
1958        let lv = body_of(listed).await;
1959        assert_eq!(
1960            lv["SentimentDetectionJobPropertiesList"]
1961                .as_array()
1962                .unwrap()
1963                .len(),
1964            1
1965        );
1966    }
1967
1968    #[tokio::test]
1969    async fn flywheel_and_tagging_round_trip() {
1970        let svc = service();
1971        let create = svc
1972            .handle(req(
1973                "CreateFlywheel",
1974                json!({
1975                    "FlywheelName": "fw1",
1976                    "DataAccessRoleArn": "arn:aws:iam::000000000000:role/r",
1977                    "DataLakeS3Uri": "s3://lake/"
1978                }),
1979            ))
1980            .await
1981            .unwrap();
1982        let cv = body_of(create).await;
1983        let arn = cv["FlywheelArn"].as_str().unwrap().to_string();
1984
1985        // Describe settles CREATING -> ACTIVE.
1986        let desc = svc
1987            .handle(req("DescribeFlywheel", json!({ "FlywheelArn": arn })))
1988            .await
1989            .unwrap();
1990        let dv = body_of(desc).await;
1991        assert_eq!(dv["FlywheelProperties"]["Status"], "ACTIVE");
1992
1993        // Tag + list tags.
1994        svc.handle(req(
1995            "TagResource",
1996            json!({ "ResourceArn": arn, "Tags": [{ "Key": "team", "Value": "nlp" }] }),
1997        ))
1998        .await
1999        .unwrap();
2000        let tags = svc
2001            .handle(req("ListTagsForResource", json!({ "ResourceArn": arn })))
2002            .await
2003            .unwrap();
2004        let tv = body_of(tags).await;
2005        assert_eq!(tv["Tags"][0]["Key"], "team");
2006
2007        // Unknown resource -> ResourceNotFoundException.
2008        let bad = svc
2009            .handle(req(
2010                "ListTagsForResource",
2011                json!({ "ResourceArn": "arn:aws:comprehend:us-east-1:000000000000:flywheel/nope" }),
2012            ))
2013            .await
2014            .err()
2015            .unwrap();
2016        assert_eq!(bad.code(), "ResourceNotFoundException");
2017
2018        // Delete.
2019        svc.handle(req("DeleteFlywheel", json!({ "FlywheelArn": arn })))
2020            .await
2021            .unwrap();
2022        let after = svc
2023            .handle(req("DescribeFlywheel", json!({ "FlywheelArn": arn })))
2024            .await
2025            .err()
2026            .unwrap();
2027        assert_eq!(after.code(), "ResourceNotFoundException");
2028    }
2029
2030    #[tokio::test]
2031    async fn document_classifier_versions_summarized() {
2032        let svc = service();
2033        for v in ["v1", "v2"] {
2034            svc.handle(req(
2035                "CreateDocumentClassifier",
2036                json!({
2037                    "DocumentClassifierName": "clf",
2038                    "VersionName": v,
2039                    "DataAccessRoleArn": "arn:aws:iam::000000000000:role/r",
2040                    "LanguageCode": "en",
2041                    "InputDataConfig": { "DataFormat": "COMPREHEND_CSV", "S3Uri": "s3://in/" }
2042                }),
2043            ))
2044            .await
2045            .unwrap();
2046        }
2047        let sums = svc
2048            .handle(req("ListDocumentClassifierSummaries", json!({})))
2049            .await
2050            .unwrap();
2051        let sv = body_of(sums).await;
2052        let list = sv["DocumentClassifierSummariesList"].as_array().unwrap();
2053        assert_eq!(list.len(), 1);
2054        assert_eq!(list[0]["DocumentClassifierName"], "clf");
2055        assert_eq!(list[0]["NumberOfVersions"], 2);
2056    }
2057
2058    #[tokio::test]
2059    async fn batch_detect_indexes_each_document() {
2060        let svc = service();
2061        let resp = svc
2062            .handle(req(
2063                "BatchDetectSentiment",
2064                json!({ "TextList": ["a", "b", "c"], "LanguageCode": "en" }),
2065            ))
2066            .await
2067            .unwrap();
2068        let v = body_of(resp).await;
2069        let results = v["ResultList"].as_array().unwrap();
2070        assert_eq!(results.len(), 3);
2071        assert_eq!(results[2]["Index"], 2);
2072        assert_eq!(results[0]["Sentiment"], "NEUTRAL");
2073    }
2074}