Skip to main content

fakecloud_amplify/
service.rs

1//! AWS Amplify (`amplify`) restJson1 dispatch + operation handlers.
2//!
3//! The full 37-operation AWS Amplify control plane. Requests are routed to an
4//! operation by HTTP method + `@http` URI path; path labels are captured
5//! positionally (percent-decoded, so an ARN label whose slashes arrive
6//! percent-encoded survives intact) and query parameters are read from the raw
7//! query string. State is account-partitioned and persisted; each resource is
8//! stored as its already-output-valid wire object so `Get*` echoes exactly what
9//! `Create*` / `Update*` persisted, and the async domain-verification /
10//! job-build lifecycles settle deterministically on the next read.
11
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use http::{Method, StatusCode};
16use percent_encoding::percent_decode_str;
17use serde_json::{json, Map, Value};
18use tokio::sync::Mutex as AsyncMutex;
19
20use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
21use fakecloud_persistence::SnapshotStore;
22
23use crate::persistence::save_snapshot;
24use crate::shared;
25use crate::state::{AmplifyData, AppRecord, SharedAmplifyState};
26
27/// Every operation name in the AWS Amplify Smithy model (37 operations).
28pub const AMPLIFY_ACTIONS: &[&str] = &[
29    "CreateApp",
30    "CreateBackendEnvironment",
31    "CreateBranch",
32    "CreateDeployment",
33    "CreateDomainAssociation",
34    "CreateWebhook",
35    "DeleteApp",
36    "DeleteBackendEnvironment",
37    "DeleteBranch",
38    "DeleteDomainAssociation",
39    "DeleteJob",
40    "DeleteWebhook",
41    "GenerateAccessLogs",
42    "GetApp",
43    "GetArtifactUrl",
44    "GetBackendEnvironment",
45    "GetBranch",
46    "GetDomainAssociation",
47    "GetJob",
48    "GetWebhook",
49    "ListApps",
50    "ListArtifacts",
51    "ListBackendEnvironments",
52    "ListBranches",
53    "ListDomainAssociations",
54    "ListJobs",
55    "ListTagsForResource",
56    "ListWebhooks",
57    "StartDeployment",
58    "StartJob",
59    "StopJob",
60    "TagResource",
61    "UntagResource",
62    "UpdateApp",
63    "UpdateBranch",
64    "UpdateDomainAssociation",
65    "UpdateWebhook",
66];
67
68/// Operations that mutate persisted state on success (so a snapshot is taken).
69const MUTATING: &[&str] = &[
70    "CreateApp",
71    "UpdateApp",
72    "DeleteApp",
73    "CreateBranch",
74    "UpdateBranch",
75    "DeleteBranch",
76    "CreateDomainAssociation",
77    "UpdateDomainAssociation",
78    "DeleteDomainAssociation",
79    "CreateWebhook",
80    "UpdateWebhook",
81    "DeleteWebhook",
82    "CreateBackendEnvironment",
83    "DeleteBackendEnvironment",
84    "StartJob",
85    "StopJob",
86    "DeleteJob",
87    "CreateDeployment",
88    "StartDeployment",
89    "TagResource",
90    "UntagResource",
91];
92
93pub struct AmplifyService {
94    state: SharedAmplifyState,
95    snapshot_store: Option<Arc<dyn SnapshotStore>>,
96    snapshot_lock: Arc<AsyncMutex<()>>,
97}
98
99impl AmplifyService {
100    pub fn new(state: SharedAmplifyState) -> Self {
101        Self {
102            state,
103            snapshot_store: None,
104            snapshot_lock: Arc::new(AsyncMutex::new(())),
105        }
106    }
107
108    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
109        self.snapshot_store = Some(store);
110        self
111    }
112
113    async fn save(&self) {
114        save_snapshot(
115            &self.state,
116            self.snapshot_store.clone(),
117            &self.snapshot_lock,
118        )
119        .await;
120    }
121
122    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
123    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
124        let store = self.snapshot_store.clone()?;
125        let state = self.state.clone();
126        let lock = self.snapshot_lock.clone();
127        Some(Arc::new(move || {
128            let state = state.clone();
129            let store = store.clone();
130            let lock = lock.clone();
131            Box::pin(async move {
132                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
133            })
134        }))
135    }
136
137    /// Settle any in-flight domain / job lifecycle transition for the account.
138    /// Returns `true` if a transition fired (so the caller persists the change).
139    fn reconcile(&self, account: &str) -> bool {
140        let mut guard = self.state.write();
141        guard.get_or_create(account).reconcile()
142    }
143
144    /// Route a request to an operation name + captured path labels by HTTP
145    /// method + `@http` URI path. Returns `None` when no route matches.
146    fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Vec<String>)> {
147        let raw = req.raw_path.split('?').next().unwrap_or(&req.raw_path);
148        let trimmed = raw.strip_prefix('/').unwrap_or(raw);
149        let segs: Vec<String> = if trimmed.is_empty() {
150            Vec::new()
151        } else {
152            trimmed
153                .split('/')
154                .map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
155                .collect()
156        };
157        let s: Vec<&str> = segs.iter().map(String::as_str).collect();
158        let m = &req.method;
159        let get = m == Method::GET;
160        let post = m == Method::POST;
161        let del = m == Method::DELETE;
162        let l1 = |a: &str| vec![a.to_string()];
163        let l2 = |a: &str, b: &str| vec![a.to_string(), b.to_string()];
164        let l3 = |a: &str, b: &str, c: &str| vec![a.to_string(), b.to_string(), c.to_string()];
165        let (action, labels): (&'static str, Vec<String>) = match s.as_slice() {
166            ["apps"] if post => ("CreateApp", vec![]),
167            ["apps"] if get => ("ListApps", vec![]),
168            ["apps", app] if post => ("UpdateApp", l1(app)),
169            ["apps", app] if get => ("GetApp", l1(app)),
170            ["apps", app] if del => ("DeleteApp", l1(app)),
171            ["apps", app, "branches"] if post => ("CreateBranch", l1(app)),
172            ["apps", app, "branches"] if get => ("ListBranches", l1(app)),
173            ["apps", app, "branches", br] if post => ("UpdateBranch", l2(app, br)),
174            ["apps", app, "branches", br] if get => ("GetBranch", l2(app, br)),
175            ["apps", app, "branches", br] if del => ("DeleteBranch", l2(app, br)),
176            ["apps", app, "branches", br, "deployments"] if post => {
177                ("CreateDeployment", l2(app, br))
178            }
179            ["apps", app, "branches", br, "deployments", "start"] if post => {
180                ("StartDeployment", l2(app, br))
181            }
182            ["apps", app, "branches", br, "jobs"] if post => ("StartJob", l2(app, br)),
183            ["apps", app, "branches", br, "jobs"] if get => ("ListJobs", l2(app, br)),
184            ["apps", app, "branches", br, "jobs", job] if get => ("GetJob", l3(app, br, job)),
185            ["apps", app, "branches", br, "jobs", job] if del => ("DeleteJob", l3(app, br, job)),
186            ["apps", app, "branches", br, "jobs", job, "stop"] if del => {
187                ("StopJob", l3(app, br, job))
188            }
189            ["apps", app, "branches", br, "jobs", job, "artifacts"] if get => {
190                ("ListArtifacts", l3(app, br, job))
191            }
192            ["apps", app, "domains"] if post => ("CreateDomainAssociation", l1(app)),
193            ["apps", app, "domains"] if get => ("ListDomainAssociations", l1(app)),
194            ["apps", app, "domains", dom] if post => ("UpdateDomainAssociation", l2(app, dom)),
195            ["apps", app, "domains", dom] if get => ("GetDomainAssociation", l2(app, dom)),
196            ["apps", app, "domains", dom] if del => ("DeleteDomainAssociation", l2(app, dom)),
197            ["apps", app, "webhooks"] if post => ("CreateWebhook", l1(app)),
198            ["apps", app, "webhooks"] if get => ("ListWebhooks", l1(app)),
199            ["apps", app, "backendenvironments"] if post => ("CreateBackendEnvironment", l1(app)),
200            ["apps", app, "backendenvironments"] if get => ("ListBackendEnvironments", l1(app)),
201            ["apps", app, "backendenvironments", env] if get => {
202                ("GetBackendEnvironment", l2(app, env))
203            }
204            ["apps", app, "backendenvironments", env] if del => {
205                ("DeleteBackendEnvironment", l2(app, env))
206            }
207            ["apps", app, "accesslogs"] if post => ("GenerateAccessLogs", l1(app)),
208            ["webhooks", wh] if post => ("UpdateWebhook", l1(wh)),
209            ["webhooks", wh] if get => ("GetWebhook", l1(wh)),
210            ["webhooks", wh] if del => ("DeleteWebhook", l1(wh)),
211            ["artifacts", art] if get => ("GetArtifactUrl", l1(art)),
212            ["tags", arn] if post => ("TagResource", l1(arn)),
213            ["tags", arn] if get => ("ListTagsForResource", l1(arn)),
214            ["tags", arn] if del => ("UntagResource", l1(arn)),
215            _ => return None,
216        };
217        Some((action, labels))
218    }
219}
220
221#[async_trait]
222impl AwsService for AmplifyService {
223    fn service_name(&self) -> &str {
224        "amplify"
225    }
226
227    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
228        let Some((action, labels)) = Self::resolve_action(&req) else {
229            return Err(AwsServiceError::aws_error(
230                StatusCode::NOT_FOUND,
231                "UnknownOperationException",
232                format!("Unknown operation: {} {}", req.method, req.raw_path),
233            ));
234        };
235        let (result, settled) = self.dispatch(action, &labels, &req);
236        let success = matches!(result.as_ref(), Ok(resp) if resp.status.is_success());
237        if settled || (MUTATING.contains(&action) && success) {
238            self.save().await;
239        }
240        result
241    }
242
243    fn supported_actions(&self) -> &[&str] {
244        AMPLIFY_ACTIONS
245    }
246}
247
248/// Per-request account + region context.
249struct Ctx {
250    account: String,
251    region: String,
252}
253
254impl AmplifyService {
255    fn dispatch(
256        &self,
257        action: &str,
258        labels: &[String],
259        req: &AwsRequest,
260    ) -> (Result<AwsResponse, AwsServiceError>, bool) {
261        let body = match parse_body(req) {
262            Ok(b) => b,
263            Err(e) => return (Err(e), false),
264        };
265        if let Err(e) = crate::validate::validate_input(action, &body) {
266            return (Err(e), false);
267        }
268        let ctx = Ctx {
269            account: req.account_id.clone(),
270            region: req.region.clone(),
271        };
272        let q = parse_query(&req.raw_query);
273        // Settle any in-flight lifecycle transition before the handler reads
274        // state, remembering whether it fired so the change is persisted even
275        // for a pure read.
276        let settled = self.reconcile(&ctx.account);
277        let a = |i: usize| labels.get(i).map(String::as_str).unwrap_or_default();
278        let result = match action {
279            "CreateApp" => self.create_app(&ctx, &body),
280            "GetApp" => self.get_app(&ctx, a(0)),
281            "UpdateApp" => self.update_app(&ctx, a(0), &body),
282            "DeleteApp" => self.delete_app(&ctx, a(0)),
283            "ListApps" => self.list_apps(&ctx, &q),
284            "CreateBranch" => self.create_branch(&ctx, a(0), &body),
285            "GetBranch" => self.get_branch(&ctx, a(0), a(1)),
286            "UpdateBranch" => self.update_branch(&ctx, a(0), a(1), &body),
287            "DeleteBranch" => self.delete_branch(&ctx, a(0), a(1)),
288            "ListBranches" => self.list_branches(&ctx, a(0), &q),
289            "CreateDomainAssociation" => self.create_domain(&ctx, a(0), &body),
290            "GetDomainAssociation" => self.get_domain(&ctx, a(0), a(1)),
291            "UpdateDomainAssociation" => self.update_domain(&ctx, a(0), a(1), &body),
292            "DeleteDomainAssociation" => self.delete_domain(&ctx, a(0), a(1)),
293            "ListDomainAssociations" => self.list_domains(&ctx, a(0), &q),
294            "CreateWebhook" => self.create_webhook(&ctx, a(0), &body),
295            "GetWebhook" => self.get_webhook(&ctx, a(0)),
296            "UpdateWebhook" => self.update_webhook(&ctx, a(0), &body),
297            "DeleteWebhook" => self.delete_webhook(&ctx, a(0)),
298            "ListWebhooks" => self.list_webhooks(&ctx, a(0), &q),
299            "CreateBackendEnvironment" => self.create_backend_env(&ctx, a(0), &body),
300            "GetBackendEnvironment" => self.get_backend_env(&ctx, a(0), a(1)),
301            "DeleteBackendEnvironment" => self.delete_backend_env(&ctx, a(0), a(1)),
302            "ListBackendEnvironments" => self.list_backend_envs(&ctx, a(0), &q),
303            "StartJob" => self.start_job(&ctx, a(0), a(1), &body),
304            "GetJob" => self.get_job(&ctx, a(0), a(1), a(2)),
305            "StopJob" => self.stop_job(&ctx, a(0), a(1), a(2)),
306            "DeleteJob" => self.delete_job(&ctx, a(0), a(1), a(2)),
307            "ListJobs" => self.list_jobs(&ctx, a(0), a(1), &q),
308            "CreateDeployment" => self.create_deployment(&ctx, a(0), a(1), &body),
309            "StartDeployment" => self.start_deployment(&ctx, a(0), a(1), &body),
310            "GenerateAccessLogs" => self.generate_access_logs(&ctx, a(0), &body),
311            "GetArtifactUrl" => self.get_artifact_url(&ctx, a(0)),
312            "ListArtifacts" => self.list_artifacts(&ctx, a(0), a(1), a(2), &q),
313            "TagResource" => self.tag_resource(&ctx, a(0), &body),
314            "UntagResource" => self.untag_resource(&ctx, a(0), &q),
315            "ListTagsForResource" => self.list_tags(&ctx, a(0)),
316            _ => Err(AwsServiceError::action_not_implemented("amplify", action)),
317        };
318        (result, settled)
319    }
320}
321
322// ===================== helpers =====================
323
324fn ok(v: Value) -> Result<AwsResponse, AwsServiceError> {
325    Ok(AwsResponse::json_value(StatusCode::OK, v))
326}
327
328fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
329    if req.body.is_empty() {
330        return Ok(json!({}));
331    }
332    serde_json::from_slice(&req.body)
333        .map_err(|e| bad_request(&format!("Request body is malformed: {e}")))
334}
335
336fn bad_request(msg: &str) -> AwsServiceError {
337    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg)
338}
339
340fn not_found(msg: &str) -> AwsServiceError {
341    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "NotFoundException", msg)
342}
343
344fn resource_not_found(msg: &str) -> AwsServiceError {
345    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", msg)
346}
347
348fn parse_query(raw: &str) -> Vec<(String, String)> {
349    raw.split('&')
350        .filter(|p| !p.is_empty())
351        .map(|pair| {
352            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
353            (
354                percent_decode_str(k).decode_utf8_lossy().into_owned(),
355                percent_decode_str(v).decode_utf8_lossy().into_owned(),
356            )
357        })
358        .collect()
359}
360
361fn query_one<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
362    q.iter()
363        .find(|(k, _)| k == key)
364        .map(|(_, v)| v.as_str())
365        .filter(|v| !v.is_empty())
366}
367
368fn query_all(q: &[(String, String)], key: &str) -> Vec<String> {
369    q.iter()
370        .filter(|(k, _)| k == key)
371        .map(|(_, v)| v.clone())
372        .collect()
373}
374
375/// Validate an `appId` path label against Amplify's `^d[a-z0-9]+$` (1..=20)
376/// pattern, rejecting a malformed id with `BadRequestException` before lookup.
377fn check_app_id(app_id: &str) -> Result<(), AwsServiceError> {
378    let valid = (1..=20).contains(&app_id.len())
379        && app_id.starts_with('d')
380        && app_id
381            .chars()
382            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
383    if valid {
384        Ok(())
385    } else {
386        Err(bad_request(&format!(
387            "App id '{app_id}' does not match required pattern ^d[a-z0-9]+$ (1-20 characters)."
388        )))
389    }
390}
391
392/// Validate a `branchName` path label (1..=255 chars).
393fn check_branch_name(name: &str) -> Result<(), AwsServiceError> {
394    if (1..=255).contains(&name.len()) {
395        Ok(())
396    } else {
397        Err(bad_request(&format!(
398            "Branch name must be 1-255 characters, got {}.",
399            name.len()
400        )))
401    }
402}
403
404/// Read an optional string body member (owned), or a default.
405fn str_or(body: &Value, key: &str, default: &str) -> String {
406    body.get(key)
407        .and_then(Value::as_str)
408        .unwrap_or(default)
409        .to_string()
410}
411
412fn bool_or(body: &Value, key: &str, default: bool) -> bool {
413    body.get(key).and_then(Value::as_bool).unwrap_or(default)
414}
415
416/// Copy each named optional member from `body` into `out` verbatim when present
417/// and non-null. Used to echo the model's optional members onto stored wire
418/// objects without inventing values.
419fn echo(out: &mut Map<String, Value>, body: &Value, keys: &[&str]) {
420    for key in keys {
421        if let Some(v) = body.get(*key) {
422            if !v.is_null() {
423                out.insert((*key).to_string(), v.clone());
424            }
425        }
426    }
427}
428
429/// Paginate a list of wire objects using the request's `maxResults` /
430/// `nextToken` query params. Validates `maxResults` against `[0, max]` and
431/// `nextToken` length, returning `BadRequestException` for out-of-range values.
432fn paginate(
433    items: Vec<Value>,
434    q: &[(String, String)],
435    max_results_cap: usize,
436) -> Result<(Vec<Value>, Option<String>), AwsServiceError> {
437    if let Some(tok) = query_one(q, "nextToken") {
438        if tok.len() > 2000 {
439            return Err(bad_request("nextToken exceeds the maximum length of 2000."));
440        }
441    }
442    let max = match query_one(q, "maxResults") {
443        Some(v) => {
444            let n: i64 = v
445                .parse()
446                .map_err(|_| bad_request("maxResults must be an integer."))?;
447            if n < 0 || n as usize > max_results_cap {
448                return Err(bad_request(&format!(
449                    "maxResults must be between 0 and {max_results_cap}."
450                )));
451            }
452            if n == 0 {
453                max_results_cap
454            } else {
455                n as usize
456            }
457        }
458        None => max_results_cap.min(100),
459    };
460    let start = query_one(q, "nextToken")
461        .and_then(|v| v.parse::<usize>().ok())
462        .unwrap_or(0);
463    let end = (start + max).min(items.len());
464    let page: Vec<Value> = items.get(start..end).unwrap_or(&[]).to_vec();
465    let next = if end < items.len() {
466        Some(end.to_string())
467    } else {
468        None
469    };
470    Ok((page, next))
471}
472
473impl AmplifyService {
474    // ------------------------------- Apps -------------------------------
475
476    fn build_app(&self, ctx: &Ctx, app_id: &str, body: &Value) -> Value {
477        let now = shared::now_epoch();
478        let mut app = Map::new();
479        app.insert("appId".into(), json!(app_id));
480        app.insert(
481            "appArn".into(),
482            json!(shared::app_arn(&ctx.region, &ctx.account, app_id)),
483        );
484        app.insert("name".into(), json!(str_or(body, "name", "")));
485        app.insert("description".into(), json!(str_or(body, "description", "")));
486        app.insert("repository".into(), json!(str_or(body, "repository", "")));
487        app.insert("platform".into(), json!(str_or(body, "platform", "WEB")));
488        app.insert("createTime".into(), json!(now));
489        app.insert("updateTime".into(), json!(now));
490        app.insert(
491            "environmentVariables".into(),
492            body.get("environmentVariables")
493                .cloned()
494                .unwrap_or_else(|| json!({})),
495        );
496        app.insert(
497            "defaultDomain".into(),
498            json!(shared::default_domain(app_id)),
499        );
500        app.insert(
501            "enableBranchAutoBuild".into(),
502            json!(bool_or(body, "enableBranchAutoBuild", true)),
503        );
504        app.insert(
505            "enableBasicAuth".into(),
506            json!(bool_or(body, "enableBasicAuth", false)),
507        );
508        // The repository clone method AWS derives from the supplied credential:
509        // a personal/OAuth token yields TOKEN, otherwise SSH deploy keys.
510        let clone_method = if body.get("accessToken").is_some() || body.get("oauthToken").is_some()
511        {
512            "TOKEN"
513        } else {
514            "SSH"
515        };
516        app.insert("repositoryCloneMethod".into(), json!(clone_method));
517        echo(
518            &mut app,
519            body,
520            &[
521                "tags",
522                "computeRoleArn",
523                "iamServiceRoleArn",
524                "enableBranchAutoDeletion",
525                "basicAuthCredentials",
526                "customRules",
527                "buildSpec",
528                "customHeaders",
529                "enableAutoBranchCreation",
530                "autoBranchCreationPatterns",
531                "autoBranchCreationConfig",
532                "cacheConfig",
533                "jobConfig",
534            ],
535        );
536        Value::Object(app)
537    }
538
539    fn create_app(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
540        let app_id = shared::new_app_id();
541        let app = self.build_app(ctx, &app_id, body);
542        let mut guard = self.state.write();
543        let data = guard.get_or_create(&ctx.account);
544        data.apps.insert(
545            app_id.clone(),
546            AppRecord {
547                app: app.clone(),
548                ..Default::default()
549            },
550        );
551        ok(json!({ "app": app }))
552    }
553
554    fn get_app(&self, ctx: &Ctx, app_id: &str) -> Result<AwsResponse, AwsServiceError> {
555        check_app_id(app_id)?;
556        let guard = self.state.read();
557        match guard.get(&ctx.account).and_then(|d| d.apps.get(app_id)) {
558            Some(rec) => ok(json!({ "app": rec.app })),
559            None => Err(not_found(&format!("App {app_id} not found."))),
560        }
561    }
562
563    fn update_app(
564        &self,
565        ctx: &Ctx,
566        app_id: &str,
567        body: &Value,
568    ) -> Result<AwsResponse, AwsServiceError> {
569        check_app_id(app_id)?;
570        let mut guard = self.state.write();
571        let data = guard.get_or_create(&ctx.account);
572        let Some(rec) = data.apps.get_mut(app_id) else {
573            return Err(not_found(&format!("App {app_id} not found.")));
574        };
575        let Some(app) = rec.app.as_object_mut() else {
576            return Err(not_found(&format!("App {app_id} not found.")));
577        };
578        for key in [
579            "name",
580            "description",
581            "platform",
582            "computeRoleArn",
583            "iamServiceRoleArn",
584            "environmentVariables",
585            "basicAuthCredentials",
586            "customRules",
587            "buildSpec",
588            "customHeaders",
589            "autoBranchCreationPatterns",
590            "autoBranchCreationConfig",
591            "repository",
592            "cacheConfig",
593            "jobConfig",
594        ] {
595            if let Some(v) = body.get(key) {
596                app.insert(key.to_string(), v.clone());
597            }
598        }
599        // Boolean members renamed between input and output.
600        if let Some(v) = body.get("enableBranchAutoBuild") {
601            app.insert("enableBranchAutoBuild".into(), v.clone());
602        }
603        for key in [
604            "enableBranchAutoDeletion",
605            "enableBasicAuth",
606            "enableAutoBranchCreation",
607        ] {
608            if let Some(v) = body.get(key) {
609                app.insert(key.to_string(), v.clone());
610            }
611        }
612        app.insert("updateTime".into(), json!(shared::now_epoch()));
613        ok(json!({ "app": Value::Object(app.clone()) }))
614    }
615
616    fn delete_app(&self, ctx: &Ctx, app_id: &str) -> Result<AwsResponse, AwsServiceError> {
617        check_app_id(app_id)?;
618        let mut guard = self.state.write();
619        let data = guard.get_or_create(&ctx.account);
620        let Some(rec) = data.apps.remove(app_id) else {
621            return Err(not_found(&format!("App {app_id} not found.")));
622        };
623        // Cascade: drop webhooks that belonged to this app.
624        data.webhooks
625            .retain(|_, w| w.get("appId").and_then(Value::as_str) != Some(app_id));
626        ok(json!({ "app": rec.app }))
627    }
628
629    fn list_apps(&self, ctx: &Ctx, q: &[(String, String)]) -> Result<AwsResponse, AwsServiceError> {
630        let guard = self.state.read();
631        let apps: Vec<Value> = guard
632            .get(&ctx.account)
633            .map(|d| d.apps.values().map(|r| r.app.clone()).collect())
634            .unwrap_or_default();
635        let (page, next) = paginate(apps, q, 100)?;
636        let mut out = json!({ "apps": page });
637        if let Some(n) = next {
638            out["nextToken"] = json!(n);
639        }
640        ok(out)
641    }
642
643    // ------------------------------ Branches ----------------------------
644
645    fn build_branch(&self, ctx: &Ctx, app_id: &str, branch_name: &str, body: &Value) -> Value {
646        let now = shared::now_epoch();
647        let mut b = Map::new();
648        b.insert(
649            "branchArn".into(),
650            json!(shared::branch_arn(
651                &ctx.region,
652                &ctx.account,
653                app_id,
654                branch_name
655            )),
656        );
657        b.insert("branchName".into(), json!(branch_name));
658        b.insert("description".into(), json!(str_or(body, "description", "")));
659        b.insert("stage".into(), json!(str_or(body, "stage", "NONE")));
660        b.insert(
661            "displayName".into(),
662            json!(str_or(body, "displayName", branch_name)),
663        );
664        b.insert(
665            "enableNotification".into(),
666            json!(bool_or(body, "enableNotification", false)),
667        );
668        b.insert("createTime".into(), json!(now));
669        b.insert("updateTime".into(), json!(now));
670        b.insert(
671            "environmentVariables".into(),
672            body.get("environmentVariables")
673                .cloned()
674                .unwrap_or_else(|| json!({})),
675        );
676        b.insert(
677            "enableAutoBuild".into(),
678            json!(bool_or(body, "enableAutoBuild", true)),
679        );
680        b.insert("customDomains".into(), json!([]));
681        b.insert("framework".into(), json!(str_or(body, "framework", "")));
682        b.insert("activeJobId".into(), json!(""));
683        b.insert("totalNumberOfJobs".into(), json!("0"));
684        b.insert(
685            "enableBasicAuth".into(),
686            json!(bool_or(body, "enableBasicAuth", false)),
687        );
688        b.insert("ttl".into(), json!(str_or(body, "ttl", "5")));
689        b.insert(
690            "enablePullRequestPreview".into(),
691            json!(bool_or(body, "enablePullRequestPreview", false)),
692        );
693        b.insert(
694            "thumbnailUrl".into(),
695            json!(shared::thumbnail_url(&ctx.region, app_id, branch_name)),
696        );
697        echo(
698            &mut b,
699            body,
700            &[
701                "tags",
702                "enableSkewProtection",
703                "enablePerformanceMode",
704                "basicAuthCredentials",
705                "buildSpec",
706                "pullRequestEnvironmentName",
707                "backendEnvironmentArn",
708                "backend",
709                "computeRoleArn",
710            ],
711        );
712        Value::Object(b)
713    }
714
715    fn create_branch(
716        &self,
717        ctx: &Ctx,
718        app_id: &str,
719        body: &Value,
720    ) -> Result<AwsResponse, AwsServiceError> {
721        check_app_id(app_id)?;
722        let branch_name = body
723            .get("branchName")
724            .and_then(Value::as_str)
725            .unwrap_or_default()
726            .to_string();
727        check_branch_name(&branch_name)?;
728        let branch = self.build_branch(ctx, app_id, &branch_name, body);
729        let mut guard = self.state.write();
730        let data = guard.get_or_create(&ctx.account);
731        let Some(rec) = data.apps.get_mut(app_id) else {
732            return Err(not_found(&format!("App {app_id} not found.")));
733        };
734        if rec.branches.contains_key(&branch_name) {
735            return Err(bad_request(&format!(
736                "A branch with the name {branch_name} already exists."
737            )));
738        }
739        rec.branches.insert(branch_name, branch.clone());
740        ok(json!({ "branch": branch }))
741    }
742
743    fn get_branch(
744        &self,
745        ctx: &Ctx,
746        app_id: &str,
747        branch: &str,
748    ) -> Result<AwsResponse, AwsServiceError> {
749        check_app_id(app_id)?;
750        let guard = self.state.read();
751        match guard
752            .get(&ctx.account)
753            .and_then(|d| d.apps.get(app_id))
754            .and_then(|r| r.branches.get(branch))
755        {
756            Some(b) => ok(json!({ "branch": b })),
757            None => Err(not_found(&format!("Branch {branch} not found."))),
758        }
759    }
760
761    fn update_branch(
762        &self,
763        ctx: &Ctx,
764        app_id: &str,
765        branch: &str,
766        body: &Value,
767    ) -> Result<AwsResponse, AwsServiceError> {
768        check_app_id(app_id)?;
769        let mut guard = self.state.write();
770        let data = guard.get_or_create(&ctx.account);
771        let Some(b) = data
772            .apps
773            .get_mut(app_id)
774            .and_then(|r| r.branches.get_mut(branch))
775            .and_then(Value::as_object_mut)
776        else {
777            return Err(not_found(&format!("Branch {branch} not found.")));
778        };
779        for key in [
780            "description",
781            "framework",
782            "stage",
783            "enableNotification",
784            "enableAutoBuild",
785            "enableSkewProtection",
786            "environmentVariables",
787            "basicAuthCredentials",
788            "enableBasicAuth",
789            "enablePerformanceMode",
790            "buildSpec",
791            "ttl",
792            "displayName",
793            "enablePullRequestPreview",
794            "pullRequestEnvironmentName",
795            "backendEnvironmentArn",
796            "backend",
797            "computeRoleArn",
798        ] {
799            if let Some(v) = body.get(key) {
800                b.insert(key.to_string(), v.clone());
801            }
802        }
803        b.insert("updateTime".into(), json!(shared::now_epoch()));
804        ok(json!({ "branch": Value::Object(b.clone()) }))
805    }
806
807    fn delete_branch(
808        &self,
809        ctx: &Ctx,
810        app_id: &str,
811        branch: &str,
812    ) -> Result<AwsResponse, AwsServiceError> {
813        check_app_id(app_id)?;
814        let mut guard = self.state.write();
815        let data = guard.get_or_create(&ctx.account);
816        let Some(rec) = data.apps.get_mut(app_id) else {
817            return Err(not_found(&format!("App {app_id} not found.")));
818        };
819        match rec.branches.remove(branch) {
820            Some(b) => {
821                rec.jobs.remove(branch);
822                rec.next_job_id.remove(branch);
823                ok(json!({ "branch": b }))
824            }
825            None => Err(not_found(&format!("Branch {branch} not found."))),
826        }
827    }
828
829    fn list_branches(
830        &self,
831        ctx: &Ctx,
832        app_id: &str,
833        q: &[(String, String)],
834    ) -> Result<AwsResponse, AwsServiceError> {
835        check_app_id(app_id)?;
836        let guard = self.state.read();
837        let branches: Vec<Value> = guard
838            .get(&ctx.account)
839            .and_then(|d| d.apps.get(app_id))
840            .map(|r| r.branches.values().cloned().collect())
841            .unwrap_or_default();
842        let (page, next) = paginate(branches, q, 50)?;
843        let mut out = json!({ "branches": page });
844        if let Some(n) = next {
845            out["nextToken"] = json!(n);
846        }
847        ok(out)
848    }
849
850    // --------------------------- Domains --------------------------------
851
852    fn build_domain(&self, ctx: &Ctx, app_id: &str, domain: &str, body: &Value) -> Value {
853        let mut d = Map::new();
854        d.insert(
855            "domainAssociationArn".into(),
856            json!(shared::domain_arn(
857                &ctx.region,
858                &ctx.account,
859                app_id,
860                domain
861            )),
862        );
863        d.insert("domainName".into(), json!(domain));
864        d.insert(
865            "enableAutoSubDomain".into(),
866            json!(bool_or(body, "enableAutoSubDomain", false)),
867        );
868        d.insert("domainStatus".into(), json!("CREATING"));
869        d.insert("updateStatus".into(), json!("PENDING_VERIFICATION"));
870        d.insert("statusReason".into(), json!(""));
871        d.insert(
872            "certificateVerificationDNSRecord".into(),
873            json!(shared::certificate_verification_dns_record(domain)),
874        );
875        // Build the subdomains from the requested settings.
876        let sub_settings = body
877            .get("subDomainSettings")
878            .and_then(Value::as_array)
879            .cloned()
880            .unwrap_or_default();
881        let subdomains: Vec<Value> = sub_settings
882            .iter()
883            .map(|s| {
884                json!({
885                    "subDomainSetting": s,
886                    "verified": false,
887                    "dnsRecord": shared::subdomain_dns_record(app_id),
888                })
889            })
890            .collect();
891        d.insert("subDomains".into(), json!(subdomains));
892        // Certificate mirrors the requested certificate settings.
893        if let Some(cs) = body.get("certificateSettings").and_then(Value::as_object) {
894            let ctype = cs
895                .get("type")
896                .and_then(Value::as_str)
897                .unwrap_or("AMPLIFY_MANAGED");
898            let mut cert = Map::new();
899            cert.insert("type".into(), json!(ctype));
900            if let Some(arn) = cs.get("customCertificateArn") {
901                cert.insert("customCertificateArn".into(), arn.clone());
902            }
903            cert.insert(
904                "certificateVerificationDNSRecord".into(),
905                json!(shared::certificate_verification_dns_record(domain)),
906            );
907            d.insert("certificate".into(), Value::Object(cert));
908        }
909        echo(
910            &mut d,
911            body,
912            &["autoSubDomainCreationPatterns", "autoSubDomainIAMRole"],
913        );
914        Value::Object(d)
915    }
916
917    fn create_domain(
918        &self,
919        ctx: &Ctx,
920        app_id: &str,
921        body: &Value,
922    ) -> Result<AwsResponse, AwsServiceError> {
923        check_app_id(app_id)?;
924        let domain = body
925            .get("domainName")
926            .and_then(Value::as_str)
927            .unwrap_or_default()
928            .to_string();
929        let assoc = self.build_domain(ctx, app_id, &domain, body);
930        let mut guard = self.state.write();
931        let data = guard.get_or_create(&ctx.account);
932        let Some(rec) = data.apps.get_mut(app_id) else {
933            return Err(not_found(&format!("App {app_id} not found.")));
934        };
935        if rec.domains.contains_key(&domain) {
936            return Err(bad_request(&format!(
937                "A domain association for {domain} already exists."
938            )));
939        }
940        rec.domains.insert(domain, assoc.clone());
941        ok(json!({ "domainAssociation": assoc }))
942    }
943
944    fn get_domain(
945        &self,
946        ctx: &Ctx,
947        app_id: &str,
948        domain: &str,
949    ) -> Result<AwsResponse, AwsServiceError> {
950        check_app_id(app_id)?;
951        let guard = self.state.read();
952        match guard
953            .get(&ctx.account)
954            .and_then(|d| d.apps.get(app_id))
955            .and_then(|r| r.domains.get(domain))
956        {
957            Some(a) => ok(json!({ "domainAssociation": a })),
958            None => Err(not_found(&format!(
959                "Domain association {domain} not found."
960            ))),
961        }
962    }
963
964    fn update_domain(
965        &self,
966        ctx: &Ctx,
967        app_id: &str,
968        domain: &str,
969        body: &Value,
970    ) -> Result<AwsResponse, AwsServiceError> {
971        check_app_id(app_id)?;
972        let mut guard = self.state.write();
973        let data = guard.get_or_create(&ctx.account);
974        let Some(a) = data
975            .apps
976            .get_mut(app_id)
977            .and_then(|r| r.domains.get_mut(domain))
978            .and_then(Value::as_object_mut)
979        else {
980            return Err(not_found(&format!(
981                "Domain association {domain} not found."
982            )));
983        };
984        if let Some(v) = body.get("enableAutoSubDomain") {
985            a.insert("enableAutoSubDomain".into(), v.clone());
986        }
987        for key in ["autoSubDomainCreationPatterns", "autoSubDomainIAMRole"] {
988            if let Some(v) = body.get(key) {
989                a.insert(key.to_string(), v.clone());
990            }
991        }
992        if let Some(settings) = body.get("subDomainSettings").and_then(Value::as_array) {
993            let subs: Vec<Value> = settings
994                .iter()
995                .map(|s| {
996                    json!({
997                        "subDomainSetting": s,
998                        "verified": false,
999                        "dnsRecord": shared::subdomain_dns_record(app_id),
1000                    })
1001                })
1002                .collect();
1003            a.insert("subDomains".into(), json!(subs));
1004        }
1005        // Re-enter verification so the update settles on the next read.
1006        a.insert("domainStatus".into(), json!("UPDATING"));
1007        a.insert("updateStatus".into(), json!("PENDING_VERIFICATION"));
1008        ok(json!({ "domainAssociation": Value::Object(a.clone()) }))
1009    }
1010
1011    fn delete_domain(
1012        &self,
1013        ctx: &Ctx,
1014        app_id: &str,
1015        domain: &str,
1016    ) -> Result<AwsResponse, AwsServiceError> {
1017        check_app_id(app_id)?;
1018        let mut guard = self.state.write();
1019        let data = guard.get_or_create(&ctx.account);
1020        let Some(rec) = data.apps.get_mut(app_id) else {
1021            return Err(not_found(&format!("App {app_id} not found.")));
1022        };
1023        match rec.domains.remove(domain) {
1024            Some(a) => ok(json!({ "domainAssociation": a })),
1025            None => Err(not_found(&format!(
1026                "Domain association {domain} not found."
1027            ))),
1028        }
1029    }
1030
1031    fn list_domains(
1032        &self,
1033        ctx: &Ctx,
1034        app_id: &str,
1035        q: &[(String, String)],
1036    ) -> Result<AwsResponse, AwsServiceError> {
1037        check_app_id(app_id)?;
1038        let guard = self.state.read();
1039        let domains: Vec<Value> = guard
1040            .get(&ctx.account)
1041            .and_then(|d| d.apps.get(app_id))
1042            .map(|r| r.domains.values().cloned().collect())
1043            .unwrap_or_default();
1044        let (page, next) = paginate(domains, q, 50)?;
1045        let mut out = json!({ "domainAssociations": page });
1046        if let Some(n) = next {
1047            out["nextToken"] = json!(n);
1048        }
1049        ok(out)
1050    }
1051
1052    // --------------------------- Webhooks -------------------------------
1053
1054    fn create_webhook(
1055        &self,
1056        ctx: &Ctx,
1057        app_id: &str,
1058        body: &Value,
1059    ) -> Result<AwsResponse, AwsServiceError> {
1060        check_app_id(app_id)?;
1061        let branch = body
1062            .get("branchName")
1063            .and_then(Value::as_str)
1064            .unwrap_or_default()
1065            .to_string();
1066        check_branch_name(&branch)?;
1067        let webhook_id = shared::new_webhook_id();
1068        let now = shared::now_epoch();
1069        let mut guard = self.state.write();
1070        let data = guard.get_or_create(&ctx.account);
1071        let Some(rec) = data.apps.get_mut(app_id) else {
1072            return Err(not_found(&format!("App {app_id} not found.")));
1073        };
1074        if !rec.branches.contains_key(&branch) {
1075            return Err(not_found(&format!("Branch {branch} not found.")));
1076        }
1077        let webhook = json!({
1078            "webhookArn": shared::webhook_arn(&ctx.region, &ctx.account, app_id, &webhook_id),
1079            "webhookId": webhook_id,
1080            "webhookUrl": shared::webhook_url(&ctx.region, &webhook_id),
1081            "appId": app_id,
1082            "branchName": branch,
1083            "description": str_or(body, "description", ""),
1084            "createTime": now,
1085            "updateTime": now,
1086        });
1087        data.webhooks.insert(webhook_id, webhook.clone());
1088        ok(json!({ "webhook": webhook }))
1089    }
1090
1091    fn get_webhook(&self, ctx: &Ctx, webhook_id: &str) -> Result<AwsResponse, AwsServiceError> {
1092        let guard = self.state.read();
1093        match guard
1094            .get(&ctx.account)
1095            .and_then(|d| d.webhooks.get(webhook_id))
1096        {
1097            Some(w) => ok(json!({ "webhook": w })),
1098            None => Err(not_found(&format!("Webhook {webhook_id} not found."))),
1099        }
1100    }
1101
1102    fn update_webhook(
1103        &self,
1104        ctx: &Ctx,
1105        webhook_id: &str,
1106        body: &Value,
1107    ) -> Result<AwsResponse, AwsServiceError> {
1108        let mut guard = self.state.write();
1109        let data = guard.get_or_create(&ctx.account);
1110        let Some(w) = data
1111            .webhooks
1112            .get_mut(webhook_id)
1113            .and_then(Value::as_object_mut)
1114        else {
1115            return Err(not_found(&format!("Webhook {webhook_id} not found.")));
1116        };
1117        if let Some(v) = body.get("branchName") {
1118            w.insert("branchName".into(), v.clone());
1119        }
1120        if let Some(v) = body.get("description") {
1121            w.insert("description".into(), v.clone());
1122        }
1123        w.insert("updateTime".into(), json!(shared::now_epoch()));
1124        ok(json!({ "webhook": Value::Object(w.clone()) }))
1125    }
1126
1127    fn delete_webhook(&self, ctx: &Ctx, webhook_id: &str) -> Result<AwsResponse, AwsServiceError> {
1128        let mut guard = self.state.write();
1129        let data = guard.get_or_create(&ctx.account);
1130        match data.webhooks.remove(webhook_id) {
1131            Some(w) => ok(json!({ "webhook": w })),
1132            None => Err(not_found(&format!("Webhook {webhook_id} not found."))),
1133        }
1134    }
1135
1136    fn list_webhooks(
1137        &self,
1138        ctx: &Ctx,
1139        app_id: &str,
1140        q: &[(String, String)],
1141    ) -> Result<AwsResponse, AwsServiceError> {
1142        check_app_id(app_id)?;
1143        let guard = self.state.read();
1144        let webhooks: Vec<Value> = guard
1145            .get(&ctx.account)
1146            .map(|d| {
1147                d.webhooks
1148                    .values()
1149                    .filter(|w| w.get("appId").and_then(Value::as_str) == Some(app_id))
1150                    .cloned()
1151                    .collect()
1152            })
1153            .unwrap_or_default();
1154        let (page, next) = paginate(webhooks, q, 50)?;
1155        let mut out = json!({ "webhooks": page });
1156        if let Some(n) = next {
1157            out["nextToken"] = json!(n);
1158        }
1159        ok(out)
1160    }
1161
1162    // ---------------------- Backend environments ------------------------
1163
1164    fn create_backend_env(
1165        &self,
1166        ctx: &Ctx,
1167        app_id: &str,
1168        body: &Value,
1169    ) -> Result<AwsResponse, AwsServiceError> {
1170        check_app_id(app_id)?;
1171        let name = body
1172            .get("environmentName")
1173            .and_then(Value::as_str)
1174            .unwrap_or_default()
1175            .to_string();
1176        let now = shared::now_epoch();
1177        let mut env = Map::new();
1178        env.insert(
1179            "backendEnvironmentArn".into(),
1180            json!(shared::backend_environment_arn(
1181                &ctx.region,
1182                &ctx.account,
1183                app_id,
1184                &name
1185            )),
1186        );
1187        env.insert("environmentName".into(), json!(name));
1188        env.insert("createTime".into(), json!(now));
1189        env.insert("updateTime".into(), json!(now));
1190        echo(&mut env, body, &["stackName", "deploymentArtifacts"]);
1191        let env = Value::Object(env);
1192        let mut guard = self.state.write();
1193        let data = guard.get_or_create(&ctx.account);
1194        let Some(rec) = data.apps.get_mut(app_id) else {
1195            return Err(not_found(&format!("App {app_id} not found.")));
1196        };
1197        let name = env["environmentName"]
1198            .as_str()
1199            .unwrap_or_default()
1200            .to_string();
1201        if rec.backend_environments.contains_key(&name) {
1202            return Err(bad_request(&format!(
1203                "A backend environment {name} already exists."
1204            )));
1205        }
1206        rec.backend_environments.insert(name, env.clone());
1207        ok(json!({ "backendEnvironment": env }))
1208    }
1209
1210    fn get_backend_env(
1211        &self,
1212        ctx: &Ctx,
1213        app_id: &str,
1214        name: &str,
1215    ) -> Result<AwsResponse, AwsServiceError> {
1216        check_app_id(app_id)?;
1217        let guard = self.state.read();
1218        match guard
1219            .get(&ctx.account)
1220            .and_then(|d| d.apps.get(app_id))
1221            .and_then(|r| r.backend_environments.get(name))
1222        {
1223            Some(e) => ok(json!({ "backendEnvironment": e })),
1224            None => Err(not_found(&format!("Backend environment {name} not found."))),
1225        }
1226    }
1227
1228    fn delete_backend_env(
1229        &self,
1230        ctx: &Ctx,
1231        app_id: &str,
1232        name: &str,
1233    ) -> Result<AwsResponse, AwsServiceError> {
1234        check_app_id(app_id)?;
1235        let mut guard = self.state.write();
1236        let data = guard.get_or_create(&ctx.account);
1237        let Some(rec) = data.apps.get_mut(app_id) else {
1238            return Err(not_found(&format!("App {app_id} not found.")));
1239        };
1240        match rec.backend_environments.remove(name) {
1241            Some(e) => ok(json!({ "backendEnvironment": e })),
1242            None => Err(not_found(&format!("Backend environment {name} not found."))),
1243        }
1244    }
1245
1246    fn list_backend_envs(
1247        &self,
1248        ctx: &Ctx,
1249        app_id: &str,
1250        q: &[(String, String)],
1251    ) -> Result<AwsResponse, AwsServiceError> {
1252        check_app_id(app_id)?;
1253        let filter = query_one(q, "environmentName");
1254        let guard = self.state.read();
1255        let envs: Vec<Value> = guard
1256            .get(&ctx.account)
1257            .and_then(|d| d.apps.get(app_id))
1258            .map(|r| {
1259                r.backend_environments
1260                    .values()
1261                    .filter(|e| {
1262                        filter.is_none()
1263                            || e.get("environmentName").and_then(Value::as_str) == filter
1264                    })
1265                    .cloned()
1266                    .collect()
1267            })
1268            .unwrap_or_default();
1269        let (page, next) = paginate(envs, q, 50)?;
1270        let mut out = json!({ "backendEnvironments": page });
1271        if let Some(n) = next {
1272            out["nextToken"] = json!(n);
1273        }
1274        ok(out)
1275    }
1276
1277    // ------------------------------ Jobs --------------------------------
1278
1279    /// Build a fresh `JobSummary` in the `PENDING` state.
1280    fn build_job_summary(
1281        &self,
1282        ctx: &Ctx,
1283        app_id: &str,
1284        branch: &str,
1285        job_id: &str,
1286        job_type: &str,
1287        body: &Value,
1288    ) -> Value {
1289        let now = shared::now_epoch();
1290        json!({
1291            "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, job_id),
1292            "jobId": job_id,
1293            "commitId": str_or(body, "commitId", "HEAD"),
1294            "commitMessage": str_or(body, "commitMessage", ""),
1295            "commitTime": body.get("commitTime").cloned().unwrap_or_else(|| json!(now)),
1296            "startTime": now,
1297            "status": "PENDING",
1298            "jobType": job_type,
1299        })
1300    }
1301
1302    /// Allocate the next numeric job id for a branch and bump its counter +
1303    /// the branch's `totalNumberOfJobs` / `activeJobId`.
1304    fn allocate_job(rec: &mut AppRecord, branch: &str, summary: &Value) -> String {
1305        let next = rec.next_job_id.entry(branch.to_string()).or_insert(1);
1306        let job_id = next.to_string();
1307        *next += 1;
1308        let job = json!({ "summary": summary, "steps": [] });
1309        rec.jobs
1310            .entry(branch.to_string())
1311            .or_default()
1312            .insert(job_id.clone(), job);
1313        if let Some(b) = rec.branches.get_mut(branch).and_then(Value::as_object_mut) {
1314            b.insert("activeJobId".into(), json!(job_id));
1315            let total = rec
1316                .jobs
1317                .get(branch)
1318                .map(|m| m.len())
1319                .unwrap_or(0)
1320                .to_string();
1321            b.insert("totalNumberOfJobs".into(), json!(total));
1322        }
1323        job_id
1324    }
1325
1326    fn start_job(
1327        &self,
1328        ctx: &Ctx,
1329        app_id: &str,
1330        branch: &str,
1331        body: &Value,
1332    ) -> Result<AwsResponse, AwsServiceError> {
1333        check_app_id(app_id)?;
1334        let job_type = body
1335            .get("jobType")
1336            .and_then(Value::as_str)
1337            .unwrap_or("RELEASE")
1338            .to_string();
1339        let mut guard = self.state.write();
1340        let data = guard.get_or_create(&ctx.account);
1341        let Some(rec) = data.apps.get_mut(app_id) else {
1342            return Err(not_found(&format!("App {app_id} not found.")));
1343        };
1344        if !rec.branches.contains_key(branch) {
1345            return Err(not_found(&format!("Branch {branch} not found.")));
1346        }
1347        // `StartJob` on an existing (e.g. CREATED-from-deployment) job id
1348        // restarts that job; otherwise a fresh id is allocated.
1349        let requested = body.get("jobId").and_then(Value::as_str);
1350        let (job_id, summary) = if let Some(jid) = requested.filter(|j| {
1351            rec.jobs
1352                .get(branch)
1353                .map(|m| m.contains_key(*j))
1354                .unwrap_or(false)
1355        }) {
1356            let jid = jid.to_string();
1357            let summary = self.build_job_summary(ctx, app_id, branch, &jid, &job_type, body);
1358            if let Some(job) = rec
1359                .jobs
1360                .get_mut(branch)
1361                .and_then(|m| m.get_mut(&jid))
1362                .and_then(Value::as_object_mut)
1363            {
1364                job.insert("summary".into(), summary.clone());
1365            }
1366            (jid, summary)
1367        } else {
1368            // Allocate a placeholder id to build the summary, then commit.
1369            let peek = rec
1370                .next_job_id
1371                .get(branch)
1372                .copied()
1373                .unwrap_or(1)
1374                .to_string();
1375            let summary = self.build_job_summary(ctx, app_id, branch, &peek, &job_type, body);
1376            let job_id = Self::allocate_job(rec, branch, &summary);
1377            (job_id, summary)
1378        };
1379        let _ = job_id;
1380        ok(json!({ "jobSummary": summary }))
1381    }
1382
1383    fn get_job(
1384        &self,
1385        ctx: &Ctx,
1386        app_id: &str,
1387        branch: &str,
1388        job_id: &str,
1389    ) -> Result<AwsResponse, AwsServiceError> {
1390        check_app_id(app_id)?;
1391        let guard = self.state.read();
1392        match guard
1393            .get(&ctx.account)
1394            .and_then(|d| d.apps.get(app_id))
1395            .and_then(|r| r.jobs.get(branch))
1396            .and_then(|m| m.get(job_id))
1397        {
1398            Some(job) => ok(json!({ "job": job })),
1399            None => Err(not_found(&format!("Job {job_id} not found."))),
1400        }
1401    }
1402
1403    fn stop_job(
1404        &self,
1405        ctx: &Ctx,
1406        app_id: &str,
1407        branch: &str,
1408        job_id: &str,
1409    ) -> Result<AwsResponse, AwsServiceError> {
1410        check_app_id(app_id)?;
1411        let mut guard = self.state.write();
1412        let data = guard.get_or_create(&ctx.account);
1413        let Some(job) = data
1414            .apps
1415            .get_mut(app_id)
1416            .and_then(|r| r.jobs.get_mut(branch))
1417            .and_then(|m| m.get_mut(job_id))
1418            .and_then(Value::as_object_mut)
1419        else {
1420            return Err(not_found(&format!("Job {job_id} not found.")));
1421        };
1422        let now = shared::now_epoch();
1423        if let Some(summary) = job.get_mut("summary").and_then(Value::as_object_mut) {
1424            summary.insert("status".into(), json!("CANCELLED"));
1425            summary.insert("endTime".into(), json!(now));
1426            let summary = Value::Object(summary.clone());
1427            return ok(json!({ "jobSummary": summary }));
1428        }
1429        Err(not_found(&format!("Job {job_id} not found.")))
1430    }
1431
1432    fn delete_job(
1433        &self,
1434        ctx: &Ctx,
1435        app_id: &str,
1436        branch: &str,
1437        job_id: &str,
1438    ) -> Result<AwsResponse, AwsServiceError> {
1439        check_app_id(app_id)?;
1440        let mut guard = self.state.write();
1441        let data = guard.get_or_create(&ctx.account);
1442        let Some(jobs) = data
1443            .apps
1444            .get_mut(app_id)
1445            .and_then(|r| r.jobs.get_mut(branch))
1446        else {
1447            return Err(not_found(&format!("Job {job_id} not found.")));
1448        };
1449        match jobs.remove(job_id) {
1450            Some(job) => {
1451                let summary = job.get("summary").cloned().unwrap_or_else(|| json!({}));
1452                ok(json!({ "jobSummary": summary }))
1453            }
1454            None => Err(not_found(&format!("Job {job_id} not found."))),
1455        }
1456    }
1457
1458    fn list_jobs(
1459        &self,
1460        ctx: &Ctx,
1461        app_id: &str,
1462        branch: &str,
1463        q: &[(String, String)],
1464    ) -> Result<AwsResponse, AwsServiceError> {
1465        check_app_id(app_id)?;
1466        let guard = self.state.read();
1467        let mut summaries: Vec<Value> = guard
1468            .get(&ctx.account)
1469            .and_then(|d| d.apps.get(app_id))
1470            .and_then(|r| r.jobs.get(branch))
1471            .map(|m| {
1472                m.values()
1473                    .filter_map(|j| j.get("summary").cloned())
1474                    .collect()
1475            })
1476            .unwrap_or_default();
1477        // Newest job first (Amplify orders jobs by descending id).
1478        summaries.reverse();
1479        let (page, next) = paginate(summaries, q, 50)?;
1480        let mut out = json!({ "jobSummaries": page });
1481        if let Some(n) = next {
1482            out["nextToken"] = json!(n);
1483        }
1484        ok(out)
1485    }
1486
1487    // -------------------------- Deployments -----------------------------
1488
1489    fn create_deployment(
1490        &self,
1491        ctx: &Ctx,
1492        app_id: &str,
1493        branch: &str,
1494        body: &Value,
1495    ) -> Result<AwsResponse, AwsServiceError> {
1496        // `CreateDeployment` declares no NotFoundException; it is a presign
1497        // operation that hands back S3 upload URLs. Validate the labels, then
1498        // return upload targets. When the branch exists we reserve a job id in
1499        // the `CREATED` state that a later `StartDeployment` starts.
1500        check_app_id(app_id)?;
1501        check_branch_name(branch)?;
1502        let file_map = body.get("fileMap").and_then(Value::as_object);
1503        let mut uploads = Map::new();
1504        if let Some(fm) = file_map {
1505            for name in fm.keys() {
1506                uploads.insert(
1507                    name.clone(),
1508                    json!(shared::upload_url(&ctx.region, app_id, branch, name)),
1509                );
1510            }
1511        }
1512        let zip_url = shared::upload_url(&ctx.region, app_id, branch, "deployment.zip");
1513
1514        let mut job_id: Option<String> = None;
1515        let mut guard = self.state.write();
1516        let data = guard.get_or_create(&ctx.account);
1517        if let Some(rec) = data.apps.get_mut(app_id) {
1518            if rec.branches.contains_key(branch) {
1519                let next = rec.next_job_id.entry(branch.to_string()).or_insert(1);
1520                let id = next.to_string();
1521                *next += 1;
1522                let now = shared::now_epoch();
1523                let summary = json!({
1524                    "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, &id),
1525                    "jobId": id,
1526                    "commitId": "HEAD",
1527                    "commitMessage": "",
1528                    "commitTime": now,
1529                    "startTime": now,
1530                    "status": "CREATED",
1531                    "jobType": "MANUAL",
1532                });
1533                rec.jobs
1534                    .entry(branch.to_string())
1535                    .or_default()
1536                    .insert(id.clone(), json!({ "summary": summary, "steps": [] }));
1537                job_id = Some(id);
1538            }
1539        }
1540        let mut out = json!({
1541            "fileUploadUrls": Value::Object(uploads),
1542            "zipUploadUrl": zip_url,
1543        });
1544        if let Some(id) = job_id {
1545            out["jobId"] = json!(id);
1546        }
1547        ok(out)
1548    }
1549
1550    fn start_deployment(
1551        &self,
1552        ctx: &Ctx,
1553        app_id: &str,
1554        branch: &str,
1555        body: &Value,
1556    ) -> Result<AwsResponse, AwsServiceError> {
1557        check_app_id(app_id)?;
1558        let mut guard = self.state.write();
1559        let data = guard.get_or_create(&ctx.account);
1560        let Some(rec) = data.apps.get_mut(app_id) else {
1561            return Err(not_found(&format!("App {app_id} not found.")));
1562        };
1563        if !rec.branches.contains_key(branch) {
1564            return Err(not_found(&format!("Branch {branch} not found.")));
1565        }
1566        let now = shared::now_epoch();
1567        let requested = body
1568            .get("jobId")
1569            .and_then(Value::as_str)
1570            .map(str::to_string);
1571        let (job_id, summary) = match requested.filter(|j| {
1572            rec.jobs
1573                .get(branch)
1574                .map(|m| m.contains_key(j))
1575                .unwrap_or(false)
1576        }) {
1577            Some(jid) => {
1578                let summary = json!({
1579                    "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, &jid),
1580                    "jobId": jid,
1581                    "commitId": "HEAD",
1582                    "commitMessage": "",
1583                    "commitTime": now,
1584                    "startTime": now,
1585                    "status": "PENDING",
1586                    "jobType": "MANUAL",
1587                });
1588                if let Some(job) = rec
1589                    .jobs
1590                    .get_mut(branch)
1591                    .and_then(|m| m.get_mut(&jid))
1592                    .and_then(Value::as_object_mut)
1593                {
1594                    job.insert("summary".into(), summary.clone());
1595                }
1596                (jid, summary)
1597            }
1598            None => {
1599                let peek = rec
1600                    .next_job_id
1601                    .get(branch)
1602                    .copied()
1603                    .unwrap_or(1)
1604                    .to_string();
1605                let summary = json!({
1606                    "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, &peek),
1607                    "jobId": peek,
1608                    "commitId": "HEAD",
1609                    "commitMessage": "",
1610                    "commitTime": now,
1611                    "startTime": now,
1612                    "status": "PENDING",
1613                    "jobType": "MANUAL",
1614                });
1615                let job_id = Self::allocate_job(rec, branch, &summary);
1616                (job_id, summary)
1617            }
1618        };
1619        let _ = job_id;
1620        ok(json!({ "jobSummary": summary }))
1621    }
1622
1623    // -------------------------- Artifacts -------------------------------
1624
1625    /// The deterministic artifact set for a job. A settled (`SUCCEED`) build
1626    /// exposes its build log; other states expose nothing yet.
1627    fn artifacts_for_job(app_id: &str, branch: &str, job_id: &str, job: &Value) -> Vec<Value> {
1628        let status = job
1629            .get("summary")
1630            .and_then(|s| s.get("status"))
1631            .and_then(Value::as_str)
1632            .unwrap_or("");
1633        if status != "SUCCEED" {
1634            return Vec::new();
1635        }
1636        vec![json!({
1637            "artifactFileName": "buildLogs.txt",
1638            "artifactId": shared::encode_artifact_id(app_id, branch, job_id, "buildLogs.txt"),
1639        })]
1640    }
1641
1642    fn list_artifacts(
1643        &self,
1644        ctx: &Ctx,
1645        app_id: &str,
1646        branch: &str,
1647        job_id: &str,
1648        q: &[(String, String)],
1649    ) -> Result<AwsResponse, AwsServiceError> {
1650        check_app_id(app_id)?;
1651        let guard = self.state.read();
1652        let artifacts: Vec<Value> = guard
1653            .get(&ctx.account)
1654            .and_then(|d| d.apps.get(app_id))
1655            .and_then(|r| r.jobs.get(branch))
1656            .and_then(|m| m.get(job_id))
1657            .map(|job| Self::artifacts_for_job(app_id, branch, job_id, job))
1658            .unwrap_or_default();
1659        let (page, next) = paginate(artifacts, q, 50)?;
1660        let mut out = json!({ "artifacts": page });
1661        if let Some(n) = next {
1662            out["nextToken"] = json!(n);
1663        }
1664        ok(out)
1665    }
1666
1667    fn get_artifact_url(
1668        &self,
1669        ctx: &Ctx,
1670        artifact_id: &str,
1671    ) -> Result<AwsResponse, AwsServiceError> {
1672        match shared::decode_artifact_id(artifact_id) {
1673            Some((app_id, branch, job_id, file)) => ok(json!({
1674                "artifactId": artifact_id,
1675                "artifactUrl": shared::artifact_url(&ctx.region, &app_id, &branch, &job_id, &file),
1676            })),
1677            None => Err(not_found(&format!("Artifact {artifact_id} not found."))),
1678        }
1679    }
1680
1681    // ------------------------ Access logs -------------------------------
1682
1683    fn generate_access_logs(
1684        &self,
1685        ctx: &Ctx,
1686        app_id: &str,
1687        body: &Value,
1688    ) -> Result<AwsResponse, AwsServiceError> {
1689        check_app_id(app_id)?;
1690        let domain = body
1691            .get("domainName")
1692            .and_then(Value::as_str)
1693            .ok_or_else(|| bad_request("domainName is required."))?
1694            .to_string();
1695        let guard = self.state.read();
1696        let exists = guard
1697            .get(&ctx.account)
1698            .map(|d| d.apps.contains_key(app_id))
1699            .unwrap_or(false);
1700        drop(guard);
1701        if !exists {
1702            return Err(not_found(&format!("App {app_id} not found.")));
1703        }
1704        ok(json!({
1705            "logUrl": shared::access_log_url(&ctx.region, app_id, &domain),
1706        }))
1707    }
1708
1709    // ------------------------------ Tags --------------------------------
1710
1711    /// Resolve a `ResourceArn` label to a mutable tag-carrying resource (an app
1712    /// or a branch), rejecting a malformed arn with `BadRequestException` and a
1713    /// missing resource with `ResourceNotFoundException`.
1714    fn tags_target_mut<'a>(
1715        data: &'a mut AmplifyData,
1716        arn: &str,
1717    ) -> Result<&'a mut Value, AwsServiceError> {
1718        if !arn.starts_with("arn:aws:amplify:") {
1719            return Err(bad_request(&format!(
1720                "Resource arn '{arn}' does not match required pattern ^arn:aws:amplify:."
1721            )));
1722        }
1723        let (app_id, branch) = shared::parse_resource_arn(arn)
1724            .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1725        let rec = data
1726            .apps
1727            .get_mut(&app_id)
1728            .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1729        match branch {
1730            Some(br) => rec
1731                .branches
1732                .get_mut(&br)
1733                .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found."))),
1734            None => Ok(&mut rec.app),
1735        }
1736    }
1737
1738    fn tag_resource(
1739        &self,
1740        ctx: &Ctx,
1741        arn: &str,
1742        body: &Value,
1743    ) -> Result<AwsResponse, AwsServiceError> {
1744        let mut guard = self.state.write();
1745        let data = guard.get_or_create(&ctx.account);
1746        let target = Self::tags_target_mut(data, arn)?;
1747        if let Some(obj) = target.as_object_mut() {
1748            let tags = obj.entry("tags").or_insert_with(|| json!({}));
1749            if let (Some(tags), Some(new)) = (
1750                tags.as_object_mut(),
1751                body.get("tags").and_then(Value::as_object),
1752            ) {
1753                for (k, v) in new {
1754                    tags.insert(k.clone(), v.clone());
1755                }
1756            }
1757        }
1758        ok(json!({}))
1759    }
1760
1761    fn untag_resource(
1762        &self,
1763        ctx: &Ctx,
1764        arn: &str,
1765        q: &[(String, String)],
1766    ) -> Result<AwsResponse, AwsServiceError> {
1767        let keys = query_all(q, "tagKeys");
1768        if keys.is_empty() {
1769            return Err(bad_request("tagKeys is required."));
1770        }
1771        let mut guard = self.state.write();
1772        let data = guard.get_or_create(&ctx.account);
1773        let target = Self::tags_target_mut(data, arn)?;
1774        if let Some(tags) = target
1775            .as_object_mut()
1776            .and_then(|o| o.get_mut("tags"))
1777            .and_then(Value::as_object_mut)
1778        {
1779            for k in &keys {
1780                tags.remove(k);
1781            }
1782        }
1783        ok(json!({}))
1784    }
1785
1786    fn list_tags(&self, ctx: &Ctx, arn: &str) -> Result<AwsResponse, AwsServiceError> {
1787        if !arn.starts_with("arn:aws:amplify:") {
1788            return Err(bad_request(&format!(
1789                "Resource arn '{arn}' does not match required pattern ^arn:aws:amplify:."
1790            )));
1791        }
1792        let (app_id, branch) = shared::parse_resource_arn(arn)
1793            .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1794        let guard = self.state.read();
1795        let rec = guard
1796            .get(&ctx.account)
1797            .and_then(|d| d.apps.get(&app_id))
1798            .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1799        let obj = match &branch {
1800            Some(br) => rec
1801                .branches
1802                .get(br)
1803                .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?,
1804            None => &rec.app,
1805        };
1806        let tags = obj.get("tags").cloned().unwrap_or_else(|| json!({}));
1807        ok(json!({ "tags": tags }))
1808    }
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813    use super::*;
1814    use fakecloud_core::multi_account::MultiAccountState;
1815    use parking_lot::RwLock;
1816
1817    fn svc() -> AmplifyService {
1818        AmplifyService::new(Arc::new(RwLock::new(MultiAccountState::new(
1819            "000000000000",
1820            "us-east-1",
1821            "",
1822        ))))
1823    }
1824
1825    fn ctx() -> Ctx {
1826        Ctx {
1827            account: "000000000000".into(),
1828            region: "us-east-1".into(),
1829        }
1830    }
1831
1832    fn body_json(resp: &AwsResponse) -> Value {
1833        serde_json::from_slice(resp.body.expect_bytes()).expect("json response body")
1834    }
1835
1836    fn err_of(r: Result<AwsResponse, AwsServiceError>) -> AwsServiceError {
1837        match r {
1838            Ok(_) => panic!("expected an error"),
1839            Err(e) => e,
1840        }
1841    }
1842
1843    fn make_app(s: &AmplifyService, c: &Ctx) -> String {
1844        let created = s
1845            .create_app(c, &json!({ "name": "my-app", "platform": "WEB" }))
1846            .unwrap();
1847        let body = body_json(&created);
1848        body["app"]["appId"].as_str().unwrap().to_string()
1849    }
1850
1851    #[test]
1852    fn create_app_returns_full_shape() {
1853        let s = svc();
1854        let created = s.create_app(&ctx(), &json!({ "name": "hello" })).unwrap();
1855        let app = &body_json(&created)["app"];
1856        for field in [
1857            "appId",
1858            "appArn",
1859            "name",
1860            "description",
1861            "repository",
1862            "platform",
1863            "createTime",
1864            "updateTime",
1865            "environmentVariables",
1866            "defaultDomain",
1867            "enableBranchAutoBuild",
1868            "enableBasicAuth",
1869        ] {
1870            assert!(app.get(field).is_some(), "missing {field}");
1871        }
1872        assert!(app["appId"].as_str().unwrap().starts_with('d'));
1873        assert!(app["appArn"]
1874            .as_str()
1875            .unwrap()
1876            .starts_with("arn:aws:amplify:us-east-1:000000000000:apps/"));
1877    }
1878
1879    #[test]
1880    fn create_then_get_list_delete_app() {
1881        let s = svc();
1882        let c = ctx();
1883        let app_id = make_app(&s, &c);
1884        let got = body_json(&s.get_app(&c, &app_id).unwrap());
1885        assert_eq!(got["app"]["name"], json!("my-app"));
1886        let listed = body_json(&s.list_apps(&c, &[]).unwrap());
1887        assert_eq!(listed["apps"].as_array().unwrap().len(), 1);
1888        s.delete_app(&c, &app_id).unwrap();
1889        assert_eq!(
1890            err_of(s.get_app(&c, &app_id)).status(),
1891            StatusCode::NOT_FOUND
1892        );
1893    }
1894
1895    #[test]
1896    fn get_app_malformed_id_is_bad_request() {
1897        let s = svc();
1898        assert_eq!(
1899            err_of(s.get_app(&ctx(), "not-an-app-id")).status(),
1900            StatusCode::BAD_REQUEST
1901        );
1902    }
1903
1904    #[test]
1905    fn create_branch_requires_existing_app() {
1906        let s = svc();
1907        let err =
1908            err_of(s.create_branch(&ctx(), "d0000000000000", &json!({ "branchName": "main" })));
1909        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1910    }
1911
1912    #[test]
1913    fn branch_round_trip_and_domain() {
1914        let s = svc();
1915        let c = ctx();
1916        let app_id = make_app(&s, &c);
1917        let branch = body_json(
1918            &s.create_branch(
1919                &c,
1920                &app_id,
1921                &json!({ "branchName": "main", "stage": "PRODUCTION" }),
1922            )
1923            .unwrap(),
1924        );
1925        assert_eq!(branch["branch"]["stage"], json!("PRODUCTION"));
1926        let got = body_json(&s.get_branch(&c, &app_id, "main").unwrap());
1927        assert_eq!(got["branch"]["branchName"], json!("main"));
1928        let listed = body_json(&s.list_branches(&c, &app_id, &[]).unwrap());
1929        assert_eq!(listed["branches"].as_array().unwrap().len(), 1);
1930
1931        // Domain association lifecycle.
1932        let dom = body_json(
1933            &s.create_domain(
1934                &c,
1935                &app_id,
1936                &json!({
1937                    "domainName": "example.com",
1938                    "subDomainSettings": [{ "prefix": "www", "branchName": "main" }],
1939                }),
1940            )
1941            .unwrap(),
1942        );
1943        assert_eq!(dom["domainAssociation"]["domainStatus"], json!("CREATING"));
1944        // Reconcile settles it to AVAILABLE.
1945        assert!(s.reconcile(&c.account));
1946        let dom = body_json(&s.get_domain(&c, &app_id, "example.com").unwrap());
1947        assert_eq!(dom["domainAssociation"]["domainStatus"], json!("AVAILABLE"));
1948    }
1949
1950    #[test]
1951    fn job_lifecycle_and_artifacts() {
1952        let s = svc();
1953        let c = ctx();
1954        let app_id = make_app(&s, &c);
1955        s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
1956            .unwrap();
1957        let started = body_json(
1958            &s.start_job(&c, &app_id, "main", &json!({ "jobType": "RELEASE" }))
1959                .unwrap(),
1960        );
1961        let job_id = started["jobSummary"]["jobId"].as_str().unwrap().to_string();
1962        assert_eq!(started["jobSummary"]["status"], json!("PENDING"));
1963        // Walk the deterministic build to completion.
1964        for _ in 0..4 {
1965            s.reconcile(&c.account);
1966        }
1967        let job = body_json(&s.get_job(&c, &app_id, "main", &job_id).unwrap());
1968        assert_eq!(job["job"]["summary"]["status"], json!("SUCCEED"));
1969        assert_eq!(job["job"]["steps"][0]["stepName"], json!("BUILD"));
1970        // Artifacts show up on a settled build.
1971        let arts = body_json(&s.list_artifacts(&c, &app_id, "main", &job_id, &[]).unwrap());
1972        let art = &arts["artifacts"][0];
1973        assert_eq!(art["artifactFileName"], json!("buildLogs.txt"));
1974        let art_id = art["artifactId"].as_str().unwrap();
1975        let url = body_json(&s.get_artifact_url(&c, art_id).unwrap());
1976        assert!(url["artifactUrl"].as_str().unwrap().starts_with("https://"));
1977    }
1978
1979    #[test]
1980    fn stop_job_cancels() {
1981        let s = svc();
1982        let c = ctx();
1983        let app_id = make_app(&s, &c);
1984        s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
1985            .unwrap();
1986        let started = body_json(
1987            &s.start_job(&c, &app_id, "main", &json!({ "jobType": "MANUAL" }))
1988                .unwrap(),
1989        );
1990        let job_id = started["jobSummary"]["jobId"].as_str().unwrap().to_string();
1991        let stopped = body_json(&s.stop_job(&c, &app_id, "main", &job_id).unwrap());
1992        assert_eq!(stopped["jobSummary"]["status"], json!("CANCELLED"));
1993    }
1994
1995    #[test]
1996    fn webhook_round_trip() {
1997        let s = svc();
1998        let c = ctx();
1999        let app_id = make_app(&s, &c);
2000        s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2001            .unwrap();
2002        let wh = body_json(
2003            &s.create_webhook(
2004                &c,
2005                &app_id,
2006                &json!({ "branchName": "main", "description": "hook" }),
2007            )
2008            .unwrap(),
2009        );
2010        let id = wh["webhook"]["webhookId"].as_str().unwrap().to_string();
2011        let got = body_json(&s.get_webhook(&c, &id).unwrap());
2012        assert_eq!(got["webhook"]["branchName"], json!("main"));
2013        let listed = body_json(&s.list_webhooks(&c, &app_id, &[]).unwrap());
2014        assert_eq!(listed["webhooks"].as_array().unwrap().len(), 1);
2015        s.delete_webhook(&c, &id).unwrap();
2016        assert_eq!(
2017            err_of(s.get_webhook(&c, &id)).status(),
2018            StatusCode::NOT_FOUND
2019        );
2020    }
2021
2022    #[test]
2023    fn backend_environment_round_trip() {
2024        let s = svc();
2025        let c = ctx();
2026        let app_id = make_app(&s, &c);
2027        let env = body_json(
2028            &s.create_backend_env(&c, &app_id, &json!({ "environmentName": "staging" }))
2029                .unwrap(),
2030        );
2031        assert_eq!(
2032            env["backendEnvironment"]["environmentName"],
2033            json!("staging")
2034        );
2035        let got = body_json(&s.get_backend_env(&c, &app_id, "staging").unwrap());
2036        assert_eq!(
2037            got["backendEnvironment"]["environmentName"],
2038            json!("staging")
2039        );
2040        let listed = body_json(&s.list_backend_envs(&c, &app_id, &[]).unwrap());
2041        assert_eq!(listed["backendEnvironments"].as_array().unwrap().len(), 1);
2042    }
2043
2044    #[test]
2045    fn tag_untag_by_arn() {
2046        let s = svc();
2047        let c = ctx();
2048        let app_id = make_app(&s, &c);
2049        let arn = shared::app_arn(&c.region, &c.account, &app_id);
2050        s.tag_resource(&c, &arn, &json!({ "tags": { "team": "web" } }))
2051            .unwrap();
2052        let listed = body_json(&s.list_tags(&c, &arn).unwrap());
2053        assert_eq!(listed["tags"]["team"], json!("web"));
2054        s.untag_resource(&c, &arn, &[("tagKeys".into(), "team".into())])
2055            .unwrap();
2056        let listed = body_json(&s.list_tags(&c, &arn).unwrap());
2057        assert_eq!(listed["tags"].as_object().unwrap().len(), 0);
2058    }
2059
2060    #[test]
2061    fn list_tags_unknown_resource_is_not_found() {
2062        let s = svc();
2063        let arn = "arn:aws:amplify:us-east-1:000000000000:apps/d0000000000000";
2064        assert_eq!(
2065            err_of(s.list_tags(&ctx(), arn)).status(),
2066            StatusCode::NOT_FOUND
2067        );
2068    }
2069
2070    #[test]
2071    fn list_maxresults_out_of_range_is_bad_request() {
2072        let s = svc();
2073        let c = ctx();
2074        let q = vec![("maxResults".to_string(), "999".to_string())];
2075        assert_eq!(
2076            err_of(s.list_apps(&c, &q)).status(),
2077            StatusCode::BAD_REQUEST
2078        );
2079    }
2080
2081    #[test]
2082    fn create_deployment_returns_upload_urls() {
2083        let s = svc();
2084        let c = ctx();
2085        let app_id = make_app(&s, &c);
2086        s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2087            .unwrap();
2088        let dep = body_json(
2089            &s.create_deployment(
2090                &c,
2091                &app_id,
2092                "main",
2093                &json!({ "fileMap": { "index.html": "abc" } }),
2094            )
2095            .unwrap(),
2096        );
2097        assert!(dep["zipUploadUrl"]
2098            .as_str()
2099            .unwrap()
2100            .starts_with("https://"));
2101        assert!(dep["fileUploadUrls"]["index.html"]
2102            .as_str()
2103            .unwrap()
2104            .starts_with("https://"));
2105        assert!(dep["jobId"].is_string());
2106    }
2107
2108    #[test]
2109    fn routing_covers_every_action() {
2110        // Sanity: the action table and router agree on the operation count.
2111        assert_eq!(AMPLIFY_ACTIONS.len(), 37);
2112    }
2113}