ic-query 0.18.1

Internet Computer query library for NNS, SNS, ICRC, and related public network metadata
Documentation
//! Module: ic::live
//!
//! Responsibility: live HTTP transport and wire conversion for the official IC Dashboard API.
//! Does not own: report assembly, custom-source validation, command parsing, or rendering.
//! Boundary: performs one read-only REST lookup and retains request provenance.

use crate::http_endpoint::parse_http_endpoint;
use crate::ic::{
    IcCanisterSource, IcCanisterSourceData, IcCanisterUpgrade, IcHostError, IcSourceRequest,
};
use crate::runtime::block_on_current_thread;
use reqwest::Client;
use serde::Deserialize as SerdeDeserialize;
use std::time::Duration;
use url::Url;

const HTTP_TIMEOUT: Duration = Duration::from_secs(30);

///
/// LiveIcSource
///
/// Live official IC Dashboard source used by report builders outside tests.
///

pub struct LiveIcSource;

impl IcCanisterSource for LiveIcSource {
    fn fetch_canister(
        &self,
        request: &IcSourceRequest,
        canister_id: &str,
    ) -> Result<IcCanisterSourceData, IcHostError> {
        let canister_id = super::source::canonical_canister_id(canister_id)?;
        let url = canister_url(&request.endpoint, &canister_id)?;
        let url_text = url.to_string();
        let client = Client::builder()
            .user_agent(concat!("ic-query/", env!("CARGO_PKG_VERSION")))
            .timeout(HTTP_TIMEOUT)
            .build()
            .map_err(|error| IcHostError::HttpClientBuild {
                reason: error.to_string(),
            })?;
        block_on_current_thread(fetch_canister(client, url, url_text, request))?
    }
}

async fn fetch_canister(
    client: Client,
    url: Url,
    url_text: String,
    request: &IcSourceRequest,
) -> Result<IcCanisterSourceData, IcHostError> {
    let response = client
        .get(url)
        .send()
        .await
        .map_err(|error| IcHostError::HttpRequest {
            url: url_text.clone(),
            reason: error.to_string(),
        })?;
    let status = response.status();
    if !status.is_success() {
        return Err(IcHostError::HttpStatus {
            url: url_text,
            status: status.as_u16(),
        });
    }
    let wire = response
        .json::<DashboardCanister>()
        .await
        .map_err(|error| IcHostError::JsonDecode {
            url: url_text,
            reason: error.to_string(),
        })?;
    Ok(wire.into_source_data(request))
}

fn canister_url(endpoint: &str, canister_id: &str) -> Result<Url, IcHostError> {
    let mut url =
        parse_http_endpoint(endpoint).map_err(|reason| invalid_endpoint(endpoint, reason))?;
    if url.query().is_some() || url.fragment().is_some() {
        return Err(invalid_endpoint(
            endpoint,
            "base endpoint must not include a query or fragment",
        ));
    }

    {
        let mut segments = url.path_segments_mut().map_err(|()| {
            invalid_endpoint(endpoint, "base endpoint cannot accept path segments")
        })?;
        segments.pop_if_empty();
        segments.push("canisters");
        segments.push(canister_id);
    }
    Ok(url)
}

fn invalid_endpoint(endpoint: &str, reason: impl Into<String>) -> IcHostError {
    IcHostError::InvalidEndpoint {
        endpoint: endpoint.to_string(),
        reason: reason.into(),
    }
}

#[derive(SerdeDeserialize)]
struct DashboardCanister {
    canister_id: String,
    canister_type: Option<String>,
    controllers: Vec<String>,
    id: u64,
    language: String,
    module_hash: String,
    name: String,
    subnet_id: String,
    updated_at: String,
    upgrades: Option<Vec<DashboardCanisterUpgrade>>,
}

impl DashboardCanister {
    fn into_source_data(self, request: &IcSourceRequest) -> IcCanisterSourceData {
        IcCanisterSourceData {
            source_endpoint: request.endpoint.clone(),
            fetched_at: request.fetched_at.clone(),
            fetched_by: request.fetched_by.clone(),
            canister_id: self.canister_id,
            dashboard_id: self.id,
            canister_type: self.canister_type,
            name: self.name,
            subnet_id: self.subnet_id,
            controllers: self.controllers,
            language: self.language,
            module_hash: self.module_hash,
            dashboard_updated_at: self.updated_at,
            upgrades: self.upgrades.map(|upgrades| {
                upgrades
                    .into_iter()
                    .map(DashboardCanisterUpgrade::into_public)
                    .collect()
            }),
        }
    }
}

#[derive(SerdeDeserialize)]
struct DashboardCanisterUpgrade {
    executed_timestamp_seconds: u64,
    module_hash: String,
    proposal_id: u64,
}

impl DashboardCanisterUpgrade {
    fn into_public(self) -> IcCanisterUpgrade {
        IcCanisterUpgrade {
            executed_timestamp_seconds: self.executed_timestamp_seconds,
            module_hash: self.module_hash,
            proposal_id: self.proposal_id,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn canister_url_appends_path_to_endpoints_with_or_without_trailing_slash() {
        for endpoint in [
            "https://ic-api.internetcomputer.org/api/v3",
            "https://ic-api.internetcomputer.org/api/v3/",
        ] {
            assert_eq!(
                canister_url(endpoint, "ryjl3-tyaaa-aaaaa-aaaba-cai")
                    .expect("canister URL")
                    .as_str(),
                "https://ic-api.internetcomputer.org/api/v3/canisters/ryjl3-tyaaa-aaaaa-aaaba-cai"
            );
        }
    }

    #[test]
    fn canister_url_rejects_query_and_fragment_components() {
        for endpoint in [
            "https://ic-api.internetcomputer.org/api/v3?limit=1",
            "https://ic-api.internetcomputer.org/api/v3#canisters",
        ] {
            assert!(matches!(
                canister_url(endpoint, "ryjl3-tyaaa-aaaaa-aaaba-cai"),
                Err(IcHostError::InvalidEndpoint { .. })
            ));
        }
    }

    #[test]
    fn wire_decoder_preserves_null_upgrade_history_and_ignores_additive_fields() {
        let wire: DashboardCanister = serde_json::from_str(
            r#"{
                "canister_id": "ryjl3-tyaaa-aaaaa-aaaba-cai",
                "canister_type": null,
                "controllers": [],
                "id": 1,
                "language": "",
                "module_hash": "",
                "name": "",
                "subnet_id": "tdb26-jop6k-aogll-7ltgs-eruif-6kk7m-qpktf-gdiqx-mxtrf-vb5e6-eqe",
                "updated_at": "2026-07-30T17:47:41.745647",
                "upgrades": null,
                "future_field": true
            }"#,
        )
        .expect("current Dashboard payload");

        assert_eq!(wire.canister_type, None);
        assert!(wire.upgrades.is_none());
    }
}