Skip to main content

liminal_server/services/
lys_http.rs

1//! The Lys HTTP routes the production service bindings read and ask
2//! (ACCESS-003 R1), behind one seam.
3//!
4//! `lys-pass` at the pinned commit carries only the pass, batch-check and
5//! draft routes; the budget, stop, start-command and usage routes the
6//! services bind to are Lys identity-server routes on Lys main
7//! (`budgets_api.rs`, `stop_api.rs`, `launch_api.rs`, `budgets_act.rs`).
8//! [`LysHttp`] is the seam they go through and [`HttpLys`] is its real
9//! implementation: one request, with the bearer the binding names, answered
10//! by Lys's status and JSON body unchanged. Nothing is cached and nothing is
11//! retried here; a transport failure is `lys_could_not_be_asked`, and a
12//! refusal status is carried back with Lys's own body so the refusal keeps
13//! its name.
14
15use super::ServiceEffectError;
16
17/// One Lys answer: the HTTP status and the JSON body exactly as Lys sent it
18/// (`Null` for an empty body).
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct LysAnswer {
21    /// The HTTP status code.
22    pub status: u16,
23    /// The body as JSON, unchanged.
24    pub body: serde_json::Value,
25}
26
27impl LysAnswer {
28    /// Whether Lys answered with a success status.
29    #[must_use]
30    pub fn succeeded(&self) -> bool {
31        (200..300).contains(&self.status)
32    }
33
34    /// Lys's own refusal name from the body (`refusal`, else `error`), or
35    /// the status when the body names none.
36    #[must_use]
37    pub fn refusal(&self) -> String {
38        self.body
39            .get("refusal")
40            .or_else(|| self.body.get("error"))
41            .and_then(serde_json::Value::as_str)
42            .map_or_else(|| format!("lys_status_{}", self.status), str::to_owned)
43    }
44
45    /// The answer as a typed effect error naming the route and Lys's refusal.
46    #[must_use]
47    pub fn refused(&self, route: &str) -> ServiceEffectError {
48        ServiceEffectError(format!("Lys refused {route}: {}", self.refusal()))
49    }
50}
51
52/// The Lys routes the services bind to, asked with a bearer pass.
53pub trait LysHttp: Send + Sync + std::fmt::Debug {
54    /// `GET {issuer}/{path}` with `bearer`.
55    ///
56    /// # Errors
57    /// Lys could not be asked (`lys_could_not_be_asked`), or its body was not
58    /// JSON.
59    fn get(&self, bearer: &str, path: &str) -> Result<LysAnswer, ServiceEffectError>;
60
61    /// `POST {issuer}/{path}` with `bearer` and the JSON `body`.
62    ///
63    /// # Errors
64    /// Lys could not be asked (`lys_could_not_be_asked`), or its body was not
65    /// JSON.
66    fn post(
67        &self,
68        bearer: &str,
69        path: &str,
70        body: &serde_json::Value,
71    ) -> Result<LysAnswer, ServiceEffectError>;
72}
73
74/// The error a transport failure is named by, as `lys-pass` names it.
75fn could_not_be_asked(route: &str, error: reqwest::Error) -> ServiceEffectError {
76    ServiceEffectError(format!(
77        "lys_could_not_be_asked: {route}: {}",
78        error.without_url()
79    ))
80}
81
82/// [`LysHttp`] over `reqwest`.
83///
84/// Each request is run to completion on a scoped thread against the
85/// server's tokio runtime, as the live ask is (`auth_lys::live`), so a
86/// connection process never blocks the runtime's own workers and never nests
87/// a runtime.
88pub struct HttpLys {
89    http: reqwest::Client,
90    issuer: reqwest::Url,
91    handle: tokio::runtime::Handle,
92}
93
94impl std::fmt::Debug for HttpLys {
95    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        formatter
97            .debug_struct("HttpLys")
98            .field("issuer", &self.issuer.as_str())
99            .finish_non_exhaustive()
100    }
101}
102
103impl HttpLys {
104    /// Asks `issuer`, an HTTP origin, through `handle`'s runtime.
105    ///
106    /// # Errors
107    /// The issuer is not an HTTP origin.
108    pub fn new(issuer: &str, handle: tokio::runtime::Handle) -> Result<Self, ServiceEffectError> {
109        let mut issuer = reqwest::Url::parse(issuer)
110            .map_err(|error| ServiceEffectError(format!("Lys issuer is not a URL: {error}")))?;
111        if !matches!(issuer.scheme(), "http" | "https")
112            || issuer.host_str().is_none()
113            || !issuer.username().is_empty()
114            || issuer.password().is_some()
115            || issuer.query().is_some()
116            || issuer.fragment().is_some()
117        {
118            return Err(ServiceEffectError(
119                "Lys issuer is not an HTTP origin".to_owned(),
120            ));
121        }
122        if !issuer.path().ends_with('/') {
123            let path = format!("{}/", issuer.path());
124            issuer.set_path(&path);
125        }
126        Ok(Self {
127            http: reqwest::Client::new(),
128            issuer,
129            handle,
130        })
131    }
132
133    fn endpoint(&self, path: &str) -> Result<reqwest::Url, ServiceEffectError> {
134        self.issuer
135            .join(path.trim_start_matches('/'))
136            .map_err(|error| ServiceEffectError(format!("Lys route {path} refused: {error}")))
137    }
138
139    fn run(
140        &self,
141        route: &str,
142        request: reqwest::RequestBuilder,
143    ) -> Result<LysAnswer, ServiceEffectError> {
144        let handle = &self.handle;
145        let future = async move {
146            let response = request
147                .send()
148                .await
149                .map_err(|error| could_not_be_asked(route, error))?;
150            let status = response.status().as_u16();
151            let bytes = response
152                .bytes()
153                .await
154                .map_err(|error| could_not_be_asked(route, error))?;
155            let body = if bytes.is_empty() {
156                serde_json::Value::Null
157            } else {
158                serde_json::from_slice(&bytes).map_err(|error| {
159                    ServiceEffectError(format!(
160                        "lys_could_not_be_asked: {route}: the answer is not JSON: {error}"
161                    ))
162                })?
163            };
164            Ok(LysAnswer { status, body })
165        };
166        std::thread::scope(|scope| scope.spawn(move || handle.block_on(future)).join())
167            .unwrap_or_else(|_| {
168                Err(ServiceEffectError(format!(
169                    "lys_could_not_be_asked: {route}: the request did not complete"
170                )))
171            })
172    }
173}
174
175impl LysHttp for HttpLys {
176    fn get(&self, bearer: &str, path: &str) -> Result<LysAnswer, ServiceEffectError> {
177        let url = self.endpoint(path)?;
178        self.run(path, self.http.get(url).bearer_auth(bearer))
179    }
180
181    fn post(
182        &self,
183        bearer: &str,
184        path: &str,
185        body: &serde_json::Value,
186    ) -> Result<LysAnswer, ServiceEffectError> {
187        let url = self.endpoint(path)?;
188        self.run(path, self.http.post(url).bearer_auth(bearer).json(body))
189    }
190}
191
192/// One URL path segment, percent-encoded (`.` included, so `..` can never
193/// climb), so a name can never reach another Lys route.
194#[must_use]
195pub fn segment(value: &str) -> String {
196    const HEX: &[u8; 16] = b"0123456789ABCDEF";
197    let mut encoded = String::with_capacity(value.len());
198    for byte in value.bytes() {
199        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'~') {
200            encoded.push(char::from(byte));
201        } else {
202            encoded.push('%');
203            for nibble in [byte >> 4, byte & 0x0f] {
204                if let Some(digit) = HEX.get(usize::from(nibble)) {
205                    encoded.push(char::from(*digit));
206                }
207            }
208        }
209    }
210    encoded
211}