acorn/io/api/ror.rs
1//! Module for interacting with [ROR REST API](https://ror.readme.io/docs/rest-api)
2//!
3//! Provides types and functions for constructing API queries with field validation and output column selection
4//!
5//! The ROR REST API supports three primary parameters, each of which has different use cases:
6//! - `query`
7//! - [`crate::io::api::ParamStyle::KeyValuePair`] (value should be URL encoded)
8//! - Best for finding organization records by name keywords or by non-ROR identifiers (GRID, Wikidata, Crossref Funder ID, ISNI)
9//! - `filter`
10//! - [`crate::io::api::ParamStyle::QueryPair`]
11//! - Filter by status, type, country, or continent
12//! - `affiliation`
13//! - [`crate::io::api::ParamStyle::KeyValuePair`] (value should be URL encoded)
14//! - Best for large-scale programmatic matching of complex, unstructured text strings to ROR IDs
15//!
16//! ## Example Use
17//!
18//! ### Get API status
19//! ```ignore
20//! use acorn_lib::io::api;
21//!
22//! println!("ROR REST API is healthy: {}", api::ror::is_healthy().await);
23//! ```
24use crate::io::api::{ApiResult, Configuration, Param, Params, RemoteResource, TextResponse, INCLUDED_ENDPOINTS};
25use crate::prelude::var;
26use crate::schema::pid::{PersistentIdentifierParse, ROR};
27use crate::util::{Label, Searchable};
28use bon::Builder;
29use color_eyre::eyre::eyre;
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use serde_with::skip_serializing_none;
33use tracing::debug;
34use validator::Validate;
35
36/// ROR API options
37#[derive(Builder, Clone, Debug)]
38#[builder(start_fn = with_token, on(String, into))]
39pub struct Options {
40 /// Bearer token for authentication
41 #[builder(start_fn)]
42 pub token: String,
43 /// Request body payload
44 pub body: Option<String>,
45 /// ROR API domain (defaults to api.ror.org)
46 #[builder(default = String::from("api.ror.org"))]
47 pub domain: String,
48 /// ROR identifier
49 pub identifier: Option<String>,
50 /// Custom API parameters to include in every request
51 #[builder(default = vec![])]
52 pub custom_params: Vec<Param>,
53}
54impl Configuration for Options {
55 /// Build options from ROR environment variables
56 /// - `ROR_API_TOKEN` -> `token` (optional — public API)
57 /// - `ROR_SERVER_HOST` -> `domain` (defaults to api.ror.org)
58 fn from_env() -> Self {
59 if let Err(why) = dotenvy::from_filename(".env") {
60 debug!("=> {} Load .env — {why}", Label::skip());
61 }
62 Self {
63 token: var("ROR_API_TOKEN").unwrap_or_default(),
64 body: None,
65 domain: var("ROR_SERVER_HOST").unwrap_or_else(|_| String::from("api.ror.org")),
66 identifier: None,
67 custom_params: vec![],
68 }
69 }
70 fn with_body(self, value: impl Into<String>) -> Self {
71 Self {
72 body: Some(value.into()),
73 ..self
74 }
75 }
76 fn with_domain(self, value: impl Into<String>) -> Self {
77 Self {
78 domain: value.into(),
79 ..self
80 }
81 }
82 fn with_identifier(self, value: impl Into<String>) -> Self {
83 Self {
84 identifier: Some(value.into()),
85 ..self
86 }
87 }
88 fn token(&self) -> &str {
89 &self.token
90 }
91 fn domain(&self) -> &str {
92 &self.domain
93 }
94 fn identifier(&self) -> Option<&str> {
95 self.identifier.as_deref()
96 }
97 fn with_params(self, params: Vec<Param>) -> Self {
98 Self {
99 custom_params: params,
100 ..self
101 }
102 }
103 fn params(&self) -> &[Param] {
104 &self.custom_params
105 }
106}
107
108/// ROR API status response is just "OK"
109pub type StatusResponse = TextResponse;
110/// External identifier type for ROR API search results
111#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)]
112#[serde(rename_all = "lowercase")]
113pub enum ExternalIdentifierType {
114 /// An identifier from [Wikidata](https://www.wikidata.org/wiki/)
115 #[default]
116 Wikidata,
117 /// An identifier from the [International Standard Name Identifier (ISNI)](https://isni.org/) system
118 Isni,
119 /// An identifier from the [FundRef](https://www.crossref.org/services/funder-registry/) system
120 Fundref,
121 /// An identifier from the [Global Research Identifier Database (GRID)](https://grid.ac/)
122 ///
123 /// <div class="warning">The GRID dataset is no longer being updated and the GRID identifiers are being deprecated in favor of ROR IDs. However, many ROR records still include GRID identifiers as external identifiers, so this type is included here for completeness.</div>
124 Grid,
125}
126/// Link type
127#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)]
128#[serde(rename_all = "lowercase")]
129pub enum LinkType {
130 /// Organization's website
131 Website,
132 /// Organization's Wikipedia page
133 Wikipedia,
134 /// Organization's Wikidata page
135 Wikidata,
136}
137/// ROR organization name type
138#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)]
139#[serde(rename_all = "snake_case")]
140pub enum OrganizationNameType {
141 /// Name of the organization displayed most prominently on records in ROR's web search
142 #[default]
143 RorDisplay,
144 /// One or more official acronyms or initialisms for the organization, typically consisting of the first letters of the words in the organization name
145 Acronym,
146 /// An alternative name for the organization
147 Alias,
148 /// Displays equivalent forms of the organization name in one or more languages
149 Label,
150}
151/// ROR organization type
152///
153/// See `https://ror.readme.io/docs/ror-data-structure#types` for more information
154#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)]
155#[serde(rename_all = "lowercase")]
156pub enum OrganizationType {
157 /// A specialized facility where research takes place, such as a laboratory or telescope or dedicated research area
158 #[default]
159 Facility,
160 /// An organization involved in stewarding research and cultural heritage materials. Includes libraries, museums, and zoos
161 Archive,
162 /// A private for-profit corporate entity involved in conducting or sponsoring research
163 Company,
164 /// A university or similar institution involved in providing education and educating/employing researchers
165 Education,
166 /// An organization that awards research funds or provides in-kind support
167 Funder,
168 /// A governmental organization
169 Government,
170 /// A medical care facility such as hospital or medical clinic
171 Healthcare,
172 /// A non-profit and non-governmental organization involved in conducting or funding research
173 Nonprofit,
174 /// An organization that does not fit into other categories
175 Other,
176}
177/// Type of relationship between an organization and another organization
178///
179/// See `https://ror.readme.io/docs/ror-data-structure#relationships` for more information
180#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)]
181#[serde(rename_all = "lowercase")]
182pub enum RelationshipType {
183 /// Indicate a relationship where the parent exercises control (supervisory, administrative, or financial) over the child
184 Child,
185 /// The child is a component of the parent entity, like a research center within a university
186 Parent,
187 /// Denotes less defined connections, such as resource sharing or participation without direct control
188 Related,
189 /// Indicates that an organization continues the work of a predecessor organization that has ceased operations
190 Successor,
191 /// Track organizational continuity and are used when an entity ceases operations or to redirect from erroneous records to correct ones
192 Predecessor,
193}
194/// ROR schema version
195#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)]
196pub enum SchemaVersion {
197 /// Permanently sunset in December 2025 and are no longer supported
198 #[serde(rename = "1.0")]
199 V1_0,
200 /// Recommended schema (major) version for use in production applications
201 #[serde(rename = "2.0")]
202 V2_0,
203 /// Current recommended, stable schema version — used by ROR API
204 #[serde(rename = "2.1")]
205 V2_1,
206}
207/// ROR item status
208///
209/// See `https://ror.readme.io/docs/ror-data-structure#status` for more information
210#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)]
211#[serde(rename_all = "lowercase")]
212pub enum Status {
213 /// An organization that is actively producing research outputs
214 #[default]
215 Active,
216 /// An organization that has ceased operation or producing research outputs
217 Inactive,
218 /// A record that was created in error, such as a duplicate record or a record that is not in scope for the registry
219 Withdrawn,
220}
221/// Container for administrative information
222#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
223pub struct AdminField {
224 /// Date the ROR record was created
225 pub created: DateField,
226 /// Date the ROR record was last modified
227 pub last_modified: DateField,
228}
229/// Container for date and schema version information
230#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
231pub struct DateField {
232 /// Date in ISO 8601 format (YYYY-MM-DD)
233 pub date: String,
234 /// Schema version of the date field
235 pub schema_version: SchemaVersion,
236}
237/// Container for information about identifiers in other systems ("external identifiers") that are associated with a given organization in ROR
238#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
239pub struct ExternalIdentifierField {
240 /// All external identifiers of the type specified in `external_ids.type`
241 pub all: Vec<String>,
242 /// Preferred external identifier for the organization of the specified type
243 pub preferred: Option<String>,
244 /// Identifier system that the identifiers in `external_ids.all` and `external_ids.preferred` belong to
245 #[serde(rename = "type")]
246 pub external_identifier_type: ExternalIdentifierType,
247}
248/// Link data for a given organization in ROR
249#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
250pub struct LinkField {
251 /// URL for the link
252 pub value: String,
253 /// Type of the link
254 #[serde(rename = "type")]
255 pub link_type: LinkType,
256}
257/// Location details field
258/// ### Example
259/// ```json
260/// {
261/// "continent_code": "NA",
262/// "continent_name": "North America",
263/// "country_code": "US",
264/// "country_name": "United States",
265/// "country_subdivision_code": "TN",
266/// "country_subdivision_name": "Tennessee",
267/// "lat": 36.01036,
268/// "lng": -84.26964,
269/// "name": "Oak Ridge"
270/// }
271/// ```
272#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
273pub struct LocationDetailsField {
274 /// 2-character code for the continent that the organization is located in
275 pub continent_code: String,
276 /// Name of the continent that the organization is located in
277 pub continent_name: String,
278 /// Valid 2-character ISO 3166-2 country code (uppercase)
279 pub country_code: String,
280 /// Name of the country that the organization is located in
281 pub country_name: String,
282 /// Country subdivision code (derived from ISO-3166-2)
283 pub country_subdivision_code: String,
284 /// Country subdivision name (derived from ISO-3166-2)
285 pub country_subdivision_name: String,
286 /// Latitude
287 pub lat: f64,
288 /// Longitude
289 pub lng: f64,
290 /// Name of the location (e.g., city or town)
291 pub name: String,
292}
293/// Container for location information
294#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
295pub struct LocationField {
296 /// GeoNames identifier for the city or most granular administrative region that the organization is located in
297 /// ### Note
298 /// > For most records, this ID represents a city, but for organizations not located in a city, the value in this field is ID of the most granular administrative region for the location available in [GeoNames](https://www.geonames.org/)
299 ///
300 /// See [`crate::io::api::geonames`] for additional GeoNames API functionality.
301 #[serde(rename = "geonames_id")]
302 pub identifier: u64,
303 /// Container for details derived from the GeoNames record for the GeoNames ID specified in `geonames_id`
304 #[serde(rename = "geonames_details")]
305 pub details: LocationDetailsField,
306}
307/// Individual metadata item containing aggregation information
308#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
309pub struct MetadataItem {
310 /// Identifier for the metadata item
311 #[serde(rename = "id")]
312 pub identifier: String,
313 /// Display title for the metadata item
314 pub title: String,
315 /// Count of organizations matching this metadata value in the search results
316 pub count: usize,
317}
318/// Container for name information
319#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
320pub struct NameField {
321 /// The name of the organization
322 pub value: String,
323 /// The language of the name, in ISO 639-3 format (three-letter code)
324 pub lang: Option<String>,
325 /// Type(s) associated with the name value
326 /// ### Notes
327 /// - Each name must have at least 1 type
328 /// - Exactly one name must have `ror_display` in its types
329 pub types: Vec<OrganizationNameType>,
330}
331/// Container for relationship information
332#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
333pub struct RelationshipField {
334 /// Name of another organization identified in `relationships.id`, which is related to the organization
335 pub label: String,
336 /// Type of relationship between the organization and another organization identified in `relationships.id`
337 #[serde(rename = "type")]
338 pub relationship_type: RelationshipType,
339 /// ROR ID of the related organization
340 #[serde(rename = "id")]
341 pub identifier: String,
342}
343/// Metadata for a search query to the ROR API
344/// ### Example
345/// ```json
346/// {
347/// "types": [
348/// {
349/// "id": "facility",
350/// "title": "facility",
351/// "count": 1
352/// },
353/// {
354/// "id": "government",
355/// "title": "government",
356/// "count": 1
357/// }
358/// ],
359/// "countries": [
360/// {
361/// "id": "us",
362/// "title": "United States",
363/// "count": 1
364/// }
365/// ],
366/// "continents": [
367/// {
368/// "id": "na",
369/// "title": "North America",
370/// "count": 1
371/// }
372/// ],
373/// "statuses": [
374/// {
375/// "id": "inactive",
376/// "title": "inactive",
377/// "count": 1
378/// }
379/// ]
380/// }
381/// ```
382#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
383pub struct SearchMetadata {
384 /// Listing and count of organization types contained within search results
385 pub types: Vec<MetadataItem>,
386 /// Listing and count of countries contained within search results
387 pub countries: Vec<MetadataItem>,
388 /// Listing and count of continents contained within search results
389 pub continents: Vec<MetadataItem>,
390 /// Listing and count of organization statuses contained within search results
391 pub statuses: Vec<MetadataItem>,
392}
393/// ROR API search response
394#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
395pub struct SearchResponse {
396 /// Number of results found
397 pub number_of_results: usize,
398 /// Time taken to perform the search
399 pub time_taken: usize,
400 /// List of search result items
401 #[builder(default)]
402 pub items: Vec<SingleRecord>,
403 /// Metadata about the search results
404 pub meta: SearchMetadata,
405}
406/// ROR API search result item
407///
408/// See `https://ror.readme.io/docs/ror-data-structure` for more details on the ROR API response fields.
409#[skip_serializing_none]
410#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
411#[serde(rename_all = "snake_case")]
412pub struct SingleRecord {
413 /// Unique ROR ID for the organization
414 #[serde(rename = "id")]
415 pub identifier: String,
416 /// Fully-qualified domains that belong to the organization, using the smallest number of labels needed encompass the organization (excluding www)
417 ///
418 /// <div class="warning">Values cannot be subdomains of other domains listed in the same ROR record</div>
419 pub domains: Vec<String>,
420 /// Year the organization was established (CE)
421 pub established: Option<usize>,
422 /// External identifiers associated with the organization, such as Wikidata or ISNI
423 pub external_ids: Vec<ExternalIdentifierField>,
424 /// Links associated with the organization, such as the organization's website or Wikipedia page
425 pub links: Vec<LinkField>,
426 /// Associated locations for the organization
427 ///
428 /// Location data comes from [GeoNames](https://www.geonames.org/) (see also [`crate::io::api::geonames`])
429 pub locations: Vec<LocationField>,
430 /// Names associated with the organization
431 pub names: Vec<NameField>,
432 /// Relationships with other organizations
433 pub relationships: Vec<RelationshipField>,
434 /// Status of associated ROR organization
435 pub status: Status,
436 /// Types of associated ROR organization
437 pub types: Vec<OrganizationType>,
438}
439/// Check if API is healthy
440/// ### Example
441/// ```ignore
442/// use acorn_lib::io::api;
443///
444/// println!("ROR API is healthy: {}", api::ror::is_healthy().await);
445/// ```
446pub async fn is_healthy() -> bool {
447 match status().await {
448 | Ok(StatusResponse { content, .. }) => content.eq_ignore_ascii_case("OK"),
449 | Err(_) => false,
450 }
451}
452/// Get a single ROR record by identifier from options
453/// ### Note
454/// Will only make a request if the identifier is valid ROR
455///
456/// ### Example
457/// ```ignore
458/// use acorn_lib::io::api::ror::{self, Options, SingleRecord};
459///
460/// let options = Options::from_env().with_identifier("01qz5mb56");
461/// let result: ApiResult<SingleRecord> = ror::record(&options).await;
462/// ```
463pub async fn record(options: &Options) -> ApiResult<SingleRecord> {
464 let value = options.identifier().unwrap_or_default().to_string();
465 if value.is_empty() || ROR::is_valid(&value) {
466 let name = "ROR";
467 let action = "search";
468 let params = Params::new()
469 .with_template("identifier", Some(&value))
470 .with_custom(options.params())
471 .build();
472 let data = Some(params);
473 match INCLUDED_ENDPOINTS.find_by_name(name) {
474 | Some(endpoint) => {
475 let response = endpoint.invoke(action, data).await;
476 endpoint.handle::<SingleRecord>(response)
477 }
478 | None => Err(eyre!("{name} API endpoint not found")),
479 }
480 } else {
481 Err(eyre!("Invalid ROR identifier: {}", &value))
482 }
483}
484/// Search the ROR API with given options containing query parameters and output fields
485///
486/// ### Example
487/// ```ignore
488/// use acorn::param;
489/// use acorn::io::api::ror::{self, Options, SearchResponse};
490///
491/// let options = Options::from_env()
492/// .with_params(vec![
493/// param!(FieldList, "query", "Oak Ridge"),
494/// param!(QueryPair, "filter", ("status", "inactive")),
495/// ]);
496/// let result: ApiResult<SearchResponse> = ror::search(&options).await;
497/// ```
498pub async fn search(options: &Options) -> ApiResult<SearchResponse> {
499 let name = "ROR";
500 let action = "search";
501 let params = Params::new().with_custom(options.params()).build();
502 let data = Some(params);
503 match INCLUDED_ENDPOINTS.find_by_name(name) {
504 | Some(endpoint) => {
505 let response = endpoint.invoke(action, data).await;
506 endpoint.handle::<SearchResponse>(response)
507 }
508 | None => Err(eyre!("{name} API endpoint not found")),
509 }
510}
511/// Get status of ROR API
512pub async fn status() -> ApiResult<StatusResponse> {
513 let name = "ROR";
514 let action = "status";
515 let data = None;
516 match INCLUDED_ENDPOINTS.find_by_name(name) {
517 | Some(endpoint) => {
518 let response = endpoint.invoke(action, data).await;
519 endpoint.handle::<StatusResponse>(response)
520 }
521 | None => Err(eyre!("{name} API endpoint not found")),
522 }
523}