diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e9125021..e6bf0d7490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### fix: dfx deploy urls printed for asset canisters +### feat: new subcommand: dfx canister url + +Dfx Canister URL is a new subcommand that allows you to print the URL of a canister on a specific network. Currently works for local and ic networks. + +Asset canisters will print the URL of the asset canister itself, while non-asset canisters will print the URL of the Candid UI interface. + ### chore: --emulator parameter is deprecated and will be discontinued soon Added warning that the `--emulator` is deprecated and will be discontinued soon. diff --git a/e2e/tests-dfx/canister_url.bash b/e2e/tests-dfx/canister_url.bash new file mode 100644 index 0000000000..30485b5844 --- /dev/null +++ b/e2e/tests-dfx/canister_url.bash @@ -0,0 +1,49 @@ +#!/usr/bin/env bats + +load ../utils/_ + +setup() { + standard_setup + + dfx_new hello +} + +teardown() { + dfx_stop + + standard_teardown +} + +@test "canister url performs as expected on local deploy" { + dfx_new_frontend hello + dfx_start + dfx deploy + assert_command dfx canister url hello_backend + assert_eq "http://127.0.0.1:4943/?canisterId=be2us-64aaa-aaaaa-qaabq-cai&id=bkyz2-fmaaa-aaaaa-qaaaq-cai" + assert_command dfx canister url hello_frontend + assert_eq "http://127.0.0.1:4943/?canisterId=bd3sg-teaaa-aaaaa-qaaba-cai" +} + +@test "canister url performs as expected on remote canisters" { + # set dfx.json to string + echo '{"canisters": {"whoami": {"type": "pull", "id": "ivcos-eqaaa-aaaab-qablq-cai"}}}' > dfx.json + assert_command dfx canister url whoami --network ic + assert_eq "https://a4gq6-oaaaa-aaaab-qaa4q-cai.raw.icp0.io/?id=ivcos-eqaaa-aaaab-qablq-cai" +} + +@test "missing ui canister error" { + dfx_start + dfx canister create hello_backend + assert_command_fail dfx canister url hello_backend + assert_contains "Network local does not have a ui canister id" +} + +@test "missing local id error" { + assert_command_fail dfx canister url hello_backend + assert_contains "Cannot find canister id. Please issue 'dfx canister create hello_backend'" +} + +@test "missing ic id error" { + assert_command_fail dfx canister url hello_backend --network ic + assert_contains "Cannot find canister id. Please issue 'dfx canister create hello_backend --network ic'." +} diff --git a/src/dfx-core/src/canister/mod.rs b/src/dfx-core/src/canister/mod.rs index 032f578915..5f88adf5b3 100644 --- a/src/dfx-core/src/canister/mod.rs +++ b/src/dfx-core/src/canister/mod.rs @@ -1,3 +1,4 @@ +pub mod url; use crate::{ cli::ask_for_consent, error::canister::{CanisterBuilderError, CanisterInstallError}, diff --git a/src/dfx-core/src/canister/url.rs b/src/dfx-core/src/canister/url.rs new file mode 100644 index 0000000000..a1f60c05fb --- /dev/null +++ b/src/dfx-core/src/canister/url.rs @@ -0,0 +1,105 @@ +use url::Host::Domain; +use url::ParseError; +use url::Url; + +const MAINNET_CANDID_INTERFACE_PRINCIPAL: &str = "a4gq6-oaaaa-aaaab-qaa4q-cai"; + +pub fn format_frontend_url(provider: &Url, canister_id: &str) -> Url { + let mut url = Url::clone(&provider); + if let Some(Domain(domain)) = url.host() { + if domain.ends_with("icp-api.io") || domain.ends_with("ic0.app") { + let new_domain = domain.replace("icp-api.io", "icp0.io"); + let new_domain = new_domain.replace("ic0.app", "icp0.io"); + let host = format!("{}.{}", canister_id, new_domain); + let _ = url.set_host(Some(&host)); + } else if domain.contains("localhost") { + let port = url.port().unwrap_or(4943); + let host = format!("localhost:{}", port); + let query = format!("canisterId={}", canister_id); + url.set_host(Some(&host)).unwrap(); + url.set_query(Some(&query)); + } else { + let host = format!("{}.{}", canister_id, domain); + let _ = url.set_host(Some(&host)); + } + } else { + let query = format!("canisterId={}", canister_id); + url.set_query(Some(&query)); + } + url +} + +pub fn format_ui_canister_url_ic(canister_id: &str) -> Result { + let url_result = Url::parse( + format!( + "https://{}.raw.icp0.io/?id={}", + MAINNET_CANDID_INTERFACE_PRINCIPAL, canister_id + ) + .as_str(), + ); + return url_result; +} + +pub fn format_ui_canister_url_custom( + canister_id: &str, + provider: &Url, + ui_canister_id: &str, +) -> Url { + let mut url = Url::clone(&provider); + + if let Some(Domain(domain)) = url.host() { + let host = format!("{}.{}", ui_canister_id, domain); + let query = format!("id={}", canister_id); + url.set_host(Some(&host)).unwrap(); + url.set_query(Some(&query)); + } else { + let query = format!("canisterId={}&id={}", ui_canister_id, canister_id); + url.set_query(Some(&query)); + } + + return url; +} + +#[cfg(test)] +mod test { + use crate::canister::url::format_frontend_url; + use url::Url; + + #[test] + fn print_local_frontend() { + let provider1 = &Url::parse("http://127.0.0.1:4943").unwrap(); + let provider2 = &Url::parse("http://localhost:4943").unwrap(); + let provider3 = &Url::parse("http://127.0.0.1:8000").unwrap(); + assert_eq!( + format_frontend_url(provider1, "ryjl3-tyaaa-aaaaa-aaaba-cai").as_str(), + "http://127.0.0.1:4943/?canisterId=ryjl3-tyaaa-aaaaa-aaaba-cai" + ); + assert_eq!( + format_frontend_url(provider2, "ryjl3-tyaaa-aaaaa-aaaba-cai").as_str(), + "http://localhost:4943/?canisterId=ryjl3-tyaaa-aaaaa-aaaba-cai" + ); + assert_eq!( + format_frontend_url(provider3, "ryjl3-tyaaa-aaaaa-aaaba-cai").as_str(), + "http://127.0.0.1:8000/?canisterId=ryjl3-tyaaa-aaaaa-aaaba-cai" + ); + } + + #[test] + fn print_ic_frontend() { + let provider1 = &Url::parse("https://ic0.app").unwrap(); + let provider2 = &Url::parse("https://icp-api.io").unwrap(); + let provider3 = &Url::parse("https://icp0.io").unwrap(); + assert_eq!( + format_frontend_url(provider1, "ryjl3-tyaaa-aaaaa-aaaba-cai").as_str(), + "https://ryjl3-tyaaa-aaaaa-aaaba-cai.icp0.io/" + ); + assert_eq!( + format_frontend_url(provider2, "ryjl3-tyaaa-aaaaa-aaaba-cai").as_str(), + "https://ryjl3-tyaaa-aaaaa-aaaba-cai.icp0.io/" + ); + assert_eq!( + format_frontend_url(provider3, "ryjl3-tyaaa-aaaaa-aaaba-cai").as_str(), + "https://ryjl3-tyaaa-aaaaa-aaaba-cai.icp0.io/" + ); + } +} diff --git a/src/dfx/src/commands/canister/mod.rs b/src/dfx/src/commands/canister/mod.rs index bad25894c6..c22fd3f8ec 100644 --- a/src/dfx/src/commands/canister/mod.rs +++ b/src/dfx/src/commands/canister/mod.rs @@ -23,6 +23,7 @@ mod status; mod stop; mod uninstall_code; mod update_settings; +pub mod url; /// Manages canisters deployed on a network replica. #[derive(Parser)] @@ -58,11 +59,12 @@ pub enum SubCommand { Stop(stop::CanisterStopOpts), UninstallCode(uninstall_code::UninstallCodeOpts), UpdateSettings(update_settings::UpdateSettingsOpts), + Url(url::CanisterURLOpts), } pub fn exec(env: &dyn Environment, opts: CanisterOpts) -> DfxResult { let agent_env; - let env = if matches!(&opts.subcmd, SubCommand::Id(_)) { + let env = if matches!(&opts.subcmd, SubCommand::Id(_) | SubCommand::Url(_)) { env } else { agent_env = create_agent_environment(env, opts.network.to_network_name())?; @@ -90,6 +92,7 @@ pub fn exec(env: &dyn Environment, opts: CanisterOpts) -> DfxResult { SubCommand::Stop(v) => stop::exec(env, v, &call_sender).await, SubCommand::UninstallCode(v) => uninstall_code::exec(env, v, &call_sender).await, SubCommand::UpdateSettings(v) => update_settings::exec(env, v, &call_sender).await, + SubCommand::Url(v) => url::exec(env, v), } }) } diff --git a/src/dfx/src/commands/canister/url.rs b/src/dfx/src/commands/canister/url.rs new file mode 100644 index 0000000000..c59d06a3bd --- /dev/null +++ b/src/dfx/src/commands/canister/url.rs @@ -0,0 +1,110 @@ +use crate::lib::canister_info::CanisterInfo; +use crate::lib::error::DfxResult; +use crate::lib::network::network_opt::NetworkOpt; +use crate::lib::{environment::Environment, named_canister}; +use anyhow::Context; +use candid::Principal; +use clap::Parser; +use dfx_core::canister::url::{ + format_frontend_url, format_ui_canister_url_custom, format_ui_canister_url_ic, +}; +use dfx_core::config::model::canister_id_store::CanisterIdStore; +use dfx_core::config::model::network_descriptor::NetworkDescriptor; +use dfx_core::network::provider::{create_network_descriptor, LocalBindDetermination}; +use fn_error_context::context; +use url::Url; + +/// Prints the URL of a canister. +#[derive(Parser)] +pub struct CanisterURLOpts { + /// Specifies the name of the canister. + canister: String, + #[command(flatten)] + network: NetworkOpt, +} + +#[context("Failed to construct frontend url for canister {} on network '{}'.", canister_id, network.name)] +pub fn construct_frontend_url( + network: &NetworkDescriptor, + canister_id: &Principal, +) -> DfxResult { + let url = Url::parse(&network.providers[0]).with_context(|| { + format!( + "Failed to parse url for network provider {}.", + &network.providers[0] + ) + })?; + + Ok(format_frontend_url(&url, &canister_id.to_string())) +} + +#[context("Failed to construct ui canister url for {} on network '{}'.", canister_id, network.name)] +pub fn construct_ui_canister_url( + network: &NetworkDescriptor, + canister_id: &Principal, + ui_canister_id: Option, +) -> DfxResult { + let provider = Url::parse(&network.providers[0]).with_context(|| { + format!( + "Failed to parse url for network provider {}.", + &network.providers[0] + ) + })?; + if network.is_ic { + let formatted_url = format_ui_canister_url_ic(&canister_id.to_string())?; + return Ok(formatted_url); + } else { + if let Some(ui_canister_id) = ui_canister_id { + let formatted_url = format_ui_canister_url_custom( + &canister_id.to_string(), + &provider, + &ui_canister_id.to_string().as_str(), + ); + return Ok(formatted_url); + } else { + return Err(anyhow::anyhow!( + "Network {} does not have a ui canister id", + network.name + )); + } + } +} + +pub fn exec(env: &dyn Environment, opts: CanisterURLOpts) -> DfxResult { + env.get_config_or_anyhow()?; + let network_descriptor = create_network_descriptor( + env.get_config(), + env.get_networks_config(), + opts.network.to_network_name(), + None, + LocalBindDetermination::AsConfigured, + )?; + let canister_name = opts.canister.as_str(); + let canister_id_store = + CanisterIdStore::new(env.get_logger(), &network_descriptor, env.get_config())?; + let canister_id = + Principal::from_text(canister_name).or_else(|_| canister_id_store.get(canister_name))?; + let config = env.get_config_or_anyhow()?; + let canister_info = CanisterInfo::load(&config, canister_name, Some(canister_id))?; + + let ui_canister_id = named_canister::get_ui_canister_id(&canister_id_store); + // If the canister is an assets canister or has a frontend section, we can display a frontend url. + if let Some(canisters) = &config.get_config().canisters { + let canister_config = canisters.get(canister_name).unwrap(); + let is_assets = canister_info.is_assets() || canister_config.frontend.is_some(); + if is_assets { + let url = construct_frontend_url(&network_descriptor, &canister_id)?; + println!("{}", url.as_str()); + Ok(()) + } else { + let url = construct_ui_canister_url(&network_descriptor, &canister_id, ui_canister_id)?; + println!("{}", url.as_str()); + Ok(()) + } + } else { + Err(anyhow::anyhow!( + "Canister {} does not have a frontend section", + canister_name + )) + } +} diff --git a/src/dfx/src/commands/deploy.rs b/src/dfx/src/commands/deploy.rs index 7a429dcd85..4d2e338491 100644 --- a/src/dfx/src/commands/deploy.rs +++ b/src/dfx/src/commands/deploy.rs @@ -13,20 +13,19 @@ use anyhow::{anyhow, bail, Context}; use candid::Principal; use clap::Parser; use console::Style; +use dfx_core::canister::url::{ + format_frontend_url, format_ui_canister_url_custom, format_ui_canister_url_ic, +}; use dfx_core::config::model::network_descriptor::NetworkDescriptor; use dfx_core::identity::CallSender; -use fn_error_context::context; use ic_utils::interfaces::management_canister::builders::InstallMode; use slog::info; use std::collections::BTreeMap; use std::path::PathBuf; use std::str::FromStr; use tokio::runtime::Runtime; -use url::Host::Domain; use url::Url; -const MAINNET_CANDID_INTERFACE_PRINCIPAL: &str = "a4gq6-oaaaa-aaaab-qaa4q-cai"; - /// Deploys all or a specific canister from the code in your project. By default, all canisters are deployed. #[derive(Parser)] pub struct DeployOpts { @@ -212,15 +211,34 @@ fn display_urls(env: &dyn Environment) -> DfxResult { // If the canister is an assets canister or has a frontend section, we can display a frontend url. let is_assets = canister_info.is_assets() || canister_config.frontend.is_some(); + let provider = Url::parse(&network.providers[0]).with_context(|| { + format!( + "Failed to parse url for network provider {}.", + &network.providers[0] + ) + })?; if is_assets { - let url = construct_frontend_url(network, &canister_id)?; + let url = format_frontend_url(&provider, &canister_id.to_string()); frontend_urls.insert(canister_name, url); } if !canister_info.is_assets() { - let url = construct_ui_canister_url(network, &canister_id, ui_canister_id)?; - if let Some(ui_canister_url) = url { - candid_urls.insert(canister_name, ui_canister_url); + let is_local = env.get_network_descriptor().name == "local"; + if is_local { + let ui_canister_id = ui_canister_id.ok_or_else(|| { + anyhow!( + "The ui canister id is not set in the canister_id_store.json file." + ) + })?; + let url = format_ui_canister_url_custom( + &&canister_id.to_string(), + &provider, + &ui_canister_id.to_string(), + ); + candid_urls.insert(canister_name, url); + } else { + let url = format_ui_canister_url_ic(&canister_id.to_string())?; + candid_urls.insert(canister_name, url); } } } @@ -246,65 +264,3 @@ fn display_urls(env: &dyn Environment) -> DfxResult { Ok(()) } - -#[context("Failed to construct frontend url for canister {} on network '{}'.", canister_id, network.name)] -fn construct_frontend_url(network: &NetworkDescriptor, canister_id: &Principal) -> DfxResult { - let mut url = Url::parse(&network.providers[0]).with_context(|| { - format!( - "Failed to parse url for network provider {}.", - &network.providers[0] - ) - })?; - - if let Some(Domain(domain)) = url.host() { - let host = format!("{}.{}", canister_id, domain); - url.set_host(Some(&host)) - .with_context(|| format!("Failed to set host to {}.", host))?; - } else { - let query = format!("canisterId={}", canister_id); - url.set_query(Some(&query)); - }; - - Ok(url) -} - -#[context("Failed to construct ui canister url for {} on network '{}'.", canister_id, network.name)] -fn construct_ui_canister_url( - network: &NetworkDescriptor, - canister_id: &Principal, - ui_canister_id: Option, -) -> DfxResult> { - if network.is_ic { - let url = format!( - "https://{}.raw.icp0.io/?id={}", - MAINNET_CANDID_INTERFACE_PRINCIPAL, canister_id - ); - let url = Url::parse(&url).with_context(|| { - format!( - "Failed to parse candid url {} for canister {}.", - &url, canister_id - ) - })?; - Ok(Some(url)) - } else if let Some(ui_canister_id) = ui_canister_id { - let mut url = Url::parse(&network.providers[0]).with_context(|| { - format!( - "Failed to parse network provider {}.", - &network.providers[0] - ) - })?; - if let Some(Domain(domain)) = url.host() { - let host = format!("{}.{}", ui_canister_id, domain); - let query = format!("id={}", canister_id); - url.set_host(Some(&host)) - .with_context(|| format!("Failed to set host to {}", &host))?; - url.set_query(Some(&query)); - } else { - let query = format!("canisterId={}&id={}", ui_canister_id, canister_id); - url.set_query(Some(&query)); - } - Ok(Some(url)) - } else { - Ok(None) - } -}