kontext-dev-sdk-core 0.2.1

Core types for the Kontext SDK.
Documentation
use serde::Deserialize;
use serde::Serialize;
use url::Url;

pub mod types;
pub use types::*;

pub const DEFAULT_SCOPE: &str = "";
pub const DEFAULT_SERVER: &str = "https://api.kontext.dev";
pub const DEFAULT_SERVER_NAME: &str = "kontext-dev";
pub const DEFAULT_RESOURCE: &str = "mcp-gateway";
pub const DEFAULT_AUTH_TIMEOUT_SECONDS: i64 = 300;
pub const DEFAULT_REDIRECT_URI: &str = "http://localhost:3333/callback";

fn default_scope() -> String {
    DEFAULT_SCOPE.to_string()
}

fn default_server() -> String {
    DEFAULT_SERVER.to_string()
}

fn default_server_name() -> String {
    DEFAULT_SERVER_NAME.to_string()
}

fn default_resource() -> String {
    DEFAULT_RESOURCE.to_string()
}

fn default_open_connect_page_on_login() -> bool {
    true
}

fn default_auth_timeout_seconds() -> i64 {
    DEFAULT_AUTH_TIMEOUT_SECONDS
}

fn default_redirect_uri() -> String {
    DEFAULT_REDIRECT_URI.to_string()
}

fn default_token_type() -> String {
    "Bearer".to_string()
}

/// Configuration for the Kontext-Dev Rust SDK.
///
/// Runtime configuration uses a single `server` origin with PKCE + token exchange.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct KontextDevConfig {
    /// Base API origin (for example: https://api.kontext.dev).
    #[serde(default = "default_server")]
    pub server: String,

    /// OAuth client id.
    pub client_id: String,

    /// Optional OAuth client secret (for confidential clients).
    #[serde(default)]
    pub client_secret: Option<String>,

    /// Optional OAuth scopes for authorization (space-delimited).
    ///
    /// Empty by default to avoid requesting scopes that are not allowed
    /// for a given OAuth client configuration.
    #[serde(default = "default_scope")]
    pub scope: String,

    /// Name used when registering this server in clients.
    #[serde(default = "default_server_name")]
    pub server_name: String,

    /// Default resource identifier for RFC 8693 token exchange.
    #[serde(default = "default_resource")]
    pub resource: String,

    /// Optional hosted connect UI URL (for example: https://app.kontext.dev).
    #[serde(default)]
    pub integration_ui_url: Option<String>,

    /// Optional return URL for per-integration OAuth flows.
    #[serde(default)]
    pub integration_return_to: Option<String>,

    /// If true, SDK consumers may open the connect page after login.
    #[serde(default = "default_open_connect_page_on_login")]
    pub open_connect_page_on_login: bool,

    /// OAuth callback wait timeout in seconds.
    #[serde(default = "default_auth_timeout_seconds")]
    pub auth_timeout_seconds: i64,

    /// Optional token cache path for persisted login state.
    #[serde(default)]
    pub token_cache_path: Option<String>,

    /// OAuth redirect URI used by PKCE browser login.
    ///
    /// Must match one of the application's pre-registered redirect URIs.
    #[serde(default = "default_redirect_uri")]
    pub redirect_uri: String,
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct AccessToken {
    pub access_token: String,
    #[serde(default = "default_token_type")]
    pub token_type: String,
    #[serde(default)]
    pub expires_in: Option<i64>,
    #[serde(default)]
    pub refresh_token: Option<String>,
    #[serde(default)]
    pub scope: Option<String>,
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct TokenExchangeToken {
    pub access_token: String,
    pub issued_token_type: String,
    pub token_type: String,
    #[serde(default)]
    pub expires_in: Option<i64>,
    #[serde(default)]
    pub scope: Option<String>,
    #[serde(default)]
    pub refresh_token: Option<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum KontextDevCoreError {
    #[error("Kontext-Dev server URL is missing. Set `server`.")]
    MissingServerUrl,
    #[error("Kontext-Dev access token is empty")]
    EmptyAccessToken,
    #[error("failed to parse URL `{url}`")]
    InvalidUrl {
        url: String,
        source: url::ParseError,
    },
}

pub fn normalize_server_url(server: &str) -> String {
    let mut url = server.trim().trim_end_matches('/').to_string();

    if let Some(stripped) = url.strip_suffix("/api/v1") {
        url = stripped.to_string();
    }

    if let Some(stripped) = url.strip_suffix("/mcp") {
        url = stripped.to_string();
    }

    url.trim_end_matches('/').to_string()
}

fn parse_url(raw: &str) -> Result<Url, KontextDevCoreError> {
    Url::parse(raw).map_err(|source| KontextDevCoreError::InvalidUrl {
        url: raw.to_string(),
        source,
    })
}

pub fn resolve_server_base_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
    let candidate = normalize_server_url(&config.server);

    if candidate.is_empty() {
        return Err(KontextDevCoreError::MissingServerUrl);
    }

    let parsed = parse_url(&candidate)?;
    Ok(parsed.to_string().trim_end_matches('/').to_string())
}

fn join_url(base: &str, suffix: &str) -> Result<String, KontextDevCoreError> {
    let base = format!("{}/", base.trim_end_matches('/'));
    let base_url = parse_url(&base)?;
    let joined = base_url
        .join(suffix.trim_start_matches('/'))
        .map_err(|source| KontextDevCoreError::InvalidUrl {
            url: format!("{base}{suffix}"),
            source,
        })?;
    Ok(joined.to_string())
}

pub fn resolve_mcp_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
    let base = resolve_server_base_url(config)?;
    join_url(&base, "mcp")
}

pub fn resolve_token_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
    let base = resolve_server_base_url(config)?;
    join_url(&base, "oauth2/token")
}

pub fn resolve_authorize_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
    let base = resolve_server_base_url(config)?;
    join_url(&base, "oauth2/authorize")
}

pub fn resolve_connect_session_url(
    config: &KontextDevConfig,
) -> Result<String, KontextDevCoreError> {
    let base = resolve_server_base_url(config)?;
    join_url(&base, "mcp/connect-session")
}

pub fn resolve_integration_oauth_init_url(
    config: &KontextDevConfig,
    integration_id: &str,
) -> Result<String, KontextDevCoreError> {
    let base = resolve_server_base_url(config)?;
    join_url(
        &base,
        &format!("mcp/integrations/{integration_id}/oauth/init"),
    )
}

pub fn resolve_integration_connection_url(
    config: &KontextDevConfig,
    integration_id: &str,
) -> Result<String, KontextDevCoreError> {
    let base = resolve_server_base_url(config)?;
    join_url(
        &base,
        &format!("mcp/integrations/{integration_id}/oauth/connection"),
    )
}

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

    fn config() -> KontextDevConfig {
        KontextDevConfig {
            server: "http://localhost:4000".to_string(),
            client_id: "client".to_string(),
            client_secret: None,
            scope: default_scope(),
            server_name: default_server_name(),
            resource: default_resource(),
            integration_ui_url: None,
            integration_return_to: None,
            open_connect_page_on_login: default_open_connect_page_on_login(),
            auth_timeout_seconds: default_auth_timeout_seconds(),
            token_cache_path: None,
            redirect_uri: default_redirect_uri(),
        }
    }

    #[test]
    fn normalize_server_url_strips_api_and_mcp() {
        assert_eq!(
            normalize_server_url("http://localhost:4000"),
            "http://localhost:4000"
        );
        assert_eq!(
            normalize_server_url("http://localhost:4000/api/v1"),
            "http://localhost:4000"
        );
        assert_eq!(
            normalize_server_url("http://localhost:4000/mcp"),
            "http://localhost:4000"
        );
    }

    #[test]
    fn default_scope_is_empty() {
        assert_eq!(DEFAULT_SCOPE, "");
        assert_eq!(default_scope(), "");
    }

    #[test]
    fn default_server_is_api_origin() {
        assert_eq!(DEFAULT_SERVER, "https://api.kontext.dev");
        assert_eq!(default_server(), DEFAULT_SERVER);
    }

    #[test]
    fn deserialize_without_server_uses_default_server() {
        let cfg: KontextDevConfig = serde_json::from_value(serde_json::json!({
            "client_id": "client",
            "redirect_uri": "http://localhost:3000/callback"
        }))
        .expect("config should deserialize");

        assert_eq!(cfg.server, DEFAULT_SERVER);
        assert_eq!(cfg.client_id, "client");
        assert_eq!(cfg.redirect_uri, "http://localhost:3000/callback");
    }

    #[test]
    fn resolve_urls_from_server() {
        let cfg = config();
        assert_eq!(
            resolve_mcp_url(&cfg).expect("mcp"),
            "http://localhost:4000/mcp"
        );
        assert_eq!(
            resolve_token_url(&cfg).expect("token"),
            "http://localhost:4000/oauth2/token"
        );
        assert_eq!(
            resolve_authorize_url(&cfg).expect("authorize"),
            "http://localhost:4000/oauth2/authorize"
        );
    }

    #[test]
    fn reject_empty_server() {
        let mut cfg = config();
        cfg.server = "   ".to_string();
        assert!(matches!(
            resolve_server_base_url(&cfg),
            Err(KontextDevCoreError::MissingServerUrl)
        ));
    }
}