Skip to main content

zai_rs/client/
endpoint.rs

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