Skip to main content

acorn/io/api/
raid.rs

1//! Module for communicating with RAiD service point API
2//!
3//! See <https://metadata.raid.org> for more information
4//!
5use crate::io::api::{require_non_empty_secret, ApiResult, Configuration, Endpoint, Fallback, Param, Params, RemoteResource};
6use crate::param;
7use crate::prelude::var;
8use crate::schema::pid::raid::Metadata;
9use crate::schema::validate::is_ror;
10use crate::util::constants::env::RAID_TOKEN_VARIABLE_NAMES;
11use crate::util::Label;
12use bon::Builder;
13use color_eyre::eyre::eyre;
14use dotenvy;
15use serde::{Deserialize, Serialize};
16use tracing::debug;
17use validator::Validate;
18
19/// Server response for RAiD API errors
20/// ### Example JSON output
21/// ```json
22/// {
23///   "type": "https://raid.org.au/errors#InvalidDateException",
24///   "title": "Invalid date",
25///   "status": 400,
26///   "detail": "2023-08-28; 2023-08; 2023 is an invalid date or has an unsupported format.",
27///   "instance": "https://raid.org.au",
28///   "failures": [
29///     {
30///       "fieldId": "access.statement.language.schemaUri",
31///       "errorType": "invalidValue",
32///       "message": "schema is unknown/unsupported"
33///     }
34///   ]
35/// }
36/// ```
37#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
38#[builder(start_fn = with_token, on(String, into))]
39pub struct ErrorResponse {
40    /// Error type
41    #[serde(rename = "type")]
42    pub error_type: String,
43    /// Error title
44    pub title: String,
45    /// HTTP status code
46    pub status: u16,
47    /// Detailed error message
48    pub detail: String,
49    /// Error instance
50    pub instance: String,
51    /// Validation failures associated with this error response
52    pub failures: Option<Vec<ErrorResponseFailure>>,
53}
54/// RAiD API error response failure
55/// ### Example JSON output
56/// ```json
57/// {
58///   "fieldId": "access.statement.language.schemaUri",
59///   "errorType": "invalidValue",
60///   "message": "schema is unknown/unsupported"
61/// }
62/// ```
63#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
64#[builder(start_fn = with_token, on(String, into))]
65pub struct ErrorResponseFailure {
66    /// Field associated with this validation failure
67    #[serde(rename = "fieldId")]
68    pub field_id: String,
69    /// Error type for this failure
70    #[serde(rename = "errorType")]
71    pub error_type: String,
72    /// Human-readable failure message
73    pub message: String,
74}
75/// RAiD API options
76///
77/// Configuration options for RAiD API operations
78#[derive(Builder, Clone, Debug)]
79#[builder(start_fn = with_token, on(String, into))]
80pub struct Options {
81    /// Bearer token for authentication
82    #[builder(start_fn)]
83    pub token: String,
84    /// Request body payload
85    pub body: Option<String>,
86    /// RAiD server domain (defaults to api.raid.org)
87    #[builder(default = String::from("api.raid.org"))]
88    pub domain: String,
89    /// Service point identifier
90    pub identifier: Option<String>,
91    /// Metadata for minting a new RAiD record
92    pub metadata: Option<Metadata>,
93    /// RAiD prefix (e.g., "10.83962")
94    pub prefix: Option<String>,
95    /// RAiD suffix (e.g., "000001")
96    pub suffix: Option<String>,
97    /// Custom API parameters to include in every request
98    #[builder(default = vec![])]
99    pub custom_params: Vec<Param>,
100}
101/// RAiD service point
102///
103/// Primary entry point for interfacing with a RAiD service point
104#[derive(Builder, Debug, Deserialize, Serialize, Validate)]
105#[builder(start_fn = with_token, on(String, into))]
106pub struct ServicePoint {
107    /// Service point identifier
108    pub identifier: String,
109    /// Service point endpoint URL
110    #[validate(url)]
111    pub url: String,
112    /// Service point bearer token for authentication
113    pub token: Option<String>,
114}
115/// RAiD service point response
116///
117/// Primary entry point for interfacing with a RAiD service point
118///
119/// ###Example Response
120/// ```json
121/// {
122///   "id": 20000033,
123///   "name": "Oak Ridge National Laboratory",
124///   "identifierOwner": "https://ror.org/01qz5mb56",
125///   "repositoryId": "ATHH.AZKTIF",
126///   "prefix": "10.83962",
127///   "groupId": "212777f8-ecfe-43a6-a809-e6a551d393e3",
128///   "techEmail": "research@ornl.gov",
129///   "adminEmail": "raid@ornl.gov",
130///   "enabled": true,
131///   "appWritesEnabled": true
132/// }
133/// ```
134#[derive(Debug, Deserialize, Serialize, Validate)]
135#[serde(deny_unknown_fields, rename_all = "camelCase")]
136pub struct ServicePointResponse {
137    /// Identifier of associated response
138    #[serde(alias = "id")]
139    pub identifier: u64,
140    /// Name of service point associated with response
141    pub name: String,
142    /// ROR of service point owner
143    #[validate(custom(function = "is_ror"))]
144    #[serde(alias = "identifierOwner")]
145    pub owner: String,
146    /// Repository identifier
147    #[serde(alias = "repositoryId")]
148    pub repository: Option<String>,
149    /// RAiD prefix used by service point
150    pub prefix: String,
151    /// Group identifier
152    #[serde(alias = "groupId")]
153    pub group: String,
154    /// Email address for technical support
155    #[serde(alias = "techEmail")]
156    pub tech_email: String,
157    /// Email address for administrative support
158    #[serde(alias = "adminEmail")]
159    pub admin_email: String,
160    /// Status of service point
161    pub enabled: bool,
162    /// Status of app writes
163    #[serde(alias = "appWritesEnabled")]
164    pub app_writes_enabled: bool,
165}
166impl Configuration for Options {
167    /// Build options from RAiD environment variables
168    /// - `RAID_API_TOKEN` -> `token`
169    /// - `RAID_SERVER_HOST` -> `domain` (defaults to api.raid.org when unset)
170    fn from_env() -> Self {
171        if let Err(why) = dotenvy::from_filename(".env") {
172            debug!("=> {} Load .env — {why}", Label::skip());
173        }
174        Self {
175            token: var("RAID_API_TOKEN").unwrap_or_default(),
176            body: None,
177            domain: var("RAID_SERVER_HOST").unwrap_or_else(|_| String::from("api.raid.org")),
178            prefix: None,
179            suffix: None,
180            identifier: None,
181            metadata: None,
182            custom_params: vec![],
183        }
184    }
185    /// Return a copy of options with request body payload set
186    fn with_body(self, value: impl Into<String>) -> Self {
187        Self {
188            body: Some(value.into()),
189            ..self
190        }
191    }
192    /// Return a copy of options with RAiD server domain set
193    fn with_domain(self, value: impl Into<String>) -> Self {
194        Self {
195            domain: value.into(),
196            ..self
197        }
198    }
199    /// Return a copy of options with service point identifier set
200    fn with_identifier(self, value: impl Into<String>) -> Self {
201        Self {
202            identifier: Some(value.into()),
203            ..self
204        }
205    }
206    /// Return the authentication token
207    fn token(&self) -> &str {
208        &self.token
209    }
210    /// Return the RAiD server domain
211    fn domain(&self) -> &str {
212        &self.domain
213    }
214    /// Return the optional service point identifier
215    fn identifier(&self) -> Option<&str> {
216        self.identifier.as_deref()
217    }
218    /// Return a copy of options with custom API parameters set
219    fn with_params(self, params: Vec<Param>) -> Self {
220        Self {
221            custom_params: params,
222            ..self
223        }
224    }
225    /// Return any custom API parameters
226    fn params(&self) -> &[Param] {
227        &self.custom_params
228    }
229}
230impl Options {
231    /// Return a copy of options with RAiD metadata set
232    pub fn with_metadata(self, value: impl Into<Metadata>) -> Self {
233        Self {
234            metadata: Some(value.into()),
235            ..self
236        }
237    }
238}
239/// Create a RAiD record with provided metadata
240pub async fn create_record(options: &Options) -> ApiResult<Metadata> {
241    let template = "raid::api";
242    let action = "record::create";
243    let path = format!("{template}::{action}");
244    match require_non_empty_secret(&options.token, &path, &RAID_TOKEN_VARIABLE_NAMES) {
245        | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(&options.domain)) {
246            | Ok(endpoint) => match &options.metadata {
247                | Some(value) => match serde_json::to_string(value) {
248                    | Ok(body) => {
249                        let params = Params::new()
250                            .with_auth(&token, None)
251                            .with(param!(Body, &body))
252                            .with_custom(options.params())
253                            .build();
254                        let response = endpoint.invoke(action, Some(params)).await;
255                        endpoint.handle_or::<Metadata, Fallback<ErrorResponse>>(response)
256                    }
257                    | Err(why) => Err(eyre!("Failed to serialize RAiD metadata to JSON — {why:#?}")),
258                },
259                | None => Err(eyre!("RAiD metadata is required for creating a record")),
260            },
261            | Err(why) => Err(why),
262        },
263        | Err(why) => Err(why),
264    }
265}
266/// Retrieve RAiD record(s) attached to associated service point of provided options
267///
268/// If no identifier is provided, will return all RAiD records attached to the associated service point.
269///
270/// ### Example
271/// ```ignore
272/// use acorn::io::api::raid;
273///
274/// let options = raid::Options::from_env();
275/// let result = raid::record(&options).await;
276/// ```
277pub async fn record(options: &Options) -> ApiResult<Vec<Metadata>> {
278    let template = "raid::api";
279    let action = "record";
280    let path = format!("{template}::{action}");
281    match require_non_empty_secret(&options.token, &path, &RAID_TOKEN_VARIABLE_NAMES) {
282        | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(&options.domain)) {
283            | Ok(endpoint) => match &options.identifier {
284                | Some(value) if !value.is_empty() => {
285                    let params = Params::new()
286                        .with_auth(&token, None)
287                        .with_template("identifier", Some(value))
288                        .with_custom(options.params())
289                        .build();
290                    let response = endpoint.invoke(action, Some(params)).await;
291                    endpoint.handle::<Metadata>(response).map(|r| vec![r])
292                }
293                | Some(_) | None => {
294                    let params = Params::new().with_auth(&token, None).with_custom(options.params()).build();
295                    let response = endpoint.invoke(action, Some(params)).await;
296                    endpoint.handle::<Vec<Metadata>>(response)
297                }
298            },
299            | Err(why) => Err(why),
300        },
301        | Err(why) => Err(why),
302    }
303}
304/// Retrieve all service point(s) for a given server
305///
306/// If no identifier is provided, will return all service points for the associated server
307///
308/// ### Example
309/// ```ignore
310/// use acorn::io::api::raid;
311///
312/// let options = raid::Options::from_env();
313/// let result = raid::service_point(&options).await;
314/// ```
315pub async fn service_point(options: &Options) -> ApiResult<Vec<ServicePointResponse>> {
316    let template = "raid::api";
317    let action = "service-point";
318    let path = format!("{template}::{action}");
319    match require_non_empty_secret(&options.token, &path, &RAID_TOKEN_VARIABLE_NAMES) {
320        | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(&options.domain)) {
321            | Ok(endpoint) => match &options.identifier {
322                | Some(value) if !value.is_empty() => {
323                    let params = Params::new()
324                        .with_auth(&token, None)
325                        .with_template("identifier", Some(value))
326                        .with_custom(options.params())
327                        .build();
328                    let response = endpoint.invoke(action, Some(params)).await;
329                    endpoint.handle::<ServicePointResponse>(response).map(|r| vec![r])
330                }
331                | Some(_) | None => {
332                    let params = Params::new().with_auth(&token, None).with_custom(options.params()).build();
333                    let response = endpoint.invoke(action, Some(params)).await;
334                    endpoint.handle::<Vec<ServicePointResponse>>(response)
335                }
336            },
337            | Err(why) => Err(why),
338        },
339        | Err(why) => Err(why),
340    }
341}