Skip to main content

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}