git_policy_cli/standards_review_client.rs
1//! What:
2//! The one HTTPS call of the standards-review shadow run, made in-process with a total deadline.
3//! Why:
4//! The root `mise.toml` task `shadow-review` calls `fetch` with a 30 s signal. This port calls the same endpoint from
5//! the wrapper itself, so no `node` or `mise` is started. The deadline covers connecting, TLS, sending and reading
6//! the body, so the call can never hold a caller longer than the deadline. The trust roots are a parameter: production
7//! uses the roots embedded in the binary, a test passes the fixture authority of its local TLS server.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const response = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(30000) });
12//! ```
13
14use std::time::Duration;
15use ureq::tls::{Certificate, RootCerts, TlsConfig};
16
17/// The script's timeout.
18pub const SCRIPT_DEADLINE: Duration = Duration::from_secs(30);
19
20/// What:
21/// Where and how to call: the URL, the bearer key, the deadline, and optionally the one trusted authority (PEM).
22/// Why:
23/// Tests point `url` at a loopback server; production passes `ENDPOINT`, the script's deadline and no authority.
24///
25/// In TS you'd write (pseudocode):
26/// ```ts
27/// type Gateway = { url: string; apiKey: string; deadline: number; trustedPem?: Uint8Array };
28/// ```
29#[derive(Clone, Copy, Debug)]
30pub struct Gateway<'a> {
31 /// The decisions endpoint.
32 pub url: &'a str,
33 /// The bearer credential.
34 pub api_key: &'a str,
35 /// The total time the call may take.
36 pub deadline: Duration,
37 /// A PEM authority that replaces the embedded roots; `None` keeps them.
38 pub trusted_pem: Option<&'a [u8]>,
39}
40
41/// What:
42/// The gateway's status and body text.
43/// Why:
44/// The answer reading decides what a status means; the client only transports.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type Reply = { status: number; body: string };
49/// ```
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct Reply {
52 /// HTTP status code.
53 pub status: u16,
54 /// Response body as text.
55 pub body: String,
56}
57
58/// What:
59/// POST `body` as JSON with the bearer key, within the deadline; any HTTP status is a reply, not an error.
60/// Redirects are not followed and no proxy variable is read.
61/// Why:
62/// The script's failure line names timeouts and transport failures; a redirect or a proxy would send the key and the
63/// repository text somewhere the endpoint constant does not name.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + key }, body, signal });
68/// ```
69pub fn post_decision(gateway: &Gateway, body: &str) -> Result<Reply, String> {
70 let tls: TlsConfig = match gateway.trusted_pem {
71 Some(pem) => {
72 let authority: Certificate<'static> = Certificate::from_pem(pem).map_err(|error| return format!("the trusted authority is unreadable: {error}"))?;
73 TlsConfig::builder().root_certs(RootCerts::new_with_certs(&[authority])).build()
74 }
75 None => TlsConfig::default(),
76 };
77 let agent: ureq::Agent = ureq::Agent::config_builder()
78 .timeout_global(Some(gateway.deadline))
79 .http_status_as_error(false)
80 .max_redirects(0)
81 .proxy(None)
82 .tls_config(tls)
83 .build()
84 .into();
85 let mut response = agent
86 .post(gateway.url)
87 .header("content-type", "application/json")
88 .header("authorization", format!("Bearer {}", gateway.api_key))
89 .send(body.as_bytes())
90 .map_err(describe)?;
91 let status: u16 = response.status().as_u16();
92 let text: String = response.body_mut().read_to_string().map_err(describe)?;
93 return Ok(Reply { status, body: text });
94}
95
96/// What:
97/// A failure as the log line prints it: the script's timeout wording, otherwise `fetch failed` and the cause.
98/// Why:
99/// Node prints only `fetch failed` and hides the cause; the cause is what a person needs.
100///
101/// In TS you'd write (pseudocode):
102/// ```ts
103/// error instanceof Error ? error.message : String(error)
104/// ```
105fn describe(error: ureq::Error) -> String {
106 return match error {
107 ureq::Error::Timeout(_) => String::from("The operation was aborted due to timeout"),
108 other => format!("fetch failed: {other}"),
109 };
110}
111
112/// Controls stay out of the release executable.
113#[cfg(test)]
114#[path = "standards_review_client_tests.rs"]
115mod tests;