1use 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
27pub 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
68const 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 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 fn reconcile(&self, account: &str) -> bool {
140 let mut guard = self.state.write();
141 guard.get_or_create(account).reconcile()
142 }
143
144 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
248struct 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 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
322fn ok(v: Value) -> Result<AwsResponse, AwsServiceError> {
325 Ok(AwsResponse::json_value(StatusCode::OK, v))
326}
327
328fn build_certificate(body: &Value, domain: &str) -> Option<Value> {
333 let cs = body.get("certificateSettings").and_then(Value::as_object)?;
334 let ctype = cs
335 .get("type")
336 .and_then(Value::as_str)
337 .unwrap_or("AMPLIFY_MANAGED");
338 let mut cert = Map::new();
339 cert.insert("type".into(), json!(ctype));
340 if let Some(arn) = cs.get("customCertificateArn") {
341 cert.insert("customCertificateArn".into(), arn.clone());
342 }
343 cert.insert(
344 "certificateVerificationDNSRecord".into(),
345 json!(shared::certificate_verification_dns_record(domain)),
346 );
347 Some(Value::Object(cert))
348}
349
350fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
351 if req.body.is_empty() {
352 return Ok(json!({}));
353 }
354 serde_json::from_slice(&req.body)
355 .map_err(|e| bad_request(&format!("Request body is malformed: {e}")))
356}
357
358fn bad_request(msg: &str) -> AwsServiceError {
359 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg)
360}
361
362fn not_found(msg: &str) -> AwsServiceError {
363 AwsServiceError::aws_error(StatusCode::NOT_FOUND, "NotFoundException", msg)
364}
365
366fn resource_not_found(msg: &str) -> AwsServiceError {
367 AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", msg)
368}
369
370fn parse_query(raw: &str) -> Vec<(String, String)> {
371 raw.split('&')
372 .filter(|p| !p.is_empty())
373 .map(|pair| {
374 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
375 (
376 percent_decode_str(k).decode_utf8_lossy().into_owned(),
377 percent_decode_str(v).decode_utf8_lossy().into_owned(),
378 )
379 })
380 .collect()
381}
382
383fn query_one<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
384 q.iter()
385 .find(|(k, _)| k == key)
386 .map(|(_, v)| v.as_str())
387 .filter(|v| !v.is_empty())
388}
389
390fn query_all(q: &[(String, String)], key: &str) -> Vec<String> {
391 q.iter()
392 .filter(|(k, _)| k == key)
393 .map(|(_, v)| v.clone())
394 .collect()
395}
396
397fn check_app_id(app_id: &str) -> Result<(), AwsServiceError> {
400 let valid = (1..=20).contains(&app_id.len())
401 && app_id.starts_with('d')
402 && app_id
403 .chars()
404 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
405 if valid {
406 Ok(())
407 } else {
408 Err(bad_request(&format!(
409 "App id '{app_id}' does not match required pattern ^d[a-z0-9]+$ (1-20 characters)."
410 )))
411 }
412}
413
414fn check_branch_name(name: &str) -> Result<(), AwsServiceError> {
416 if (1..=255).contains(&name.len()) {
417 Ok(())
418 } else {
419 Err(bad_request(&format!(
420 "Branch name must be 1-255 characters, got {}.",
421 name.len()
422 )))
423 }
424}
425
426fn str_or(body: &Value, key: &str, default: &str) -> String {
428 body.get(key)
429 .and_then(Value::as_str)
430 .unwrap_or(default)
431 .to_string()
432}
433
434fn bool_or(body: &Value, key: &str, default: bool) -> bool {
435 body.get(key).and_then(Value::as_bool).unwrap_or(default)
436}
437
438fn echo(out: &mut Map<String, Value>, body: &Value, keys: &[&str]) {
442 for key in keys {
443 if let Some(v) = body.get(*key) {
444 if !v.is_null() {
445 out.insert((*key).to_string(), v.clone());
446 }
447 }
448 }
449}
450
451fn paginate(
455 items: Vec<Value>,
456 q: &[(String, String)],
457 max_results_cap: usize,
458) -> Result<(Vec<Value>, Option<String>), AwsServiceError> {
459 if let Some(tok) = query_one(q, "nextToken") {
460 if tok.len() > 2000 {
461 return Err(bad_request("nextToken exceeds the maximum length of 2000."));
462 }
463 }
464 let max = match query_one(q, "maxResults") {
465 Some(v) => {
466 let n: i64 = v
467 .parse()
468 .map_err(|_| bad_request("maxResults must be an integer."))?;
469 if n < 0 || n as usize > max_results_cap {
470 return Err(bad_request(&format!(
471 "maxResults must be between 0 and {max_results_cap}."
472 )));
473 }
474 if n == 0 {
475 max_results_cap
476 } else {
477 n as usize
478 }
479 }
480 None => max_results_cap.min(100),
481 };
482 let start = query_one(q, "nextToken")
483 .and_then(|v| v.parse::<usize>().ok())
484 .unwrap_or(0);
485 let end = (start + max).min(items.len());
486 let page: Vec<Value> = items.get(start..end).unwrap_or(&[]).to_vec();
487 let next = if end < items.len() {
488 Some(end.to_string())
489 } else {
490 None
491 };
492 Ok((page, next))
493}
494
495impl AmplifyService {
496 fn build_app(&self, ctx: &Ctx, app_id: &str, body: &Value) -> Value {
499 let now = shared::now_epoch();
500 let mut app = Map::new();
501 app.insert("appId".into(), json!(app_id));
502 app.insert(
503 "appArn".into(),
504 json!(shared::app_arn(&ctx.region, &ctx.account, app_id)),
505 );
506 app.insert("name".into(), json!(str_or(body, "name", "")));
507 app.insert("description".into(), json!(str_or(body, "description", "")));
508 app.insert("repository".into(), json!(str_or(body, "repository", "")));
509 app.insert("platform".into(), json!(str_or(body, "platform", "WEB")));
510 app.insert("createTime".into(), json!(now));
511 app.insert("updateTime".into(), json!(now));
512 app.insert(
513 "environmentVariables".into(),
514 body.get("environmentVariables")
515 .cloned()
516 .unwrap_or_else(|| json!({})),
517 );
518 app.insert(
519 "defaultDomain".into(),
520 json!(shared::default_domain(app_id)),
521 );
522 app.insert(
523 "enableBranchAutoBuild".into(),
524 json!(bool_or(body, "enableBranchAutoBuild", true)),
525 );
526 app.insert(
527 "enableBasicAuth".into(),
528 json!(bool_or(body, "enableBasicAuth", false)),
529 );
530 let clone_method = if body.get("accessToken").is_some() || body.get("oauthToken").is_some()
533 {
534 "TOKEN"
535 } else {
536 "SSH"
537 };
538 app.insert("repositoryCloneMethod".into(), json!(clone_method));
539 echo(
540 &mut app,
541 body,
542 &[
543 "tags",
544 "computeRoleArn",
545 "iamServiceRoleArn",
546 "enableBranchAutoDeletion",
547 "basicAuthCredentials",
548 "customRules",
549 "buildSpec",
550 "customHeaders",
551 "enableAutoBranchCreation",
552 "autoBranchCreationPatterns",
553 "autoBranchCreationConfig",
554 "cacheConfig",
555 "jobConfig",
556 ],
557 );
558 Value::Object(app)
559 }
560
561 fn create_app(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
562 let app_id = shared::new_app_id();
563 let app = self.build_app(ctx, &app_id, body);
564 let mut guard = self.state.write();
565 let data = guard.get_or_create(&ctx.account);
566 data.apps.insert(
567 app_id.clone(),
568 AppRecord {
569 app: app.clone(),
570 ..Default::default()
571 },
572 );
573 ok(json!({ "app": app }))
574 }
575
576 fn get_app(&self, ctx: &Ctx, app_id: &str) -> Result<AwsResponse, AwsServiceError> {
577 check_app_id(app_id)?;
578 let guard = self.state.read();
579 match guard.get(&ctx.account).and_then(|d| d.apps.get(app_id)) {
580 Some(rec) => ok(json!({ "app": rec.app })),
581 None => Err(not_found(&format!("App {app_id} not found."))),
582 }
583 }
584
585 fn update_app(
586 &self,
587 ctx: &Ctx,
588 app_id: &str,
589 body: &Value,
590 ) -> Result<AwsResponse, AwsServiceError> {
591 check_app_id(app_id)?;
592 let mut guard = self.state.write();
593 let data = guard.get_or_create(&ctx.account);
594 let Some(rec) = data.apps.get_mut(app_id) else {
595 return Err(not_found(&format!("App {app_id} not found.")));
596 };
597 let Some(app) = rec.app.as_object_mut() else {
598 return Err(not_found(&format!("App {app_id} not found.")));
599 };
600 for key in [
601 "name",
602 "description",
603 "platform",
604 "computeRoleArn",
605 "iamServiceRoleArn",
606 "environmentVariables",
607 "basicAuthCredentials",
608 "customRules",
609 "buildSpec",
610 "customHeaders",
611 "autoBranchCreationPatterns",
612 "autoBranchCreationConfig",
613 "repository",
614 "cacheConfig",
615 "jobConfig",
616 ] {
617 if let Some(v) = body.get(key) {
618 app.insert(key.to_string(), v.clone());
619 }
620 }
621 if let Some(v) = body.get("enableBranchAutoBuild") {
623 app.insert("enableBranchAutoBuild".into(), v.clone());
624 }
625 for key in [
626 "enableBranchAutoDeletion",
627 "enableBasicAuth",
628 "enableAutoBranchCreation",
629 ] {
630 if let Some(v) = body.get(key) {
631 app.insert(key.to_string(), v.clone());
632 }
633 }
634 app.insert("updateTime".into(), json!(shared::now_epoch()));
635 ok(json!({ "app": Value::Object(app.clone()) }))
636 }
637
638 fn delete_app(&self, ctx: &Ctx, app_id: &str) -> Result<AwsResponse, AwsServiceError> {
639 check_app_id(app_id)?;
640 let mut guard = self.state.write();
641 let data = guard.get_or_create(&ctx.account);
642 let Some(rec) = data.apps.remove(app_id) else {
643 return Err(not_found(&format!("App {app_id} not found.")));
644 };
645 data.webhooks
647 .retain(|_, w| w.get("appId").and_then(Value::as_str) != Some(app_id));
648 ok(json!({ "app": rec.app }))
649 }
650
651 fn list_apps(&self, ctx: &Ctx, q: &[(String, String)]) -> Result<AwsResponse, AwsServiceError> {
652 let guard = self.state.read();
653 let apps: Vec<Value> = guard
654 .get(&ctx.account)
655 .map(|d| d.apps.values().map(|r| r.app.clone()).collect())
656 .unwrap_or_default();
657 let (page, next) = paginate(apps, q, 100)?;
658 let mut out = json!({ "apps": page });
659 if let Some(n) = next {
660 out["nextToken"] = json!(n);
661 }
662 ok(out)
663 }
664
665 fn build_branch(&self, ctx: &Ctx, app_id: &str, branch_name: &str, body: &Value) -> Value {
668 let now = shared::now_epoch();
669 let mut b = Map::new();
670 b.insert(
671 "branchArn".into(),
672 json!(shared::branch_arn(
673 &ctx.region,
674 &ctx.account,
675 app_id,
676 branch_name
677 )),
678 );
679 b.insert("branchName".into(), json!(branch_name));
680 b.insert("description".into(), json!(str_or(body, "description", "")));
681 b.insert("stage".into(), json!(str_or(body, "stage", "NONE")));
682 b.insert(
683 "displayName".into(),
684 json!(str_or(body, "displayName", branch_name)),
685 );
686 b.insert(
687 "enableNotification".into(),
688 json!(bool_or(body, "enableNotification", false)),
689 );
690 b.insert("createTime".into(), json!(now));
691 b.insert("updateTime".into(), json!(now));
692 b.insert(
693 "environmentVariables".into(),
694 body.get("environmentVariables")
695 .cloned()
696 .unwrap_or_else(|| json!({})),
697 );
698 b.insert(
699 "enableAutoBuild".into(),
700 json!(bool_or(body, "enableAutoBuild", true)),
701 );
702 b.insert("customDomains".into(), json!([]));
703 b.insert("framework".into(), json!(str_or(body, "framework", "")));
704 b.insert("activeJobId".into(), json!(""));
705 b.insert("totalNumberOfJobs".into(), json!("0"));
706 b.insert(
707 "enableBasicAuth".into(),
708 json!(bool_or(body, "enableBasicAuth", false)),
709 );
710 b.insert("ttl".into(), json!(str_or(body, "ttl", "5")));
711 b.insert(
712 "enablePullRequestPreview".into(),
713 json!(bool_or(body, "enablePullRequestPreview", false)),
714 );
715 b.insert(
716 "thumbnailUrl".into(),
717 json!(shared::thumbnail_url(&ctx.region, app_id, branch_name)),
718 );
719 echo(
720 &mut b,
721 body,
722 &[
723 "tags",
724 "enableSkewProtection",
725 "enablePerformanceMode",
726 "basicAuthCredentials",
727 "buildSpec",
728 "pullRequestEnvironmentName",
729 "backendEnvironmentArn",
730 "backend",
731 "computeRoleArn",
732 ],
733 );
734 Value::Object(b)
735 }
736
737 fn create_branch(
738 &self,
739 ctx: &Ctx,
740 app_id: &str,
741 body: &Value,
742 ) -> Result<AwsResponse, AwsServiceError> {
743 check_app_id(app_id)?;
744 let branch_name = body
745 .get("branchName")
746 .and_then(Value::as_str)
747 .unwrap_or_default()
748 .to_string();
749 check_branch_name(&branch_name)?;
750 let branch = self.build_branch(ctx, app_id, &branch_name, body);
751 let mut guard = self.state.write();
752 let data = guard.get_or_create(&ctx.account);
753 let Some(rec) = data.apps.get_mut(app_id) else {
754 return Err(not_found(&format!("App {app_id} not found.")));
755 };
756 if rec.branches.contains_key(&branch_name) {
757 return Err(bad_request(&format!(
758 "A branch with the name {branch_name} already exists."
759 )));
760 }
761 rec.branches.insert(branch_name, branch.clone());
762 ok(json!({ "branch": branch }))
763 }
764
765 fn get_branch(
766 &self,
767 ctx: &Ctx,
768 app_id: &str,
769 branch: &str,
770 ) -> Result<AwsResponse, AwsServiceError> {
771 check_app_id(app_id)?;
772 let guard = self.state.read();
773 match guard
774 .get(&ctx.account)
775 .and_then(|d| d.apps.get(app_id))
776 .and_then(|r| r.branches.get(branch))
777 {
778 Some(b) => ok(json!({ "branch": b })),
779 None => Err(not_found(&format!("Branch {branch} not found."))),
780 }
781 }
782
783 fn update_branch(
784 &self,
785 ctx: &Ctx,
786 app_id: &str,
787 branch: &str,
788 body: &Value,
789 ) -> Result<AwsResponse, AwsServiceError> {
790 check_app_id(app_id)?;
791 let mut guard = self.state.write();
792 let data = guard.get_or_create(&ctx.account);
793 let Some(b) = data
794 .apps
795 .get_mut(app_id)
796 .and_then(|r| r.branches.get_mut(branch))
797 .and_then(Value::as_object_mut)
798 else {
799 return Err(not_found(&format!("Branch {branch} not found.")));
800 };
801 for key in [
802 "description",
803 "framework",
804 "stage",
805 "enableNotification",
806 "enableAutoBuild",
807 "enableSkewProtection",
808 "environmentVariables",
809 "basicAuthCredentials",
810 "enableBasicAuth",
811 "enablePerformanceMode",
812 "buildSpec",
813 "ttl",
814 "displayName",
815 "enablePullRequestPreview",
816 "pullRequestEnvironmentName",
817 "backendEnvironmentArn",
818 "backend",
819 "computeRoleArn",
820 ] {
821 if let Some(v) = body.get(key) {
822 b.insert(key.to_string(), v.clone());
823 }
824 }
825 b.insert("updateTime".into(), json!(shared::now_epoch()));
826 ok(json!({ "branch": Value::Object(b.clone()) }))
827 }
828
829 fn delete_branch(
830 &self,
831 ctx: &Ctx,
832 app_id: &str,
833 branch: &str,
834 ) -> Result<AwsResponse, AwsServiceError> {
835 check_app_id(app_id)?;
836 let mut guard = self.state.write();
837 let data = guard.get_or_create(&ctx.account);
838 let Some(rec) = data.apps.get_mut(app_id) else {
839 return Err(not_found(&format!("App {app_id} not found.")));
840 };
841 match rec.branches.remove(branch) {
842 Some(b) => {
843 rec.jobs.remove(branch);
844 rec.next_job_id.remove(branch);
845 ok(json!({ "branch": b }))
846 }
847 None => Err(not_found(&format!("Branch {branch} not found."))),
848 }
849 }
850
851 fn list_branches(
852 &self,
853 ctx: &Ctx,
854 app_id: &str,
855 q: &[(String, String)],
856 ) -> Result<AwsResponse, AwsServiceError> {
857 check_app_id(app_id)?;
858 let guard = self.state.read();
859 let branches: Vec<Value> = guard
860 .get(&ctx.account)
861 .and_then(|d| d.apps.get(app_id))
862 .map(|r| r.branches.values().cloned().collect())
863 .unwrap_or_default();
864 let (page, next) = paginate(branches, q, 50)?;
865 let mut out = json!({ "branches": page });
866 if let Some(n) = next {
867 out["nextToken"] = json!(n);
868 }
869 ok(out)
870 }
871
872 fn build_domain(&self, ctx: &Ctx, app_id: &str, domain: &str, body: &Value) -> Value {
875 let mut d = Map::new();
876 d.insert(
877 "domainAssociationArn".into(),
878 json!(shared::domain_arn(
879 &ctx.region,
880 &ctx.account,
881 app_id,
882 domain
883 )),
884 );
885 d.insert("domainName".into(), json!(domain));
886 d.insert(
887 "enableAutoSubDomain".into(),
888 json!(bool_or(body, "enableAutoSubDomain", false)),
889 );
890 d.insert("domainStatus".into(), json!("CREATING"));
891 d.insert("updateStatus".into(), json!("PENDING_VERIFICATION"));
892 d.insert("statusReason".into(), json!(""));
893 d.insert(
894 "certificateVerificationDNSRecord".into(),
895 json!(shared::certificate_verification_dns_record(domain)),
896 );
897 let sub_settings = body
899 .get("subDomainSettings")
900 .and_then(Value::as_array)
901 .cloned()
902 .unwrap_or_default();
903 let subdomains: Vec<Value> = sub_settings
904 .iter()
905 .map(|s| {
906 json!({
907 "subDomainSetting": s,
908 "verified": false,
909 "dnsRecord": shared::subdomain_dns_record(app_id),
910 })
911 })
912 .collect();
913 d.insert("subDomains".into(), json!(subdomains));
914 if let Some(cert) = build_certificate(body, domain) {
916 d.insert("certificate".into(), cert);
917 }
918 echo(
919 &mut d,
920 body,
921 &["autoSubDomainCreationPatterns", "autoSubDomainIAMRole"],
922 );
923 Value::Object(d)
924 }
925
926 fn create_domain(
927 &self,
928 ctx: &Ctx,
929 app_id: &str,
930 body: &Value,
931 ) -> Result<AwsResponse, AwsServiceError> {
932 check_app_id(app_id)?;
933 let domain = body
934 .get("domainName")
935 .and_then(Value::as_str)
936 .unwrap_or_default()
937 .to_string();
938 let assoc = self.build_domain(ctx, app_id, &domain, body);
939 let mut guard = self.state.write();
940 let data = guard.get_or_create(&ctx.account);
941 let Some(rec) = data.apps.get_mut(app_id) else {
942 return Err(not_found(&format!("App {app_id} not found.")));
943 };
944 if rec.domains.contains_key(&domain) {
945 return Err(bad_request(&format!(
946 "A domain association for {domain} already exists."
947 )));
948 }
949 rec.domains.insert(domain, assoc.clone());
950 ok(json!({ "domainAssociation": assoc }))
951 }
952
953 fn get_domain(
954 &self,
955 ctx: &Ctx,
956 app_id: &str,
957 domain: &str,
958 ) -> Result<AwsResponse, AwsServiceError> {
959 check_app_id(app_id)?;
960 let guard = self.state.read();
961 match guard
962 .get(&ctx.account)
963 .and_then(|d| d.apps.get(app_id))
964 .and_then(|r| r.domains.get(domain))
965 {
966 Some(a) => ok(json!({ "domainAssociation": a })),
967 None => Err(not_found(&format!(
968 "Domain association {domain} not found."
969 ))),
970 }
971 }
972
973 fn update_domain(
974 &self,
975 ctx: &Ctx,
976 app_id: &str,
977 domain: &str,
978 body: &Value,
979 ) -> Result<AwsResponse, AwsServiceError> {
980 check_app_id(app_id)?;
981 let mut guard = self.state.write();
982 let data = guard.get_or_create(&ctx.account);
983 let Some(a) = data
984 .apps
985 .get_mut(app_id)
986 .and_then(|r| r.domains.get_mut(domain))
987 .and_then(Value::as_object_mut)
988 else {
989 return Err(not_found(&format!(
990 "Domain association {domain} not found."
991 )));
992 };
993 if let Some(v) = body.get("enableAutoSubDomain") {
994 a.insert("enableAutoSubDomain".into(), v.clone());
995 }
996 for key in ["autoSubDomainCreationPatterns", "autoSubDomainIAMRole"] {
997 if let Some(v) = body.get(key) {
998 a.insert(key.to_string(), v.clone());
999 }
1000 }
1001 if let Some(settings) = body.get("subDomainSettings").and_then(Value::as_array) {
1002 let subs: Vec<Value> = settings
1003 .iter()
1004 .map(|s| {
1005 json!({
1006 "subDomainSetting": s,
1007 "verified": false,
1008 "dnsRecord": shared::subdomain_dns_record(app_id),
1009 })
1010 })
1011 .collect();
1012 a.insert("subDomains".into(), json!(subs));
1013 }
1014 if let Some(cert) = build_certificate(body, domain) {
1017 a.insert("certificate".into(), cert);
1018 }
1019 a.insert("domainStatus".into(), json!("UPDATING"));
1021 a.insert("updateStatus".into(), json!("PENDING_VERIFICATION"));
1022 ok(json!({ "domainAssociation": Value::Object(a.clone()) }))
1023 }
1024
1025 fn delete_domain(
1026 &self,
1027 ctx: &Ctx,
1028 app_id: &str,
1029 domain: &str,
1030 ) -> Result<AwsResponse, AwsServiceError> {
1031 check_app_id(app_id)?;
1032 let mut guard = self.state.write();
1033 let data = guard.get_or_create(&ctx.account);
1034 let Some(rec) = data.apps.get_mut(app_id) else {
1035 return Err(not_found(&format!("App {app_id} not found.")));
1036 };
1037 match rec.domains.remove(domain) {
1038 Some(a) => ok(json!({ "domainAssociation": a })),
1039 None => Err(not_found(&format!(
1040 "Domain association {domain} not found."
1041 ))),
1042 }
1043 }
1044
1045 fn list_domains(
1046 &self,
1047 ctx: &Ctx,
1048 app_id: &str,
1049 q: &[(String, String)],
1050 ) -> Result<AwsResponse, AwsServiceError> {
1051 check_app_id(app_id)?;
1052 let guard = self.state.read();
1053 let domains: Vec<Value> = guard
1054 .get(&ctx.account)
1055 .and_then(|d| d.apps.get(app_id))
1056 .map(|r| r.domains.values().cloned().collect())
1057 .unwrap_or_default();
1058 let (page, next) = paginate(domains, q, 50)?;
1059 let mut out = json!({ "domainAssociations": page });
1060 if let Some(n) = next {
1061 out["nextToken"] = json!(n);
1062 }
1063 ok(out)
1064 }
1065
1066 fn create_webhook(
1069 &self,
1070 ctx: &Ctx,
1071 app_id: &str,
1072 body: &Value,
1073 ) -> Result<AwsResponse, AwsServiceError> {
1074 check_app_id(app_id)?;
1075 let branch = body
1076 .get("branchName")
1077 .and_then(Value::as_str)
1078 .unwrap_or_default()
1079 .to_string();
1080 check_branch_name(&branch)?;
1081 let webhook_id = shared::new_webhook_id();
1082 let now = shared::now_epoch();
1083 let mut guard = self.state.write();
1084 let data = guard.get_or_create(&ctx.account);
1085 let Some(rec) = data.apps.get_mut(app_id) else {
1086 return Err(not_found(&format!("App {app_id} not found.")));
1087 };
1088 if !rec.branches.contains_key(&branch) {
1089 return Err(not_found(&format!("Branch {branch} not found.")));
1090 }
1091 let webhook = json!({
1092 "webhookArn": shared::webhook_arn(&ctx.region, &ctx.account, app_id, &webhook_id),
1093 "webhookId": webhook_id,
1094 "webhookUrl": shared::webhook_url(&ctx.region, &webhook_id),
1095 "appId": app_id,
1096 "branchName": branch,
1097 "description": str_or(body, "description", ""),
1098 "createTime": now,
1099 "updateTime": now,
1100 });
1101 data.webhooks.insert(webhook_id, webhook.clone());
1102 ok(json!({ "webhook": webhook }))
1103 }
1104
1105 fn get_webhook(&self, ctx: &Ctx, webhook_id: &str) -> Result<AwsResponse, AwsServiceError> {
1106 let guard = self.state.read();
1107 match guard
1108 .get(&ctx.account)
1109 .and_then(|d| d.webhooks.get(webhook_id))
1110 {
1111 Some(w) => ok(json!({ "webhook": w })),
1112 None => Err(not_found(&format!("Webhook {webhook_id} not found."))),
1113 }
1114 }
1115
1116 fn update_webhook(
1117 &self,
1118 ctx: &Ctx,
1119 webhook_id: &str,
1120 body: &Value,
1121 ) -> Result<AwsResponse, AwsServiceError> {
1122 let mut guard = self.state.write();
1123 let data = guard.get_or_create(&ctx.account);
1124 let Some(w) = data
1125 .webhooks
1126 .get_mut(webhook_id)
1127 .and_then(Value::as_object_mut)
1128 else {
1129 return Err(not_found(&format!("Webhook {webhook_id} not found.")));
1130 };
1131 if let Some(v) = body.get("branchName") {
1132 w.insert("branchName".into(), v.clone());
1133 }
1134 if let Some(v) = body.get("description") {
1135 w.insert("description".into(), v.clone());
1136 }
1137 w.insert("updateTime".into(), json!(shared::now_epoch()));
1138 ok(json!({ "webhook": Value::Object(w.clone()) }))
1139 }
1140
1141 fn delete_webhook(&self, ctx: &Ctx, webhook_id: &str) -> Result<AwsResponse, AwsServiceError> {
1142 let mut guard = self.state.write();
1143 let data = guard.get_or_create(&ctx.account);
1144 match data.webhooks.remove(webhook_id) {
1145 Some(w) => ok(json!({ "webhook": w })),
1146 None => Err(not_found(&format!("Webhook {webhook_id} not found."))),
1147 }
1148 }
1149
1150 fn list_webhooks(
1151 &self,
1152 ctx: &Ctx,
1153 app_id: &str,
1154 q: &[(String, String)],
1155 ) -> Result<AwsResponse, AwsServiceError> {
1156 check_app_id(app_id)?;
1157 let guard = self.state.read();
1158 let webhooks: Vec<Value> = guard
1159 .get(&ctx.account)
1160 .map(|d| {
1161 d.webhooks
1162 .values()
1163 .filter(|w| w.get("appId").and_then(Value::as_str) == Some(app_id))
1164 .cloned()
1165 .collect()
1166 })
1167 .unwrap_or_default();
1168 let (page, next) = paginate(webhooks, q, 50)?;
1169 let mut out = json!({ "webhooks": page });
1170 if let Some(n) = next {
1171 out["nextToken"] = json!(n);
1172 }
1173 ok(out)
1174 }
1175
1176 fn create_backend_env(
1179 &self,
1180 ctx: &Ctx,
1181 app_id: &str,
1182 body: &Value,
1183 ) -> Result<AwsResponse, AwsServiceError> {
1184 check_app_id(app_id)?;
1185 let name = body
1186 .get("environmentName")
1187 .and_then(Value::as_str)
1188 .unwrap_or_default()
1189 .to_string();
1190 let now = shared::now_epoch();
1191 let mut env = Map::new();
1192 env.insert(
1193 "backendEnvironmentArn".into(),
1194 json!(shared::backend_environment_arn(
1195 &ctx.region,
1196 &ctx.account,
1197 app_id,
1198 &name
1199 )),
1200 );
1201 env.insert("environmentName".into(), json!(name));
1202 env.insert("createTime".into(), json!(now));
1203 env.insert("updateTime".into(), json!(now));
1204 echo(&mut env, body, &["stackName", "deploymentArtifacts"]);
1205 let env = Value::Object(env);
1206 let mut guard = self.state.write();
1207 let data = guard.get_or_create(&ctx.account);
1208 let Some(rec) = data.apps.get_mut(app_id) else {
1209 return Err(not_found(&format!("App {app_id} not found.")));
1210 };
1211 let name = env["environmentName"]
1212 .as_str()
1213 .unwrap_or_default()
1214 .to_string();
1215 if rec.backend_environments.contains_key(&name) {
1216 return Err(bad_request(&format!(
1217 "A backend environment {name} already exists."
1218 )));
1219 }
1220 rec.backend_environments.insert(name, env.clone());
1221 ok(json!({ "backendEnvironment": env }))
1222 }
1223
1224 fn get_backend_env(
1225 &self,
1226 ctx: &Ctx,
1227 app_id: &str,
1228 name: &str,
1229 ) -> Result<AwsResponse, AwsServiceError> {
1230 check_app_id(app_id)?;
1231 let guard = self.state.read();
1232 match guard
1233 .get(&ctx.account)
1234 .and_then(|d| d.apps.get(app_id))
1235 .and_then(|r| r.backend_environments.get(name))
1236 {
1237 Some(e) => ok(json!({ "backendEnvironment": e })),
1238 None => Err(not_found(&format!("Backend environment {name} not found."))),
1239 }
1240 }
1241
1242 fn delete_backend_env(
1243 &self,
1244 ctx: &Ctx,
1245 app_id: &str,
1246 name: &str,
1247 ) -> Result<AwsResponse, AwsServiceError> {
1248 check_app_id(app_id)?;
1249 let mut guard = self.state.write();
1250 let data = guard.get_or_create(&ctx.account);
1251 let Some(rec) = data.apps.get_mut(app_id) else {
1252 return Err(not_found(&format!("App {app_id} not found.")));
1253 };
1254 match rec.backend_environments.remove(name) {
1255 Some(e) => ok(json!({ "backendEnvironment": e })),
1256 None => Err(not_found(&format!("Backend environment {name} not found."))),
1257 }
1258 }
1259
1260 fn list_backend_envs(
1261 &self,
1262 ctx: &Ctx,
1263 app_id: &str,
1264 q: &[(String, String)],
1265 ) -> Result<AwsResponse, AwsServiceError> {
1266 check_app_id(app_id)?;
1267 let filter = query_one(q, "environmentName");
1268 let guard = self.state.read();
1269 let envs: Vec<Value> = guard
1270 .get(&ctx.account)
1271 .and_then(|d| d.apps.get(app_id))
1272 .map(|r| {
1273 r.backend_environments
1274 .values()
1275 .filter(|e| {
1276 filter.is_none()
1277 || e.get("environmentName").and_then(Value::as_str) == filter
1278 })
1279 .cloned()
1280 .collect()
1281 })
1282 .unwrap_or_default();
1283 let (page, next) = paginate(envs, q, 50)?;
1284 let mut out = json!({ "backendEnvironments": page });
1285 if let Some(n) = next {
1286 out["nextToken"] = json!(n);
1287 }
1288 ok(out)
1289 }
1290
1291 fn build_job_summary(
1295 &self,
1296 ctx: &Ctx,
1297 app_id: &str,
1298 branch: &str,
1299 job_id: &str,
1300 job_type: &str,
1301 body: &Value,
1302 ) -> Value {
1303 let now = shared::now_epoch();
1304 json!({
1305 "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, job_id),
1306 "jobId": job_id,
1307 "commitId": str_or(body, "commitId", "HEAD"),
1308 "commitMessage": str_or(body, "commitMessage", ""),
1309 "commitTime": body.get("commitTime").cloned().unwrap_or_else(|| json!(now)),
1310 "startTime": now,
1311 "status": "PENDING",
1312 "jobType": job_type,
1313 })
1314 }
1315
1316 fn allocate_job(rec: &mut AppRecord, branch: &str, summary: &Value) -> String {
1319 let next = rec.next_job_id.entry(branch.to_string()).or_insert(1);
1320 let job_id = next.to_string();
1321 *next += 1;
1322 let job = json!({ "summary": summary, "steps": [] });
1323 rec.jobs
1324 .entry(branch.to_string())
1325 .or_default()
1326 .insert(job_id.clone(), job);
1327 if let Some(b) = rec.branches.get_mut(branch).and_then(Value::as_object_mut) {
1328 b.insert("activeJobId".into(), json!(job_id));
1329 let total = rec
1330 .jobs
1331 .get(branch)
1332 .map(|m| m.len())
1333 .unwrap_or(0)
1334 .to_string();
1335 b.insert("totalNumberOfJobs".into(), json!(total));
1336 }
1337 job_id
1338 }
1339
1340 fn start_job(
1341 &self,
1342 ctx: &Ctx,
1343 app_id: &str,
1344 branch: &str,
1345 body: &Value,
1346 ) -> Result<AwsResponse, AwsServiceError> {
1347 check_app_id(app_id)?;
1348 let job_type = body
1349 .get("jobType")
1350 .and_then(Value::as_str)
1351 .unwrap_or("RELEASE")
1352 .to_string();
1353 let mut guard = self.state.write();
1354 let data = guard.get_or_create(&ctx.account);
1355 let Some(rec) = data.apps.get_mut(app_id) else {
1356 return Err(not_found(&format!("App {app_id} not found.")));
1357 };
1358 if !rec.branches.contains_key(branch) {
1359 return Err(not_found(&format!("Branch {branch} not found.")));
1360 }
1361 let requested = body.get("jobId").and_then(Value::as_str);
1364 let (job_id, summary) = if let Some(jid) = requested.filter(|j| {
1365 rec.jobs
1366 .get(branch)
1367 .map(|m| m.contains_key(*j))
1368 .unwrap_or(false)
1369 }) {
1370 let jid = jid.to_string();
1371 let summary = self.build_job_summary(ctx, app_id, branch, &jid, &job_type, body);
1372 if let Some(job) = rec
1373 .jobs
1374 .get_mut(branch)
1375 .and_then(|m| m.get_mut(&jid))
1376 .and_then(Value::as_object_mut)
1377 {
1378 job.insert("summary".into(), summary.clone());
1379 }
1380 (jid, summary)
1381 } else {
1382 let peek = rec
1384 .next_job_id
1385 .get(branch)
1386 .copied()
1387 .unwrap_or(1)
1388 .to_string();
1389 let summary = self.build_job_summary(ctx, app_id, branch, &peek, &job_type, body);
1390 let job_id = Self::allocate_job(rec, branch, &summary);
1391 (job_id, summary)
1392 };
1393 let _ = job_id;
1394 ok(json!({ "jobSummary": summary }))
1395 }
1396
1397 fn get_job(
1398 &self,
1399 ctx: &Ctx,
1400 app_id: &str,
1401 branch: &str,
1402 job_id: &str,
1403 ) -> Result<AwsResponse, AwsServiceError> {
1404 check_app_id(app_id)?;
1405 let guard = self.state.read();
1406 match guard
1407 .get(&ctx.account)
1408 .and_then(|d| d.apps.get(app_id))
1409 .and_then(|r| r.jobs.get(branch))
1410 .and_then(|m| m.get(job_id))
1411 {
1412 Some(job) => ok(json!({ "job": job })),
1413 None => Err(not_found(&format!("Job {job_id} not found."))),
1414 }
1415 }
1416
1417 fn stop_job(
1418 &self,
1419 ctx: &Ctx,
1420 app_id: &str,
1421 branch: &str,
1422 job_id: &str,
1423 ) -> Result<AwsResponse, AwsServiceError> {
1424 check_app_id(app_id)?;
1425 let mut guard = self.state.write();
1426 let data = guard.get_or_create(&ctx.account);
1427 let Some(job) = data
1428 .apps
1429 .get_mut(app_id)
1430 .and_then(|r| r.jobs.get_mut(branch))
1431 .and_then(|m| m.get_mut(job_id))
1432 .and_then(Value::as_object_mut)
1433 else {
1434 return Err(not_found(&format!("Job {job_id} not found.")));
1435 };
1436 let now = shared::now_epoch();
1437 if let Some(summary) = job.get_mut("summary").and_then(Value::as_object_mut) {
1438 summary.insert("status".into(), json!("CANCELLED"));
1439 summary.insert("endTime".into(), json!(now));
1440 let summary = Value::Object(summary.clone());
1441 return ok(json!({ "jobSummary": summary }));
1442 }
1443 Err(not_found(&format!("Job {job_id} not found.")))
1444 }
1445
1446 fn delete_job(
1447 &self,
1448 ctx: &Ctx,
1449 app_id: &str,
1450 branch: &str,
1451 job_id: &str,
1452 ) -> Result<AwsResponse, AwsServiceError> {
1453 check_app_id(app_id)?;
1454 let mut guard = self.state.write();
1455 let data = guard.get_or_create(&ctx.account);
1456 let Some(jobs) = data
1457 .apps
1458 .get_mut(app_id)
1459 .and_then(|r| r.jobs.get_mut(branch))
1460 else {
1461 return Err(not_found(&format!("Job {job_id} not found.")));
1462 };
1463 match jobs.remove(job_id) {
1464 Some(job) => {
1465 let summary = job.get("summary").cloned().unwrap_or_else(|| json!({}));
1466 ok(json!({ "jobSummary": summary }))
1467 }
1468 None => Err(not_found(&format!("Job {job_id} not found."))),
1469 }
1470 }
1471
1472 fn list_jobs(
1473 &self,
1474 ctx: &Ctx,
1475 app_id: &str,
1476 branch: &str,
1477 q: &[(String, String)],
1478 ) -> Result<AwsResponse, AwsServiceError> {
1479 check_app_id(app_id)?;
1480 let guard = self.state.read();
1481 let mut summaries: Vec<Value> = guard
1482 .get(&ctx.account)
1483 .and_then(|d| d.apps.get(app_id))
1484 .and_then(|r| r.jobs.get(branch))
1485 .map(|m| {
1486 m.values()
1487 .filter_map(|j| j.get("summary").cloned())
1488 .collect()
1489 })
1490 .unwrap_or_default();
1491 summaries.reverse();
1493 let (page, next) = paginate(summaries, q, 50)?;
1494 let mut out = json!({ "jobSummaries": page });
1495 if let Some(n) = next {
1496 out["nextToken"] = json!(n);
1497 }
1498 ok(out)
1499 }
1500
1501 fn create_deployment(
1504 &self,
1505 ctx: &Ctx,
1506 app_id: &str,
1507 branch: &str,
1508 body: &Value,
1509 ) -> Result<AwsResponse, AwsServiceError> {
1510 check_app_id(app_id)?;
1515 check_branch_name(branch)?;
1516 let file_map = body.get("fileMap").and_then(Value::as_object);
1517 let mut uploads = Map::new();
1518 if let Some(fm) = file_map {
1519 for name in fm.keys() {
1520 uploads.insert(
1521 name.clone(),
1522 json!(shared::upload_url(&ctx.region, app_id, branch, name)),
1523 );
1524 }
1525 }
1526 let zip_url = shared::upload_url(&ctx.region, app_id, branch, "deployment.zip");
1527
1528 let mut job_id: Option<String> = None;
1529 let mut guard = self.state.write();
1530 let data = guard.get_or_create(&ctx.account);
1531 if let Some(rec) = data.apps.get_mut(app_id) {
1532 if rec.branches.contains_key(branch) {
1533 let next = rec.next_job_id.entry(branch.to_string()).or_insert(1);
1534 let id = next.to_string();
1535 *next += 1;
1536 let now = shared::now_epoch();
1537 let summary = json!({
1538 "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, &id),
1539 "jobId": id,
1540 "commitId": "HEAD",
1541 "commitMessage": "",
1542 "commitTime": now,
1543 "startTime": now,
1544 "status": "CREATED",
1545 "jobType": "MANUAL",
1546 });
1547 rec.jobs
1548 .entry(branch.to_string())
1549 .or_default()
1550 .insert(id.clone(), json!({ "summary": summary, "steps": [] }));
1551 job_id = Some(id);
1552 }
1553 }
1554 let mut out = json!({
1555 "fileUploadUrls": Value::Object(uploads),
1556 "zipUploadUrl": zip_url,
1557 });
1558 if let Some(id) = job_id {
1559 out["jobId"] = json!(id);
1560 }
1561 ok(out)
1562 }
1563
1564 fn start_deployment(
1565 &self,
1566 ctx: &Ctx,
1567 app_id: &str,
1568 branch: &str,
1569 body: &Value,
1570 ) -> Result<AwsResponse, AwsServiceError> {
1571 check_app_id(app_id)?;
1572 let mut guard = self.state.write();
1573 let data = guard.get_or_create(&ctx.account);
1574 let Some(rec) = data.apps.get_mut(app_id) else {
1575 return Err(not_found(&format!("App {app_id} not found.")));
1576 };
1577 if !rec.branches.contains_key(branch) {
1578 return Err(not_found(&format!("Branch {branch} not found.")));
1579 }
1580 let now = shared::now_epoch();
1581 let requested = body
1582 .get("jobId")
1583 .and_then(Value::as_str)
1584 .map(str::to_string);
1585 let (job_id, summary) = match requested.filter(|j| {
1586 rec.jobs
1587 .get(branch)
1588 .map(|m| m.contains_key(j))
1589 .unwrap_or(false)
1590 }) {
1591 Some(jid) => {
1592 let summary = json!({
1593 "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, &jid),
1594 "jobId": jid,
1595 "commitId": "HEAD",
1596 "commitMessage": "",
1597 "commitTime": now,
1598 "startTime": now,
1599 "status": "PENDING",
1600 "jobType": "MANUAL",
1601 });
1602 if let Some(job) = rec
1603 .jobs
1604 .get_mut(branch)
1605 .and_then(|m| m.get_mut(&jid))
1606 .and_then(Value::as_object_mut)
1607 {
1608 job.insert("summary".into(), summary.clone());
1609 }
1610 (jid, summary)
1611 }
1612 None => {
1613 let peek = rec
1614 .next_job_id
1615 .get(branch)
1616 .copied()
1617 .unwrap_or(1)
1618 .to_string();
1619 let summary = json!({
1620 "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, &peek),
1621 "jobId": peek,
1622 "commitId": "HEAD",
1623 "commitMessage": "",
1624 "commitTime": now,
1625 "startTime": now,
1626 "status": "PENDING",
1627 "jobType": "MANUAL",
1628 });
1629 let job_id = Self::allocate_job(rec, branch, &summary);
1630 (job_id, summary)
1631 }
1632 };
1633 let _ = job_id;
1634 ok(json!({ "jobSummary": summary }))
1635 }
1636
1637 fn artifacts_for_job(app_id: &str, branch: &str, job_id: &str, job: &Value) -> Vec<Value> {
1642 let status = job
1643 .get("summary")
1644 .and_then(|s| s.get("status"))
1645 .and_then(Value::as_str)
1646 .unwrap_or("");
1647 if status != "SUCCEED" {
1648 return Vec::new();
1649 }
1650 vec![json!({
1651 "artifactFileName": "buildLogs.txt",
1652 "artifactId": shared::encode_artifact_id(app_id, branch, job_id, "buildLogs.txt"),
1653 })]
1654 }
1655
1656 fn list_artifacts(
1657 &self,
1658 ctx: &Ctx,
1659 app_id: &str,
1660 branch: &str,
1661 job_id: &str,
1662 q: &[(String, String)],
1663 ) -> Result<AwsResponse, AwsServiceError> {
1664 check_app_id(app_id)?;
1665 let guard = self.state.read();
1666 let artifacts: Vec<Value> = guard
1667 .get(&ctx.account)
1668 .and_then(|d| d.apps.get(app_id))
1669 .and_then(|r| r.jobs.get(branch))
1670 .and_then(|m| m.get(job_id))
1671 .map(|job| Self::artifacts_for_job(app_id, branch, job_id, job))
1672 .unwrap_or_default();
1673 let (page, next) = paginate(artifacts, q, 50)?;
1674 let mut out = json!({ "artifacts": page });
1675 if let Some(n) = next {
1676 out["nextToken"] = json!(n);
1677 }
1678 ok(out)
1679 }
1680
1681 fn get_artifact_url(
1682 &self,
1683 ctx: &Ctx,
1684 artifact_id: &str,
1685 ) -> Result<AwsResponse, AwsServiceError> {
1686 match shared::decode_artifact_id(artifact_id) {
1687 Some((app_id, branch, job_id, file)) => ok(json!({
1688 "artifactId": artifact_id,
1689 "artifactUrl": shared::artifact_url(&ctx.region, &app_id, &branch, &job_id, &file),
1690 })),
1691 None => Err(not_found(&format!("Artifact {artifact_id} not found."))),
1692 }
1693 }
1694
1695 fn generate_access_logs(
1698 &self,
1699 ctx: &Ctx,
1700 app_id: &str,
1701 body: &Value,
1702 ) -> Result<AwsResponse, AwsServiceError> {
1703 check_app_id(app_id)?;
1704 let domain = body
1705 .get("domainName")
1706 .and_then(Value::as_str)
1707 .ok_or_else(|| bad_request("domainName is required."))?
1708 .to_string();
1709 let guard = self.state.read();
1710 let exists = guard
1711 .get(&ctx.account)
1712 .map(|d| d.apps.contains_key(app_id))
1713 .unwrap_or(false);
1714 drop(guard);
1715 if !exists {
1716 return Err(not_found(&format!("App {app_id} not found.")));
1717 }
1718 ok(json!({
1719 "logUrl": shared::access_log_url(&ctx.region, app_id, &domain),
1720 }))
1721 }
1722
1723 fn tags_target_mut<'a>(
1729 data: &'a mut AmplifyData,
1730 arn: &str,
1731 ) -> Result<&'a mut Value, AwsServiceError> {
1732 if !arn.starts_with("arn:aws:amplify:") {
1733 return Err(bad_request(&format!(
1734 "Resource arn '{arn}' does not match required pattern ^arn:aws:amplify:."
1735 )));
1736 }
1737 let (app_id, branch) = shared::parse_resource_arn(arn)
1738 .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1739 let rec = data
1740 .apps
1741 .get_mut(&app_id)
1742 .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1743 match branch {
1744 Some(br) => rec
1745 .branches
1746 .get_mut(&br)
1747 .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found."))),
1748 None => Ok(&mut rec.app),
1749 }
1750 }
1751
1752 fn tag_resource(
1753 &self,
1754 ctx: &Ctx,
1755 arn: &str,
1756 body: &Value,
1757 ) -> Result<AwsResponse, AwsServiceError> {
1758 let mut guard = self.state.write();
1759 let data = guard.get_or_create(&ctx.account);
1760 let target = Self::tags_target_mut(data, arn)?;
1761 if let Some(obj) = target.as_object_mut() {
1762 let tags = obj.entry("tags").or_insert_with(|| json!({}));
1763 if let (Some(tags), Some(new)) = (
1764 tags.as_object_mut(),
1765 body.get("tags").and_then(Value::as_object),
1766 ) {
1767 for (k, v) in new {
1768 tags.insert(k.clone(), v.clone());
1769 }
1770 }
1771 }
1772 ok(json!({}))
1773 }
1774
1775 fn untag_resource(
1776 &self,
1777 ctx: &Ctx,
1778 arn: &str,
1779 q: &[(String, String)],
1780 ) -> Result<AwsResponse, AwsServiceError> {
1781 let keys = query_all(q, "tagKeys");
1782 if keys.is_empty() {
1783 return Err(bad_request("tagKeys is required."));
1784 }
1785 let mut guard = self.state.write();
1786 let data = guard.get_or_create(&ctx.account);
1787 let target = Self::tags_target_mut(data, arn)?;
1788 if let Some(tags) = target
1789 .as_object_mut()
1790 .and_then(|o| o.get_mut("tags"))
1791 .and_then(Value::as_object_mut)
1792 {
1793 for k in &keys {
1794 tags.remove(k);
1795 }
1796 }
1797 ok(json!({}))
1798 }
1799
1800 fn list_tags(&self, ctx: &Ctx, arn: &str) -> Result<AwsResponse, AwsServiceError> {
1801 if !arn.starts_with("arn:aws:amplify:") {
1802 return Err(bad_request(&format!(
1803 "Resource arn '{arn}' does not match required pattern ^arn:aws:amplify:."
1804 )));
1805 }
1806 let (app_id, branch) = shared::parse_resource_arn(arn)
1807 .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1808 let guard = self.state.read();
1809 let rec = guard
1810 .get(&ctx.account)
1811 .and_then(|d| d.apps.get(&app_id))
1812 .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1813 let obj = match &branch {
1814 Some(br) => rec
1815 .branches
1816 .get(br)
1817 .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?,
1818 None => &rec.app,
1819 };
1820 let tags = obj.get("tags").cloned().unwrap_or_else(|| json!({}));
1821 ok(json!({ "tags": tags }))
1822 }
1823}
1824
1825#[cfg(test)]
1826mod tests {
1827 use super::*;
1828 use fakecloud_core::multi_account::MultiAccountState;
1829 use parking_lot::RwLock;
1830
1831 fn svc() -> AmplifyService {
1832 AmplifyService::new(Arc::new(RwLock::new(MultiAccountState::new(
1833 "000000000000",
1834 "us-east-1",
1835 "",
1836 ))))
1837 }
1838
1839 fn ctx() -> Ctx {
1840 Ctx {
1841 account: "000000000000".into(),
1842 region: "us-east-1".into(),
1843 }
1844 }
1845
1846 fn body_json(resp: &AwsResponse) -> Value {
1847 serde_json::from_slice(resp.body.expect_bytes()).expect("json response body")
1848 }
1849
1850 fn err_of(r: Result<AwsResponse, AwsServiceError>) -> AwsServiceError {
1851 match r {
1852 Ok(_) => panic!("expected an error"),
1853 Err(e) => e,
1854 }
1855 }
1856
1857 fn make_app(s: &AmplifyService, c: &Ctx) -> String {
1858 let created = s
1859 .create_app(c, &json!({ "name": "my-app", "platform": "WEB" }))
1860 .unwrap();
1861 let body = body_json(&created);
1862 body["app"]["appId"].as_str().unwrap().to_string()
1863 }
1864
1865 #[test]
1866 fn update_domain_persists_certificate_settings() {
1867 let s = svc();
1870 let c = ctx();
1871 let app_id = make_app(&s, &c);
1872 s.create_domain(
1873 &c,
1874 &app_id,
1875 &json!({
1876 "domainName": "example.com",
1877 "subDomainSettings": [{ "prefix": "www", "branchName": "main" }],
1878 "certificateSettings": { "type": "AMPLIFY_MANAGED" }
1879 }),
1880 )
1881 .unwrap();
1882
1883 let arn = "arn:aws:acm:us-east-1:000000000000:certificate/abc";
1885 s.update_domain(
1886 &c,
1887 &app_id,
1888 "example.com",
1889 &json!({
1890 "certificateSettings": { "type": "CUSTOM", "customCertificateArn": arn }
1891 }),
1892 )
1893 .unwrap();
1894
1895 let dom = body_json(&s.get_domain(&c, &app_id, "example.com").unwrap());
1896 let cert = &dom["domainAssociation"]["certificate"];
1897 assert_eq!(cert["type"], "CUSTOM");
1898 assert_eq!(cert["customCertificateArn"], arn);
1899 }
1900
1901 #[test]
1902 fn create_app_returns_full_shape() {
1903 let s = svc();
1904 let created = s.create_app(&ctx(), &json!({ "name": "hello" })).unwrap();
1905 let app = &body_json(&created)["app"];
1906 for field in [
1907 "appId",
1908 "appArn",
1909 "name",
1910 "description",
1911 "repository",
1912 "platform",
1913 "createTime",
1914 "updateTime",
1915 "environmentVariables",
1916 "defaultDomain",
1917 "enableBranchAutoBuild",
1918 "enableBasicAuth",
1919 ] {
1920 assert!(app.get(field).is_some(), "missing {field}");
1921 }
1922 assert!(app["appId"].as_str().unwrap().starts_with('d'));
1923 assert!(app["appArn"]
1924 .as_str()
1925 .unwrap()
1926 .starts_with("arn:aws:amplify:us-east-1:000000000000:apps/"));
1927 }
1928
1929 #[test]
1930 fn create_then_get_list_delete_app() {
1931 let s = svc();
1932 let c = ctx();
1933 let app_id = make_app(&s, &c);
1934 let got = body_json(&s.get_app(&c, &app_id).unwrap());
1935 assert_eq!(got["app"]["name"], json!("my-app"));
1936 let listed = body_json(&s.list_apps(&c, &[]).unwrap());
1937 assert_eq!(listed["apps"].as_array().unwrap().len(), 1);
1938 s.delete_app(&c, &app_id).unwrap();
1939 assert_eq!(
1940 err_of(s.get_app(&c, &app_id)).status(),
1941 StatusCode::NOT_FOUND
1942 );
1943 }
1944
1945 #[test]
1946 fn get_app_malformed_id_is_bad_request() {
1947 let s = svc();
1948 assert_eq!(
1949 err_of(s.get_app(&ctx(), "not-an-app-id")).status(),
1950 StatusCode::BAD_REQUEST
1951 );
1952 }
1953
1954 #[test]
1955 fn create_branch_requires_existing_app() {
1956 let s = svc();
1957 let err =
1958 err_of(s.create_branch(&ctx(), "d0000000000000", &json!({ "branchName": "main" })));
1959 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1960 }
1961
1962 #[test]
1963 fn branch_round_trip_and_domain() {
1964 let s = svc();
1965 let c = ctx();
1966 let app_id = make_app(&s, &c);
1967 let branch = body_json(
1968 &s.create_branch(
1969 &c,
1970 &app_id,
1971 &json!({ "branchName": "main", "stage": "PRODUCTION" }),
1972 )
1973 .unwrap(),
1974 );
1975 assert_eq!(branch["branch"]["stage"], json!("PRODUCTION"));
1976 let got = body_json(&s.get_branch(&c, &app_id, "main").unwrap());
1977 assert_eq!(got["branch"]["branchName"], json!("main"));
1978 let listed = body_json(&s.list_branches(&c, &app_id, &[]).unwrap());
1979 assert_eq!(listed["branches"].as_array().unwrap().len(), 1);
1980
1981 let dom = body_json(
1983 &s.create_domain(
1984 &c,
1985 &app_id,
1986 &json!({
1987 "domainName": "example.com",
1988 "subDomainSettings": [{ "prefix": "www", "branchName": "main" }],
1989 }),
1990 )
1991 .unwrap(),
1992 );
1993 assert_eq!(dom["domainAssociation"]["domainStatus"], json!("CREATING"));
1994 assert!(s.reconcile(&c.account));
1996 let dom = body_json(&s.get_domain(&c, &app_id, "example.com").unwrap());
1997 assert_eq!(dom["domainAssociation"]["domainStatus"], json!("AVAILABLE"));
1998 }
1999
2000 #[test]
2001 fn job_lifecycle_and_artifacts() {
2002 let s = svc();
2003 let c = ctx();
2004 let app_id = make_app(&s, &c);
2005 s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2006 .unwrap();
2007 let started = body_json(
2008 &s.start_job(&c, &app_id, "main", &json!({ "jobType": "RELEASE" }))
2009 .unwrap(),
2010 );
2011 let job_id = started["jobSummary"]["jobId"].as_str().unwrap().to_string();
2012 assert_eq!(started["jobSummary"]["status"], json!("PENDING"));
2013 for _ in 0..4 {
2015 s.reconcile(&c.account);
2016 }
2017 let job = body_json(&s.get_job(&c, &app_id, "main", &job_id).unwrap());
2018 assert_eq!(job["job"]["summary"]["status"], json!("SUCCEED"));
2019 assert_eq!(job["job"]["steps"][0]["stepName"], json!("BUILD"));
2020 let arts = body_json(&s.list_artifacts(&c, &app_id, "main", &job_id, &[]).unwrap());
2022 let art = &arts["artifacts"][0];
2023 assert_eq!(art["artifactFileName"], json!("buildLogs.txt"));
2024 let art_id = art["artifactId"].as_str().unwrap();
2025 let url = body_json(&s.get_artifact_url(&c, art_id).unwrap());
2026 assert!(url["artifactUrl"].as_str().unwrap().starts_with("https://"));
2027 }
2028
2029 #[test]
2030 fn stop_job_cancels() {
2031 let s = svc();
2032 let c = ctx();
2033 let app_id = make_app(&s, &c);
2034 s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2035 .unwrap();
2036 let started = body_json(
2037 &s.start_job(&c, &app_id, "main", &json!({ "jobType": "MANUAL" }))
2038 .unwrap(),
2039 );
2040 let job_id = started["jobSummary"]["jobId"].as_str().unwrap().to_string();
2041 let stopped = body_json(&s.stop_job(&c, &app_id, "main", &job_id).unwrap());
2042 assert_eq!(stopped["jobSummary"]["status"], json!("CANCELLED"));
2043 }
2044
2045 #[test]
2046 fn webhook_round_trip() {
2047 let s = svc();
2048 let c = ctx();
2049 let app_id = make_app(&s, &c);
2050 s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2051 .unwrap();
2052 let wh = body_json(
2053 &s.create_webhook(
2054 &c,
2055 &app_id,
2056 &json!({ "branchName": "main", "description": "hook" }),
2057 )
2058 .unwrap(),
2059 );
2060 let id = wh["webhook"]["webhookId"].as_str().unwrap().to_string();
2061 let got = body_json(&s.get_webhook(&c, &id).unwrap());
2062 assert_eq!(got["webhook"]["branchName"], json!("main"));
2063 let listed = body_json(&s.list_webhooks(&c, &app_id, &[]).unwrap());
2064 assert_eq!(listed["webhooks"].as_array().unwrap().len(), 1);
2065 s.delete_webhook(&c, &id).unwrap();
2066 assert_eq!(
2067 err_of(s.get_webhook(&c, &id)).status(),
2068 StatusCode::NOT_FOUND
2069 );
2070 }
2071
2072 #[test]
2073 fn backend_environment_round_trip() {
2074 let s = svc();
2075 let c = ctx();
2076 let app_id = make_app(&s, &c);
2077 let env = body_json(
2078 &s.create_backend_env(&c, &app_id, &json!({ "environmentName": "staging" }))
2079 .unwrap(),
2080 );
2081 assert_eq!(
2082 env["backendEnvironment"]["environmentName"],
2083 json!("staging")
2084 );
2085 let got = body_json(&s.get_backend_env(&c, &app_id, "staging").unwrap());
2086 assert_eq!(
2087 got["backendEnvironment"]["environmentName"],
2088 json!("staging")
2089 );
2090 let listed = body_json(&s.list_backend_envs(&c, &app_id, &[]).unwrap());
2091 assert_eq!(listed["backendEnvironments"].as_array().unwrap().len(), 1);
2092 }
2093
2094 #[test]
2095 fn tag_untag_by_arn() {
2096 let s = svc();
2097 let c = ctx();
2098 let app_id = make_app(&s, &c);
2099 let arn = shared::app_arn(&c.region, &c.account, &app_id);
2100 s.tag_resource(&c, &arn, &json!({ "tags": { "team": "web" } }))
2101 .unwrap();
2102 let listed = body_json(&s.list_tags(&c, &arn).unwrap());
2103 assert_eq!(listed["tags"]["team"], json!("web"));
2104 s.untag_resource(&c, &arn, &[("tagKeys".into(), "team".into())])
2105 .unwrap();
2106 let listed = body_json(&s.list_tags(&c, &arn).unwrap());
2107 assert_eq!(listed["tags"].as_object().unwrap().len(), 0);
2108 }
2109
2110 #[test]
2111 fn list_tags_unknown_resource_is_not_found() {
2112 let s = svc();
2113 let arn = "arn:aws:amplify:us-east-1:000000000000:apps/d0000000000000";
2114 assert_eq!(
2115 err_of(s.list_tags(&ctx(), arn)).status(),
2116 StatusCode::NOT_FOUND
2117 );
2118 }
2119
2120 #[test]
2121 fn list_maxresults_out_of_range_is_bad_request() {
2122 let s = svc();
2123 let c = ctx();
2124 let q = vec![("maxResults".to_string(), "999".to_string())];
2125 assert_eq!(
2126 err_of(s.list_apps(&c, &q)).status(),
2127 StatusCode::BAD_REQUEST
2128 );
2129 }
2130
2131 #[test]
2132 fn create_deployment_returns_upload_urls() {
2133 let s = svc();
2134 let c = ctx();
2135 let app_id = make_app(&s, &c);
2136 s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2137 .unwrap();
2138 let dep = body_json(
2139 &s.create_deployment(
2140 &c,
2141 &app_id,
2142 "main",
2143 &json!({ "fileMap": { "index.html": "abc" } }),
2144 )
2145 .unwrap(),
2146 );
2147 assert!(dep["zipUploadUrl"]
2148 .as_str()
2149 .unwrap()
2150 .starts_with("https://"));
2151 assert!(dep["fileUploadUrls"]["index.html"]
2152 .as_str()
2153 .unwrap()
2154 .starts_with("https://"));
2155 assert!(dep["jobId"].is_string());
2156 }
2157
2158 #[test]
2159 fn routing_covers_every_action() {
2160 assert_eq!(AMPLIFY_ACTIONS.len(), 37);
2162 }
2163}