use std::collections::{HashMap, HashSet};
use std::io::{Read, Write};
use std::time::Duration;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use crate::client::DspClient;
use crate::client::builtins::builtin_field_value_type;
use crate::client::jwt::extract_exp;
use crate::diagnostic::Diagnostic;
use crate::model::auth::LoginResponse;
use crate::model::resource::{DatePoint, DateValue, FieldValues, FileValue, Value, ValueContent};
use crate::model::{
Cardinality, CreateDumpOutcome, DataModel, DataModelDetail, DataModelStructure, DataModelSummary, DumpStatus,
DumpTask, Field, LocalizedText, Project, ProjectDescription, ProjectDetail, ProjectRef, Relation, RelationKind,
Representation, ResourceAccess, ResourceDetail, ResourcePage, ResourceSummary, ResourceTypeDetail,
ResourceTypeSummary, ResourceVisibility, ValueType, Vocabulary, VocabularyHeader, VocabularyNode, VocabularyTree,
};
#[derive(serde::Deserialize)]
struct LoginApiResponse {
token: String,
}
#[derive(serde::Deserialize)]
struct ProjectGetApiResponse {
project: ProjectApiDto,
}
#[derive(serde::Deserialize)]
struct ProjectApiDto {
id: String,
shortcode: String,
shortname: String,
}
#[derive(serde::Deserialize)]
struct DataTaskStatusApiResponse {
id: String,
status: String,
#[serde(default, rename = "errorMessage")]
error_message: Option<String>,
#[serde(default, rename = "createdAt")]
created_at: Option<String>,
}
#[derive(serde::Deserialize)]
struct V3ErrorBody {
#[serde(default)]
errors: Vec<V3ErrorItem>,
}
#[derive(serde::Deserialize)]
struct V3ErrorItem {
code: String,
#[serde(default)]
details: std::collections::HashMap<String, String>,
}
#[derive(serde::Deserialize)]
struct OntologyAndResourceClassesDto {
#[serde(rename = "classesAndCount", default)]
classes_and_count: Vec<ClassAndCountDto>,
}
#[derive(serde::Deserialize)]
struct ClassAndCountDto {
#[serde(rename = "resourceClass")]
resource_class: ResourceClassRefDto,
#[serde(rename = "itemCount")]
item_count: u64,
}
#[derive(serde::Deserialize)]
struct ResourceClassRefDto {
iri: String,
}
#[derive(serde::Deserialize)]
struct ProjectsListApiResponse {
projects: Vec<ProjectListItemDto>,
}
#[derive(serde::Deserialize)]
struct ProjectListItemDto {
id: String,
shortname: String,
shortcode: String,
#[serde(default)]
longname: Option<String>,
#[serde(default)]
ontologies: Vec<String>,
}
#[derive(serde::Deserialize)]
struct ProjectDetailApiResponse {
project: ProjectDetailApiDto,
}
#[derive(serde::Deserialize)]
struct ProjectDetailApiDto {
id: String,
shortcode: String,
shortname: String,
#[serde(default)]
longname: Option<String>,
#[serde(default)]
description: Vec<ProjectDescriptionDto>,
#[serde(default)]
keywords: Vec<String>,
#[serde(default)]
ontologies: Vec<String>,
}
#[derive(serde::Deserialize)]
struct ProjectDescriptionDto {
value: String,
#[serde(default)]
language: Option<String>,
}
#[derive(serde::Deserialize)]
struct OntologyMetadataResponse {
#[serde(rename = "@graph")]
graph: Option<Vec<OntologyMetadataDto>>,
#[serde(rename = "@id")]
id: Option<String>,
#[serde(rename = "rdfs:label")]
label: Option<String>,
#[serde(rename = "knora-api:lastModificationDate", default)]
last_modification_date: Option<LastModDto>,
}
#[derive(serde::Deserialize)]
struct OntologyMetadataDto {
#[serde(rename = "@id")]
id: String,
#[serde(rename = "rdfs:label")]
label: Option<String>,
#[serde(rename = "knora-api:lastModificationDate", default)]
last_modification_date: Option<LastModDto>,
}
#[derive(serde::Deserialize)]
struct LastModDto {
#[serde(rename = "@value")]
value: String,
}
#[derive(serde::Deserialize)]
struct OntologyAllEntitiesResponse {
#[serde(rename = "@id")]
id: String,
#[serde(rename = "rdfs:label")]
label: Option<String>,
#[serde(rename = "knora-api:lastModificationDate", default)]
last_modification_date: Option<LastModDto>,
#[serde(rename = "@graph", default)]
graph: Vec<OntologyEntityDto>,
#[serde(rename = "@context", default)]
context: HashMap<String, serde_json::Value>,
}
#[derive(serde::Deserialize)]
struct OntologyEntityDto {
#[serde(rename = "@id")]
id: String,
#[serde(rename = "rdfs:label")]
label: Option<String>,
#[serde(rename = "knora-api:isResourceClass", default)]
is_resource_class: bool,
#[serde(rename = "rdfs:subClassOf", default)]
sub_class_of: Vec<serde_json::Value>,
#[serde(rename = "knora-api:objectType")]
object_type: Option<ObjectTypeDto>,
#[serde(rename = "knora-api:isLinkProperty", default)]
is_link_property: bool,
#[serde(rename = "knora-api:isLinkValueProperty", default)]
is_link_value_property: bool,
#[serde(rename = "knora-api:isResourceProperty", default)]
is_resource_property: bool,
}
#[derive(serde::Deserialize, Clone)]
struct ObjectTypeDto {
#[serde(rename = "@id")]
id: String,
}
struct ExportExists<'a> {
id: Option<&'a str>,
project_iri: Option<&'a str>,
}
impl V3ErrorBody {
fn export_exists(&self) -> Option<ExportExists<'_>> {
self.errors.iter().find(|e| e.code == "export_exists").map(|e| ExportExists {
id: e.details.get("id").map(String::as_str),
project_iri: e.details.get("projectIri").map(String::as_str),
})
}
}
impl DataTaskStatusApiResponse {
fn into_dump_task(self) -> Result<DumpTask, Diagnostic> {
validate_dump_id(&self.id)?;
let status = match self.status.as_str() {
"in_progress" => DumpStatus::InProgress,
"completed" => DumpStatus::Completed,
"failed" => DumpStatus::Failed,
other => {
return Err(Diagnostic::ServerError(format!(
"server returned unknown dump status: '{other}'"
)));
}
};
let error_message = self.error_message.map(|raw| {
let truncated = if raw.chars().count() > 500 {
raw.chars().take(500).collect::<String>()
} else {
raw
};
tracing::trace!("dump task error_message (truncated): {}", truncated);
truncated
});
let created_at = self.created_at.and_then(|s| match chrono::DateTime::parse_from_rfc3339(&s) {
Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
Err(_) => {
tracing::debug!(raw = %s, "dump task createdAt could not be parsed as RFC3339; using None");
None
}
});
Ok(DumpTask { id: self.id, status, error_message, created_at })
}
}
#[derive(serde::Deserialize)]
struct ListsListApiResponse {
lists: Vec<ListSummaryDto>,
}
#[derive(serde::Deserialize)]
struct ListSummaryDto {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
labels: Vec<ListLabelDto>,
#[serde(default)]
comments: Vec<ListLabelDto>,
}
#[derive(serde::Deserialize, Clone)]
struct ListLabelDto {
value: String,
#[serde(default)]
language: Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum ListGetResponseDto {
Root(ListRootResponseDto),
Node(ListNodeGetResponseDto),
}
#[derive(serde::Deserialize)]
struct ListRootResponseDto {
list: ListRootDto,
}
#[derive(serde::Deserialize)]
struct ListRootDto {
listinfo: ListInfoDto,
#[serde(default)]
children: Vec<ListNodeDto>,
}
#[derive(serde::Deserialize)]
struct ListInfoDto {
id: String,
#[serde(rename = "projectIri")]
project_iri: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
labels: Vec<ListLabelDto>,
#[serde(default)]
comments: Vec<ListLabelDto>,
}
#[derive(serde::Deserialize)]
struct ListNodeGetResponseDto {
node: ListNodeGetDto,
}
#[derive(serde::Deserialize)]
struct ListNodeGetDto {
nodeinfo: ListNodeInfoDto,
}
#[derive(serde::Deserialize)]
struct ListNodeInfoDto {
#[serde(rename = "hasRootNode")]
has_root_node: String,
}
#[derive(serde::Deserialize)]
struct ListNodeDto {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
labels: Vec<ListLabelDto>,
#[serde(default)]
comments: Vec<ListLabelDto>,
position: i32,
#[serde(default)]
children: Vec<ListNodeDto>,
}
fn into_localized_texts(dtos: Vec<ListLabelDto>) -> Vec<LocalizedText> {
dtos.into_iter()
.map(|d| LocalizedText { value: d.value, language: d.language })
.collect()
}
fn build_vocabulary_tree(list: ListRootDto, requested_node: Option<String>) -> VocabularyTree {
VocabularyTree {
root: VocabularyHeader {
iri: list.listinfo.id,
name: list.listinfo.name,
labels: into_localized_texts(list.listinfo.labels),
comments: into_localized_texts(list.listinfo.comments),
},
children: convert_list_nodes(list.children),
project_iri: list.listinfo.project_iri,
requested_node,
}
}
struct ListNodeConversionFrame {
header: VocabularyHeader,
position: i32,
remaining_children: std::collections::VecDeque<ListNodeDto>,
converted_children: Vec<VocabularyNode>,
}
fn convert_list_nodes(dtos: Vec<ListNodeDto>) -> Vec<VocabularyNode> {
fn dto_to_frame(dto: ListNodeDto) -> ListNodeConversionFrame {
let mut children = dto.children;
children.sort_by_key(|c| c.position);
ListNodeConversionFrame {
header: VocabularyHeader {
iri: dto.id,
name: dto.name,
labels: into_localized_texts(dto.labels),
comments: into_localized_texts(dto.comments),
},
position: dto.position,
remaining_children: children.into(),
converted_children: Vec::new(),
}
}
let mut top_level = dtos;
top_level.sort_by_key(|d| d.position);
let mut top_level: std::collections::VecDeque<ListNodeDto> = top_level.into();
let mut result: Vec<VocabularyNode> = Vec::new();
let mut stack: Vec<ListNodeConversionFrame> = Vec::new();
loop {
let next_dto = match stack.last_mut() {
Some(frame) => frame.remaining_children.pop_front(),
None => top_level.pop_front(),
};
match next_dto {
Some(dto) => stack.push(dto_to_frame(dto)),
None => {
match stack.pop() {
Some(frame) => {
let node = VocabularyNode {
header: frame.header,
position: frame.position,
children: frame.converted_children,
};
match stack.last_mut() {
Some(parent) => parent.converted_children.push(node),
None => result.push(node),
}
}
None => break,
}
}
}
}
result
}
fn identifier_key(user: &str) -> &'static str {
if user.starts_with("http://") || user.starts_with("https://") {
"iri"
} else if user.contains('@') {
"email"
} else {
"username"
}
}
enum ProjectIdent<'a> {
Iri(&'a str),
Shortcode(&'a str),
Shortname(&'a str),
}
fn classify(project: &str) -> ProjectIdent<'_> {
if project.starts_with("http://") || project.starts_with("https://") {
ProjectIdent::Iri(project)
} else if project.len() == 4 && project.chars().all(|c| c.is_ascii_hexdigit()) {
ProjectIdent::Shortcode(project)
} else {
ProjectIdent::Shortname(project)
}
}
fn enc(iri: &str) -> String {
utf8_percent_encode(iri, NON_ALPHANUMERIC).to_string()
}
fn map_unexpected_status(status: reqwest::StatusCode, url: &str) -> Diagnostic {
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
Diagnostic::AuthRequired(
"your token may be missing, expired, or lack permission — run \
`dsp auth login` to (re)authenticate"
.into(),
)
} else if status.is_server_error() {
Diagnostic::ServerError(format!("server returned {status} for {url}"))
} else {
Diagnostic::ServerError(format!("unexpected status {status} for {url}"))
}
}
fn validate_dump_id(id: &str) -> Result<(), Diagnostic> {
if id.is_empty()
|| id.len() > 256 || !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
let preview: String = id.chars().take(40).collect();
let suffix = if id.chars().count() > 40 { "…" } else { "" };
return Err(Diagnostic::ServerError(format!(
"server returned an invalid dump id: '{preview}{suffix}'"
)));
}
Ok(())
}
fn project_lookup_url(base: &str, project: &str) -> String {
match classify(project) {
ProjectIdent::Shortcode(code) => {
format!("{base}/admin/projects/shortcode/{code}")
}
ProjectIdent::Shortname(name) => {
format!("{base}/admin/projects/shortname/{name}")
}
ProjectIdent::Iri(iri) => {
format!("{base}/admin/projects/iri/{}", enc(iri))
}
}
}
fn is_safe_shortcode(s: &str) -> bool {
!s.is_empty() && s.len() <= 32 && s.chars().all(|c| c.is_ascii_alphanumeric())
}
fn local_name(id: &str) -> &str {
id.rsplit(['#', '/', ':']).next().unwrap_or(id)
}
fn expand_class_id(id: &str, prefixes: &HashMap<String, String>) -> (String, String) {
let name = local_name(id).to_string();
let iri = match id.split_once(':') {
Some((prefix, local)) if !local.starts_with("//") => prefixes
.get(prefix)
.map(|ns| format!("{ns}{local}"))
.unwrap_or_else(|| id.to_string()),
_ => id.to_string(), };
(name, iri)
}
pub(crate) fn data_model_name_from_iri(iri: &str) -> String {
let t = iri.trim_end_matches('/');
let t = t.strip_suffix("/v2").unwrap_or(t);
t.rsplit('/').next().unwrap_or(t).to_string()
}
const SYSTEM_PREFIXES: &[&str] = &[
"knora-api",
"knora-base",
"rdf",
"rdfs",
"owl",
"salsah-gui",
"standoff",
"xsd",
];
const FILE_VALUE_PROPS: &[(&str, Representation)] = &[
("hasStillImageFileValue", Representation::StillImage),
("hasMovingImageFileValue", Representation::MovingImage),
("hasAudioFileValue", Representation::Audio),
("hasDocumentFileValue", Representation::Document),
("hasArchiveFileValue", Representation::Archive),
("hasTextFileValue", Representation::Text),
];
const MAX_SIBLING_FETCHES: usize = 16;
fn is_system_prefix(prefix: &str) -> bool {
SYSTEM_PREFIXES.contains(&prefix)
}
fn map_object_type_to_value_type(local: &str) -> ValueType {
match local {
"TextValue" => ValueType::Text,
"IntValue" => ValueType::Integer,
"DecimalValue" => ValueType::Decimal,
"BooleanValue" => ValueType::Boolean,
"DateValue" => ValueType::Date,
"TimeValue" => ValueType::Time,
"UriValue" => ValueType::Uri,
"ColorValue" => ValueType::Color,
"GeonameValue" => ValueType::Geoname,
"ListValue" => ValueType::VocabularyItem,
"StillImageFileValue" => ValueType::StillImage,
"MovingImageFileValue" => ValueType::MovingImage,
"AudioFileValue" => ValueType::Audio,
"DocumentFileValue" => ValueType::Document,
"ArchiveFileValue" => ValueType::Archive,
other => ValueType::Other(object_type_to_kebab(other)),
}
}
fn object_type_to_kebab(local: &str) -> String {
let base = local.strip_suffix("Value").unwrap_or(local);
let mut result = String::with_capacity(base.len() + 4);
let chars: Vec<char> = base.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if i > 0 && ch.is_uppercase() {
if chars[i - 1].is_lowercase() {
result.push('-');
}
}
result.push(ch);
}
result.to_lowercase()
}
fn decode_cardinality(restriction: &serde_json::Value) -> Cardinality {
let as_u64 = |key: &str| -> Option<u64> { restriction.get(key).and_then(serde_json::Value::as_u64) };
if let Some(v) = as_u64("owl:cardinality") {
if v == 1 {
return Cardinality::One;
}
tracing::warn!(
value = v,
"owl:cardinality had unexpected value (expected 1); falling back to ZeroOrMore"
);
return Cardinality::ZeroOrMore;
}
if let Some(v) = as_u64("owl:maxCardinality") {
if v == 1 {
return Cardinality::ZeroOrOne;
}
tracing::warn!(
value = v,
"owl:maxCardinality had unexpected value (expected 1); falling back to ZeroOrMore"
);
return Cardinality::ZeroOrMore;
}
if let Some(v) = as_u64("owl:minCardinality") {
return match v {
0 => Cardinality::ZeroOrMore,
1 => Cardinality::OneOrMore,
other => {
tracing::warn!(
value = other,
"owl:minCardinality had unexpected value (expected 0 or 1); falling back to ZeroOrMore"
);
Cardinality::ZeroOrMore
}
};
}
tracing::warn!("owl:Restriction has no recognized cardinality key; falling back to ZeroOrMore");
Cardinality::ZeroOrMore
}
fn detect_representation(restriction_prop_locals: &[&str]) -> Option<Representation> {
for local in restriction_prop_locals {
for (file_val_local, repr) in FILE_VALUE_PROPS {
if local == file_val_local {
return Some(*repr);
}
}
}
None
}
fn curie_prefix(id: &str) -> Option<&str> {
id.split_once(':')
.filter(|(_, local)| !local.starts_with("//"))
.map(|(prefix, _)| prefix)
}
#[derive(serde::Deserialize)]
struct ResourceListDto {
#[serde(rename = "@graph", default)]
graph: Option<Vec<ResourceNodeDto>>,
#[serde(rename = "@id", default)]
id: Option<String>,
#[serde(rename = "@type", default)]
type_field: Option<serde_json::Value>,
#[serde(rename = "rdfs:label", default)]
label: Option<serde_json::Value>,
#[serde(rename = "knora-api:arkUrl", default)]
ark_url: Option<serde_json::Value>,
#[serde(rename = "knora-api:creationDate", default)]
creation_date: Option<serde_json::Value>,
#[serde(rename = "knora-api:lastModificationDate", default)]
last_modification_date: Option<serde_json::Value>,
#[serde(rename = "knora-api:mayHaveMoreResults", default)]
may_have_more_results: bool,
}
#[derive(serde::Deserialize)]
struct ResourceNodeDto {
#[serde(rename = "@id")]
id: String,
#[serde(rename = "@type", default)]
type_field: Option<serde_json::Value>,
#[serde(rename = "rdfs:label", default)]
label: Option<serde_json::Value>,
#[serde(rename = "knora-api:arkUrl", default)]
ark_url: Option<serde_json::Value>,
#[serde(rename = "knora-api:creationDate", default)]
creation_date: Option<serde_json::Value>,
#[serde(rename = "knora-api:lastModificationDate", default)]
last_modification_date: Option<serde_json::Value>,
}
fn extract_string_value(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(map) => map
.get("@value")
.or_else(|| map.get("@id"))
.and_then(|inner| inner.as_str())
.map(str::to_owned),
_ => None,
}
}
fn extract_resource_type(type_val: Option<&serde_json::Value>) -> String {
match type_val {
None => "unknown".to_string(),
Some(serde_json::Value::String(s)) => local_name(s).to_string(),
Some(serde_json::Value::Array(arr)) => arr
.first()
.and_then(|v| v.as_str())
.map(|s| local_name(s).to_string())
.unwrap_or_else(|| "unknown".to_string()),
_ => "unknown".to_string(),
}
}
fn node_dto_to_summary(
id: String,
type_val: Option<&serde_json::Value>,
label_val: Option<&serde_json::Value>,
ark_val: Option<&serde_json::Value>,
creation_val: Option<&serde_json::Value>,
last_modification_val: Option<&serde_json::Value>,
) -> ResourceSummary {
let label = label_val.and_then(extract_string_value).unwrap_or_default();
let resource_type = extract_resource_type(type_val);
let ark_url = ark_val.and_then(extract_string_value);
let creation_date = creation_val.and_then(extract_string_value);
let last_modified = last_modification_val.and_then(extract_string_value);
ResourceSummary {
label,
iri: id,
ark_url,
creation_date,
last_modified,
resource_type,
}
}
#[derive(serde::Deserialize)]
struct ResourceDetailDto {
#[serde(rename = "@id")]
id: String,
#[serde(rename = "@type", default)]
type_field: Option<serde_json::Value>,
#[serde(rename = "rdfs:label", default)]
label: Option<serde_json::Value>,
#[serde(rename = "knora-api:arkUrl", default)]
ark_url: Option<serde_json::Value>,
#[serde(rename = "knora-api:creationDate", default)]
creation_date: Option<serde_json::Value>,
#[serde(rename = "knora-api:lastModificationDate", default)]
last_modification_date: Option<serde_json::Value>,
#[serde(rename = "knora-api:attachedToProject", default)]
attached_to_project: Option<serde_json::Value>,
#[serde(rename = "knora-api:attachedToUser", default)]
attached_to_user: Option<serde_json::Value>,
#[serde(rename = "knora-api:hasPermissions", default)]
has_permissions: Option<String>,
#[serde(rename = "knora-api:userHasPermission", default)]
user_has_permission: Option<String>,
#[serde(rename = "@context", default)]
context: Option<serde_json::Value>,
#[serde(flatten)]
extra: serde_json::Map<String, serde_json::Value>,
}
fn permission_rank(code: &str) -> u8 {
match code {
"RV" => 1,
"V" => 2,
"M" => 6,
"D" => 7,
"CR" => 8,
_ => 0,
}
}
fn derive_access(user_has_permission: &str) -> Option<ResourceAccess> {
match user_has_permission {
"RV" => Some(ResourceAccess::RestrictedView),
"V" => Some(ResourceAccess::View),
"M" => Some(ResourceAccess::Edit),
"D" => Some(ResourceAccess::Delete),
"CR" => Some(ResourceAccess::Manage),
_ => None,
}
}
fn derive_visibility(has_permissions: &str) -> Option<ResourceVisibility> {
if has_permissions.trim().is_empty() {
return None;
}
let mut unknown_rank: u8 = 0;
let mut known_rank: u8 = 0;
let mut parsed_any = false;
for entry in has_permissions.split('|') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let Some((code, group_list)) = entry.split_once(' ') else {
continue;
};
parsed_any = true;
let rank = permission_rank(code);
for group in group_list.split(',') {
let group_local = local_name(group.trim());
if group_local == "UnknownUser" {
unknown_rank = unknown_rank.max(rank);
} else if group_local == "KnownUser" {
known_rank = known_rank.max(rank);
}
}
}
if !parsed_any {
return None;
}
let v_rank = permission_rank("V");
let rv_rank = permission_rank("RV");
if unknown_rank >= v_rank {
Some(ResourceVisibility::Public)
} else if unknown_rank >= rv_rank {
Some(ResourceVisibility::PublicRestricted)
} else if known_rank >= rv_rank {
Some(ResourceVisibility::LoggedInUsers)
} else {
Some(ResourceVisibility::ProjectMembers)
}
}
fn dsp_api_default_headers() -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::HeaderName::from_static("dsp-client"),
reqwest::header::HeaderValue::from_static(crate::util::DSP_CLIENT_HEADER),
);
headers
}
pub struct HttpDspClient {
client: reqwest::blocking::Client,
download_client: reqwest::blocking::Client,
}
impl HttpDspClient {
pub fn new() -> Result<Self, Diagnostic> {
let client = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.user_agent(crate::util::USER_AGENT)
.default_headers(dsp_api_default_headers())
.build()
.map_err(|e| Diagnostic::Internal(format!("failed to build HTTP client: {e}")))?;
let download_client = reqwest::blocking::Client::builder()
.connect_timeout(Some(Duration::from_secs(30)))
.timeout(None)
.user_agent(crate::util::USER_AGENT)
.default_headers(dsp_api_default_headers())
.build()
.map_err(|e| Diagnostic::Internal(format!("failed to build download HTTP client: {e}")))?;
Ok(Self { client, download_client })
}
fn fetch_allentities(
&self,
server: &str,
ontology_iri: &str,
token: Option<&str>,
) -> Result<OntologyAllEntitiesResponse, Diagnostic> {
let url = format!(
"{}/v2/ontologies/allentities/{}",
server.trim_end_matches('/'),
enc(ontology_iri)
);
let req = self.client.get(&url);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let resp: OntologyAllEntitiesResponse = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("data-model response could not be parsed: {e}")))?;
Ok(resp)
} else {
Err(map_unexpected_status(status, &url))
}
}
fn fetch_list_get(&self, server: &str, iri: &str, token: Option<&str>) -> Result<ListGetResponseDto, Diagnostic> {
let url = format!("{}/admin/lists/{}", server.trim_end_matches('/'), enc(iri));
let req = self.client.get(&url);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
response
.json::<ListGetResponseDto>()
.map_err(|e| Diagnostic::ServerError(format!("vocabulary response could not be parsed: {e}")))
} else {
Err(map_unexpected_status(status, &url))
}
}
}
impl HttpDspClient {
fn parse_resource_values(
&self,
server: &str,
token: Option<&str>,
context_val: &Option<serde_json::Value>,
extra: &serde_json::Map<String, serde_json::Value>,
) -> Vec<FieldValues> {
let prefixes: HashMap<String, String> = build_prefix_map(context_val);
const DENYLIST: &[&str] = &[
"knora-api:hasIncomingLinkValue",
"knora-api:hasStandoffLinkToValue",
"knora-api:hasStandoffLinkValue", ];
let mut field_entries: Vec<(&str, Vec<&serde_json::Value>)> = Vec::new();
for (key, val) in extra.iter() {
if DENYLIST.contains(&key.as_str()) {
continue;
}
let objs: Vec<&serde_json::Value> = match val {
serde_json::Value::Array(arr) => arr.iter().collect(),
obj @ serde_json::Value::Object(_) => vec![obj],
_ => continue, };
if objs.is_empty() {
continue;
}
let first = match objs.first() {
Some(v) => v,
None => continue,
};
if !has_value_class_type(first) {
continue;
}
field_entries.push((key.as_str(), objs));
}
struct ParsedField<'a> {
key: &'a str,
is_link: bool,
values: Vec<Value>,
}
let mut parsed_fields: Vec<ParsedField> = Vec::new();
for (key, objs) in &field_entries {
let mut contents: Vec<Value> = Vec::new();
let mut any_link = false;
for obj in objs {
if get_type_local(obj) == "DeletedValue" {
continue;
}
let (content, is_link) = parse_value(obj);
if is_link {
any_link = true;
}
contents.push(content);
}
if contents.is_empty() {
continue;
}
parsed_fields.push(ParsedField { key, is_link: any_link, values: contents });
}
let mut ontology_labels: HashMap<String, HashMap<String, String>> = HashMap::new(); let mut fetched_ontologies: HashSet<String> = HashSet::new();
for pf in &parsed_fields {
let prefix = curie_prefix(pf.key).unwrap_or("");
if is_system_prefix(prefix) || prefix.is_empty() {
continue; }
let namespace = match prefixes.get(prefix) {
Some(ns) => ns,
None => continue,
};
let ont_iri = namespace.trim_end_matches(['#', '/']).to_string();
if fetched_ontologies.insert(ont_iri.clone()) {
match self.fetch_allentities(server, &ont_iri, token) {
Ok(resp) => {
let mut prop_map: HashMap<String, String> = HashMap::new();
let ctx_prefixes: HashMap<String, String> = resp
.context
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect();
for entity in resp.graph {
if let Some(lbl) = entity.label {
let (_, iri) = expand_class_id(&entity.id, &ctx_prefixes);
prop_map.insert(iri, lbl);
}
}
ontology_labels.insert(ont_iri, prop_map);
}
Err(e) => {
tracing::warn!(
prefix = %prefix,
error = %e,
"field-label ontology fetch failed; using local name as fallback"
);
}
}
}
}
let mut node_labels: HashMap<String, Option<String>> = HashMap::new();
for pf in &parsed_fields {
for v in &pf.values {
if let ValueContent::VocabularyItem { node_iri, .. } = &v.content {
node_labels.entry(node_iri.clone()).or_insert(None);
}
}
}
for (node_iri, label_slot) in node_labels.iter_mut() {
let url = format!("{}/v2/node/{}", server.trim_end_matches('/'), enc(node_iri));
let req = self.client.get(&url);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
match req.send() {
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>() {
Ok(body) => {
let lbl = body.get("rdfs:label").and_then(extract_string_value);
*label_slot = lbl;
}
Err(_) => {
tracing::debug!(
node_iri = %node_iri,
"list-node label response could not be parsed as JSON; using node IRI as fallback"
);
}
}
}
Ok(resp) => {
tracing::debug!(
node_iri = %node_iri,
status = %resp.status(),
"list-node label fetch returned non-success; using node IRI as fallback"
);
}
Err(e) => {
tracing::debug!(
node_iri = %node_iri,
error = %e,
"list-node label fetch failed; using node IRI as fallback"
);
}
}
}
let mut result: Vec<FieldValues> = Vec::new();
for pf in parsed_fields {
let raw_name = local_name(pf.key).to_string();
let name = if pf.is_link {
raw_name.strip_suffix("Value").unwrap_or(&raw_name).to_string()
} else {
raw_name
};
let label: Option<String> = {
let prefix = curie_prefix(pf.key).unwrap_or("");
if is_system_prefix(prefix) || prefix.is_empty() {
None
} else if let Some(ns) = prefixes.get(prefix) {
let ont_iri = ns.trim_end_matches(['#', '/']).to_string();
let local = local_name(pf.key);
let prop_iri = format!("{}{}", ns, local);
ontology_labels.get(&ont_iri).and_then(|m| m.get(&prop_iri).cloned())
} else {
None
}
};
let values: Vec<Value> = pf
.values
.into_iter()
.map(|v| match v.content {
ValueContent::VocabularyItem { node_iri, label: _ } => {
let resolved = node_labels.get(&node_iri).cloned().flatten();
Value {
content: ValueContent::VocabularyItem { node_iri, label: resolved },
comment: v.comment,
}
}
other => Value { content: other, comment: v.comment },
})
.collect();
result.push(FieldValues { name, label, values });
}
result
}
}
fn has_value_class_type(val: &serde_json::Value) -> bool {
let type_local = get_type_local(val);
type_local.ends_with("Value") && !type_local.is_empty() && {
let raw_type = val
.as_object()
.and_then(|m| m.get("@type"))
.and_then(|t| t.as_str())
.unwrap_or("");
raw_type.starts_with("knora-api:")
}
}
fn get_type_local(val: &serde_json::Value) -> &str {
val.as_object()
.and_then(|m| m.get("@type"))
.and_then(|t| t.as_str())
.map(local_name)
.unwrap_or("")
}
fn build_prefix_map(context_val: &Option<serde_json::Value>) -> HashMap<String, String> {
match context_val {
Some(serde_json::Value::Object(map)) => map
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect(),
_ => HashMap::new(),
}
}
fn parse_value_content(obj: &serde_json::Value) -> (ValueContent, bool) {
let type_local = get_type_local(obj);
match type_local {
"TextValue" => {
let content = if let Some(xml) = obj.get("knora-api:textValueAsXml").and_then(|v| v.as_str()) {
crate::util::text::html_to_text(xml)
} else {
obj.get("knora-api:valueAsString")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
};
(ValueContent::Text(content), false)
}
"IntValue" => {
let n = obj.get("knora-api:intValueAsInt").and_then(|v| v.as_i64()).unwrap_or(0);
(ValueContent::Integer(n), false)
}
"DecimalValue" => {
let s = obj
.get("knora-api:decimalValueAsDecimal")
.and_then(|v| {
if let Some(s) = v.as_str() {
Some(s.to_string())
} else {
v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
}
})
.unwrap_or_default();
(ValueContent::Decimal(s), false)
}
"BooleanValue" => {
let b = obj
.get("knora-api:booleanValueAsBoolean")
.and_then(|v| v.as_bool())
.unwrap_or(false);
(ValueContent::Boolean(b), false)
}
"DateValue" => {
let calendar = obj
.get("knora-api:dateValueHasCalendar")
.and_then(|v| v.as_str())
.unwrap_or("GREGORIAN")
.to_string();
let parse_point = |prefix: &str| -> DatePoint {
let year_key = format!("knora-api:{prefix}Year");
let month_key = format!("knora-api:{prefix}Month");
let day_key = format!("knora-api:{prefix}Day");
let era_key = format!("knora-api:{prefix}Era");
DatePoint {
year: obj.get(year_key.as_str()).and_then(|v| v.as_i64()).map(|v| v as i32),
month: obj.get(month_key.as_str()).and_then(|v| v.as_u64()).map(|v| v as u32),
day: obj.get(day_key.as_str()).and_then(|v| v.as_u64()).map(|v| v as u32),
era: obj.get(era_key.as_str()).and_then(|v| v.as_str()).map(str::to_owned),
}
};
let start = parse_point("dateValueHasStart");
let end = parse_point("dateValueHasEnd");
if start.year.is_none() && end.year.is_none() {
let raw_text = obj
.get("knora-api:valueAsString")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return (ValueContent::Raw { value_type: "date".to_string(), text: raw_text }, false);
}
(ValueContent::Date(DateValue { calendar, start, end }), false)
}
"TimeValue" => {
let s = obj
.get("knora-api:timeValueAsTimeStamp")
.and_then(|v| {
if let Some(s) = v.as_str() {
Some(s.to_string())
} else {
v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
}
})
.unwrap_or_default();
(ValueContent::Time(s), false)
}
"UriValue" => {
let s = obj
.get("knora-api:uriValueAsUri")
.and_then(|v| {
if let Some(s) = v.as_str() {
Some(s.to_string())
} else {
v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
}
})
.unwrap_or_default();
(ValueContent::Uri(s), false)
}
"ColorValue" => {
let s = obj
.get("knora-api:colorValueAsColor")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(ValueContent::Color(s), false)
}
"GeonameValue" => {
let s = obj
.get("knora-api:geonameValueAsGeonameCode")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(ValueContent::Geoname(s), false)
}
"ListValue" => {
let node_iri = obj
.get("knora-api:listValueAsListNode")
.and_then(|v| v.get("@id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(
ValueContent::VocabularyItem {
node_iri,
label: None, },
false,
)
}
"LinkValue" => {
let (target_iri, target_label) = if let Some(target_obj) = obj.get("knora-api:linkValueHasTarget") {
let iri = target_obj.get("@id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let lbl = target_obj.get("rdfs:label").and_then(extract_string_value);
(iri, lbl)
} else {
let iri = obj
.get("knora-api:linkValueHasTargetIri")
.and_then(|v| v.get("@id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(iri, None)
};
(
ValueContent::Link { target_iri, target_label },
true, )
}
t if t.ends_with("FileValue") => {
let filename = obj
.get("knora-api:fileValueHasFilename")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let url_str = obj
.get("knora-api:fileValueAsUrl")
.and_then(|v| {
if let Some(s) = v.as_str() {
Some(s.to_string())
} else {
v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
}
})
.unwrap_or_default();
let value_type_opt = if t.starts_with("StillImage") {
Some(ValueType::StillImage)
} else if t.starts_with("MovingImage") {
Some(ValueType::MovingImage)
} else if t.starts_with("Audio") {
Some(ValueType::Audio)
} else if t.starts_with("Document") || t.starts_with("Text") {
Some(ValueType::Document)
} else if t.starts_with("Archive") {
Some(ValueType::Archive)
} else {
None };
match value_type_opt {
Some(vt) => {
let (width, height) = if vt == ValueType::StillImage {
let w = obj
.get("knora-api:stillImageFileValueHasDimX")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let h = obj
.get("knora-api:stillImageFileValueHasDimY")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
(w, h)
} else {
(None, None)
};
(
ValueContent::File(FileValue { value_type: vt, filename, url: url_str, width, height }),
false,
)
}
None => {
let raw_text = obj
.get("knora-api:valueAsString")
.and_then(|v| v.as_str())
.unwrap_or(&filename)
.to_string();
(ValueContent::Raw { value_type: object_type_to_kebab(t), text: raw_text }, false)
}
}
}
other => {
let value_type = object_type_to_kebab(other);
let raw_text = obj
.get("knora-api:valueAsString")
.and_then(|v| v.as_str())
.map(str::to_owned)
.unwrap_or_else(|| compact_value_text(obj));
(ValueContent::Raw { value_type, text: raw_text }, false)
}
}
}
fn parse_value(obj: &serde_json::Value) -> (Value, bool) {
let (content, is_link) = parse_value_content(obj);
let comment = obj
.get("knora-api:valueHasComment")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(str::to_owned);
(Value { content, comment }, is_link)
}
const VALUE_META_KEYS: &[&str] = &[
"@id",
"@type",
"knora-api:attachedToUser",
"knora-api:hasPermissions",
"knora-api:userHasPermission",
"knora-api:valueCreationDate",
"knora-api:valueHasComment",
"knora-api:isDeleted",
"knora-api:arkUrl",
"knora-api:versionArkUrl",
"knora-api:valueHasUUID",
];
fn compact_value_text(obj: &serde_json::Value) -> String {
if let Some(map) = obj.as_object() {
let filtered: serde_json::Map<String, serde_json::Value> = map
.iter()
.filter(|(k, _)| !VALUE_META_KEYS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if filtered.is_empty() {
String::new()
} else {
serde_json::to_string(&serde_json::Value::Object(filtered)).unwrap_or_default()
}
} else {
String::new()
}
}
impl DspClient for HttpDspClient {
fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic> {
let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
let mut body = serde_json::Map::with_capacity(2);
body.insert(identifier_key(user).to_owned(), serde_json::Value::from(user));
body.insert("password".to_owned(), serde_json::Value::from(password));
let response = self
.client
.post(&url)
.json(&body)
.send()
.map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let api: LoginApiResponse = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("login response could not be parsed: {e}")))?;
let expires_at = extract_exp(&api.token);
Ok(LoginResponse { token: api.token, user: user.to_string(), expires_at })
} else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
let body = response.text().unwrap_or_default();
let preview: String = body.chars().take(200).collect();
tracing::trace!("auth failure response body (capped): {}", preview);
Err(Diagnostic::AuthRequired(format!("Authentication failed on {server}")))
} else if status == reqwest::StatusCode::NOT_FOUND {
Err(Diagnostic::NotFound(format!(
"endpoint not found at {url}; check that --server resolves to a DSP-API instance, not just any HTTPS host"
)))
} else if status.is_server_error() {
let body = response.text().unwrap_or_default();
let preview: String = body.chars().take(200).collect();
tracing::trace!("server error response body (capped): {}", preview);
Err(Diagnostic::ServerError(format!("server returned {status}")))
} else {
Err(Diagnostic::ServerError(format!("unexpected status: {status}")))
}
}
fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic> {
let base = server.trim_end_matches('/');
let url = project_lookup_url(base, project);
let response = self.client.get(&url).send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let api: ProjectGetApiResponse = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}")))?;
if !is_safe_shortcode(&api.project.shortcode) {
return Err(Diagnostic::ServerError(
"server returned a project with an unexpected shortcode".into(),
));
}
Ok(ProjectRef {
iri: api.project.id,
shortcode: api.project.shortcode,
shortname: api.project.shortname,
})
} else if status == reqwest::StatusCode::NOT_FOUND {
let display_input = truncate_for_display(project);
Err(Diagnostic::NotFound(format!("project '{display_input}' not found on {server}")))
} else {
Err(map_unexpected_status(status, &url))
}
}
fn create_project_dump(
&self,
server: &str,
project_iri: &str,
skip_assets: bool,
token: &str,
) -> Result<CreateDumpOutcome, Diagnostic> {
let base = server.trim_end_matches('/');
let url = format!("{base}/v3/projects/{}/exports?skipAssets={skip_assets}", enc(project_iri));
let response = self
.client
.post(&url)
.bearer_auth(token)
.send()
.map_err(|e: reqwest::Error| Diagnostic::Network(e.to_string()))?;
let status = response.status();
match status.as_u16() {
202 => {
let api: DataTaskStatusApiResponse = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("dump trigger response could not be parsed: {e}")))?;
api.into_dump_task().map(CreateDumpOutcome::Created)
}
409 => {
let body_text = response.text().unwrap_or_default();
let error_body: Option<V3ErrorBody> = if body_text.len() <= 65536 {
serde_json::from_str(&body_text).ok()
} else {
None
};
match error_body.as_ref().and_then(|b| b.export_exists()) {
Some(ex) => {
let id = ex.id.ok_or_else(|| {
Diagnostic::ServerError(
"the server's dump-conflict response was missing the dump id".into(),
)
})?;
validate_dump_id(id)?;
match ex.project_iri {
Some(owner) if owner == project_iri => Ok(CreateDumpOutcome::Exists { id: id.to_string() }),
Some(owner) => Ok(CreateDumpOutcome::ExistsForOtherProject {
id: id.to_string(),
project_iri: owner.to_string(),
}),
None => Err(Diagnostic::ServerError(
"the server's dump-conflict response did not identify which \
project owns the existing dump; cannot safely proceed"
.into(),
)),
}
}
None => Err(Diagnostic::ServerError(
"server reported a 409 conflict whose detail could not be parsed".into(),
)),
}
}
401 | 403 => Err(Diagnostic::AuthRequired(
"triggering a project dump requires a system-administrator token".into(),
)),
404 => Err(Diagnostic::NotFound(format!("project not found at {url}"))),
_ => Err(map_unexpected_status(status, &url)),
}
}
fn get_project_dump_status(
&self,
server: &str,
project_iri: &str,
dump_id: &str,
token: &str,
) -> Result<DumpTask, Diagnostic> {
validate_dump_id(dump_id)?;
let base = server.trim_end_matches('/');
let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
let response = self
.client
.get(&url)
.bearer_auth(token)
.send()
.map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
match status.as_u16() {
200 => {
let api: DataTaskStatusApiResponse = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("dump status response could not be parsed: {e}")))?;
api.into_dump_task()
}
404 => Err(Diagnostic::NotFound(format!("dump '{dump_id}' not found for project at {url}"))),
401 | 403 => Err(Diagnostic::AuthRequired(
"fetching dump status requires a system-administrator token".into(),
)),
_ => Err(map_unexpected_status(status, &url)),
}
}
fn download_project_dump(
&self,
server: &str,
project_iri: &str,
dump_id: &str,
token: &str,
dest: &mut dyn Write,
) -> Result<u64, Diagnostic> {
validate_dump_id(dump_id)?;
let base = server.trim_end_matches('/');
let url = format!("{base}/v3/projects/{}/exports/{dump_id}/download", enc(project_iri));
let mut response = self
.download_client
.get(&url)
.bearer_auth(token)
.send()
.map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
match status.as_u16() {
200 => {
let mut buf = [0u8; 64 * 1024];
let mut total: u64 = 0;
loop {
let n = response
.read(&mut buf)
.map_err(|e| Diagnostic::Network(format!("download interrupted: {e}")))?;
if n == 0 {
break;
}
dest.write_all(&buf[..n])
.map_err(|e| Diagnostic::Io(format!("failed to write dump to disk: {e}")))?;
total += n as u64;
}
Ok(total)
}
409 => Err(Diagnostic::Conflict("dump not ready — still in progress or failed".into())),
404 => Err(Diagnostic::NotFound(format!("dump '{dump_id}' not found at {url}"))),
401 | 403 => Err(Diagnostic::AuthRequired(
"downloading a project dump requires a system-administrator token".into(),
)),
_ => Err(map_unexpected_status(status, &url)),
}
}
fn delete_project_dump(
&self,
server: &str,
project_iri: &str,
dump_id: &str,
token: &str,
) -> Result<(), Diagnostic> {
validate_dump_id(dump_id)?;
let base = server.trim_end_matches('/');
let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
let response = self
.client
.delete(&url)
.bearer_auth(token)
.send()
.map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
match status.as_u16() {
204 => Ok(()),
409 => Err(Diagnostic::Conflict(
"dump is still in progress and cannot be deleted yet".into(),
)),
404 => Err(Diagnostic::NotFound(format!("dump '{dump_id}' not found at {url}"))),
401 | 403 => Err(Diagnostic::AuthRequired(
"deleting a project dump requires a system-administrator token".into(),
)),
_ => Err(map_unexpected_status(status, &url)),
}
}
fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic> {
let base = server.trim_end_matches('/');
let url = format!("{base}/admin/projects");
let req = self.client.get(&url);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let api: ProjectsListApiResponse = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("projects list response could not be parsed: {e}")))?;
let projects = api
.projects
.into_iter()
.map(|dto| Project {
iri: dto.id,
shortcode: dto.shortcode,
shortname: dto.shortname,
longname: dto.longname,
data_models: dto.ontologies.len(),
})
.collect();
Ok(projects)
} else {
Err(map_unexpected_status(status, &url))
}
}
fn describe_project(&self, server: &str, project: &str, token: Option<&str>) -> Result<ProjectDetail, Diagnostic> {
let base = server.trim_end_matches('/');
let url = project_lookup_url(base, project);
let req = self.client.get(&url);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let api: ProjectDetailApiResponse = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}")))?;
let dto = api.project;
let description = dto
.description
.into_iter()
.map(|d| ProjectDescription { value: d.value, language: d.language })
.collect();
let mut data_models: Vec<DataModelSummary> = dto
.ontologies
.into_iter()
.map(|iri| {
let name = data_model_name_from_iri(&iri);
DataModelSummary { name, iri }
})
.collect();
data_models.sort_by(|a, b| a.name.cmp(&b.name));
Ok(ProjectDetail {
iri: dto.id,
shortcode: dto.shortcode,
shortname: dto.shortname,
longname: dto.longname,
description,
keywords: dto.keywords,
data_models,
})
} else if status == reqwest::StatusCode::NOT_FOUND {
let display_input = truncate_for_display(project);
Err(Diagnostic::NotFound(format!(
"project '{display_input}' not found on {server}. Run `dsp vre project list --server {server}` to see available projects."
)))
} else {
Err(map_unexpected_status(status, &url))
}
}
fn describe_data_model(
&self,
server: &str,
data_model_iri: &str,
token: Option<&str>,
) -> Result<DataModelDetail, Diagnostic> {
let resp = self.fetch_allentities(server, data_model_iri, token)?;
let prefixes: HashMap<String, String> = resp
.context
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect();
let mut resource_types: Vec<ResourceTypeSummary> = resp
.graph
.into_iter()
.filter(|dto| dto.is_resource_class)
.map(|dto| {
let (name, iri) = expand_class_id(&dto.id, &prefixes);
ResourceTypeSummary { name, iri, label: dto.label }
})
.collect();
resource_types.sort_by(|a, b| a.name.cmp(&b.name));
Ok(DataModelDetail {
name: data_model_name_from_iri(&resp.id),
iri: resp.id,
label: resp.label,
last_modified: resp.last_modification_date.map(|d| d.value),
resource_types,
})
}
fn data_model_structure(
&self,
server: &str,
data_model_iri: &str,
token: Option<&str>,
) -> Result<DataModelStructure, Diagnostic> {
let resp = self.fetch_allentities(server, data_model_iri, token)?;
let graph_entities: Vec<OntologyEntityDto> = resp.graph;
let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
let mut class_nodes: Vec<OntologyEntityDto> = Vec::new();
for entity in graph_entities {
if entity.is_resource_class {
class_nodes.push(entity);
} else if entity.object_type.is_some() || entity.is_link_property || entity.is_resource_property {
prop_lookup.insert(entity.id.clone(), entity);
}
}
let mut relations: Vec<Relation> = Vec::new();
for class in &class_nodes {
let source = local_name(&class.id).to_string();
for element in &class.sub_class_of {
if let Some(type_val) = element.get("@type")
&& type_val.as_str() == Some("owl:Restriction")
{
let on_prop_id = match element
.get("owl:onProperty")
.and_then(|v| v.get("@id"))
.and_then(serde_json::Value::as_str)
{
Some(s) => s,
None => continue,
};
let node = match prop_lookup.get(on_prop_id) {
Some(n) => n,
None => continue, };
if node.is_link_value_property {
continue;
}
if !node.is_link_property {
continue;
}
let target_id = match node.object_type.as_ref() {
Some(ot) => &ot.id,
None => continue, };
let target = local_name(target_id).to_string();
let t_prefix = curie_prefix(target_id).unwrap_or("");
let target_data_model = if is_system_prefix(t_prefix) || t_prefix.is_empty() {
None
} else {
Some(t_prefix.to_string())
};
let field_prefix = curie_prefix(on_prop_id).unwrap_or("");
let is_builtin = is_system_prefix(field_prefix);
let field = local_name(on_prop_id).to_string();
relations.push(Relation {
source: source.clone(),
target,
kind: RelationKind::Link,
field: Some(field),
target_data_model,
is_builtin,
});
} else if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
let target = local_name(id_val).to_string();
let sup_prefix = curie_prefix(id_val).unwrap_or("");
let is_builtin = is_system_prefix(sup_prefix);
let target_data_model = if is_system_prefix(sup_prefix) || sup_prefix.is_empty() {
None
} else {
Some(sup_prefix.to_string())
};
relations.push(Relation {
source: source.clone(),
target,
kind: RelationKind::Inherits,
field: None,
target_data_model,
is_builtin,
});
}
}
}
relations.sort_by(|a, b| {
a.source
.cmp(&b.source)
.then_with(|| a.kind.cmp(&b.kind))
.then_with(|| a.field.cmp(&b.field))
.then_with(|| a.target.cmp(&b.target))
});
Ok(DataModelStructure {
data_model: data_model_name_from_iri(data_model_iri),
relations,
})
}
fn list_resources(
&self,
server: &str,
project_iri: &str,
resource_type_iri: &str,
order_by: Option<&str>,
page: u32,
token: Option<&str>,
) -> Result<ResourcePage, Diagnostic> {
let base = server.trim_end_matches('/');
let url = format!("{base}/v2/resources");
let mut req = self.client.get(&url).query(&[
("resourceClass", resource_type_iri),
("page", &page.to_string()),
("schema", "complex"),
]);
if let Some(prop_iri) = order_by {
req = req.query(&[("orderByProperty", prop_iri)]);
}
let header_value = reqwest::header::HeaderValue::from_str(project_iri)
.map_err(|e| Diagnostic::Usage(format!("project IRI is not a valid HTTP header value: {e}")))?;
let req = req.header("x-knora-accept-project", header_value);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(map_unexpected_status(status, &url));
}
let dto: ResourceListDto = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("resource list response could not be parsed: {e}")))?;
let may_have_more_results = dto.may_have_more_results;
let resources: Vec<ResourceSummary> = if let Some(graph) = dto.graph {
graph
.into_iter()
.map(|node| {
node_dto_to_summary(
node.id,
node.type_field.as_ref(),
node.label.as_ref(),
node.ark_url.as_ref(),
node.creation_date.as_ref(),
node.last_modification_date.as_ref(),
)
})
.collect()
} else if let Some(id) = dto.id {
vec![node_dto_to_summary(
id,
dto.type_field.as_ref(),
dto.label.as_ref(),
dto.ark_url.as_ref(),
dto.creation_date.as_ref(),
dto.last_modification_date.as_ref(),
)]
} else {
vec![]
};
Ok(ResourcePage { resources, may_have_more_results })
}
fn describe_resource(
&self,
server: &str,
resource_iri: &str,
token: Option<&str>,
with_values: bool,
) -> Result<ResourceDetail, Diagnostic> {
let base = server.trim_end_matches('/');
let url = format!("{base}/v2/resources/{}", enc(resource_iri));
let req = self.client.get(&url).query(&[("schema", "complex")]);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let dto: ResourceDetailDto = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("resource describe response could not be parsed: {e}")))?;
let label = dto.label.as_ref().and_then(extract_string_value).unwrap_or_default();
let resource_type = extract_resource_type(dto.type_field.as_ref());
let ark_url = dto.ark_url.as_ref().and_then(extract_string_value);
let creation_date = dto.creation_date.as_ref().and_then(extract_string_value);
let last_modified = dto.last_modification_date.as_ref().and_then(extract_string_value);
let attached_project = dto.attached_to_project.as_ref().and_then(extract_string_value);
let owner = dto.attached_to_user.as_ref().and_then(extract_string_value);
let visibility = dto.has_permissions.as_deref().and_then(derive_visibility);
let your_access = dto.user_has_permission.as_deref().and_then(derive_access);
let values = if with_values {
Some(self.parse_resource_values(server, token, &dto.context, &dto.extra))
} else {
None
};
Ok(ResourceDetail {
label,
iri: dto.id,
resource_type,
ark_url,
creation_date,
last_modified,
attached_project,
owner,
visibility,
your_access,
values,
})
} else if status == reqwest::StatusCode::NOT_FOUND {
let display_iri = truncate_for_display(resource_iri);
Err(Diagnostic::NotFound(format!("resource '{display_iri}' not found")))
} else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
let display_iri = truncate_for_display(resource_iri);
Err(Diagnostic::AuthRequired(format!(
"access denied for resource '{display_iri}' — log in to view this resource"
)))
} else {
Err(map_unexpected_status(status, &url))
}
}
fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic> {
let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
let response = self
.client
.get(&url)
.bearer_auth(token)
.send()
.map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let body = response.text().unwrap_or_default();
let preview: String = body.chars().take(200).collect();
tracing::trace!("verify_token success response body (capped): {}", preview);
Ok(())
} else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
let body = response.text().unwrap_or_default();
let preview: String = body.chars().take(200).collect();
tracing::trace!("verify_token rejection response body (capped): {}", preview);
Err(Diagnostic::AuthRequired(format!(
"token rejected by {server} — it may be expired, revoked, or for a different environment"
)))
} else {
Err(map_unexpected_status(status, &url))
}
}
fn list_data_models(
&self,
server: &str,
project_iri: &str,
token: Option<&str>,
) -> Result<Vec<DataModel>, Diagnostic> {
let url = format!("{}/v2/ontologies/metadata/{}", server.trim_end_matches('/'), enc(project_iri));
let req = self.client.get(&url);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let resp: OntologyMetadataResponse = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("data-models response could not be parsed: {e}")))?;
let dtos: Vec<OntologyMetadataDto> = match resp.graph {
Some(g) => g,
None => match resp.id {
Some(id) => vec![OntologyMetadataDto {
id,
label: resp.label,
last_modification_date: resp.last_modification_date,
}],
None => vec![],
},
};
let data_models = dtos
.into_iter()
.map(|dto| DataModel {
name: data_model_name_from_iri(&dto.id),
iri: dto.id,
label: dto.label,
last_modified: dto.last_modification_date.map(|d| d.value),
is_builtin: false,
})
.collect();
Ok(data_models)
} else {
Err(map_unexpected_status(status, &url))
}
}
fn describe_resource_type(
&self,
server: &str,
data_model_iri: &str,
resource_type: &str,
token: Option<&str>,
) -> Result<ResourceTypeDetail, Diagnostic> {
let resp = self.fetch_allentities(server, data_model_iri, token)?;
let prefixes: HashMap<String, String> = resp
.context
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect();
let queried_id = resp.id;
let mut graph_entities: Vec<OntologyEntityDto> = resp.graph;
let target_idx = graph_entities.iter().position(|e| {
if !e.is_resource_class {
return false;
}
let (type_local, expanded_iri) = expand_class_id(&e.id, &prefixes);
type_local.eq_ignore_ascii_case(resource_type) || expanded_iri == resource_type
});
let target_idx = match target_idx {
Some(i) => i,
None => {
let display = truncate_for_display(resource_type);
return Err(Diagnostic::NotFound(format!(
"resource-type '{display}' not found in data-model '{}' on {server}",
data_model_name_from_iri(data_model_iri)
)));
}
};
let target = graph_entities.swap_remove(target_idx);
struct Restriction {
on_property_id: String,
cardinality: Cardinality,
gui_order: u32,
}
let mut restrictions: Vec<Restriction> = Vec::new();
let mut super_type_ids: Vec<String> = Vec::new();
let mut restriction_prop_locals: Vec<String> = Vec::new();
for element in &target.sub_class_of {
if let Some(type_val) = element.get("@type")
&& type_val.as_str() == Some("owl:Restriction")
{
let on_prop_id = element
.get("owl:onProperty")
.and_then(|v| v.get("@id"))
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
if on_prop_id.is_empty() {
tracing::warn!("owl:Restriction missing owl:onProperty @id; skipping");
continue;
}
let cardinality = decode_cardinality(element);
let gui_order = element
.get("salsah-gui:guiOrder")
.and_then(serde_json::Value::as_u64)
.map(|v| v as u32)
.unwrap_or(u32::MAX);
restriction_prop_locals.push(local_name(&on_prop_id).to_string());
restrictions.push(Restriction { on_property_id: on_prop_id, cardinality, gui_order });
continue;
}
if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
super_type_ids.push(id_val.to_string());
}
}
let representation =
detect_representation(&restriction_prop_locals.iter().map(String::as_str).collect::<Vec<_>>());
let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
for entity in graph_entities {
if entity.object_type.is_some() || entity.is_link_property || entity.is_resource_property {
prop_lookup.insert(entity.id.clone(), entity);
}
}
let mut missing_prefixes: Vec<String> = Vec::new();
let mut seen_prefixes: HashSet<String> = HashSet::new();
for restriction in &restrictions {
if prop_lookup.contains_key(&restriction.on_property_id) {
continue;
}
let prefix = match curie_prefix(&restriction.on_property_id) {
Some(p) => p,
None => continue,
};
if is_system_prefix(prefix) {
continue;
}
if seen_prefixes.insert(prefix.to_string()) {
missing_prefixes.push(prefix.to_string());
}
}
let mut fetched_sibling_iris: HashSet<String> = HashSet::new();
let queried_iri_trimmed = data_model_iri.trim_end_matches(['#', '/']);
let mut siblings_to_fetch: Vec<String> = Vec::new();
for prefix in &missing_prefixes {
let namespace = match prefixes.get(prefix.as_str()) {
Some(ns) => ns,
None => {
tracing::warn!(
prefix = %prefix,
"missing @context entry for prefix of cross-DM field; leaving best-effort"
);
continue;
}
};
let sibling_iri = namespace.trim_end_matches(['#', '/']).to_string();
if sibling_iri == queried_iri_trimmed {
continue;
}
if fetched_sibling_iris.insert(sibling_iri.clone()) {
siblings_to_fetch.push(sibling_iri);
}
}
if siblings_to_fetch.len() > MAX_SIBLING_FETCHES {
tracing::warn!(
count = siblings_to_fetch.len(),
max = MAX_SIBLING_FETCHES,
"too many sibling ontologies to fetch; capping at MAX_SIBLING_FETCHES"
);
siblings_to_fetch.truncate(MAX_SIBLING_FETCHES);
}
for sibling_iri in &siblings_to_fetch {
match self.fetch_allentities(server, sibling_iri, token) {
Ok(sibling_resp) => {
for entity in sibling_resp.graph {
if entity.object_type.is_some() || entity.is_link_property || entity.is_resource_property {
prop_lookup.entry(entity.id.clone()).or_insert(entity);
}
}
}
Err(e) => {
tracing::warn!(
iri = %sibling_iri,
error = %e,
"sibling ontology fetch failed; affected fields left best-effort"
);
}
}
}
let mut fields: Vec<(u32, Field)> = Vec::new();
for restriction in &restrictions {
let prop_id = &restriction.on_property_id;
let node = prop_lookup.get(prop_id.as_str());
if let Some(n) = node {
if n.is_link_value_property {
continue;
}
} else {
let prop_local = local_name(prop_id);
if let Some(base) = prop_local.strip_suffix("Value") {
let base_present = restrictions.iter().any(|r| local_name(&r.on_property_id) == base);
if base_present {
continue;
}
}
}
let prop_prefix = curie_prefix(prop_id).unwrap_or("");
let is_builtin = is_system_prefix(prop_prefix);
let (prop_local, prop_iri) = expand_class_id(prop_id, &prefixes);
let field_data_model = if is_builtin {
None
} else {
if prop_prefix.is_empty() {
None
} else {
Some(prop_prefix.to_string())
}
};
let (value_type, link_target) = if let Some(n) = node {
if n.is_link_property {
let target_name = n
.object_type
.as_ref()
.map(|ot| local_name(&ot.id).to_string())
.unwrap_or_else(|| "unknown".to_string());
(ValueType::Link, Some(target_name))
} else {
let obj_local = n.object_type.as_ref().map(|ot| local_name(&ot.id)).unwrap_or("");
(map_object_type_to_value_type(obj_local), None)
}
} else {
if is_builtin {
if let Some(vt) = builtin_field_value_type(&prop_local) {
(vt, None)
} else {
(ValueType::Other("—".to_string()), None)
}
} else {
(ValueType::Other("—".to_string()), None)
}
};
let label = node.and_then(|n| n.label.clone());
debug_assert!(
(value_type == ValueType::Link) == link_target.is_some(),
"link_target must be Some iff value_type is Link"
);
fields.push((
restriction.gui_order,
Field {
name: prop_local,
iri: prop_iri,
label,
value_type,
link_target,
cardinality: restriction.cardinality,
is_builtin,
data_model: field_data_model,
},
));
}
fields.sort_by(|(order_a, field_a), (order_b, field_b)| {
order_a.cmp(order_b).then_with(|| field_a.name.cmp(&field_b.name))
});
let sorted_fields: Vec<Field> = fields.into_iter().map(|(_, f)| f).collect();
let super_types: Vec<String> = super_type_ids
.iter()
.filter(|id| {
let prefix = curie_prefix(id).unwrap_or("");
!is_system_prefix(prefix)
})
.map(|id| local_name(id).to_string())
.collect();
let (class_name, class_iri) = expand_class_id(&target.id, &prefixes);
let class_label = target.label;
let dm_name = data_model_name_from_iri(&queried_id);
Ok(ResourceTypeDetail {
name: class_name,
iri: class_iri,
label: class_label,
data_model: dm_name,
representation,
super_types,
fields: sorted_fields,
count: None,
})
}
fn resource_counts(
&self,
server: &str,
project_iri: &str,
token: Option<&str>,
) -> Result<HashMap<String, u64>, Diagnostic> {
let url = format!(
"{}/v3/projects/{}/resourcesPerOntology",
server.trim_end_matches('/'),
enc(project_iri)
);
let req = self.client.get(&url);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let entries: Vec<OntologyAndResourceClassesDto> = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("resource-counts response could not be parsed: {e}")))?;
let mut counts = HashMap::new();
for entry in entries {
for cc in entry.classes_and_count {
counts.insert(cc.resource_class.iri, cc.item_count);
}
}
Ok(counts)
} else if status == reqwest::StatusCode::NOT_FOUND {
Err(Diagnostic::NotFound(format!("project not found at {url}")))
} else {
Err(map_unexpected_status(status, &url))
}
}
fn list_vocabularies(
&self,
server: &str,
project_iri: &str,
token: Option<&str>,
) -> Result<Vec<Vocabulary>, Diagnostic> {
let url = format!("{}/admin/lists?projectIri={}", server.trim_end_matches('/'), enc(project_iri));
let req = self.client.get(&url);
let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
let resp: ListsListApiResponse = response
.json()
.map_err(|e| Diagnostic::ServerError(format!("vocabulary list response could not be parsed: {e}")))?;
Ok(resp
.lists
.into_iter()
.map(|dto| Vocabulary {
header: VocabularyHeader {
iri: dto.id,
name: dto.name,
labels: into_localized_texts(dto.labels),
comments: into_localized_texts(dto.comments),
},
node_count: None,
depth: None,
})
.collect())
} else {
Err(map_unexpected_status(status, &url))
}
}
fn describe_vocabulary(&self, server: &str, iri: &str, token: Option<&str>) -> Result<VocabularyTree, Diagnostic> {
match self.fetch_list_get(server, iri, token)? {
ListGetResponseDto::Root(root) => Ok(build_vocabulary_tree(root.list, None)),
ListGetResponseDto::Node(node) => {
let root_iri = node.node.nodeinfo.has_root_node;
match self.fetch_list_get(server, &root_iri, token)? {
ListGetResponseDto::Root(root) => Ok(build_vocabulary_tree(root.list, Some(iri.to_string()))),
ListGetResponseDto::Node(_) => Err(Diagnostic::ServerError(format!(
"resolving vocabulary node {iri} to its root ({root_iri}) returned \
another node, not a root"
))),
}
}
}
}
fn sparql_query(
&self,
server: &str,
token: &str,
query: &str,
accept: &str,
timeout_secs: u64,
) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
let url = format!("{}/admin/sparql/query", server.trim_end_matches('/'));
tracing::debug!(method = "POST", url = %url, "sparql_query: sending request");
let sparql_client = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::none())
.user_agent(crate::util::USER_AGENT)
.default_headers(dsp_api_default_headers())
.build()
.map_err(|e| Diagnostic::Internal(format!("failed to build SPARQL HTTP client: {e}")))?;
let req = sparql_client
.post(&url)
.bearer_auth(token)
.header(reqwest::header::CONTENT_TYPE, "application/sparql-query")
.header(reqwest::header::ACCEPT, accept)
.body(query.to_string());
let response = req.send().map_err(|e| {
if e.is_timeout() {
Diagnostic::Network(format!(
"SPARQL request to {} timed out on the client side \
after {timeout_secs}s (--timeout) — this is distinct from \
the server's own passthrough timeout, which would come \
back as an HTTP 504: {e}",
crate::util::text::sanitise_and_cap(&url)
))
} else {
Diagnostic::Network(e.to_string())
}
})?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let body = response.bytes().map_err(|e| Diagnostic::Network(e.to_string()))?;
tracing::debug!(
status = status.as_u16(),
content_type = content_type.as_deref().unwrap_or(""),
"sparql_query: received response"
);
match classify_sparql_status(status.as_u16(), content_type.as_deref(), &body, &url) {
SparqlOutcome::DspApiError(diag) => Err(diag),
SparqlOutcome::Relay => {
if !status.is_success() {
tracing::trace!(
"sparql_query: non-2xx relay body preview (capped): {}",
crate::util::text::sanitise_bytes_for_prose(&body)
);
}
Ok(
crate::client::sparql::SparqlResponse {
status: status.as_u16(),
content_type,
body: body.to_vec(),
},
)
}
}
}
}
fn truncate_for_display(s: &str) -> String {
let mut truncated: String = s.chars().take(80).collect();
if s.chars().count() > 80 {
truncated.push('…');
}
truncated
}
#[derive(serde::Deserialize)]
struct SparqlErrorBody {
message: String,
}
enum SparqlOutcome {
DspApiError(Diagnostic),
Relay,
}
fn classify_sparql_status(status: u16, content_type: Option<&str>, body: &[u8], url: &str) -> SparqlOutcome {
match status {
401 => SparqlOutcome::DspApiError(Diagnostic::AuthRequired(
"authentication is required — run `dsp auth login`".into(),
)),
403 => SparqlOutcome::DspApiError(Diagnostic::AuthRequired(
"your token is valid but is not a system administrator; \
re-running `dsp auth login` will not help — the SPARQL \
passthrough endpoint requires a SystemAdmin account"
.into(),
)),
404 => SparqlOutcome::DspApiError(Diagnostic::NotFound(format!(
"the SPARQL passthrough is not available at {}. Any of these \
looks identical from here: the endpoint is off on this deployment \
(it is off by default — allow-sparql-passthrough), the server \
predates the endpoint, the store's dataset is misconfigured, or \
--server is wrong.",
crate::util::text::sanitise_and_cap(url)
))),
413 => SparqlOutcome::DspApiError(Diagnostic::Usage(
"the SPARQL query text exceeds the server's request-body size \
limit"
.into(),
)),
415 => SparqlOutcome::DspApiError(Diagnostic::Internal(
"the server rejected dsp-cli's own Content-Type \
(application/sparql-query) with 415 — this is either a dsp-cli \
bug or an unexpected server"
.into(),
)),
500 | 502 | 503 | 504 => {
let detail = parse_sparql_error_message(content_type, body)
.unwrap_or_else(|| crate::util::text::sanitise_bytes_for_prose(body));
let message = if detail.trim().is_empty() {
format!("the server returned HTTP {status} with no usable message")
} else {
format!("the server returned HTTP {status}: {detail}")
};
SparqlOutcome::DspApiError(Diagnostic::ServerError(message))
}
_ => SparqlOutcome::Relay,
}
}
fn parse_sparql_error_message(content_type: Option<&str>, body: &[u8]) -> Option<String> {
if !content_type.unwrap_or("").starts_with("application/json") {
return None;
}
serde_json::from_slice::<SparqlErrorBody>(body)
.ok()
.map(|b| crate::util::text::sanitise_and_cap(&b.message))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_for_display_at_exactly_80_chars_no_ellipsis() {
let s = "a".repeat(80);
let result = truncate_for_display(&s);
assert_eq!(result, s);
assert!(!result.ends_with('…'));
}
#[test]
fn truncate_for_display_over_80_chars_truncates_with_ellipsis() {
let s = "a".repeat(90);
let result = truncate_for_display(&s);
assert_eq!(result.chars().count(), 81); assert!(result.ends_with('…'));
assert_eq!(result.chars().filter(|&c| c == 'a').count(), 80);
}
#[test]
fn map_unexpected_status_401_403_are_auth_required() {
for status in [reqwest::StatusCode::UNAUTHORIZED, reqwest::StatusCode::FORBIDDEN] {
let diag = map_unexpected_status(status, "https://example.org/x");
match diag {
Diagnostic::AuthRequired(msg) => assert!(
msg.contains("dsp auth login"),
"auth message should hint at re-authentication: {msg}"
),
other => panic!("expected AuthRequired for {status}, got {other:?}"),
}
}
}
#[test]
fn map_unexpected_status_404_and_5xx_stay_server_error() {
assert!(matches!(
map_unexpected_status(reqwest::StatusCode::NOT_FOUND, "u"),
Diagnostic::ServerError(_)
));
assert!(matches!(
map_unexpected_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, "u"),
Diagnostic::ServerError(_)
));
}
#[test]
fn identifier_key_email_contains_at() {
assert_eq!(identifier_key("a@b.ch"), "email");
}
#[test]
fn identifier_key_bare_username() {
assert_eq!(identifier_key("jdoe"), "username");
}
#[test]
fn identifier_key_http_iri() {
assert_eq!(identifier_key("http://rdfh.ch/users/x"), "iri");
}
#[test]
fn identifier_key_https_iri() {
assert_eq!(identifier_key("https://rdfh.ch/users/x"), "iri");
}
#[test]
fn identifier_key_iri_with_at_uses_iri_not_email() {
assert_eq!(identifier_key("http://example.org/users/a@b"), "iri");
}
#[test]
fn classify_http_iri() {
let ident = classify("http://rdfh.ch/projects/0001");
assert!(matches!(ident, ProjectIdent::Iri(_)), "http:// prefix should classify as Iri");
}
#[test]
fn classify_https_iri() {
let ident = classify("https://rdfh.ch/projects/0001");
assert!(matches!(ident, ProjectIdent::Iri(_)), "https:// prefix should classify as Iri");
}
#[test]
fn classify_four_digit_hex_shortcode() {
let ident = classify("0001");
assert!(
matches!(ident, ProjectIdent::Shortcode(_)),
"four hex digits should classify as Shortcode"
);
}
#[test]
fn classify_four_hex_letter_shortcode() {
let ident = classify("beef");
assert!(
matches!(ident, ProjectIdent::Shortcode(_)),
"4-hex-letter input 'beef' should classify as Shortcode (documented overlap)"
);
}
#[test]
fn classify_mixed_case_hex_shortcode() {
let ident = classify("ABCD");
assert!(
matches!(ident, ProjectIdent::Shortcode(_)),
"upper-case hex digits should classify as Shortcode"
);
}
#[test]
fn classify_shortname() {
let ident = classify("incunabula");
assert!(
matches!(ident, ProjectIdent::Shortname(_)),
"alphabetic string longer than 4 chars should classify as Shortname"
);
}
#[test]
fn classify_five_digit_hex_is_shortname() {
let ident = classify("00001");
assert!(
matches!(ident, ProjectIdent::Shortname(_)),
"5-hex-digit string should classify as Shortname, not Shortcode"
);
}
#[test]
fn classify_three_digit_hex_is_shortname() {
let ident = classify("001");
assert!(
matches!(ident, ProjectIdent::Shortname(_)),
"3-hex-digit string should classify as Shortname, not Shortcode"
);
}
#[test]
fn classify_non_hex_four_chars_is_shortname() {
let ident = classify("zzzz");
assert!(
matches!(ident, ProjectIdent::Shortname(_)),
"4-char non-hex string should classify as Shortname"
);
}
#[test]
fn validate_dump_id_valid_accepts() {
assert!(super::validate_dump_id("abc123").is_ok());
assert!(super::validate_dump_id("abc-123_XYZ").is_ok());
let max_id = "a".repeat(256);
assert!(super::validate_dump_id(&max_id).is_ok(), "256-char id must be accepted");
}
#[test]
fn validate_dump_id_empty_is_rejected() {
let result = super::validate_dump_id("");
assert!(matches!(result, Err(Diagnostic::ServerError(_))), "empty id must be rejected");
}
#[test]
fn validate_dump_id_too_long_is_rejected() {
let long_id = "a".repeat(257);
let result = super::validate_dump_id(&long_id);
assert!(
matches!(result, Err(Diagnostic::ServerError(_))),
"257-char id must be rejected"
);
}
#[test]
fn validate_dump_id_invalid_chars_rejected() {
let result = super::validate_dump_id("abc/def");
assert!(
matches!(result, Err(Diagnostic::ServerError(_))),
"id with '/' must be rejected"
);
}
#[test]
fn into_dump_task_in_progress() {
let api = DataTaskStatusApiResponse {
id: "abc123".into(),
status: "in_progress".into(),
error_message: None,
created_at: None,
};
let task = api.into_dump_task().expect("should parse in_progress");
assert_eq!(task.id, "abc123");
assert_eq!(task.status, DumpStatus::InProgress);
assert!(task.error_message.is_none());
assert!(task.created_at.is_none());
}
#[test]
fn into_dump_task_completed() {
let api = DataTaskStatusApiResponse {
id: "done42".into(),
status: "completed".into(),
error_message: None,
created_at: None,
};
let task = api.into_dump_task().expect("should parse completed");
assert_eq!(task.status, DumpStatus::Completed);
}
#[test]
fn into_dump_task_failed_with_message() {
let api = DataTaskStatusApiResponse {
id: "fail7".into(),
status: "failed".into(),
error_message: Some("disk full".into()),
created_at: None,
};
let task = api.into_dump_task().expect("should parse failed");
assert_eq!(task.status, DumpStatus::Failed);
assert_eq!(task.error_message.as_deref(), Some("disk full"));
}
#[test]
fn into_dump_task_unknown_status_is_server_error() {
let api = DataTaskStatusApiResponse {
id: "x".into(),
status: "pending".into(), error_message: None,
created_at: None,
};
let result = api.into_dump_task();
assert!(result.is_err(), "unknown status should yield an error");
assert!(
matches!(result.unwrap_err(), Diagnostic::ServerError(_)),
"unknown status should yield ServerError"
);
}
#[test]
fn into_dump_task_long_error_message_is_truncated() {
let long_msg = "x".repeat(501);
let api = DataTaskStatusApiResponse {
id: "trunc".into(),
status: "failed".into(),
error_message: Some(long_msg),
created_at: None,
};
let task = api.into_dump_task().expect("should parse even with long message");
let stored = task.error_message.unwrap();
assert_eq!(
stored.len(),
500,
"error_message must be truncated to ≤500 chars at the client boundary"
);
}
#[test]
fn into_dump_task_exact_500_chars_not_truncated() {
let exact_msg = "y".repeat(500);
let api = DataTaskStatusApiResponse {
id: "exact".into(),
status: "failed".into(),
error_message: Some(exact_msg.clone()),
created_at: None,
};
let task = api.into_dump_task().expect("should parse");
assert_eq!(task.error_message.unwrap(), exact_msg);
}
#[test]
fn into_dump_task_valid_created_at_is_parsed() {
let api = DataTaskStatusApiResponse {
id: "ts-test".into(),
status: "completed".into(),
error_message: None,
created_at: Some("2026-05-20T14:03:00Z".into()),
};
let task = api.into_dump_task().expect("should parse with created_at");
use chrono::Datelike;
let ts = task.created_at.expect("created_at should be Some");
assert_eq!(ts.year(), 2026);
assert_eq!(ts.month(), 5);
assert_eq!(ts.day(), 20);
}
#[test]
fn into_dump_task_garbage_created_at_yields_none() {
let api = DataTaskStatusApiResponse {
id: "ts-bad".into(),
status: "in_progress".into(),
error_message: None,
created_at: Some("not-a-date!!".into()),
};
let task = api.into_dump_task().expect("garbage created_at must not fail parse");
assert!(task.created_at.is_none(), "garbage created_at must map to None");
}
#[test]
fn export_exists_present_with_both_fields() {
let body = V3ErrorBody {
errors: vec![V3ErrorItem {
code: "export_exists".into(),
details: [
("id".to_string(), "dGVzdC1pZA".to_string()),
("projectIri".to_string(), "http://rdfh.ch/projects/0001".to_string()),
]
.into(),
}],
};
let ex = body.export_exists().expect("export_exists must be Some");
assert_eq!(ex.id, Some("dGVzdC1pZA"));
assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
}
#[test]
fn export_exists_wrong_code_returns_none() {
let body = V3ErrorBody {
errors: vec![V3ErrorItem {
code: "some_other_error".into(),
details: [("id".to_string(), "abc".to_string())].into(),
}],
};
assert!(body.export_exists().is_none(), "wrong code must not match");
}
#[test]
fn export_exists_missing_details_id_returns_some_with_none_id() {
let body = V3ErrorBody {
errors: vec![V3ErrorItem {
code: "export_exists".into(),
details: [("projectIri".to_string(), "http://rdfh.ch/projects/0001".to_string())].into(),
}],
};
let ex = body.export_exists().expect("export_exists must be Some when code matches");
assert!(ex.id.is_none(), "id must be None when 'id' key is absent");
assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
}
#[test]
fn export_exists_empty_errors_returns_none() {
let body = V3ErrorBody { errors: vec![] };
assert!(body.export_exists().is_none());
}
#[test]
fn export_exists_missing_project_iri_returns_some_with_none_iri() {
let body = V3ErrorBody {
errors: vec![V3ErrorItem {
code: "export_exists".into(),
details: [("id".to_string(), "abc123".to_string())].into(),
}],
};
let ex = body.export_exists().expect("export_exists must be Some when code matches");
assert_eq!(ex.id, Some("abc123"));
assert!(
ex.project_iri.is_none(),
"project_iri must be None when 'projectIri' key is absent"
);
}
#[test]
fn is_safe_shortcode_valid_hex_shortcode() {
assert!(super::is_safe_shortcode("0001"), "4-hex-digit shortcode must be accepted");
assert!(super::is_safe_shortcode("ABCD"), "upper-case hex shortcode must be accepted");
assert!(super::is_safe_shortcode("beef"), "lower-case hex shortcode must be accepted");
}
#[test]
fn is_safe_shortcode_alphanumeric_within_32_chars_accepted() {
let long_code = "a".repeat(32);
assert!(super::is_safe_shortcode(&long_code), "32-char alphanumeric must be accepted");
}
#[test]
fn is_safe_shortcode_empty_is_rejected() {
assert!(!super::is_safe_shortcode(""), "empty shortcode must be rejected");
}
#[test]
fn is_safe_shortcode_too_long_is_rejected() {
let long_code = "a".repeat(33);
assert!(!super::is_safe_shortcode(&long_code), "33-char shortcode must be rejected");
}
#[test]
fn is_safe_shortcode_slash_is_rejected() {
assert!(!super::is_safe_shortcode("ab/cd"), "shortcode with '/' must be rejected");
assert!(!super::is_safe_shortcode("/evil"), "absolute path shortcode must be rejected");
}
#[test]
fn is_safe_shortcode_dot_dot_is_rejected() {
assert!(
!super::is_safe_shortcode("../evil"),
"path traversal shortcode must be rejected"
);
assert!(!super::is_safe_shortcode(".."), "'..' shortcode must be rejected");
}
#[test]
fn is_safe_shortcode_backslash_is_rejected() {
assert!(!super::is_safe_shortcode("ab\\cd"), "shortcode with '\\' must be rejected");
}
#[test]
fn is_safe_shortcode_dot_is_rejected() {
assert!(!super::is_safe_shortcode("ab.cd"), "shortcode with '.' must be rejected");
}
#[test]
fn resolve_project_rejects_unsafe_shortcode() {
let unsafe_examples = ["../evil", "/abs", "ab/cd", "a\\b", ""];
for s in &unsafe_examples {
assert!(
!super::is_safe_shortcode(s),
"is_safe_shortcode must reject '{s}' — resolve_project would have returned ServerError for this input"
);
}
}
#[test]
fn data_model_name_from_iri_standard_form() {
assert_eq!(
super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2"),
"beol"
);
}
#[test]
fn data_model_name_from_iri_no_v2_suffix() {
assert_eq!(
super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol"),
"beol"
);
}
#[test]
fn data_model_name_from_iri_trailing_slash() {
assert_eq!(
super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2/"),
"beol"
);
}
#[test]
fn data_model_name_from_iri_bare_name() {
assert_eq!(super::data_model_name_from_iri("beol"), "beol");
}
#[test]
fn data_model_name_from_iri_empty_string() {
assert_eq!(super::data_model_name_from_iri(""), "");
}
fn beol_prefixes() -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("beol".to_string(), "http://api.dasch.swiss/ontology/0801/beol/v2#".to_string());
m
}
#[test]
fn expand_class_id_curie_expands_with_known_prefix() {
let (name, iri) = super::expand_class_id("beol:Archive", &beol_prefixes());
assert_eq!(name, "Archive");
assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
}
#[test]
fn expand_class_id_unknown_prefix_falls_back_to_raw_id() {
let (name, iri) = super::expand_class_id("urn:uuid:x", &HashMap::new());
assert_eq!(name, "x");
assert_eq!(iri, "urn:uuid:x");
}
#[test]
fn expand_class_id_full_iri_passes_through() {
let (name, iri) =
super::expand_class_id("http://api.dasch.swiss/ontology/0801/beol/v2#Letter", &beol_prefixes());
assert_eq!(name, "Letter");
assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Letter");
}
#[test]
fn expand_class_id_no_colon_degenerate() {
let (name, iri) = super::expand_class_id("bare", &HashMap::new());
assert_eq!(name, "bare");
assert_eq!(iri, "bare");
}
#[test]
fn local_name_hash_iri() {
assert_eq!(super::local_name("http://example.org/onto#Thing"), "Thing");
}
#[test]
fn local_name_slash_iri() {
assert_eq!(super::local_name("http://example.org/onto/Thing"), "Thing");
}
#[test]
fn local_name_curie_colon() {
assert_eq!(super::local_name("incunabula:Page"), "Page");
}
#[test]
fn local_name_bare_name_fallback() {
assert_eq!(super::local_name("Page"), "Page");
}
#[test]
fn local_name_empty_string() {
assert_eq!(super::local_name(""), "");
}
#[test]
fn local_name_trailing_separator() {
assert_eq!(super::local_name("foo#"), "");
}
#[test]
fn object_type_to_kebab_text_value() {
assert_eq!(super::object_type_to_kebab("TextValue"), "text");
}
#[test]
fn object_type_to_kebab_geom_value() {
assert_eq!(super::object_type_to_kebab("GeomValue"), "geom");
}
#[test]
fn object_type_to_kebab_geo_name_value() {
assert_eq!(super::object_type_to_kebab("GeoNameValue"), "geo-name");
}
#[test]
fn object_type_to_kebab_uri_value() {
assert_eq!(super::object_type_to_kebab("URIValue"), "uri");
}
#[test]
fn object_type_to_kebab_interval_value() {
assert_eq!(super::object_type_to_kebab("IntervalValue"), "interval");
}
#[test]
fn object_type_to_kebab_no_value_suffix() {
assert_eq!(super::object_type_to_kebab("Geom"), "geom");
}
#[test]
fn map_object_type_known_text_value() {
use crate::model::ValueType;
assert_eq!(super::map_object_type_to_value_type("TextValue"), ValueType::Text);
}
#[test]
fn map_object_type_known_list_value() {
use crate::model::ValueType;
assert_eq!(super::map_object_type_to_value_type("ListValue"), ValueType::VocabularyItem);
}
#[test]
fn map_object_type_other_geom() {
use crate::model::ValueType;
assert_eq!(
super::map_object_type_to_value_type("GeomValue"),
ValueType::Other("geom".to_string())
);
}
#[test]
fn map_object_type_other_uri_value() {
use crate::model::ValueType;
assert_eq!(
super::map_object_type_to_value_type("URIValue"),
ValueType::Other("uri".to_string())
);
}
#[test]
fn map_object_type_other_geo_name_value() {
use crate::model::ValueType;
assert_eq!(
super::map_object_type_to_value_type("GeoNameValue"),
ValueType::Other("geo-name".to_string())
);
}
#[test]
fn decode_cardinality_owl_cardinality_1() {
use crate::model::Cardinality;
let v = serde_json::json!({"owl:cardinality": 1});
assert_eq!(super::decode_cardinality(&v), Cardinality::One);
}
#[test]
fn decode_cardinality_owl_max_cardinality_1() {
use crate::model::Cardinality;
let v = serde_json::json!({"owl:maxCardinality": 1});
assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrOne);
}
#[test]
fn decode_cardinality_owl_min_cardinality_0() {
use crate::model::Cardinality;
let v = serde_json::json!({"owl:minCardinality": 0});
assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
}
#[test]
fn decode_cardinality_owl_min_cardinality_1() {
use crate::model::Cardinality;
let v = serde_json::json!({"owl:minCardinality": 1});
assert_eq!(super::decode_cardinality(&v), Cardinality::OneOrMore);
}
#[test]
fn decode_cardinality_fallback_no_key() {
use crate::model::Cardinality;
let v = serde_json::json!({});
assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
}
#[test]
fn decode_cardinality_fallback_owl_cardinality_unexpected_value() {
use crate::model::Cardinality;
let v = serde_json::json!({"owl:cardinality": 5});
assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
}
#[test]
fn decode_cardinality_fallback_owl_max_cardinality_gt1() {
use crate::model::Cardinality;
let v = serde_json::json!({"owl:maxCardinality": 2});
assert_eq!(
super::decode_cardinality(&v),
Cardinality::ZeroOrMore,
"owl:maxCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
);
}
#[test]
fn decode_cardinality_fallback_owl_min_cardinality_gt1() {
use crate::model::Cardinality;
let v = serde_json::json!({"owl:minCardinality": 2});
assert_eq!(
super::decode_cardinality(&v),
Cardinality::ZeroOrMore,
"owl:minCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
);
}
#[test]
fn detect_representation_still_image() {
use crate::model::Representation;
let locals = vec!["hasStillImageFileValue"];
assert_eq!(super::detect_representation(&locals), Some(Representation::StillImage));
}
#[test]
fn detect_representation_moving_image() {
use crate::model::Representation;
let locals = vec!["hasMovingImageFileValue"];
assert_eq!(super::detect_representation(&locals), Some(Representation::MovingImage));
}
#[test]
fn detect_representation_audio() {
use crate::model::Representation;
let locals = vec!["hasAudioFileValue"];
assert_eq!(super::detect_representation(&locals), Some(Representation::Audio));
}
#[test]
fn detect_representation_none_when_absent() {
let locals = vec!["hasTitle", "hasAuthor"];
assert_eq!(super::detect_representation(&locals), None);
}
#[test]
fn detect_representation_takes_first() {
use crate::model::Representation;
let locals = vec!["hasDocumentFileValue", "hasStillImageFileValue"];
assert_eq!(super::detect_representation(&locals), Some(Representation::Document));
}
#[test]
fn is_system_prefix_knora_api() {
assert!(super::is_system_prefix("knora-api"));
}
#[test]
fn is_system_prefix_rdf() {
assert!(super::is_system_prefix("rdf"));
}
#[test]
fn is_system_prefix_project_prefix_is_not_system() {
assert!(!super::is_system_prefix("incunabula"));
assert!(!super::is_system_prefix("beol"));
assert!(!super::is_system_prefix("biblio"));
}
#[test]
fn curie_prefix_returns_prefix_for_curie() {
assert_eq!(super::curie_prefix("knora-api:arkUrl"), Some("knora-api"));
assert_eq!(super::curie_prefix("beol:hasTitle"), Some("beol"));
}
#[test]
fn curie_prefix_returns_none_for_full_iri() {
assert_eq!(
super::curie_prefix("http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle"),
None
);
}
#[test]
fn curie_prefix_returns_none_for_no_colon() {
assert_eq!(super::curie_prefix("hasTitle"), None);
}
#[test]
fn sibling_iri_trim_hash_delimiter() {
let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
let trimmed = namespace.trim_end_matches(['#', '/']);
assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
}
#[test]
fn sibling_iri_trim_slash_delimiter() {
let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2/";
let trimmed = namespace.trim_end_matches(['#', '/']);
assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
}
#[test]
fn sibling_iri_self_loop_detected() {
let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
let namespace = "http://api.dasch.swiss/ontology/0801/beol/v2#";
let sibling_iri = namespace.trim_end_matches(['#', '/']);
let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
assert_eq!(sibling_iri, queried_trimmed); }
#[test]
fn sibling_iri_different_ontology_is_not_self_loop() {
let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
let sibling_iri = namespace.trim_end_matches(['#', '/']);
let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
assert_ne!(sibling_iri, queried_trimmed); }
#[test]
fn missing_prefix_in_context_is_skipped() {
let prefixes: HashMap<String, String> = HashMap::new();
let result = prefixes.get("biblio");
assert!(result.is_none()); }
#[test]
fn derive_access_rv() {
assert_eq!(super::derive_access("RV"), Some(super::ResourceAccess::RestrictedView));
}
#[test]
fn derive_access_v() {
assert_eq!(super::derive_access("V"), Some(super::ResourceAccess::View));
}
#[test]
fn derive_access_m() {
assert_eq!(super::derive_access("M"), Some(super::ResourceAccess::Edit));
}
#[test]
fn derive_access_d() {
assert_eq!(super::derive_access("D"), Some(super::ResourceAccess::Delete));
}
#[test]
fn derive_access_cr() {
assert_eq!(super::derive_access("CR"), Some(super::ResourceAccess::Manage));
}
#[test]
fn derive_access_unknown_is_none() {
assert_eq!(super::derive_access("XYZ"), None);
}
#[test]
fn derive_access_empty_is_none() {
assert_eq!(super::derive_access(""), None);
}
#[test]
fn derive_visibility_public_when_unknown_user_has_view() {
let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|V knora-admin:KnownUser,knora-admin:UnknownUser";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::Public));
}
#[test]
fn derive_visibility_public_when_unknown_user_has_cr() {
let acl = "CR knora-admin:UnknownUser";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::Public));
}
#[test]
fn derive_visibility_public_restricted_when_unknown_user_has_rv() {
let acl = "RV knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::PublicRestricted));
}
#[test]
fn derive_visibility_logged_in_when_known_user_has_rv_unknown_absent() {
let acl = "RV knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::LoggedInUsers));
}
#[test]
fn derive_visibility_logged_in_when_known_user_has_v() {
let acl = "V knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::LoggedInUsers));
}
#[test]
fn derive_visibility_project_members_when_neither_world_group_granted() {
let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|M knora-admin:ProjectMember";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::ProjectMembers));
}
#[test]
fn derive_visibility_empty_string_is_none() {
assert_eq!(super::derive_visibility(""), None);
}
#[test]
fn derive_visibility_whitespace_only_is_none() {
assert_eq!(super::derive_visibility(" "), None);
}
#[test]
fn derive_visibility_malformed_entry_without_space_is_skipped() {
let acl = "CRMALFORMED|CR knora-admin:ProjectAdmin";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::ProjectMembers));
}
#[test]
fn derive_visibility_unknown_code_ranks_zero_no_implicit_grant() {
let acl = "BOGUS knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::ProjectMembers));
}
#[test]
fn derive_visibility_same_group_two_entries_max_wins() {
let acl = "RV knora-admin:UnknownUser|V knora-admin:UnknownUser";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::Public));
}
#[test]
fn derive_visibility_both_world_groups_unknown_user_decides() {
let acl = "V knora-admin:UnknownUser|CR knora-admin:KnownUser";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::Public));
}
#[test]
fn derive_visibility_super_unknown_user_does_not_match() {
let acl = "CR knora-admin:SuperUnknownUser|CR knora-admin:ProjectAdmin";
assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::ProjectMembers));
}
#[test]
fn derive_visibility_all_malformed_entries_no_space_returns_none() {
let acl = "NOSPACE|ALSONOSPACE|STILLNOSPACE";
assert_eq!(
super::derive_visibility(acl),
None,
"all-malformed ACL (no space in any entry) must return None"
);
}
use crate::model::ValueType;
use crate::model::resource::{DatePoint, DateValue, FileValue, ValueContent};
#[test]
fn parse_value_text_plain() {
let obj = serde_json::json!({
"@type": "knora-api:TextValue",
"knora-api:valueAsString": "Hello world"
});
let (content, is_link) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Text("Hello world".into()));
assert!(!is_link);
}
#[test]
fn parse_value_text_standoff_xml_stripped() {
let obj = serde_json::json!({
"@type": "knora-api:TextValue",
"knora-api:textValueAsXml": "<p>Hello <b>world</b></p>",
"knora-api:valueAsString": "This is ignored when xml present"
});
let (content, is_link) = super::parse_value_content(&obj);
assert!(matches!(content, ValueContent::Text(_)));
assert!(!is_link);
if let ValueContent::Text(s) = content {
assert!(!s.contains('<'), "no raw tags: {s:?}");
assert!(s.contains("Hello"), "text retained: {s:?}");
}
}
#[test]
fn parse_value_integer() {
let obj = serde_json::json!({
"@type": "knora-api:IntValue",
"knora-api:intValueAsInt": 42
});
let (content, is_link) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Integer(42));
assert!(!is_link);
}
#[test]
fn parse_value_integer_negative() {
let obj = serde_json::json!({
"@type": "knora-api:IntValue",
"knora-api:intValueAsInt": -7
});
let (content, _) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Integer(-7));
}
#[test]
fn parse_value_decimal_object_form() {
let obj = serde_json::json!({
"@type": "knora-api:DecimalValue",
"knora-api:decimalValueAsDecimal": {"@value": "3.14159", "@type": "xsd:decimal"}
});
let (content, is_link) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Decimal("3.14159".into()));
assert!(!is_link);
}
#[test]
fn parse_value_decimal_bare_string_form() {
let obj = serde_json::json!({
"@type": "knora-api:DecimalValue",
"knora-api:decimalValueAsDecimal": "2.71828"
});
let (content, _) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Decimal("2.71828".into()));
}
#[test]
fn parse_value_boolean_true() {
let obj = serde_json::json!({
"@type": "knora-api:BooleanValue",
"knora-api:booleanValueAsBoolean": true
});
let (content, is_link) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Boolean(true));
assert!(!is_link);
}
#[test]
fn parse_value_boolean_false() {
let obj = serde_json::json!({
"@type": "knora-api:BooleanValue",
"knora-api:booleanValueAsBoolean": false
});
let (content, _) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Boolean(false));
}
#[test]
fn parse_value_date_single_point() {
let obj = serde_json::json!({
"@type": "knora-api:DateValue",
"knora-api:dateValueHasCalendar": "GREGORIAN",
"knora-api:dateValueHasStartYear": 1489,
"knora-api:dateValueHasStartEra": "CE",
"knora-api:dateValueHasEndYear": 1489,
"knora-api:dateValueHasEndEra": "CE"
});
let (content, is_link) = super::parse_value_content(&obj);
assert!(!is_link);
let expected = ValueContent::Date(DateValue {
calendar: "GREGORIAN".into(),
start: DatePoint {
year: Some(1489),
month: None,
day: None,
era: Some("CE".into()),
},
end: DatePoint {
year: Some(1489),
month: None,
day: None,
era: Some("CE".into()),
},
});
assert_eq!(content, expected);
}
#[test]
fn parse_value_date_range() {
let obj = serde_json::json!({
"@type": "knora-api:DateValue",
"knora-api:dateValueHasCalendar": "GREGORIAN",
"knora-api:dateValueHasStartYear": 1489,
"knora-api:dateValueHasStartEra": "CE",
"knora-api:dateValueHasEndYear": 1490,
"knora-api:dateValueHasEndEra": "CE"
});
let (content, _) = super::parse_value_content(&obj);
if let ValueContent::Date(dv) = content {
assert_eq!(dv.start.year, Some(1489));
assert_eq!(dv.end.year, Some(1490));
assert_ne!(dv.start, dv.end, "range: start != end");
} else {
panic!("expected DateValue, got {content:?}");
}
}
#[test]
fn parse_value_date_full_day_precision() {
let obj = serde_json::json!({
"@type": "knora-api:DateValue",
"knora-api:dateValueHasCalendar": "JULIAN",
"knora-api:dateValueHasStartYear": 1456,
"knora-api:dateValueHasStartMonth": 3,
"knora-api:dateValueHasStartDay": 14,
"knora-api:dateValueHasStartEra": "CE",
"knora-api:dateValueHasEndYear": 1456,
"knora-api:dateValueHasEndMonth": 3,
"knora-api:dateValueHasEndDay": 14,
"knora-api:dateValueHasEndEra": "CE"
});
let (content, _) = super::parse_value_content(&obj);
if let ValueContent::Date(dv) = content {
assert_eq!(dv.calendar, "JULIAN");
assert_eq!(dv.start.month, Some(3));
assert_eq!(dv.start.day, Some(14));
} else {
panic!("expected DateValue, got {content:?}");
}
}
#[test]
fn parse_value_date_no_year_falls_back_to_raw() {
let obj = serde_json::json!({
"@type": "knora-api:DateValue",
"knora-api:dateValueHasCalendar": "GREGORIAN",
"knora-api:valueAsString": "some date"
});
let (content, _) = super::parse_value_content(&obj);
assert!(
matches!(content, ValueContent::Raw { value_type, .. } if value_type == "date"),
"missing years must degrade to Raw date"
);
}
#[test]
fn parse_value_time() {
let obj = serde_json::json!({
"@type": "knora-api:TimeValue",
"knora-api:timeValueAsTimeStamp": {"@value": "2021-01-01T12:00:00Z", "@type": "xsd:dateTimeStamp"}
});
let (content, is_link) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Time("2021-01-01T12:00:00Z".into()));
assert!(!is_link);
}
#[test]
fn parse_value_time_bare_string() {
let obj = serde_json::json!({
"@type": "knora-api:TimeValue",
"knora-api:timeValueAsTimeStamp": "2022-06-01T00:00:00Z"
});
let (content, _) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Time("2022-06-01T00:00:00Z".into()));
}
#[test]
fn parse_value_uri() {
let obj = serde_json::json!({
"@type": "knora-api:UriValue",
"knora-api:uriValueAsUri": {"@value": "https://example.com", "@type": "xsd:anyURI"}
});
let (content, is_link) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Uri("https://example.com".into()));
assert!(!is_link);
}
#[test]
fn parse_value_color() {
let obj = serde_json::json!({
"@type": "knora-api:ColorValue",
"knora-api:colorValueAsColor": "#ff0000"
});
let (content, is_link) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Color("#ff0000".into()));
assert!(!is_link);
}
#[test]
fn parse_value_geoname() {
let obj = serde_json::json!({
"@type": "knora-api:GeonameValue",
"knora-api:geonameValueAsGeonameCode": "2661552"
});
let (content, is_link) = super::parse_value_content(&obj);
assert_eq!(content, ValueContent::Geoname("2661552".into()));
assert!(!is_link);
}
#[test]
fn parse_value_vocabulary_item() {
let obj = serde_json::json!({
"@type": "knora-api:ListValue",
"knora-api:listValueAsListNode": {"@id": "http://rdfh.ch/lists/0001/node1"}
});
let (content, is_link) = super::parse_value_content(&obj);
assert_eq!(
content,
ValueContent::VocabularyItem {
node_iri: "http://rdfh.ch/lists/0001/node1".into(),
label: None, }
);
assert!(!is_link);
}
#[test]
fn parse_value_link_with_embedded_target() {
let obj = serde_json::json!({
"@type": "knora-api:LinkValue",
"knora-api:linkValueHasTarget": {
"@id": "http://rdfh.ch/0803/res1",
"@type": "incunabula:Book",
"rdfs:label": "Incunabula Book 1"
}
});
let (content, is_link) = super::parse_value_content(&obj);
assert!(is_link, "LinkValue must set is_link=true");
assert_eq!(
content,
ValueContent::Link {
target_iri: "http://rdfh.ch/0803/res1".into(),
target_label: Some("Incunabula Book 1".into()),
}
);
}
#[test]
fn parse_value_link_with_target_iri_only() {
let obj = serde_json::json!({
"@type": "knora-api:LinkValue",
"knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res2"}
});
let (content, is_link) = super::parse_value_content(&obj);
assert!(is_link);
assert_eq!(
content,
ValueContent::Link {
target_iri: "http://rdfh.ch/0803/res2".into(),
target_label: None,
}
);
}
#[test]
fn parse_value_still_image_file() {
let obj = serde_json::json!({
"@type": "knora-api:StillImageFileValue",
"knora-api:fileValueHasFilename": "image.jp2",
"knora-api:fileValueAsUrl": {"@value": "https://iiif.example.com/image.jp2/full/max/0/default.jpg"},
"knora-api:stillImageFileValueHasDimX": 1200,
"knora-api:stillImageFileValueHasDimY": 800
});
let (content, is_link) = super::parse_value_content(&obj);
assert!(!is_link);
assert_eq!(
content,
ValueContent::File(FileValue {
value_type: ValueType::StillImage,
filename: "image.jp2".into(),
url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
width: Some(1200),
height: Some(800),
})
);
}
#[test]
fn parse_value_still_image_external_file_value() {
let obj = serde_json::json!({
"@type": "knora-api:StillImageExternalFileValue",
"knora-api:fileValueHasFilename": "external.jpg",
"knora-api:fileValueAsUrl": {"@value": "https://iiif.external.com/image.jpg"}
});
let (content, _) = super::parse_value_content(&obj);
if let ValueContent::File(fv) = content {
assert_eq!(fv.value_type, ValueType::StillImage, "StillImageExternal* → StillImage");
} else {
panic!("expected File, got {content:?}");
}
}
#[test]
fn parse_value_moving_image_file() {
let obj = serde_json::json!({
"@type": "knora-api:MovingImageFileValue",
"knora-api:fileValueHasFilename": "video.mp4",
"knora-api:fileValueAsUrl": {"@value": "https://example.com/video.mp4"}
});
let (content, is_link) = super::parse_value_content(&obj);
assert!(!is_link);
assert_eq!(
content,
ValueContent::File(FileValue {
value_type: ValueType::MovingImage,
filename: "video.mp4".into(),
url: "https://example.com/video.mp4".into(),
width: None,
height: None,
})
);
}
#[test]
fn parse_value_audio_file() {
let obj = serde_json::json!({
"@type": "knora-api:AudioFileValue",
"knora-api:fileValueHasFilename": "sound.wav",
"knora-api:fileValueAsUrl": {"@value": "https://example.com/sound.wav"}
});
let (content, _) = super::parse_value_content(&obj);
assert_eq!(
content,
ValueContent::File(FileValue {
value_type: ValueType::Audio,
filename: "sound.wav".into(),
url: "https://example.com/sound.wav".into(),
width: None,
height: None,
})
);
}
#[test]
fn parse_value_document_file() {
let obj = serde_json::json!({
"@type": "knora-api:DocumentFileValue",
"knora-api:fileValueHasFilename": "doc.pdf",
"knora-api:fileValueAsUrl": {"@value": "https://example.com/doc.pdf"}
});
let (content, _) = super::parse_value_content(&obj);
assert_eq!(
content,
ValueContent::File(FileValue {
value_type: ValueType::Document,
filename: "doc.pdf".into(),
url: "https://example.com/doc.pdf".into(),
width: None,
height: None,
})
);
}
#[test]
fn parse_value_archive_file() {
let obj = serde_json::json!({
"@type": "knora-api:ArchiveFileValue",
"knora-api:fileValueHasFilename": "data.zip",
"knora-api:fileValueAsUrl": {"@value": "https://example.com/data.zip"}
});
let (content, _) = super::parse_value_content(&obj);
assert_eq!(
content,
ValueContent::File(FileValue {
value_type: ValueType::Archive,
filename: "data.zip".into(),
url: "https://example.com/data.zip".into(),
width: None,
height: None,
})
);
}
#[test]
fn parse_value_text_file_value_maps_to_document() {
let obj = serde_json::json!({
"@type": "knora-api:TextFileValue",
"knora-api:fileValueHasFilename": "text.txt",
"knora-api:fileValueAsUrl": {"@value": "https://example.com/text.txt"}
});
let (content, _) = super::parse_value_content(&obj);
if let ValueContent::File(fv) = content {
assert_eq!(fv.value_type, ValueType::Document, "TextFileValue → Document");
} else {
panic!("expected File, got {content:?}");
}
}
#[test]
fn parse_value_interval_raw_fallback() {
let obj = serde_json::json!({
"@type": "knora-api:IntervalValue",
"knora-api:intervalValueHasStart": {"@value": "0.0", "@type": "xsd:decimal"},
"knora-api:intervalValueHasEnd": {"@value": "10.5", "@type": "xsd:decimal"},
"knora-api:valueAsString": "0.0 - 10.5"
});
let (content, is_link) = super::parse_value_content(&obj);
assert!(!is_link);
assert!(
matches!(content, ValueContent::Raw { ref value_type, .. } if value_type == "interval"),
"IntervalValue must degrade to Raw with token 'interval'"
);
if let ValueContent::Raw { text, .. } = content {
assert_eq!(text, "0.0 - 10.5");
}
}
#[test]
fn parse_value_geom_raw_fallback() {
let obj = serde_json::json!({
"@type": "knora-api:GeomValue",
"knora-api:geometryValueAsGeometry": "POINT(1 2)"
});
let (content, _) = super::parse_value_content(&obj);
assert!(
matches!(content, ValueContent::Raw { value_type, .. } if value_type == "geom"),
"GeomValue must degrade to Raw with token 'geom'"
);
}
#[test]
fn parse_value_with_comment() {
let obj = serde_json::json!({
"@type": "knora-api:TextValue",
"knora-api:valueAsString": "Hello world",
"knora-api:valueHasComment": "reading uncertain"
});
let (value, is_link) = super::parse_value(&obj);
assert_eq!(value.content, ValueContent::Text("Hello world".into()));
assert_eq!(value.comment.as_deref(), Some("reading uncertain"));
assert!(!is_link);
}
#[test]
fn parse_value_without_comment() {
let obj = serde_json::json!({
"@type": "knora-api:TextValue",
"knora-api:valueAsString": "Hello world"
});
let (value, is_link) = super::parse_value(&obj);
assert_eq!(value.content, ValueContent::Text("Hello world".into()));
assert_eq!(value.comment, None);
assert!(!is_link);
}
#[test]
fn parse_value_with_empty_comment() {
let obj = serde_json::json!({
"@type": "knora-api:TextValue",
"knora-api:valueAsString": "Hello world",
"knora-api:valueHasComment": ""
});
let (value, is_link) = super::parse_value(&obj);
assert_eq!(value.content, ValueContent::Text("Hello world".into()));
assert_eq!(value.comment, None);
assert!(!is_link);
}
#[test]
fn parse_value_link_is_link_true() {
let obj = serde_json::json!({
"@type": "knora-api:LinkValue",
"knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
});
let (_, is_link) = super::parse_value_content(&obj);
assert!(is_link, "LinkValue must report is_link=true for name derivation");
}
#[test]
fn field_name_link_strips_value_suffix() {
let key = "incunabula:isPartOfBookValue";
let link_obj = serde_json::json!({
"@type": "knora-api:LinkValue",
"knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
});
let (_, is_link) = super::parse_value_content(&link_obj);
assert!(is_link, "LinkValue must report is_link=true for name derivation");
let raw_name = super::local_name(key).to_string();
let name = if is_link {
raw_name.strip_suffix("Value").unwrap_or(&raw_name).to_string()
} else {
raw_name
};
assert_eq!(name, "isPartOfBook");
}
#[test]
fn field_name_non_link_does_not_strip_value_suffix() {
let key = "incunabula:hasAValue";
let text_obj = serde_json::json!({
"@type": "knora-api:TextValue",
"knora-api:valueAsString": "some text"
});
let (_, is_link) = super::parse_value_content(&text_obj);
assert!(!is_link, "TextValue must report is_link=false");
let raw_name = super::local_name(key).to_string();
let name = if is_link {
raw_name.strip_suffix("Value").unwrap_or(&raw_name).to_string()
} else {
raw_name
};
assert_eq!(
name, "hasAValue",
"non-link ending in Value must NOT be stripped; is_link={is_link}"
);
}
#[test]
fn has_value_class_type_rejects_xsd_any_uri() {
let obj = serde_json::json!({
"@value": "http://ark.dasch.swiss/ark:/…",
"@type": "xsd:anyURI"
});
assert!(
!super::has_value_class_type(&obj),
"xsd:anyURI must not pass the value-class test"
);
}
#[test]
fn has_value_class_type_rejects_scalar() {
let obj = serde_json::json!("just a string");
assert!(!super::has_value_class_type(&obj));
}
#[test]
fn has_value_class_type_accepts_text_value() {
let obj = serde_json::json!({
"@type": "knora-api:TextValue",
"knora-api:valueAsString": "hello"
});
assert!(super::has_value_class_type(&obj));
}
#[test]
fn has_value_class_type_accepts_still_image_file_value() {
let obj = serde_json::json!({
"@type": "knora-api:StillImageFileValue",
"knora-api:fileValueHasFilename": "img.jp2"
});
assert!(super::has_value_class_type(&obj));
}
#[test]
fn build_prefix_map_string_entries_only() {
let ctx = Some(serde_json::json!({
"incunabula": "http://api.dasch.swiss/ontology/0803/incunabula/v2#",
"knora-api": "http://api.knora.org/ontology/knora-api/v2#",
"someterm": {"@id": "http://example.com/term", "@type": "@id"}
}));
let map = super::build_prefix_map(&ctx);
assert_eq!(
map.get("incunabula").map(String::as_str),
Some("http://api.dasch.swiss/ontology/0803/incunabula/v2#")
);
assert_eq!(
map.get("knora-api").map(String::as_str),
Some("http://api.knora.org/ontology/knora-api/v2#")
);
assert!(!map.contains_key("someterm"), "object-valued entry must be skipped");
}
#[test]
fn build_prefix_map_empty_when_no_context() {
let map = super::build_prefix_map(&None);
assert!(map.is_empty());
}
#[test]
fn compact_value_text_excludes_meta_keys() {
let obj = serde_json::json!({
"@id": "http://rdfh.ch/0803/val1",
"@type": "knora-api:GeomValue",
"knora-api:geometryValueAsGeometry": "POINT(1 2)"
});
let text = super::compact_value_text(&obj);
assert!(text.contains("geometryValueAsGeometry"), "geometry key present: {text}");
assert!(!text.contains("@id"), "@id must be excluded: {text}");
assert!(!text.contains("@type"), "@type must be excluded: {text}");
}
#[test]
fn compact_value_text_all_meta_yields_empty() {
let obj = serde_json::json!({
"@id": "http://rdfh.ch/0803/val1",
"@type": "knora-api:IntervalValue"
});
let text = super::compact_value_text(&obj);
assert!(text.is_empty(), "all-meta object must yield empty string: {text:?}");
}
#[test]
fn list_get_response_root_shape_parses_as_root_variant() {
let json = serde_json::json!({
"type": "ListGetResponseADM",
"list": {
"listinfo": {
"id": "http://rdfh.ch/lists/0001/root",
"projectIri": "http://rdfh.ch/projects/0001",
"name": "root-name",
"labels": [
{"value": "Root EN", "language": "en"},
{"value": "Root DE", "language": "de"}
],
"comments": []
},
"children": [
{"id": "n2", "name": "n2", "labels": [], "comments": [], "position": 1, "children": []},
{"id": "n1", "name": "n1", "labels": [], "comments": [], "position": 0, "children": [
{"id": "n1a", "name": "n1a", "labels": [], "comments": [], "position": 0, "children": []}
]}
]
}
});
let parsed: ListGetResponseDto = serde_json::from_value(json).expect("root shape must parse");
let root = match parsed {
ListGetResponseDto::Root(root) => root,
ListGetResponseDto::Node(_) => panic!("expected Root variant, got Node"),
};
let tree = build_vocabulary_tree(root.list, None);
assert_eq!(tree.root.iri, "http://rdfh.ch/lists/0001/root");
assert_eq!(tree.root.name.as_deref(), Some("root-name"));
assert_eq!(tree.root.labels.len(), 2, "both languages kept (D4)");
assert_eq!(tree.project_iri, "http://rdfh.ch/projects/0001");
assert_eq!(tree.requested_node, None);
assert_eq!(tree.children.len(), 2);
assert_eq!(tree.children[0].header.iri, "n1");
assert_eq!(tree.children[1].header.iri, "n2");
assert_eq!(tree.children[0].children.len(), 1);
assert_eq!(tree.children[0].children[0].header.iri, "n1a");
}
#[test]
fn list_get_response_node_shape_parses_as_node_variant_and_extracts_has_root_node() {
let json = serde_json::json!({
"type": "ListNodeGetResponseADM",
"node": {
"nodeinfo": {
"id": "http://rdfh.ch/lists/0001/n1",
"name": "n1",
"labels": [{"value": "N1", "language": "en"}],
"comments": [],
"position": 0,
"hasRootNode": "http://rdfh.ch/lists/0001/root"
},
"children": []
}
});
let parsed: ListGetResponseDto = serde_json::from_value(json).expect("node shape must parse");
match parsed {
ListGetResponseDto::Node(node) => {
assert_eq!(node.node.nodeinfo.has_root_node, "http://rdfh.ch/lists/0001/root");
}
ListGetResponseDto::Root(_) => panic!("expected Node variant, got Root"),
}
}
#[test]
fn list_get_response_neither_key_fails_parse() {
let json = serde_json::json!({"type": "SomethingUnexpected", "foo": "bar"});
let parsed = serde_json::from_value::<ListGetResponseDto>(json);
assert!(parsed.is_err(), "a response with neither `list` nor `node` must fail to parse");
}
#[test]
fn into_localized_texts_keeps_all_languages_no_filtering() {
let dtos = vec![
ListLabelDto { value: "a".into(), language: Some("en".into()) },
ListLabelDto { value: "b".into(), language: None },
];
let texts = into_localized_texts(dtos);
assert_eq!(texts.len(), 2);
assert_eq!(texts[0].value, "a");
assert_eq!(texts[0].language.as_deref(), Some("en"));
assert_eq!(texts[1].value, "b");
assert_eq!(texts[1].language, None);
}
#[test]
fn convert_list_nodes_sorts_and_nests_out_of_order_input() {
let leaf_2b1 = ListNodeDto {
id: "2b1".into(),
name: None,
labels: vec![],
comments: vec![],
position: 0,
children: vec![],
};
let node_2b = ListNodeDto {
id: "2b".into(),
name: None,
labels: vec![],
comments: vec![],
position: 1,
children: vec![leaf_2b1],
};
let node_2a = ListNodeDto {
id: "2a".into(),
name: None,
labels: vec![],
comments: vec![],
position: 0,
children: vec![],
};
let node_2 = ListNodeDto {
id: "2".into(),
name: None,
labels: vec![],
comments: vec![],
position: 1,
children: vec![node_2b, node_2a],
};
let node_1 = ListNodeDto {
id: "1".into(),
name: None,
labels: vec![],
comments: vec![],
position: 0,
children: vec![],
};
let converted = convert_list_nodes(vec![node_2, node_1]);
assert_eq!(converted.len(), 2);
assert_eq!(converted[0].header.iri, "1");
assert_eq!(converted[0].position, 0);
assert_eq!(converted[1].header.iri, "2");
assert_eq!(converted[1].position, 1);
let node2_children = &converted[1].children;
assert_eq!(node2_children.len(), 2);
assert_eq!(node2_children[0].header.iri, "2a");
assert_eq!(node2_children[1].header.iri, "2b");
assert_eq!(node2_children[1].children.len(), 1);
assert_eq!(node2_children[1].children[0].header.iri, "2b1");
}
#[test]
fn build_vocabulary_tree_sets_requested_node_when_provided() {
let list = ListRootDto {
listinfo: ListInfoDto {
id: "root".into(),
project_iri: "proj".into(),
name: Some("Root".into()),
labels: vec![],
comments: vec![],
},
children: vec![],
};
let tree = build_vocabulary_tree(list, Some("node-iri".into()));
assert_eq!(tree.requested_node.as_deref(), Some("node-iri"));
assert_eq!(tree.root.iri, "root");
assert_eq!(tree.project_iri, "proj");
assert!(tree.children.is_empty());
}
}