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 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
375fn 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
392fn 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
404fn 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
416fn 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
429fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn create_deployment(
1490 &self,
1491 ctx: &Ctx,
1492 app_id: &str,
1493 branch: &str,
1494 body: &Value,
1495 ) -> Result<AwsResponse, AwsServiceError> {
1496 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 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 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 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 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 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 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 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 assert_eq!(AMPLIFY_ACTIONS.len(), 37);
2112 }
2113}