Skip to main content

kontext_dev_sdk_core/
lib.rs

1use serde::Deserialize;
2use serde::Serialize;
3use url::Url;
4
5pub mod types;
6pub use types::*;
7
8pub const DEFAULT_SCOPE: &str = "";
9pub const DEFAULT_SERVER: &str = "https://api.kontext.dev";
10pub const DEFAULT_SERVER_NAME: &str = "kontext-dev";
11pub const DEFAULT_RESOURCE: &str = "mcp-gateway";
12pub const DEFAULT_AUTH_TIMEOUT_SECONDS: i64 = 300;
13pub const DEFAULT_REDIRECT_URI: &str = "http://localhost:3333/callback";
14
15fn default_scope() -> String {
16    DEFAULT_SCOPE.to_string()
17}
18
19fn default_server() -> String {
20    DEFAULT_SERVER.to_string()
21}
22
23fn default_server_name() -> String {
24    DEFAULT_SERVER_NAME.to_string()
25}
26
27fn default_resource() -> String {
28    DEFAULT_RESOURCE.to_string()
29}
30
31fn default_open_connect_page_on_login() -> bool {
32    true
33}
34
35fn default_auth_timeout_seconds() -> i64 {
36    DEFAULT_AUTH_TIMEOUT_SECONDS
37}
38
39fn default_redirect_uri() -> String {
40    DEFAULT_REDIRECT_URI.to_string()
41}
42
43fn default_token_type() -> String {
44    "Bearer".to_string()
45}
46
47/// Configuration for the Kontext-Dev Rust SDK.
48///
49/// Runtime configuration uses a single `server` origin with PKCE + token exchange.
50#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
51#[serde(deny_unknown_fields)]
52pub struct KontextDevConfig {
53    /// Base API origin (for example: https://api.kontext.dev).
54    #[serde(default = "default_server")]
55    pub server: String,
56
57    /// OAuth client id.
58    pub client_id: String,
59
60    /// Optional OAuth client secret (for confidential clients).
61    #[serde(default)]
62    pub client_secret: Option<String>,
63
64    /// Optional OAuth scopes for authorization (space-delimited).
65    ///
66    /// Empty by default to avoid requesting scopes that are not allowed
67    /// for a given OAuth client configuration.
68    #[serde(default = "default_scope")]
69    pub scope: String,
70
71    /// Name used when registering this server in clients.
72    #[serde(default = "default_server_name")]
73    pub server_name: String,
74
75    /// Default resource identifier for RFC 8693 token exchange.
76    #[serde(default = "default_resource")]
77    pub resource: String,
78
79    /// Optional hosted connect UI URL (for example: https://app.kontext.dev).
80    #[serde(default)]
81    pub integration_ui_url: Option<String>,
82
83    /// Optional return URL for per-integration OAuth flows.
84    #[serde(default)]
85    pub integration_return_to: Option<String>,
86
87    /// If true, SDK consumers may open the connect page after login.
88    #[serde(default = "default_open_connect_page_on_login")]
89    pub open_connect_page_on_login: bool,
90
91    /// OAuth callback wait timeout in seconds.
92    #[serde(default = "default_auth_timeout_seconds")]
93    pub auth_timeout_seconds: i64,
94
95    /// Optional token cache path for persisted login state.
96    #[serde(default)]
97    pub token_cache_path: Option<String>,
98
99    /// OAuth redirect URI used by PKCE browser login.
100    ///
101    /// Must match one of the application's pre-registered redirect URIs.
102    #[serde(default = "default_redirect_uri")]
103    pub redirect_uri: String,
104}
105
106#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
107pub struct AccessToken {
108    pub access_token: String,
109    #[serde(default = "default_token_type")]
110    pub token_type: String,
111    #[serde(default)]
112    pub expires_in: Option<i64>,
113    #[serde(default)]
114    pub refresh_token: Option<String>,
115    #[serde(default)]
116    pub scope: Option<String>,
117}
118
119#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
120pub struct TokenExchangeToken {
121    pub access_token: String,
122    pub issued_token_type: String,
123    pub token_type: String,
124    #[serde(default)]
125    pub expires_in: Option<i64>,
126    #[serde(default)]
127    pub scope: Option<String>,
128    #[serde(default)]
129    pub refresh_token: Option<String>,
130}
131
132#[derive(Debug, thiserror::Error)]
133pub enum KontextDevCoreError {
134    #[error("Kontext-Dev server URL is missing. Set `server`.")]
135    MissingServerUrl,
136    #[error("Kontext-Dev access token is empty")]
137    EmptyAccessToken,
138    #[error("failed to parse URL `{url}`")]
139    InvalidUrl {
140        url: String,
141        source: url::ParseError,
142    },
143}
144
145pub fn normalize_server_url(server: &str) -> String {
146    let mut url = server.trim().trim_end_matches('/').to_string();
147
148    if let Some(stripped) = url.strip_suffix("/api/v1") {
149        url = stripped.to_string();
150    }
151
152    if let Some(stripped) = url.strip_suffix("/mcp") {
153        url = stripped.to_string();
154    }
155
156    url.trim_end_matches('/').to_string()
157}
158
159fn parse_url(raw: &str) -> Result<Url, KontextDevCoreError> {
160    Url::parse(raw).map_err(|source| KontextDevCoreError::InvalidUrl {
161        url: raw.to_string(),
162        source,
163    })
164}
165
166pub fn resolve_server_base_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
167    let candidate = normalize_server_url(&config.server);
168
169    if candidate.is_empty() {
170        return Err(KontextDevCoreError::MissingServerUrl);
171    }
172
173    let parsed = parse_url(&candidate)?;
174    Ok(parsed.to_string().trim_end_matches('/').to_string())
175}
176
177fn join_url(base: &str, suffix: &str) -> Result<String, KontextDevCoreError> {
178    let base = format!("{}/", base.trim_end_matches('/'));
179    let base_url = parse_url(&base)?;
180    let joined = base_url
181        .join(suffix.trim_start_matches('/'))
182        .map_err(|source| KontextDevCoreError::InvalidUrl {
183            url: format!("{base}{suffix}"),
184            source,
185        })?;
186    Ok(joined.to_string())
187}
188
189pub fn resolve_mcp_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
190    let base = resolve_server_base_url(config)?;
191    join_url(&base, "mcp")
192}
193
194pub fn resolve_token_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
195    let base = resolve_server_base_url(config)?;
196    join_url(&base, "oauth2/token")
197}
198
199pub fn resolve_authorize_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
200    let base = resolve_server_base_url(config)?;
201    join_url(&base, "oauth2/authorize")
202}
203
204pub fn resolve_connect_session_url(
205    config: &KontextDevConfig,
206) -> Result<String, KontextDevCoreError> {
207    let base = resolve_server_base_url(config)?;
208    join_url(&base, "mcp/connect-session")
209}
210
211pub fn resolve_integration_oauth_init_url(
212    config: &KontextDevConfig,
213    integration_id: &str,
214) -> Result<String, KontextDevCoreError> {
215    let base = resolve_server_base_url(config)?;
216    join_url(
217        &base,
218        &format!("mcp/integrations/{integration_id}/oauth/init"),
219    )
220}
221
222pub fn resolve_integration_connection_url(
223    config: &KontextDevConfig,
224    integration_id: &str,
225) -> Result<String, KontextDevCoreError> {
226    let base = resolve_server_base_url(config)?;
227    join_url(
228        &base,
229        &format!("mcp/integrations/{integration_id}/oauth/connection"),
230    )
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use pretty_assertions::assert_eq;
237
238    fn config() -> KontextDevConfig {
239        KontextDevConfig {
240            server: "http://localhost:4000".to_string(),
241            client_id: "client".to_string(),
242            client_secret: None,
243            scope: default_scope(),
244            server_name: default_server_name(),
245            resource: default_resource(),
246            integration_ui_url: None,
247            integration_return_to: None,
248            open_connect_page_on_login: default_open_connect_page_on_login(),
249            auth_timeout_seconds: default_auth_timeout_seconds(),
250            token_cache_path: None,
251            redirect_uri: default_redirect_uri(),
252        }
253    }
254
255    #[test]
256    fn normalize_server_url_strips_api_and_mcp() {
257        assert_eq!(
258            normalize_server_url("http://localhost:4000"),
259            "http://localhost:4000"
260        );
261        assert_eq!(
262            normalize_server_url("http://localhost:4000/api/v1"),
263            "http://localhost:4000"
264        );
265        assert_eq!(
266            normalize_server_url("http://localhost:4000/mcp"),
267            "http://localhost:4000"
268        );
269    }
270
271    #[test]
272    fn default_scope_is_empty() {
273        assert_eq!(DEFAULT_SCOPE, "");
274        assert_eq!(default_scope(), "");
275    }
276
277    #[test]
278    fn default_server_is_api_origin() {
279        assert_eq!(DEFAULT_SERVER, "https://api.kontext.dev");
280        assert_eq!(default_server(), DEFAULT_SERVER);
281    }
282
283    #[test]
284    fn deserialize_without_server_uses_default_server() {
285        let cfg: KontextDevConfig = serde_json::from_value(serde_json::json!({
286            "client_id": "client",
287            "redirect_uri": "http://localhost:3000/callback"
288        }))
289        .expect("config should deserialize");
290
291        assert_eq!(cfg.server, DEFAULT_SERVER);
292        assert_eq!(cfg.client_id, "client");
293        assert_eq!(cfg.redirect_uri, "http://localhost:3000/callback");
294    }
295
296    #[test]
297    fn resolve_urls_from_server() {
298        let cfg = config();
299        assert_eq!(
300            resolve_mcp_url(&cfg).expect("mcp"),
301            "http://localhost:4000/mcp"
302        );
303        assert_eq!(
304            resolve_token_url(&cfg).expect("token"),
305            "http://localhost:4000/oauth2/token"
306        );
307        assert_eq!(
308            resolve_authorize_url(&cfg).expect("authorize"),
309            "http://localhost:4000/oauth2/authorize"
310        );
311    }
312
313    #[test]
314    fn reject_empty_server() {
315        let mut cfg = config();
316        cfg.server = "   ".to_string();
317        assert!(matches!(
318            resolve_server_base_url(&cfg),
319            Err(KontextDevCoreError::MissingServerUrl)
320        ));
321    }
322}