opencloudmesh 0.2.1

Implementation of the OpenCloudMesh protocol
Documentation
// SPDX-FileCopyrightText: 2026 Matthias Kraus <info@opengeomesh.org>
//
// SPDX-License-Identifier: LGPL-3.0-or-later

use std::str::FromStr;

use http::{Uri, uri::Scheme};
use ocm_types::{
    discovery::Discovery,
    error::{Error, ValidationError},
};

use crate::common::HttpClient;

/// Well-Known Uri for OCM Discovery
pub const DISCOVERY_ENDPOINT: &str = "/.well-known/ocm";
/// Uri for OCM Discovery of legacy OCM Servers
pub const LEGACY_DISCOVERY_ENDPOINT: &str = "/ocm-provider";

#[derive(Debug)]
/// Potential Errors when discovering a remote OCM Server
pub enum DiscoveryError {
    /// Target address for discovery is invalid
    InvalidOcmServerAddress(String),
    /// Failed to connect to OCM Server
    RequestError(String),
    /// Failed to parse Discovery Response
    DeserializationFailed(serde_json::Error),
}

impl DiscoveryError {
    /// Translate this error into an appropriate HTTP StatusCode
    pub fn status_code(&self) -> http::StatusCode {
        match self {
            DiscoveryError::InvalidOcmServerAddress(_) => http::StatusCode::NOT_ACCEPTABLE,
            DiscoveryError::RequestError(_) => http::StatusCode::BAD_GATEWAY,
            DiscoveryError::DeserializationFailed(_) => http::StatusCode::BAD_GATEWAY,
        }
    }
}

impl From<DiscoveryError> for Error {
    fn from(value: DiscoveryError) -> Self {
        match value {
            DiscoveryError::InvalidOcmServerAddress(target_uri) => Error {
                message: "INVALID_OCM_SERVER_ADDRRESS".to_string(),
                validation_errors: vec![ValidationError {
                    name: Some("Missing Host".to_string()),
                    message: Some(format!("'{target_uri}' does not contain a host")),
                }],
            },
            DiscoveryError::RequestError(e) => Error {
                message: "REQUEST_ERROR".to_string(),
                validation_errors: vec![ValidationError {
                    name: Some("OCM Server rejected request".to_string()),
                    message: Some(e.to_string()),
                }],
            },
            DiscoveryError::DeserializationFailed(e) => Error {
                message: "INVALID_DISCOVERY_RESPONSE".to_string(),
                validation_errors: vec![ValidationError {
                    name: Some("Failed to deserialize Discovery Response".to_string()),
                    message: Some(e.to_string()),
                }],
            },
        }
    }
}

impl From<serde_json::Error> for DiscoveryError {
    fn from(value: serde_json::Error) -> Self {
        Self::DeserializationFailed(value)
    }
}

/// Discover API endpoint, version, public key and capabilities of an OCM Server
///
/// * `target` - Adress of the OCM Server to discover
/// * `allow_http_fallback` - Discovery SHOULD only be done via HTTPS. This allows Fallback to HTTP if HTTPS is not
///   available for the given target Uri.
pub async fn discover(
    http_client: &impl HttpClient,
    target: &Uri,
) -> Result<Discovery, DiscoveryError> {
    // At the start of the process, the Discovering Server has either an OCM Address, or just an
    // FQDN from for instance the `recipientProvider` field of an Invite Acceptance Request.

    // Step 2: The Discovering Server SHOULD attempt OCM API discovery a HTTP GET request to `https://<fqdn>/.well-known/ocm`.
    let discovery = fetch_discovery(Scheme::from_str("https").unwrap(), http_client, target).await;
    if discovery.is_err() && http_client.allow_http() {
        fetch_discovery(Scheme::from_str("http").unwrap(), http_client, target).await
    } else {
        discovery
    }
}

async fn fetch_discovery(
    scheme: Scheme,
    http_client: &impl HttpClient,
    target: &Uri,
) -> Result<Discovery, DiscoveryError> {
    let uri = derive_discovery_endpoint(scheme.clone(), target, DISCOVERY_ENDPOINT)?;

    let response: Result<String, DiscoveryError> = http_client
        .get(&uri)
        .await
        .map_err(DiscoveryError::RequestError);
    // Step 3: If that results in a valid HTTP response with a valid JSON response body within reasonable time, go to step 7.
    let discovery: Result<Discovery, DiscoveryError> =
        response.and_then(|res: String| serde_json::from_str(&res).map_err(|e| e.into()));
    // Step 4: If not, try a HTTP GET with `https://<fqdn>/ocm-provider` as the URL instead.
    if discovery.is_err() {
        let uri = derive_discovery_endpoint(scheme, target, LEGACY_DISCOVERY_ENDPOINT)?;
        let response: Result<String, DiscoveryError> = http_client
            .get(&uri)
            .await
            .map_err(DiscoveryError::RequestError);
        // Step 5: If that results in a valid HTTP response with a valid JSON response body within reasonable time, go to step 7.
        response.and_then(|res: String| serde_json::from_str(&res).map_err(|e| e.into()))
        // Step 6: If not, fail.
    } else {
        // Step 7: The JSON response body is the data that was discovered.
        discovery
    }
}

fn derive_discovery_endpoint(
    scheme: Scheme,
    target: &Uri,
    endpoint_path: &str,
) -> Result<Uri, DiscoveryError> {
    let fqdn = target
        .host()
        .ok_or(DiscoveryError::InvalidOcmServerAddress(target.to_string()))?;
    let port = target
        .port()
        .map(|p| format!(":{}", p.as_str()))
        .unwrap_or("".to_string());
    let uri = Uri::builder()
        .scheme(scheme)
        .authority(format!("{fqdn}{port}").as_str())
        .path_and_query(endpoint_path)
        .build()
        .unwrap();
    Ok(uri)
}