Skip to main content

iri_client/
openapi_client.rs

1use reqwest::Method;
2use serde_json::Value;
3use url::form_urlencoded::byte_serialize;
4
5use crate::{ApiClient, BlockingApiClient, ClientError};
6
7/// Metadata for one `OpenAPI` operation.
8///
9/// Values are generated from `openapi/openapi.json` at build time.
10#[derive(Clone, Copy, Debug)]
11pub struct OperationDefinition {
12    /// Stable `OpenAPI` operation identifier.
13    pub operation_id: &'static str,
14    /// Uppercase HTTP method (for example `GET`, `POST`).
15    pub method: &'static str,
16    /// Path template, potentially containing `{param}` placeholders.
17    pub path_template: &'static str,
18    /// Required path parameter names extracted from `path_template`.
19    pub path_params: &'static [&'static str],
20}
21
22// Generated file contract (`$OUT_DIR/openapi_operations.rs`):
23// 1. `OPENAPI_DEFAULT_SERVER_URL: &str`
24//    - Default base URL resolved from `openapi/openapi.json` (`servers[0].url`).
25// 2. `OPENAPI_OPERATIONS: &[OperationDefinition]`
26//    - One entry per OpenAPI operation with:
27//      - `operation_id`
28//      - `method` (uppercase)
29//      - `path_template`
30//      - `path_params`
31//
32// This contract is produced by `build.rs` and consumed by this module via `include!`.
33include!(concat!(env!("OUT_DIR"), "/openapi_operations.rs"));
34
35/// Async IRI API client backed by the `OpenAPI` operation registry.
36///
37/// Use this when you want to call endpoints via `operation_id` rather than
38/// hard-coded URL paths.
39#[derive(Clone, Debug)]
40pub struct IriClient {
41    inner: ApiClient,
42}
43
44impl IriClient {
45    /// Creates a client with an explicit base URL.
46    pub fn new(base_url: impl AsRef<str>) -> Result<Self, ClientError> {
47        Ok(Self {
48            inner: ApiClient::new(base_url)?,
49        })
50    }
51
52    /// Creates a client using the first server URL from the `OpenAPI` spec.
53    pub fn from_openapi_default_server() -> Result<Self, ClientError> {
54        Self::new(openapi_default_server_url())
55    }
56
57    /// Returns a new client with a raw access token attached to all requests.
58    ///
59    /// This sets `Authorization: <token>` (without `Bearer ` prefix).
60    #[must_use]
61    pub fn with_authorization_token(mut self, token: impl Into<String>) -> Self {
62        self.inner = self.inner.with_authorization_token(token);
63        self
64    }
65
66    /// Returns all operations discovered from the `OpenAPI` spec.
67    pub fn operations() -> &'static [OperationDefinition] {
68        OPENAPI_OPERATIONS
69    }
70
71    /// Sends a request using a raw path and method.
72    ///
73    /// This bypasses operation-id lookup but keeps IRI client configuration.
74    pub async fn request_json_with_query(
75        &self,
76        method: Method,
77        path: &str,
78        query: &[(&str, &str)],
79        body: Option<Value>,
80    ) -> Result<Value, ClientError> {
81        self.inner
82            .request_json_with_query(method, path, query, body)
83            .await
84    }
85
86    /// Calls an endpoint by `OpenAPI` `operation_id`.
87    ///
88    /// `path_params` replaces `{param}` segments in the operation path template.
89    /// Missing required parameters return
90    /// [`ClientError::MissingPathParameter`].
91    pub async fn call_operation(
92        &self,
93        operation_id: &str,
94        path_params: &[(&str, &str)],
95        query: &[(&str, &str)],
96        body: Option<Value>,
97    ) -> Result<Value, ClientError> {
98        let operation = find_operation(operation_id)?;
99        let rendered_path = render_path(operation, path_params)?;
100        let method = parse_method(operation)?;
101        self.inner
102            .request_json_with_query(method, &rendered_path, query, body)
103            .await
104    }
105}
106
107/// Blocking IRI API client backed by the `OpenAPI` operation registry.
108///
109/// This is the synchronous counterpart of [`IriClient`].
110#[derive(Debug)]
111pub struct BlockingIriClient {
112    inner: BlockingApiClient,
113}
114
115impl BlockingIriClient {
116    /// Creates a client with an explicit base URL.
117    pub fn new(base_url: impl AsRef<str>) -> Result<Self, ClientError> {
118        Ok(Self {
119            inner: BlockingApiClient::new(base_url)?,
120        })
121    }
122
123    /// Creates a client using the first server URL from the `OpenAPI` spec.
124    pub fn from_openapi_default_server() -> Result<Self, ClientError> {
125        Self::new(openapi_default_server_url())
126    }
127
128    /// Returns a new client with a raw access token attached to all requests.
129    ///
130    /// This sets `Authorization: <token>` (without `Bearer ` prefix).
131    #[must_use]
132    pub fn with_authorization_token(mut self, token: impl Into<String>) -> Self {
133        self.inner = self.inner.with_authorization_token(token);
134        self
135    }
136
137    /// Returns all operations discovered from the `OpenAPI` spec.
138    pub fn operations() -> &'static [OperationDefinition] {
139        OPENAPI_OPERATIONS
140    }
141
142    /// Sends a request using a raw path and method.
143    ///
144    /// This bypasses operation-id lookup but keeps IRI client configuration.
145    pub fn request_json_with_query(
146        &self,
147        method: Method,
148        path: &str,
149        query: &[(&str, &str)],
150        body: Option<Value>,
151    ) -> Result<Value, ClientError> {
152        self.inner
153            .request_json_with_query(method, path, query, body)
154    }
155
156    /// Calls an endpoint by `OpenAPI` `operation_id`.
157    ///
158    /// `path_params` replaces `{param}` segments in the operation path template.
159    /// Missing required parameters return
160    /// [`ClientError::MissingPathParameter`].
161    pub fn call_operation(
162        &self,
163        operation_id: &str,
164        path_params: &[(&str, &str)],
165        query: &[(&str, &str)],
166        body: Option<Value>,
167    ) -> Result<Value, ClientError> {
168        let operation = find_operation(operation_id)?;
169        let rendered_path = render_path(operation, path_params)?;
170        let method = parse_method(operation)?;
171        self.inner
172            .request_json_with_query(method, &rendered_path, query, body)
173    }
174}
175
176/// Returns the default server URL from the `OpenAPI` spec.
177///
178/// This is the first element of the `OpenAPI` `servers` array when present.
179pub fn openapi_default_server_url() -> &'static str {
180    OPENAPI_DEFAULT_SERVER_URL
181}
182
183fn find_operation(operation_id: &str) -> Result<&'static OperationDefinition, ClientError> {
184    OPENAPI_OPERATIONS
185        .iter()
186        .find(|op| op.operation_id == operation_id)
187        .ok_or_else(|| ClientError::UnknownOperation(operation_id.to_owned()))
188}
189
190fn parse_method(operation: &OperationDefinition) -> Result<Method, ClientError> {
191    Method::from_bytes(operation.method.as_bytes())
192        .map_err(|_| ClientError::UnknownOperation(operation.operation_id.to_owned()))
193}
194
195fn render_path(
196    operation: &OperationDefinition,
197    path_params: &[(&str, &str)],
198) -> Result<String, ClientError> {
199    let mut rendered = operation.path_template.to_owned();
200
201    for required_param in operation.path_params {
202        let value = path_params
203            .iter()
204            .find(|(name, _)| name == required_param)
205            .map(|(_, value)| *value)
206            .ok_or_else(|| ClientError::MissingPathParameter {
207                operation_id: operation.operation_id.to_owned(),
208                parameter: (*required_param).to_owned(),
209            })?;
210
211        let placeholder = format!("{{{required_param}}}");
212        rendered = rendered.replace(&placeholder, &encode_path_segment(value));
213    }
214
215    Ok(rendered)
216}
217
218fn encode_path_segment(value: &str) -> String {
219    byte_serialize(value.as_bytes()).collect()
220}
221
222#[cfg(test)]
223mod tests {
224    use super::{IriClient, find_operation, render_path};
225    use crate::ClientError;
226
227    #[test]
228    fn operation_catalog_is_non_empty() {
229        assert!(!IriClient::operations().is_empty());
230    }
231
232    #[test]
233    fn render_path_replaces_required_path_params() {
234        let op = find_operation("getSite").expect("operation exists");
235        let path = render_path(op, &[("site_id", "site-1")]).expect("path renders");
236        assert_eq!(path, "/api/v1/facility/sites/site-1");
237    }
238
239    #[test]
240    fn render_path_reports_missing_parameter() {
241        let op = find_operation("getSite").expect("operation exists");
242        let error = render_path(op, &[]).expect_err("missing parameter should error");
243        match error {
244            ClientError::MissingPathParameter {
245                operation_id,
246                parameter,
247            } => {
248                assert_eq!(operation_id, "getSite");
249                assert_eq!(parameter, "site_id");
250            }
251            other => panic!("unexpected error: {other}"),
252        }
253    }
254}