Skip to main content

fakecloud_translate/
service.rs

1//! Amazon Translate awsJson1_1 dispatch + operation handlers.
2//!
3//! Requests carry `X-Amz-Target: AWSShineFrontendService_20170701.<Operation>`;
4//! dispatch keys off `req.action`. Every operation runs model-driven input
5//! validation first (required / length / range / enum / pattern), then real,
6//! account-partitioned, persisted CRUD. Dereferencing a resource that does not
7//! exist returns Translate's `ResourceNotFoundException`; a duplicate parallel-
8//! data name returns `ConflictException`.
9//!
10//! Honest machine-translation gap: fakecloud runs no MT model, so `TranslateText`
11//! / `TranslateDocument` echo the input content verbatim as the translated
12//! output with the requested target language applied -- a structurally correct
13//! passthrough, never a fabricated translation.
14
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use http::StatusCode;
19use serde_json::{json, Map, Value};
20use tokio::sync::Mutex as AsyncMutex;
21
22use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
23use fakecloud_persistence::SnapshotStore;
24
25use crate::shared::{
26    data_location, decode_blob, job_id, now_epoch, parse_file, parse_resource_arn, resource_arn,
27};
28use crate::state::{SharedTranslateState, TranslateData};
29
30/// Every operation name in the Amazon Translate Smithy model (19 operations).
31pub const TRANSLATE_ACTIONS: &[&str] = &[
32    "CreateParallelData",
33    "DeleteParallelData",
34    "DeleteTerminology",
35    "DescribeTextTranslationJob",
36    "GetParallelData",
37    "GetTerminology",
38    "ImportTerminology",
39    "ListLanguages",
40    "ListParallelData",
41    "ListTagsForResource",
42    "ListTerminologies",
43    "ListTextTranslationJobs",
44    "StartTextTranslationJob",
45    "StopTextTranslationJob",
46    "TagResource",
47    "TranslateDocument",
48    "TranslateText",
49    "UntagResource",
50    "UpdateParallelData",
51];
52
53/// Read-only verbs; any other action mutates persisted state and triggers a
54/// snapshot after success. `TranslateText` / `TranslateDocument` are pure reads
55/// (they persist nothing), so they are excluded from the mutating set too.
56fn is_mutating_action(action: &str) -> bool {
57    const READ_PREFIXES: &[&str] = &["Get", "List", "Describe"];
58    if action == "TranslateText" || action == "TranslateDocument" {
59        return false;
60    }
61    !READ_PREFIXES.iter().any(|p| action.starts_with(p))
62}
63
64pub struct TranslateService {
65    state: SharedTranslateState,
66    snapshot_store: Option<Arc<dyn SnapshotStore>>,
67    snapshot_lock: Arc<AsyncMutex<()>>,
68}
69
70impl TranslateService {
71    pub fn new(state: SharedTranslateState) -> Self {
72        Self {
73            state,
74            snapshot_store: None,
75            snapshot_lock: Arc::new(AsyncMutex::new(())),
76        }
77    }
78
79    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
80        self.snapshot_store = Some(store);
81        self
82    }
83
84    async fn save(&self) {
85        crate::persistence::save_snapshot(
86            &self.state,
87            self.snapshot_store.clone(),
88            &self.snapshot_lock,
89        )
90        .await;
91    }
92
93    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
94    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
95        let store = self.snapshot_store.clone()?;
96        let state = self.state.clone();
97        let lock = self.snapshot_lock.clone();
98        Some(Arc::new(move || {
99            let state = state.clone();
100            let store = store.clone();
101            let lock = lock.clone();
102            Box::pin(async move {
103                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
104            })
105        }))
106    }
107
108    /// Run `f` against this account's mutable state.
109    fn with_account_mut<R>(&self, req: &AwsRequest, f: impl FnOnce(&mut TranslateData) -> R) -> R {
110        let mut guard = self.state.write();
111        let acct = guard.get_or_create(&req.account_id);
112        f(acct)
113    }
114}
115
116#[async_trait]
117impl AwsService for TranslateService {
118    fn service_name(&self) -> &str {
119        "translate"
120    }
121
122    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
123        let action = req.action.clone();
124        if let Err(msg) = crate::validate::validate_input(&action, &req.json_body()) {
125            let code = crate::validate::validation_error_code(&action);
126            return Err(AwsServiceError::aws_error(
127                StatusCode::BAD_REQUEST,
128                code,
129                msg,
130            ));
131        }
132        let result = self.dispatch(&action, &req);
133        if is_mutating_action(&action)
134            && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
135        {
136            self.save().await;
137        }
138        result
139    }
140
141    fn supported_actions(&self) -> &[&str] {
142        TRANSLATE_ACTIONS
143    }
144}
145
146impl TranslateService {
147    fn dispatch(&self, action: &str, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
148        let body = req.json_body();
149        match action {
150            // Synchronous translation
151            "TranslateText" => self.translate_text(req, &body),
152            "TranslateDocument" => self.translate_document(req, &body),
153            // Batch translation jobs
154            "StartTextTranslationJob" => self.start_text_translation_job(req, &body),
155            "DescribeTextTranslationJob" => self.describe_text_translation_job(req, &body),
156            "ListTextTranslationJobs" => self.list_text_translation_jobs(req, &body),
157            "StopTextTranslationJob" => self.stop_text_translation_job(req, &body),
158            // Parallel data
159            "CreateParallelData" => self.create_parallel_data(req, &body),
160            "GetParallelData" => self.get_parallel_data(req, &body),
161            "UpdateParallelData" => self.update_parallel_data(req, &body),
162            "ListParallelData" => self.list_parallel_data(req, &body),
163            "DeleteParallelData" => self.delete_parallel_data(req, &body),
164            // Custom terminologies
165            "ImportTerminology" => self.import_terminology(req, &body),
166            "GetTerminology" => self.get_terminology(req, &body),
167            "ListTerminologies" => self.list_terminologies(req, &body),
168            "DeleteTerminology" => self.delete_terminology(req, &body),
169            // Supported languages
170            "ListLanguages" => self.list_languages(req, &body),
171            // Tagging
172            "TagResource" => self.tag_resource(req, &body),
173            "UntagResource" => self.untag_resource(req, &body),
174            "ListTagsForResource" => self.list_tags_for_resource(req, &body),
175            _ => Err(AwsServiceError::action_not_implemented(
176                self.service_name(),
177                action,
178            )),
179        }
180    }
181}
182
183// ---- error / response helpers --------------------------------------------
184
185fn invalid_parameter(msg: impl Into<String>) -> AwsServiceError {
186    AwsServiceError::aws_error(
187        StatusCode::BAD_REQUEST,
188        "InvalidParameterValueException",
189        msg.into(),
190    )
191}
192
193fn conflict(msg: impl Into<String>) -> AwsServiceError {
194    AwsServiceError::aws_error(StatusCode::CONFLICT, "ConflictException", msg.into())
195}
196
197fn not_found(msg: impl Into<String>) -> AwsServiceError {
198    AwsServiceError::aws_error(
199        StatusCode::NOT_FOUND,
200        "ResourceNotFoundException",
201        msg.into(),
202    )
203}
204
205fn ok(value: Value) -> Result<AwsResponse, AwsServiceError> {
206    Ok(AwsResponse::ok_json(value))
207}
208
209// ---- small member helpers -------------------------------------------------
210
211fn str_member<'a>(body: &'a Value, name: &str) -> Option<&'a str> {
212    body.get(name).and_then(Value::as_str)
213}
214
215/// Copy each named member from `body` into `obj` when present and non-null.
216fn copy_members(body: &Value, obj: &mut Map<String, Value>, names: &[&str]) {
217    for name in names {
218        if let Some(v) = body.get(*name).filter(|v| !v.is_null()) {
219            obj.insert((*name).to_string(), v.clone());
220        }
221    }
222}
223
224/// Convert a `TagList` (`[{Key,Value}]`) into a sorted key/value map.
225fn taglist_to_map(tags: &Value) -> std::collections::BTreeMap<String, String> {
226    let mut out = std::collections::BTreeMap::new();
227    if let Some(arr) = tags.as_array() {
228        for t in arr {
229            if let (Some(k), Some(v)) = (str_member(t, "Key"), str_member(t, "Value")) {
230                out.insert(k.to_string(), v.to_string());
231            }
232        }
233    }
234    out
235}
236
237/// Render a key/value map back into a `TagList`.
238fn map_to_taglist(map: &std::collections::BTreeMap<String, String>) -> Value {
239    Value::Array(
240        map.iter()
241            .map(|(k, v)| json!({ "Key": k, "Value": v }))
242            .collect(),
243    )
244}
245
246impl TranslateData {
247    /// Record `Tags` from a create/import request under `arn`, if any.
248    fn store_tags(&mut self, arn: &str, body: &Value) {
249        if let Some(tags) = body.get("Tags").filter(|v| !v.is_null()) {
250            let map = taglist_to_map(tags);
251            if !map.is_empty() {
252                self.tags.insert(arn.to_string(), map);
253            }
254        }
255    }
256
257    /// True if the resource named by an ARN's `(type, name)` exists.
258    fn resource_exists(&self, rtype: &str, name: &str) -> bool {
259        match rtype {
260            "terminology" => self.terminologies.contains_key(name),
261            "parallel-data" => self.parallel_data.contains_key(name),
262            _ => false,
263        }
264    }
265}
266
267// ---- synchronous translation ----------------------------------------------
268
269impl TranslateService {
270    fn translate_text(
271        &self,
272        req: &AwsRequest,
273        body: &Value,
274    ) -> Result<AwsResponse, AwsServiceError> {
275        let text = str_member(body, "Text").unwrap_or_default().to_string();
276        let source = str_member(body, "SourceLanguageCode")
277            .unwrap_or_default()
278            .to_string();
279        let target = str_member(body, "TargetLanguageCode")
280            .unwrap_or_default()
281            .to_string();
282        let applied = self.applied_terminologies(req, body)?;
283        let mut out = Map::new();
284        // Honest MT gap: no translation model runs -- the input text is echoed
285        // verbatim as the translated output with the requested target applied.
286        out.insert("TranslatedText".into(), json!(text));
287        out.insert("SourceLanguageCode".into(), json!(source));
288        out.insert("TargetLanguageCode".into(), json!(target));
289        if let Some(a) = applied {
290            out.insert("AppliedTerminologies".into(), a);
291        }
292        if let Some(s) = body.get("Settings").filter(|v| !v.is_null()) {
293            out.insert("AppliedSettings".into(), s.clone());
294        }
295        ok(Value::Object(out))
296    }
297
298    fn translate_document(
299        &self,
300        req: &AwsRequest,
301        body: &Value,
302    ) -> Result<AwsResponse, AwsServiceError> {
303        let source = str_member(body, "SourceLanguageCode")
304            .unwrap_or_default()
305            .to_string();
306        let target = str_member(body, "TargetLanguageCode")
307            .unwrap_or_default()
308            .to_string();
309        // Echo the document content verbatim (no MT model runs).
310        let content = body
311            .get("Document")
312            .and_then(|d| d.get("Content"))
313            .cloned()
314            .unwrap_or(json!(""));
315        let applied = self.applied_terminologies(req, body)?;
316        let mut out = Map::new();
317        out.insert("TranslatedDocument".into(), json!({ "Content": content }));
318        out.insert("SourceLanguageCode".into(), json!(source));
319        out.insert("TargetLanguageCode".into(), json!(target));
320        if let Some(a) = applied {
321            out.insert("AppliedTerminologies".into(), a);
322        }
323        if let Some(s) = body.get("Settings").filter(|v| !v.is_null()) {
324            out.insert("AppliedSettings".into(), s.clone());
325        }
326        ok(Value::Object(out))
327    }
328
329    /// Resolve the `AppliedTerminologies` list for a translate call: each
330    /// requested terminology must exist, else `ResourceNotFoundException`.
331    /// Returns `None` when no terminology names were requested.
332    fn applied_terminologies(
333        &self,
334        req: &AwsRequest,
335        body: &Value,
336    ) -> Result<Option<Value>, AwsServiceError> {
337        let Some(names) = body.get("TerminologyNames").and_then(Value::as_array) else {
338            return Ok(None);
339        };
340        if names.is_empty() {
341            return Ok(None);
342        }
343        self.with_account_mut(req, |d| {
344            let mut applied = Vec::new();
345            for n in names.iter().filter_map(Value::as_str) {
346                if !d.terminologies.contains_key(n) {
347                    return Err(not_found(format!(
348                        "The resource you are looking for has not been found. Review the resource you're looking for and try again: {n}"
349                    )));
350                }
351                // No terms are actually applied (no MT model runs), so the
352                // applied entry carries an empty term list.
353                applied.push(json!({ "Name": n, "Terms": [] }));
354            }
355            Ok(Some(Value::Array(applied)))
356        })
357    }
358}
359
360// ---- batch translation jobs -----------------------------------------------
361
362impl TranslateService {
363    fn start_text_translation_job(
364        &self,
365        req: &AwsRequest,
366        body: &Value,
367    ) -> Result<AwsResponse, AwsServiceError> {
368        self.with_account_mut(req, |d| {
369            // Referenced terminologies / parallel data must exist.
370            for n in body
371                .get("TerminologyNames")
372                .and_then(Value::as_array)
373                .into_iter()
374                .flatten()
375                .filter_map(Value::as_str)
376            {
377                if !d.terminologies.contains_key(n) {
378                    return Err(not_found(format!(
379                        "The resource you are looking for has not been found. Review the resource you're looking for and try again: {n}"
380                    )));
381                }
382            }
383            for n in body
384                .get("ParallelDataNames")
385                .and_then(Value::as_array)
386                .into_iter()
387                .flatten()
388                .filter_map(Value::as_str)
389            {
390                if !d.parallel_data.contains_key(n) {
391                    return Err(not_found(format!(
392                        "The resource you are looking for has not been found. Review the resource you're looking for and try again: {n}"
393                    )));
394                }
395            }
396            let id = job_id();
397            let now = now_epoch();
398            let mut obj = Map::new();
399            obj.insert("JobId".into(), json!(id));
400            obj.insert("JobStatus".into(), json!("SUBMITTED"));
401            obj.insert("SubmittedTime".into(), json!(now));
402            copy_members(
403                body,
404                &mut obj,
405                &[
406                    "JobName",
407                    "InputDataConfig",
408                    "OutputDataConfig",
409                    "DataAccessRoleArn",
410                    "SourceLanguageCode",
411                    "TargetLanguageCodes",
412                    "TerminologyNames",
413                    "ParallelDataNames",
414                    "Settings",
415                ],
416            );
417            d.text_translation_jobs
418                .insert(id.clone(), Value::Object(obj));
419            ok(json!({ "JobId": id, "JobStatus": "SUBMITTED" }))
420        })
421    }
422
423    fn describe_text_translation_job(
424        &self,
425        req: &AwsRequest,
426        body: &Value,
427    ) -> Result<AwsResponse, AwsServiceError> {
428        let id = str_member(body, "JobId").unwrap_or_default().to_string();
429        self.with_account_mut(req, |d| {
430            d.reconcile();
431            let Some(job) = d.text_translation_jobs.get(&id).cloned() else {
432                return Err(not_found(format!(
433                    "Cannot find the resource you are looking for. Review the resource and try again: {id}"
434                )));
435            };
436            ok(json!({ "TextTranslationJobProperties": job }))
437        })
438    }
439
440    fn list_text_translation_jobs(
441        &self,
442        req: &AwsRequest,
443        body: &Value,
444    ) -> Result<AwsResponse, AwsServiceError> {
445        self.with_account_mut(req, |d| {
446            d.reconcile();
447            let filter = body.get("Filter");
448            let name_eq = filter.and_then(|f| str_member(f, "JobName"));
449            let status_eq = filter.and_then(|f| str_member(f, "JobStatus"));
450            let jobs: Vec<Value> = d
451                .text_translation_jobs
452                .values()
453                .filter(|j| {
454                    name_eq
455                        .map(|n| j.get("JobName").and_then(Value::as_str) == Some(n))
456                        .unwrap_or(true)
457                        && status_eq
458                            .map(|s| j.get("JobStatus").and_then(Value::as_str) == Some(s))
459                            .unwrap_or(true)
460                })
461                .cloned()
462                .collect();
463            ok(json!({ "TextTranslationJobPropertiesList": jobs }))
464        })
465    }
466
467    fn stop_text_translation_job(
468        &self,
469        req: &AwsRequest,
470        body: &Value,
471    ) -> Result<AwsResponse, AwsServiceError> {
472        let id = str_member(body, "JobId").unwrap_or_default().to_string();
473        self.with_account_mut(req, |d| {
474            let Some(job) = d.text_translation_jobs.get_mut(&id) else {
475                return Err(not_found(format!(
476                    "Cannot find the resource you are looking for. Review the resource and try again: {id}"
477                )));
478            };
479            let status = job
480                .get("JobStatus")
481                .and_then(Value::as_str)
482                .unwrap_or("")
483                .to_string();
484            let new_status = if matches!(status.as_str(), "SUBMITTED" | "IN_PROGRESS") {
485                if let Some(obj) = job.as_object_mut() {
486                    obj.insert("JobStatus".into(), json!("STOP_REQUESTED"));
487                }
488                "STOP_REQUESTED".to_string()
489            } else {
490                status
491            };
492            ok(json!({ "JobId": id, "JobStatus": new_status }))
493        })
494    }
495}
496
497// ---- parallel data --------------------------------------------------------
498
499impl TranslateService {
500    fn create_parallel_data(
501        &self,
502        req: &AwsRequest,
503        body: &Value,
504    ) -> Result<AwsResponse, AwsServiceError> {
505        let name = str_member(body, "Name").unwrap_or_default().to_string();
506        let region = req.region.clone();
507        let account = req.account_id.clone();
508        self.with_account_mut(req, |d| {
509            if d.parallel_data.contains_key(&name) {
510                return Err(conflict(format!(
511                    "Parallel data with the name {name} already exists. Use a different name and try again."
512                )));
513            }
514            let now = now_epoch();
515            let arn = resource_arn(&region, &account, "parallel-data", &name);
516            let mut obj = Map::new();
517            obj.insert("Name".into(), json!(name));
518            obj.insert("Arn".into(), json!(arn));
519            obj.insert("Status".into(), json!("CREATING"));
520            obj.insert("ImportedDataSize".into(), json!(0));
521            obj.insert("ImportedRecordCount".into(), json!(0));
522            obj.insert("FailedRecordCount".into(), json!(0));
523            obj.insert("SkippedRecordCount".into(), json!(0));
524            obj.insert("CreatedAt".into(), json!(now));
525            obj.insert("LastUpdatedAt".into(), json!(now));
526            copy_members(
527                body,
528                &mut obj,
529                &["Description", "ParallelDataConfig", "EncryptionKey"],
530            );
531            d.parallel_data
532                .insert(name.clone(), Value::Object(obj));
533            d.store_tags(&arn, body);
534            ok(json!({ "Name": name, "Status": "CREATING" }))
535        })
536    }
537
538    fn get_parallel_data(
539        &self,
540        req: &AwsRequest,
541        body: &Value,
542    ) -> Result<AwsResponse, AwsServiceError> {
543        let name = str_member(body, "Name").unwrap_or_default().to_string();
544        let region = req.region.clone();
545        let account = req.account_id.clone();
546        self.with_account_mut(req, |d| {
547            d.reconcile();
548            let Some(pd) = d.parallel_data.get(&name).cloned() else {
549                return Err(not_found(format!(
550                    "The resource you are looking for has not been found. Review the resource you're looking for and try again: {name}"
551                )));
552            };
553            let loc = json!({
554                "RepositoryType": "S3",
555                "Location": data_location(&region, &account, "parallel-data", &name, "input.tsv"),
556            });
557            ok(json!({
558                "ParallelDataProperties": pd,
559                "DataLocation": loc,
560            }))
561        })
562    }
563
564    fn update_parallel_data(
565        &self,
566        req: &AwsRequest,
567        body: &Value,
568    ) -> Result<AwsResponse, AwsServiceError> {
569        let name = str_member(body, "Name").unwrap_or_default().to_string();
570        self.with_account_mut(req, |d| {
571            d.reconcile();
572            let Some(pd) = d.parallel_data.get_mut(&name) else {
573                return Err(not_found(format!(
574                    "The resource you are looking for has not been found. Review the resource you're looking for and try again: {name}"
575                )));
576            };
577            let now = now_epoch();
578            if let Some(obj) = pd.as_object_mut() {
579                obj.insert("LatestUpdateAttemptStatus".into(), json!("UPDATING"));
580                obj.insert("LatestUpdateAttemptAt".into(), json!(now));
581                obj.insert("LastUpdatedAt".into(), json!(now));
582                if let Some(cfg) = body.get("ParallelDataConfig").filter(|v| !v.is_null()) {
583                    obj.insert("ParallelDataConfig".into(), cfg.clone());
584                }
585                if let Some(desc) = body.get("Description").filter(|v| !v.is_null()) {
586                    obj.insert("Description".into(), desc.clone());
587                }
588            }
589            let status = pd
590                .get("Status")
591                .cloned()
592                .unwrap_or(json!("ACTIVE"));
593            ok(json!({
594                "Name": name,
595                "Status": status,
596                "LatestUpdateAttemptStatus": "UPDATING",
597                "LatestUpdateAttemptAt": now,
598            }))
599        })
600    }
601
602    fn list_parallel_data(
603        &self,
604        req: &AwsRequest,
605        _body: &Value,
606    ) -> Result<AwsResponse, AwsServiceError> {
607        self.with_account_mut(req, |d| {
608            d.reconcile();
609            let list: Vec<Value> = d.parallel_data.values().cloned().collect();
610            ok(json!({ "ParallelDataPropertiesList": list }))
611        })
612    }
613
614    fn delete_parallel_data(
615        &self,
616        req: &AwsRequest,
617        body: &Value,
618    ) -> Result<AwsResponse, AwsServiceError> {
619        let name = str_member(body, "Name").unwrap_or_default().to_string();
620        let region = req.region.clone();
621        let account = req.account_id.clone();
622        self.with_account_mut(req, |d| {
623            if d.parallel_data.remove(&name).is_none() {
624                return Err(not_found(format!(
625                    "The resource you are looking for has not been found. Review the resource you're looking for and try again: {name}"
626                )));
627            }
628            d.tags
629                .remove(&resource_arn(&region, &account, "parallel-data", &name));
630            ok(json!({ "Name": name, "Status": "DELETING" }))
631        })
632    }
633}
634
635// ---- custom terminologies -------------------------------------------------
636
637impl TranslateService {
638    fn import_terminology(
639        &self,
640        req: &AwsRequest,
641        body: &Value,
642    ) -> Result<AwsResponse, AwsServiceError> {
643        let name = str_member(body, "Name").unwrap_or_default().to_string();
644        let region = req.region.clone();
645        let account = req.account_id.clone();
646        let td = body.get("TerminologyData");
647        let format = td
648            .and_then(|t| str_member(t, "Format"))
649            .unwrap_or("CSV")
650            .to_string();
651        let directionality = td
652            .and_then(|t| str_member(t, "Directionality"))
653            .unwrap_or("UNI")
654            .to_string();
655        let facts = td
656            .and_then(|t| str_member(t, "File"))
657            .map(|f| parse_file(&decode_blob(f), &format))
658            .unwrap_or_default();
659        self.with_account_mut(req, |d| {
660            let now = now_epoch();
661            // MergeStrategy is OVERWRITE: re-importing replaces, preserving the
662            // original CreatedAt when the terminology already exists.
663            let created_at = d
664                .terminologies
665                .get(&name)
666                .and_then(|t| t.get("CreatedAt"))
667                .cloned()
668                .unwrap_or(json!(now));
669            let arn = resource_arn(&region, &account, "terminology", &name);
670            let mut obj = Map::new();
671            obj.insert("Name".into(), json!(name));
672            obj.insert("Arn".into(), json!(arn));
673            obj.insert("Format".into(), json!(format));
674            obj.insert("Directionality".into(), json!(directionality));
675            obj.insert("SizeBytes".into(), json!(facts.size_bytes));
676            obj.insert("TermCount".into(), json!(facts.record_count));
677            obj.insert("SkippedTermCount".into(), json!(0));
678            obj.insert("CreatedAt".into(), created_at);
679            obj.insert("LastUpdatedAt".into(), json!(now));
680            if let Some(src) = &facts.source_language {
681                obj.insert("SourceLanguageCode".into(), json!(src));
682            }
683            if !facts.target_languages.is_empty() {
684                obj.insert("TargetLanguageCodes".into(), json!(facts.target_languages));
685            }
686            copy_members(body, &mut obj, &["Description", "EncryptionKey"]);
687            d.terminologies
688                .insert(name.clone(), Value::Object(obj.clone()));
689            d.store_tags(&arn, body);
690            ok(json!({ "TerminologyProperties": Value::Object(obj) }))
691        })
692    }
693
694    fn get_terminology(
695        &self,
696        req: &AwsRequest,
697        body: &Value,
698    ) -> Result<AwsResponse, AwsServiceError> {
699        let name = str_member(body, "Name").unwrap_or_default().to_string();
700        let region = req.region.clone();
701        let account = req.account_id.clone();
702        self.with_account_mut(req, |d| {
703            let Some(term) = d.terminologies.get(&name).cloned() else {
704                return Err(not_found(format!(
705                    "The resource you are looking for has not been found. Review the resource you're looking for and try again: {name}"
706                )));
707            };
708            let format = term
709                .get("Format")
710                .and_then(Value::as_str)
711                .unwrap_or("CSV")
712                .to_lowercase();
713            let loc = json!({
714                "RepositoryType": "S3",
715                "Location": data_location(&region, &account, "terminology", &name, &format!("terminology.{format}")),
716            });
717            ok(json!({
718                "TerminologyProperties": term,
719                "TerminologyDataLocation": loc,
720            }))
721        })
722    }
723
724    fn list_terminologies(
725        &self,
726        req: &AwsRequest,
727        _body: &Value,
728    ) -> Result<AwsResponse, AwsServiceError> {
729        self.with_account_mut(req, |d| {
730            let list: Vec<Value> = d.terminologies.values().cloned().collect();
731            ok(json!({ "TerminologyPropertiesList": list }))
732        })
733    }
734
735    fn delete_terminology(
736        &self,
737        req: &AwsRequest,
738        body: &Value,
739    ) -> Result<AwsResponse, AwsServiceError> {
740        let name = str_member(body, "Name").unwrap_or_default().to_string();
741        let region = req.region.clone();
742        let account = req.account_id.clone();
743        self.with_account_mut(req, |d| {
744            if d.terminologies.remove(&name).is_none() {
745                return Err(not_found(format!(
746                    "The resource you are looking for has not been found. Review the resource you're looking for and try again: {name}"
747                )));
748            }
749            d.tags
750                .remove(&resource_arn(&region, &account, "terminology", &name));
751            ok(json!({}))
752        })
753    }
754}
755
756// ---- supported languages --------------------------------------------------
757
758/// The languages Amazon Translate supports, as `(LanguageCode, English name)`.
759/// `LanguageName` is returned in English regardless of `DisplayLanguageCode`;
760/// localized display names are out of scope for the emulator. The set itself is
761/// real (the published supported-languages list).
762const SUPPORTED_LANGUAGES: &[(&str, &str)] = &[
763    ("af", "Afrikaans"),
764    ("sq", "Albanian"),
765    ("am", "Amharic"),
766    ("ar", "Arabic"),
767    ("hy", "Armenian"),
768    ("az", "Azerbaijani"),
769    ("bn", "Bengali"),
770    ("bs", "Bosnian"),
771    ("bg", "Bulgarian"),
772    ("ca", "Catalan"),
773    ("zh", "Chinese (Simplified)"),
774    ("zh-TW", "Chinese (Traditional)"),
775    ("hr", "Croatian"),
776    ("cs", "Czech"),
777    ("da", "Danish"),
778    ("fa-AF", "Dari"),
779    ("nl", "Dutch"),
780    ("en", "English"),
781    ("et", "Estonian"),
782    ("fa", "Farsi (Persian)"),
783    ("tl", "Filipino, Tagalog"),
784    ("fi", "Finnish"),
785    ("fr", "French"),
786    ("fr-CA", "French (Canada)"),
787    ("ka", "Georgian"),
788    ("de", "German"),
789    ("el", "Greek"),
790    ("gu", "Gujarati"),
791    ("ht", "Haitian Creole"),
792    ("ha", "Hausa"),
793    ("he", "Hebrew"),
794    ("hi", "Hindi"),
795    ("hu", "Hungarian"),
796    ("is", "Icelandic"),
797    ("id", "Indonesian"),
798    ("ga", "Irish"),
799    ("it", "Italian"),
800    ("ja", "Japanese"),
801    ("kn", "Kannada"),
802    ("kk", "Kazakh"),
803    ("ko", "Korean"),
804    ("lv", "Latvian"),
805    ("lt", "Lithuanian"),
806    ("mk", "Macedonian"),
807    ("ms", "Malay"),
808    ("ml", "Malayalam"),
809    ("mt", "Maltese"),
810    ("mr", "Marathi"),
811    ("mn", "Mongolian"),
812    ("no", "Norwegian (Bokmal)"),
813    ("ps", "Pashto"),
814    ("pl", "Polish"),
815    ("pt", "Portuguese (Brazil)"),
816    ("pt-PT", "Portuguese (Portugal)"),
817    ("pa", "Punjabi"),
818    ("ro", "Romanian"),
819    ("ru", "Russian"),
820    ("sr", "Serbian"),
821    ("si", "Sinhala"),
822    ("sk", "Slovak"),
823    ("sl", "Slovenian"),
824    ("so", "Somali"),
825    ("es", "Spanish"),
826    ("es-MX", "Spanish (Mexico)"),
827    ("sw", "Swahili"),
828    ("sv", "Swedish"),
829    ("ta", "Tamil"),
830    ("te", "Telugu"),
831    ("th", "Thai"),
832    ("tr", "Turkish"),
833    ("uk", "Ukrainian"),
834    ("ur", "Urdu"),
835    ("uz", "Uzbek"),
836    ("vi", "Vietnamese"),
837    ("cy", "Welsh"),
838];
839
840impl TranslateService {
841    fn list_languages(
842        &self,
843        _req: &AwsRequest,
844        body: &Value,
845    ) -> Result<AwsResponse, AwsServiceError> {
846        let display = str_member(body, "DisplayLanguageCode").unwrap_or("en");
847        let languages: Vec<Value> = SUPPORTED_LANGUAGES
848            .iter()
849            .map(|(code, name)| json!({ "LanguageCode": code, "LanguageName": name }))
850            .collect();
851        ok(json!({
852            "Languages": languages,
853            "DisplayLanguageCode": display,
854        }))
855    }
856}
857
858// ---- tagging --------------------------------------------------------------
859
860impl TranslateService {
861    fn tag_resource(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
862        let arn = str_member(body, "ResourceArn")
863            .unwrap_or_default()
864            .to_string();
865        let Some((rtype, name)) = parse_resource_arn(&arn) else {
866            return Err(invalid_parameter(format!("Invalid resource ARN: {arn}")));
867        };
868        self.with_account_mut(req, |d| {
869            if !d.resource_exists(&rtype, &name) {
870                return Err(not_found(format!(
871                    "The resource you are looking for has not been found. Review the resource you're looking for and try again: {arn}"
872                )));
873            }
874            let entry = d.tags.entry(arn.clone()).or_default();
875            for (k, v) in taglist_to_map(body.get("Tags").unwrap_or(&Value::Null)) {
876                entry.insert(k, v);
877            }
878            ok(json!({}))
879        })
880    }
881
882    fn untag_resource(
883        &self,
884        req: &AwsRequest,
885        body: &Value,
886    ) -> Result<AwsResponse, AwsServiceError> {
887        let arn = str_member(body, "ResourceArn")
888            .unwrap_or_default()
889            .to_string();
890        let Some((rtype, name)) = parse_resource_arn(&arn) else {
891            return Err(invalid_parameter(format!("Invalid resource ARN: {arn}")));
892        };
893        self.with_account_mut(req, |d| {
894            if !d.resource_exists(&rtype, &name) {
895                return Err(not_found(format!(
896                    "The resource you are looking for has not been found. Review the resource you're looking for and try again: {arn}"
897                )));
898            }
899            if let Some(entry) = d.tags.get_mut(&arn) {
900                if let Some(keys) = body.get("TagKeys").and_then(Value::as_array) {
901                    for k in keys.iter().filter_map(Value::as_str) {
902                        entry.remove(k);
903                    }
904                }
905            }
906            ok(json!({}))
907        })
908    }
909
910    fn list_tags_for_resource(
911        &self,
912        req: &AwsRequest,
913        body: &Value,
914    ) -> Result<AwsResponse, AwsServiceError> {
915        let arn = str_member(body, "ResourceArn")
916            .unwrap_or_default()
917            .to_string();
918        let Some((rtype, name)) = parse_resource_arn(&arn) else {
919            return Err(invalid_parameter(format!("Invalid resource ARN: {arn}")));
920        };
921        self.with_account_mut(req, |d| {
922            if !d.resource_exists(&rtype, &name) {
923                return Err(not_found(format!(
924                    "The resource you are looking for has not been found. Review the resource you're looking for and try again: {arn}"
925                )));
926            }
927            let tags = d.tags.get(&arn).map(map_to_taglist).unwrap_or(json!([]));
928            ok(json!({ "Tags": tags }))
929        })
930    }
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936    use base64::Engine;
937    use fakecloud_core::multi_account::MultiAccountState;
938    use parking_lot::RwLock;
939
940    fn service() -> TranslateService {
941        TranslateService::new(Arc::new(RwLock::new(MultiAccountState::new(
942            "000000000000",
943            "us-east-1",
944            "",
945        ))))
946    }
947
948    fn req(action: &str, body: Value) -> AwsRequest {
949        AwsRequest {
950            service: "translate".to_string(),
951            action: action.to_string(),
952            region: "us-east-1".to_string(),
953            account_id: "000000000000".to_string(),
954            request_id: "test".to_string(),
955            headers: http::HeaderMap::new(),
956            query_params: std::collections::HashMap::new(),
957            body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap()),
958            body_stream: parking_lot::Mutex::new(None),
959            path_segments: Vec::new(),
960            raw_path: String::new(),
961            raw_query: String::new(),
962            method: http::Method::POST,
963            is_query_protocol: false,
964            access_key_id: None,
965            principal: None,
966        }
967    }
968
969    fn body_of(resp: AwsResponse) -> Value {
970        serde_json::from_slice(resp.body.expect_bytes()).unwrap()
971    }
972
973    #[tokio::test]
974    async fn translate_text_echoes_passthrough() {
975        let svc = service();
976        let resp = svc
977            .handle(req(
978                "TranslateText",
979                json!({ "Text": "hello", "SourceLanguageCode": "en", "TargetLanguageCode": "fr" }),
980            ))
981            .await
982            .unwrap();
983        let v = body_of(resp);
984        assert_eq!(v["TranslatedText"], "hello");
985        assert_eq!(v["SourceLanguageCode"], "en");
986        assert_eq!(v["TargetLanguageCode"], "fr");
987    }
988
989    #[tokio::test]
990    async fn terminology_round_trip_and_tagging() {
991        let svc = service();
992        let file = base64::engine::general_purpose::STANDARD
993            .encode("en,fr\nhello,bonjour\ndog,chien\n".as_bytes());
994        svc.handle(req(
995            "ImportTerminology",
996            json!({
997                "Name": "my-term",
998                "MergeStrategy": "OVERWRITE",
999                "TerminologyData": { "File": file, "Format": "CSV" }
1000            }),
1001        ))
1002        .await
1003        .unwrap();
1004
1005        let got = body_of(
1006            svc.handle(req("GetTerminology", json!({ "Name": "my-term" })))
1007                .await
1008                .unwrap(),
1009        );
1010        let props = &got["TerminologyProperties"];
1011        assert_eq!(props["Name"], "my-term");
1012        assert_eq!(props["SourceLanguageCode"], "en");
1013        assert_eq!(props["TargetLanguageCodes"][0], "fr");
1014        assert_eq!(props["TermCount"], 2);
1015        assert!(got["TerminologyDataLocation"]["Location"].is_string());
1016
1017        // Tagging.
1018        let arn = "arn:aws:translate:us-east-1:000000000000:terminology/my-term";
1019        svc.handle(req(
1020            "TagResource",
1021            json!({ "ResourceArn": arn, "Tags": [{ "Key": "team", "Value": "loc" }] }),
1022        ))
1023        .await
1024        .unwrap();
1025        let tags = body_of(
1026            svc.handle(req("ListTagsForResource", json!({ "ResourceArn": arn })))
1027                .await
1028                .unwrap(),
1029        );
1030        assert_eq!(tags["Tags"][0]["Key"], "team");
1031
1032        // Tagging a missing resource -> ResourceNotFoundException.
1033        let bad = svc
1034            .handle(req(
1035                "ListTagsForResource",
1036                json!({ "ResourceArn": "arn:aws:translate:us-east-1:000000000000:terminology/nope" }),
1037            ))
1038            .await;
1039        assert_eq!(bad.err().unwrap().code(), "ResourceNotFoundException");
1040
1041        // Delete -> subsequent Get 404s.
1042        svc.handle(req("DeleteTerminology", json!({ "Name": "my-term" })))
1043            .await
1044            .unwrap();
1045        let after = svc
1046            .handle(req("GetTerminology", json!({ "Name": "my-term" })))
1047            .await;
1048        assert_eq!(after.err().unwrap().code(), "ResourceNotFoundException");
1049    }
1050
1051    #[tokio::test]
1052    async fn batch_job_settles_to_completed() {
1053        let svc = service();
1054        let started = body_of(
1055            svc.handle(req(
1056                "StartTextTranslationJob",
1057                json!({
1058                    "InputDataConfig": { "S3Uri": "s3://in/", "ContentType": "text/plain" },
1059                    "OutputDataConfig": { "S3Uri": "s3://out/" },
1060                    "DataAccessRoleArn": "arn:aws:iam::000000000000:role/translate",
1061                    "SourceLanguageCode": "en",
1062                    "TargetLanguageCodes": ["fr"],
1063                    "ClientToken": "tok-1"
1064                }),
1065            ))
1066            .await
1067            .unwrap(),
1068        );
1069        let id = started["JobId"].as_str().unwrap().to_string();
1070        assert_eq!(started["JobStatus"], "SUBMITTED");
1071        assert_eq!(id.len(), 32);
1072
1073        let described = body_of(
1074            svc.handle(req("DescribeTextTranslationJob", json!({ "JobId": id })))
1075                .await
1076                .unwrap(),
1077        );
1078        assert_eq!(
1079            described["TextTranslationJobProperties"]["JobStatus"],
1080            "COMPLETED"
1081        );
1082
1083        // Missing job -> ResourceNotFoundException.
1084        let missing = svc
1085            .handle(req(
1086                "DescribeTextTranslationJob",
1087                json!({ "JobId": "doesnotexist" }),
1088            ))
1089            .await;
1090        assert_eq!(missing.err().unwrap().code(), "ResourceNotFoundException");
1091    }
1092
1093    #[tokio::test]
1094    async fn parallel_data_lifecycle() {
1095        let svc = service();
1096        svc.handle(req(
1097            "CreateParallelData",
1098            json!({
1099                "Name": "pd1",
1100                "ParallelDataConfig": { "S3Uri": "s3://b/pd.tsv", "Format": "TSV" },
1101                "ClientToken": "tok-2"
1102            }),
1103        ))
1104        .await
1105        .unwrap();
1106        let got = body_of(
1107            svc.handle(req("GetParallelData", json!({ "Name": "pd1" })))
1108                .await
1109                .unwrap(),
1110        );
1111        assert_eq!(got["ParallelDataProperties"]["Status"], "ACTIVE");
1112
1113        // Duplicate -> ConflictException.
1114        let dup = svc
1115            .handle(req(
1116                "CreateParallelData",
1117                json!({ "Name": "pd1", "ParallelDataConfig": {}, "ClientToken": "t" }),
1118            ))
1119            .await;
1120        assert_eq!(dup.err().unwrap().code(), "ConflictException");
1121
1122        svc.handle(req("DeleteParallelData", json!({ "Name": "pd1" })))
1123            .await
1124            .unwrap();
1125        let after = svc
1126            .handle(req("GetParallelData", json!({ "Name": "pd1" })))
1127            .await;
1128        assert_eq!(after.err().unwrap().code(), "ResourceNotFoundException");
1129    }
1130
1131    #[tokio::test]
1132    async fn list_languages_returns_catalogue() {
1133        let svc = service();
1134        let v = body_of(svc.handle(req("ListLanguages", json!({}))).await.unwrap());
1135        let langs = v["Languages"].as_array().unwrap();
1136        assert!(langs.iter().any(|l| l["LanguageCode"] == "en"));
1137        assert!(langs.iter().any(|l| l["LanguageCode"] == "fr"));
1138    }
1139
1140    #[tokio::test]
1141    async fn missing_required_is_validation_error() {
1142        let svc = service();
1143        // TranslateText missing Text -> InvalidRequestException (its declared
1144        // validation error).
1145        let err = svc
1146            .handle(req(
1147                "TranslateText",
1148                json!({ "SourceLanguageCode": "en", "TargetLanguageCode": "fr" }),
1149            ))
1150            .await
1151            .err()
1152            .unwrap();
1153        assert_eq!(err.code(), "InvalidRequestException");
1154    }
1155}