Skip to main content

zai_rs/client/
endpoint.rs

1//! Validated, URL-based endpoint configuration (plan P02.5–P02.8).
2//!
3//! Unlike the legacy string-concatenating `client::EndpointConfig (0.4 legacy)`,
4//! this stores each family's base as a parsed [`url::Url`]. Building rejects
5//! relative URLs, userinfo, query strings and fragments; the scheme is checked
6//! against the family (HTTPS/WSS by default, HTTP/WS only when insecure
7//! transport is explicitly allowed AND the host is loopback/localhost). Dynamic
8//! path segments go through [`EndpointConfig::resolve`], which
9//! percent-encodes via `url::PathSegmentsMut` and rejects empty / `.` / `..`
10//! segments — never raw string concatenation.
11
12use url::Url;
13
14use super::routes::{Route, Segment};
15use crate::{ZaiError, ZaiResult, client::error::codes};
16
17/// The fixed API endpoint families (plan §4 / P02.5).
18///
19/// Each variant carries its official default base URL and the scheme class it
20/// accepts. Defaults are HTTPS/WSS; HTTP/WS is only permitted for custom bases
21/// when the caller explicitly enables insecure transport AND the host resolves
22/// to loopback.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ApiFamily {
25    PaasV4,
26    CodingPaasV4,
27    AgentV1,
28    LlmApplication,
29    ApplicationV2,
30    ApplicationV3,
31    Zrag,
32    Monitor,
33    /// WebSocket realtime endpoint.
34    Realtime,
35}
36
37impl ApiFamily {
38    /// The official default base URL for this family.
39    pub const fn default_base(self) -> &'static str {
40        match self {
41            ApiFamily::PaasV4 => "https://open.bigmodel.cn/api/paas/v4",
42            ApiFamily::CodingPaasV4 => "https://open.bigmodel.cn/api/coding/paas/v4",
43            ApiFamily::AgentV1 => "https://open.bigmodel.cn/api/v1",
44            // ApplicationV2/V3 share the LLM-application base per the plan.
45            ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
46                "https://open.bigmodel.cn/api/llm-application/open"
47            },
48            ApiFamily::Zrag => "https://open.bigmodel.cn/api/zrag",
49            ApiFamily::Monitor => "https://open.bigmodel.cn/api/monitor",
50            ApiFamily::Realtime => "wss://open.bigmodel.cn/api/paas/v4/realtime",
51        }
52    }
53
54    /// Whether this family is a WebSocket family (scheme ws/wss).
55    pub const fn is_realtime(self) -> bool {
56        matches!(self, ApiFamily::Realtime)
57    }
58
59    /// The secure scheme for this family (`wss` for realtime, `https` otherwise).
60    pub const fn secure_scheme(self) -> &'static str {
61        if self.is_realtime() { "wss" } else { "https" }
62    }
63
64    /// The insecure scheme for this family (`ws` for realtime, `http` otherwise).
65    pub const fn insecure_scheme(self) -> &'static str {
66        if self.is_realtime() { "ws" } else { "http" }
67    }
68}
69
70/// A validated set of per-family base URLs.
71///
72/// Each field is a parsed [`Url`]; the field is private and only reachable via
73/// [`EndpointConfig::resolve`], which percent-encodes dynamic segments. There is
74/// no string-fallback path.
75#[derive(Clone)]
76pub struct EndpointConfig {
77    paas_v4: Url,
78    coding_paas_v4: Url,
79    agent_v1: Url,
80    llm_application: Url,
81    zrag: Url,
82    monitor: Url,
83    realtime: Url,
84}
85
86impl std::fmt::Debug for EndpointConfig {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("EndpointConfig")
89            .field("paas_v4", &self.paas_v4.as_str())
90            .field("coding_paas_v4", &self.coding_paas_v4.as_str())
91            .field("agent_v1", &self.agent_v1.as_str())
92            .field("llm_application", &self.llm_application.as_str())
93            .field("zrag", &self.zrag.as_str())
94            .field("monitor", &self.monitor.as_str())
95            .field("realtime", &self.realtime.as_str())
96            .finish()
97    }
98}
99
100impl EndpointConfig {
101    /// Build the default (official) endpoint set.
102    pub fn defaults() -> ZaiResult<Self> {
103        Self::builder().build(false)
104    }
105
106    /// Start a builder.
107    pub fn builder() -> EndpointConfigBuilder {
108        EndpointConfigBuilder {
109            paas_v4: ApiFamily::PaasV4.default_base(),
110            coding_paas_v4: ApiFamily::CodingPaasV4.default_base(),
111            agent_v1: ApiFamily::AgentV1.default_base(),
112            llm_application: ApiFamily::LlmApplication.default_base(),
113            zrag: ApiFamily::Zrag.default_base(),
114            monitor: ApiFamily::Monitor.default_base(),
115            realtime: ApiFamily::Realtime.default_base(),
116        }
117    }
118
119    /// The validated base [`Url`] for `family`.
120    pub fn base(&self, family: ApiFamily) -> &Url {
121        match family {
122            ApiFamily::PaasV4 => &self.paas_v4,
123            ApiFamily::CodingPaasV4 => &self.coding_paas_v4,
124            ApiFamily::AgentV1 => &self.agent_v1,
125            ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
126                &self.llm_application
127            },
128            ApiFamily::Zrag => &self.zrag,
129            ApiFamily::Monitor => &self.monitor,
130            ApiFamily::Realtime => &self.realtime,
131        }
132    }
133
134    /// Resolve `path_segments` (applied in order) against the family base,
135    /// returning a fully-formed URL string. Each segment is percent-encoded;
136    /// empty, `.` and `..` segments are rejected.
137    ///
138    /// Pass an empty slice for the bare family base.
139    pub fn resolve(&self, family: ApiFamily, segments: &[&str]) -> ZaiResult<String> {
140        let mut url = self.base(family).clone();
141        if segments.is_empty() {
142            return Ok(url.to_string());
143        }
144        // Validate every segment before mutating, so a later bad segment does
145        // not partially corrupt the URL.
146        for seg in segments {
147            validate_segment(seg)?;
148        }
149        let mut path = url
150            .path_segments_mut()
151            .map_err(|_| invalid("base URL cannot be a base"))?;
152        for seg in segments {
153            path.push(seg);
154        }
155        drop(path);
156        Ok(url.to_string())
157    }
158
159    /// Resolve a family base + segments + query pairs into a URL string.
160    pub fn resolve_with_query(
161        &self,
162        family: ApiFamily,
163        segments: &[&str],
164        query: &[(&str, &str)],
165    ) -> ZaiResult<String> {
166        let mut url = self.base(family).clone();
167        if !segments.is_empty() {
168            for seg in segments {
169                validate_segment(seg)?;
170            }
171            let mut path = url
172                .path_segments_mut()
173                .map_err(|_| invalid("base URL cannot be a base"))?;
174            for seg in segments {
175                path.push(seg);
176            }
177        }
178        if !query.is_empty() {
179            let mut pairs = url.query_pairs_mut();
180            for (k, v) in query {
181                pairs.append_pair(k, v);
182            }
183        }
184        Ok(url.to_string())
185    }
186
187    /// Resolve a route from the crate's canonical operation registry.
188    ///
189    /// `parameters` are substituted into parameter slots in declaration order
190    /// and percent-encoded as individual path segments. A count mismatch is a
191    /// validation error instead of silently producing a malformed URL.
192    pub(crate) fn resolve_route(&self, route: Route, parameters: &[&str]) -> ZaiResult<String> {
193        self.resolve_route_with_query(route, parameters, &[])
194    }
195
196    /// Resolve a canonical route and append encoded query pairs.
197    pub(crate) fn resolve_route_with_query(
198        &self,
199        route: Route,
200        parameters: &[&str],
201        query: &[(&str, &str)],
202    ) -> ZaiResult<String> {
203        for parameter in parameters {
204            validate_segment(parameter)?;
205        }
206
207        let expected = route
208            .segments()
209            .iter()
210            .filter(|segment| matches!(segment, Segment::Parameter))
211            .count();
212        if parameters.len() != expected {
213            return Err(ZaiError::ApiError {
214                code: codes::SDK_VALIDATION,
215                message: format!(
216                    "route {} expects {expected} path parameter(s), got {}",
217                    route.operation_id(),
218                    parameters.len()
219                ),
220            });
221        }
222
223        let mut url = self.base(route.family()).clone();
224        if !route.segments().is_empty() {
225            let mut parameter_index = 0;
226            let mut path = url
227                .path_segments_mut()
228                .map_err(|_| invalid("base URL cannot be a base"))?;
229            for segment in route.segments() {
230                match segment {
231                    Segment::Static(value) => path.push(value),
232                    Segment::Parameter => {
233                        path.push(parameters[parameter_index]);
234                        parameter_index += 1;
235                        &mut path
236                    },
237                };
238            }
239        }
240        if !query.is_empty() {
241            let mut pairs = url.query_pairs_mut();
242            for (key, value) in query {
243                pairs.append_pair(key, value);
244            }
245        }
246        Ok(url.to_string())
247    }
248}
249
250/// Builder for [`EndpointConfig`].
251pub struct EndpointConfigBuilder {
252    paas_v4: &'static str,
253    coding_paas_v4: &'static str,
254    agent_v1: &'static str,
255    llm_application: &'static str,
256    zrag: &'static str,
257    monitor: &'static str,
258    realtime: &'static str,
259}
260
261impl EndpointConfigBuilder {
262    /// Override the PAAS v4 base.
263    pub fn paas_v4(mut self, base: &'static str) -> Self {
264        self.paas_v4 = base;
265        self
266    }
267    /// Override the Coding PAAS v4 base.
268    pub fn coding_paas_v4(mut self, base: &'static str) -> Self {
269        self.coding_paas_v4 = base;
270        self
271    }
272    /// Override the Agent v1 base.
273    pub fn agent_v1(mut self, base: &'static str) -> Self {
274        self.agent_v1 = base;
275        self
276    }
277    /// Override the LLM-application base (also covers ApplicationV2/V3).
278    pub fn llm_application(mut self, base: &'static str) -> Self {
279        self.llm_application = base;
280        self
281    }
282    /// Override the Zrag base.
283    pub fn zrag(mut self, base: &'static str) -> Self {
284        self.zrag = base;
285        self
286    }
287    /// Override the monitor base.
288    pub fn monitor(mut self, base: &'static str) -> Self {
289        self.monitor = base;
290        self
291    }
292    /// Override the realtime base.
293    pub fn realtime(mut self, base: &'static str) -> Self {
294        self.realtime = base;
295        self
296    }
297
298    /// Finalize. When `allow_insecure` is false, every base must be HTTPS/WSS.
299    /// When true, HTTP/WS is accepted but only for loopback/localhost hosts.
300    pub fn build(self, allow_insecure: bool) -> ZaiResult<EndpointConfig> {
301        Ok(EndpointConfig {
302            paas_v4: parse_family_base(self.paas_v4, ApiFamily::PaasV4, allow_insecure)?,
303            coding_paas_v4: parse_family_base(
304                self.coding_paas_v4,
305                ApiFamily::CodingPaasV4,
306                allow_insecure,
307            )?,
308            agent_v1: parse_family_base(self.agent_v1, ApiFamily::AgentV1, allow_insecure)?,
309            llm_application: parse_family_base(
310                self.llm_application,
311                ApiFamily::LlmApplication,
312                allow_insecure,
313            )?,
314            zrag: parse_family_base(self.zrag, ApiFamily::Zrag, allow_insecure)?,
315            monitor: parse_family_base(self.monitor, ApiFamily::Monitor, allow_insecure)?,
316            realtime: parse_family_base(self.realtime, ApiFamily::Realtime, allow_insecure)?,
317        })
318    }
319}
320
321/// Parse and validate a family base URL string.
322///
323/// Rules (plan P02.6/P02.7):
324/// - must be an absolute URL (cannot-be-a-base / relative rejected);
325/// - no userinfo, no query, no fragment;
326/// - scheme must be the family's secure scheme, OR the insecure scheme when
327///   `allow_insecure` is true AND the host is loopback/localhost.
328fn parse_family_base(raw: &str, family: ApiFamily, allow_insecure: bool) -> ZaiResult<Url> {
329    let url = Url::parse(raw).map_err(|e| invalid(&format!("invalid base URL {raw:?}: {e}")))?;
330
331    if url.cannot_be_a_base() {
332        return Err(invalid("base URL must be absolute, not a relative URL"));
333    }
334    if !url.username().is_empty() || url.password().is_some() {
335        return Err(invalid("base URL must not contain userinfo"));
336    }
337    if url.query().is_some() {
338        return Err(invalid("base URL must not contain a query string"));
339    }
340    if url.fragment().is_some() {
341        return Err(invalid("base URL must not contain a fragment"));
342    }
343
344    let scheme = url.scheme();
345    let host = url.host_str().unwrap_or("");
346    if scheme == family.secure_scheme() {
347        // Always-allowed secure scheme.
348    } else if allow_insecure && scheme == family.insecure_scheme() {
349        // Insecure scheme: host must be loopback/localhost.
350        if !is_loopback(host) {
351            return Err(invalid(&format!(
352                "insecure {scheme} transport is only allowed for loopback/localhost, got host {host:?}"
353            )));
354        }
355    } else {
356        return Err(invalid(&format!(
357            "family {:?} requires scheme {} (or {} on loopback); got {scheme:?}",
358            family,
359            family.secure_scheme(),
360            family.insecure_scheme()
361        )));
362    }
363
364    Ok(url)
365}
366
367/// `true` for loopback / `localhost` hosts (the only hosts permitted with
368/// insecure transport).
369fn is_loopback(host: &str) -> bool {
370    host == "localhost"
371        || host == "127.0.0.1"
372        || host == "::1"
373        || host == "[::1]"
374        || host.starts_with("127.")
375}
376
377/// Reject empty, `.` and `..` path segments (plan P02.8).
378fn validate_segment(seg: &str) -> ZaiResult<()> {
379    if seg.is_empty() {
380        return Err(ZaiError::ApiError {
381            code: codes::SDK_VALIDATION,
382            message: "path segment must not be empty".to_string(),
383        });
384    }
385    if seg == "." || seg == ".." {
386        return Err(ZaiError::ApiError {
387            code: codes::SDK_VALIDATION,
388            message: format!("path segment must not be `{seg}`"),
389        });
390    }
391    Ok(())
392}
393
394fn invalid(msg: &str) -> ZaiError {
395    ZaiError::ApiError {
396        code: codes::SDK_CONFIG,
397        message: msg.to_string(),
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn defaults_are_official_secure_urls() {
407        let ec = EndpointConfig::defaults().unwrap();
408        assert_eq!(
409            ec.base(ApiFamily::PaasV4).as_str(),
410            "https://open.bigmodel.cn/api/paas/v4"
411        );
412        assert_eq!(ec.base(ApiFamily::Realtime).scheme(), "wss",);
413    }
414
415    #[test]
416    fn resolve_percent_encodes_dynamic_segments() {
417        let ec = EndpointConfig::defaults().unwrap();
418        // `a/b` becomes a single percent-encoded segment `a%2Fb`.
419        let url = ec.resolve(ApiFamily::PaasV4, &["a/b"]).unwrap();
420        assert!(
421            url.contains("a%2Fb") || url.contains("a/b"),
422            "segment should be encoded into the path: {url}"
423        );
424        // Exactly one path segment added beyond the base.
425        let base = ec.base(ApiFamily::PaasV4).as_str();
426        assert!(url.starts_with(base));
427    }
428
429    #[test]
430    fn resolve_rejects_empty_dot_dotdot_segments() {
431        let ec = EndpointConfig::defaults().unwrap();
432        assert!(ec.resolve(ApiFamily::PaasV4, &[""]).is_err());
433        assert!(ec.resolve(ApiFamily::PaasV4, &["."]).is_err());
434        assert!(ec.resolve(ApiFamily::PaasV4, &[".."]).is_err());
435    }
436
437    #[test]
438    fn resolve_with_query_appends_pairs() {
439        let ec = EndpointConfig::defaults().unwrap();
440        let url = ec
441            .resolve_with_query(
442                ApiFamily::PaasV4,
443                &["files"],
444                &[("limit", "10"), ("order", "desc")],
445            )
446            .unwrap();
447        assert!(url.contains("limit=10"));
448        assert!(url.contains("order=desc"));
449    }
450
451    #[test]
452    fn canonical_route_encodes_parameters_and_query() {
453        let ec = EndpointConfig::defaults().unwrap();
454        let url = ec
455            .resolve_route_with_query(
456                crate::client::routes::FILES_PARSE_RESULT,
457                &["task/with/slash", "md"],
458                &[("name", "a&b")],
459            )
460            .unwrap();
461        assert_eq!(
462            url,
463            "https://open.bigmodel.cn/api/paas/v4/files/parser/result/task%2Fwith%2Fslash/md?name=a%26b"
464        );
465    }
466
467    #[test]
468    fn canonical_route_rejects_parameter_count_mismatch() {
469        let ec = EndpointConfig::defaults().unwrap();
470        let error = ec
471            .resolve_route(crate::client::routes::FILES_GET_CONTENT, &[])
472            .unwrap_err();
473        assert!(error.message().contains("expects 1 path parameter"));
474    }
475
476    #[test]
477    fn rejects_relative_userinfo_query_fragment() {
478        // relative
479        assert!(
480            EndpointConfig::builder()
481                .paas_v4("not/a/url")
482                .build(true)
483                .is_err()
484        );
485        // userinfo
486        assert!(
487            EndpointConfig::builder()
488                .paas_v4("https://user:pass@open.bigmodel.cn/api/paas/v4")
489                .build(false)
490                .is_err()
491        );
492        // query
493        assert!(
494            EndpointConfig::builder()
495                .paas_v4("https://open.bigmodel.cn/api/paas/v4?x=1")
496                .build(false)
497                .is_err()
498        );
499        // fragment
500        assert!(
501            EndpointConfig::builder()
502                .paas_v4("https://open.bigmodel.cn/api/paas/v4#frag")
503                .build(false)
504                .is_err()
505        );
506    }
507
508    #[test]
509    fn rejects_http_public_host_without_insecure() {
510        // Public HTTP host, insecure not allowed → rejected.
511        assert!(
512            EndpointConfig::builder()
513                .paas_v4("http://open.bigmodel.cn/api/paas/v4")
514                .build(false)
515                .is_err()
516        );
517    }
518
519    #[test]
520    fn http_loopback_allowed_when_insecure_enabled() {
521        let ec = EndpointConfig::builder()
522            .paas_v4("http://127.0.0.1:8080/api/paas/v4")
523            .build(true)
524            .unwrap();
525        assert_eq!(ec.base(ApiFamily::PaasV4).scheme(), "http");
526        assert_eq!(ec.base(ApiFamily::PaasV4).host_str(), Some("127.0.0.1"));
527    }
528
529    #[test]
530    fn http_public_host_rejected_even_when_insecure_enabled() {
531        // Insecure enabled but host is not loopback → still rejected.
532        assert!(
533            EndpointConfig::builder()
534                .paas_v4("http://open.bigmodel.cn/api/paas/v4")
535                .build(true)
536                .is_err()
537        );
538    }
539
540    #[test]
541    fn ws_loopback_allowed_for_realtime_when_insecure() {
542        let ec = EndpointConfig::builder()
543            .realtime("ws://localhost:9000/realtime")
544            .build(true)
545            .unwrap();
546        assert_eq!(ec.base(ApiFamily::Realtime).scheme(), "ws");
547    }
548}