1use anyhow::{Result, anyhow};
9
10use crate::domain::{
11 CloudflareStatus, DomainStatus, fetch_domain_status, format_cloudflare_status,
12};
13
14pub struct ResolvedAppUrl {
15 pub url: String,
16 pub note: Option<String>,
17}
18
19const CONTROL_PROJECT_ID: &str = "fn0-control";
25
26pub fn default_app_url(control_url: &str, project_id: &str) -> Result<String> {
27 let mut url = reqwest::Url::parse(control_url)
28 .map_err(|e| anyhow!("control URL '{control_url}' is not a valid URL: {e}"))?;
29 let host = url
30 .host_str()
31 .ok_or_else(|| anyhow!("control URL '{control_url}' has no host"))?;
32 let apex_host = host
33 .strip_prefix(&format!("{CONTROL_PROJECT_ID}."))
34 .unwrap_or(host);
35 let app_host = format!("{project_id}.{apex_host}");
36 url.set_host(Some(&app_host))
37 .map_err(|e| anyhow!("could not build app host '{app_host}': {e}"))?;
38 url.set_path("");
39 Ok(url.to_string().trim_end_matches('/').to_string())
40}
41
42pub async fn resolve_app_url(project_id: &str) -> Result<ResolvedAppUrl> {
43 let creds = crate::credentials::load()?;
44 let control_url = creds
45 .as_ref()
46 .map(|c| c.control_url.clone())
47 .unwrap_or_else(crate::credentials::default_control_url);
48 let default_url = default_app_url(&control_url, project_id)?;
49
50 let Some(creds) = creds else {
51 return Ok(ResolvedAppUrl {
52 url: default_url,
53 note: Some("not signed in, so a custom domain could not be checked".to_string()),
54 });
55 };
56
57 let fallback = |note: String| ResolvedAppUrl {
58 url: default_url.clone(),
59 note: Some(note),
60 };
61
62 Ok(match fetch_domain_status(&creds, project_id).await {
63 Ok(DomainStatus::Configured {
64 domain,
65 cloudflare_status: CloudflareStatus::Active,
66 }) => ResolvedAppUrl {
67 url: format!("https://{domain}"),
68 note: None,
69 },
70 Ok(DomainStatus::Configured {
71 domain,
72 cloudflare_status,
73 }) => fallback(format!(
74 "custom domain '{domain}' is not serving yet ({}), so the default subdomain is used",
75 format_cloudflare_status(&cloudflare_status)
76 )),
77 Ok(DomainStatus::SelfHosted {
81 domain,
82 origin_certificate_ready: true,
83 ..
84 }) => ResolvedAppUrl {
85 url: format!("https://{domain}"),
86 note: None,
87 },
88 Ok(DomainStatus::SelfHosted { domain, .. }) => fallback(format!(
89 "custom domain '{domain}' has no origin certificate yet, so the default \
90 subdomain is used"
91 )),
92 Ok(DomainStatus::NotConfigured) => ResolvedAppUrl {
93 url: default_url,
94 note: None,
95 },
96 Ok(DomainStatus::NotLoggedIn) => {
97 fallback("control rejected the saved token; run `forte login` again".to_string())
98 }
99 Ok(DomainStatus::NotFound) => fallback(format!(
100 "project '{project_id}' was not found under your account; nothing may be deployed there"
101 )),
102 Ok(DomainStatus::InternalError) => {
103 fallback("control failed to report the custom domain".to_string())
104 }
105 Err(e) => fallback(format!(
106 "could not reach control to check a custom domain: {e}"
107 )),
108 })
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn builds_subdomain_under_the_control_host() {
117 assert_eq!(
118 default_app_url("https://fn0.dev", "abc123").unwrap(),
119 "https://abc123.fn0.dev"
120 );
121 }
122
123 #[test]
124 fn keeps_scheme_and_port_of_a_self_hosted_control() {
125 assert_eq!(
126 default_app_url("http://localhost:3000", "abc123").unwrap(),
127 "http://abc123.localhost:3000"
128 );
129 }
130
131 #[test]
132 fn drops_a_trailing_path_on_the_control_url() {
133 assert_eq!(
134 default_app_url("https://fn0.dev/", "abc123").unwrap(),
135 "https://abc123.fn0.dev"
136 );
137 }
138
139 #[test]
140 fn strips_the_control_projects_own_subdomain_to_reach_the_apex() {
141 assert_eq!(
142 default_app_url("https://fn0-control.fn0.dev", "abc123").unwrap(),
143 "https://abc123.fn0.dev"
144 );
145 }
146}