use crate::io::api::{self, Configuration, Endpoint, Param, Params, RemoteResource, ResponseContent};
use crate::io::ApiResult;
use crate::param;
use crate::util::constants::app::DEFAULT_ISNI_DOMAIN;
use crate::util::constants::env::{ISNI_API_TOKEN, ISNI_SERVER_HOST};
use acorn_core::options::{ApiExtension, ApiOptions};
use acorn_schema::namespaces::DEFAULT_ISNI_SCHEMA_URI;
use acorn_schema::pid::{normalize, PersistentIdentifier, PersistentIdentifierParse, ISNI};
use color_eyre::eyre::eyre;
use serde::{Deserialize, Serialize};
const MAXIMUM_RECORDS: &str = "2";
const RECORD_SCHEMA: &str = "isni-b";
const SRU_OPERATION: &str = "searchRetrieve";
const SRU_VERSION: &str = "1.1";
pub type Options = ApiOptions<Extension, Param>;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum IdentityKind {
Organisation,
PersonOrFiction,
}
#[derive(Clone, Debug, Default)]
pub struct Extension;
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Location {
pub country_code: Option<String>,
pub country_name: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Name {
pub forename: Option<String>,
pub main_name: Option<String>,
pub name_use: Option<String>,
pub surname: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Record {
pub canonical_isni: String,
pub confidence: Option<String>,
pub identity_kind: IdentityKind,
pub locations: Vec<Location>,
pub merged_isnis: Vec<String>,
pub names: Vec<Name>,
pub organization_types: Vec<String>,
pub sources: Vec<SourceIdentifier>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Resolution {
pub raw_xml: String,
pub record: Record,
pub requested_isni: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceIdentifier {
#[serde(alias = "sourceCode")]
pub source: Option<String>,
#[serde(alias = "sourceIdentifier")]
pub value: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Diagnostic {
message: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Diagnostics {
#[serde(default, rename = "diagnostic")]
values: Vec<Diagnostic>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EnquiryRecord {
data_confidence: Option<String>,
#[serde(default, rename = "mergedISNI")]
merged_isnis: Vec<String>,
organisation: Option<Organisation>,
#[serde(rename = "isniStatus", alias = "status")]
status: Option<String>,
#[serde(rename = "isniUnformatted")]
canonical_isni: Option<String>,
person_or_fiction: Option<PersonOrFiction>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Organisation {
#[serde(default, rename = "location")]
locations: Vec<Location>,
#[serde(default, rename = "organisationName")]
names: Vec<Name>,
#[serde(default, rename = "organisationType")]
organization_types: Vec<String>,
#[serde(default, rename = "source")]
sources: Vec<SourceIdentifier>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersonOrFiction {
#[serde(default, rename = "location")]
locations: Vec<Location>,
#[serde(default, rename = "personalName")]
names: Vec<Name>,
#[serde(default, rename = "source")]
sources: Vec<SourceIdentifier>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RecordData {
response_record: EnquiryRecord,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Records {
#[serde(default, rename = "record")]
values: Vec<SruRecord>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SearchRetrieveResponse {
diagnostics: Option<Diagnostics>,
number_of_records: usize,
records: Option<Records>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SruRecord {
record_data: RecordData,
}
impl ApiExtension for Extension {
fn default_domain() -> String {
DEFAULT_ISNI_DOMAIN.to_string()
}
fn env_domain_var() -> &'static str {
ISNI_SERVER_HOST
}
fn env_token_var() -> &'static str {
ISNI_API_TOKEN
}
}
impl EnquiryRecord {
fn canonical_identifier(&self) -> String {
self.canonical_isni
.as_deref()
.filter(|value| ISNI::is_valid(*value))
.map(ISNI::from_string)
.map(|value| value.identifier())
.unwrap_or_default()
}
fn contains(&self, requested: &str) -> bool {
self.canonical_identifier() == requested
|| self
.merged_isnis
.iter()
.map(ISNI::from_string)
.map(|value| value.identifier())
.any(|value| value == requested)
}
fn is_assigned(&self) -> bool {
self.status.as_deref().is_some_and(|value| value.eq_ignore_ascii_case("assigned"))
}
fn into_record(self) -> ApiResult<Record> {
let canonical_isni = self.canonical_identifier();
match (canonical_isni.is_empty(), self.person_or_fiction, self.organisation) {
| (true, _, _) => Err(eyre!("ISNI registry response is missing a canonical identifier")),
| (false, Some(person), _) => Ok(Record {
canonical_isni,
confidence: self.data_confidence,
identity_kind: IdentityKind::PersonOrFiction,
locations: person.locations,
merged_isnis: normalize::<ISNI>(self.merged_isnis),
names: person.names,
organization_types: Vec::new(),
sources: person.sources,
}),
| (false, None, Some(organisation)) => Ok(Record {
canonical_isni,
confidence: self.data_confidence,
identity_kind: IdentityKind::Organisation,
locations: organisation.locations,
merged_isnis: normalize::<ISNI>(self.merged_isnis),
names: organisation.names,
organization_types: organisation.organization_types,
sources: organisation.sources,
}),
| (false, None, None) => Err(eyre!("ISNI registry response is missing person or organization metadata")),
}
}
}
impl Record {
pub fn display_name(&self) -> Option<String> {
let complete_person_name = |name: &Name| {
name.forename
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.zip(name.surname.as_deref().map(str::trim).filter(|value| !value.is_empty()))
.map(|(forename, surname)| format!("{forename} {surname}"))
};
self.names
.iter()
.filter(|name| name.name_use.as_deref().is_some_and(|value| value.eq_ignore_ascii_case("public")))
.find_map(complete_person_name)
.or_else(|| self.names.iter().find_map(complete_person_name))
.or_else(|| {
self.names
.iter()
.filter_map(|name| name.main_name.as_deref().map(str::trim))
.find(|value| !value.is_empty())
.map(str::to_string)
})
}
pub fn url(&self) -> String {
format!("{DEFAULT_ISNI_SCHEMA_URI}/{}", self.canonical_isni)
}
}
impl SearchRetrieveResponse {
fn select(self, requested: &str) -> ApiResult<EnquiryRecord> {
let diagnostic = self
.diagnostics
.into_iter()
.flat_map(|value| value.values)
.find_map(|value| value.message)
.filter(|value| !value.trim().is_empty());
match diagnostic {
| Some(message) => Err(eyre!("ISNI registry diagnostic — {message}")),
| None => {
let matches = self
.records
.into_iter()
.flat_map(|records| records.values)
.map(|record| record.record_data.response_record)
.filter(|record| record.is_assigned() && record.contains(requested))
.collect::<Vec<_>>();
match (self.number_of_records, matches.len()) {
| (0, _) | (_, 0) => Err(eyre!("ISNI registry did not return an assigned record for {requested}")),
| (_, 1) => matches.into_iter().next().ok_or_else(|| eyre!("ISNI registry record selection failed")),
| _ => Err(eyre!("ISNI registry returned multiple assigned records for {requested}")),
}
}
}
}
}
pub async fn record(options: &Options) -> ApiResult<Resolution> {
let raw = options.identifier().unwrap_or_default();
let requested = ISNI::from_string(raw).identifier();
match ISNI::is_valid(raw) && !requested.is_empty() {
| false => Err(eyre!("Invalid ISNI identifier: {raw}")),
| true => {
let query = format!("pica.isn={requested}");
let params = Params::new()
.with(param!(KeyValuePair, "version", SRU_VERSION))
.with(param!(KeyValuePair, "operation", SRU_OPERATION))
.with(param!(KeyValuePair, "query", query.as_str()))
.with(param!(KeyValuePair, "recordSchema", RECORD_SCHEMA))
.with(param!(KeyValuePair, "maximumRecords", MAXIMUM_RECORDS))
.with_custom(options.params())
.build();
match Endpoint::from_template("isni").map(|endpoint| endpoint.with_domain(&options.domain)) {
| Ok(endpoint) => match endpoint.invoke("record", Some(params)).await {
| Ok(ResponseContent::Xml(raw_xml)) => api::parse_xml::<SearchRetrieveResponse>(&raw_xml)
.map_err(|why| eyre!("ISNI registry returned invalid resolver metadata — {why}"))
.and_then(|response| response.select(&requested))
.and_then(EnquiryRecord::into_record)
.map(|record| Resolution {
raw_xml,
record,
requested_isni: requested,
}),
| Ok(_) => Err(eyre!("ISNI registry returned a non-XML response")),
| Err(why) => Err(why),
},
| Err(why) => Err(why),
}
}
}
}