1use std::io::{Read, Write};
29use std::time::Duration;
30
31use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
32
33use std::collections::{HashMap, HashSet};
34
35use crate::client::DspClient;
36use crate::client::builtins::builtin_field_value_type;
37use crate::client::jwt::extract_exp;
38use crate::diagnostic::Diagnostic;
39use crate::model::auth::LoginResponse;
40use crate::model::resource::{DatePoint, DateValue, FieldValues, FileValue, Value, ValueContent};
41use crate::model::{
42 Cardinality, CreateDumpOutcome, DataModel, DataModelDetail, DataModelStructure,
43 DataModelSummary, DumpStatus, DumpTask, Field, Project, ProjectDescription, ProjectDetail,
44 ProjectRef, ProjectStatus, Relation, RelationKind, Representation, ResourceAccess,
45 ResourceDetail, ResourcePage, ResourceSummary, ResourceTypeDetail, ResourceTypeSummary,
46 ResourceVisibility, ValueType,
47};
48
49#[derive(serde::Deserialize)]
58struct LoginApiResponse {
59 token: String,
60}
61
62#[derive(serde::Deserialize)]
68struct ProjectGetApiResponse {
69 project: ProjectApiDto,
70}
71
72#[derive(serde::Deserialize)]
73struct ProjectApiDto {
74 id: String,
75 shortcode: String,
76 shortname: String,
77}
78
79#[derive(serde::Deserialize)]
86struct DataTaskStatusApiResponse {
87 id: String,
88 status: String,
89 #[serde(default, rename = "errorMessage")]
90 error_message: Option<String>,
91 #[serde(default, rename = "createdAt")]
96 created_at: Option<String>,
97}
98
99#[derive(serde::Deserialize)]
107struct V3ErrorBody {
108 #[serde(default)]
109 errors: Vec<V3ErrorItem>,
110}
111
112#[derive(serde::Deserialize)]
113struct V3ErrorItem {
114 code: String,
115 #[serde(default)]
116 details: std::collections::HashMap<String, String>,
117}
118
119#[derive(serde::Deserialize)]
124struct ProjectsListApiResponse {
125 projects: Vec<ProjectListItemDto>,
126}
127
128#[derive(serde::Deserialize)]
133struct ProjectListItemDto {
134 id: String,
135 shortname: String,
136 shortcode: String,
137 #[serde(default)]
138 longname: Option<String>,
139 status: bool,
145 #[serde(default)]
146 ontologies: Vec<String>,
147}
148
149#[derive(serde::Deserialize)]
158struct ProjectDetailApiResponse {
159 project: ProjectDetailApiDto,
160}
161
162#[derive(serde::Deserialize)]
163struct ProjectDetailApiDto {
164 id: String,
165 shortcode: String,
166 shortname: String,
167 #[serde(default)]
168 longname: Option<String>,
169 status: bool,
172 #[serde(default)]
173 description: Vec<ProjectDescriptionDto>,
174 #[serde(default)]
175 keywords: Vec<String>,
176 #[serde(default)]
177 ontologies: Vec<String>,
178}
179
180#[derive(serde::Deserialize)]
181struct ProjectDescriptionDto {
182 value: String,
183 #[serde(default)]
184 language: Option<String>,
185}
186
187#[derive(serde::Deserialize)]
199struct OntologyMetadataResponse {
200 #[serde(rename = "@graph")]
201 graph: Option<Vec<OntologyMetadataDto>>,
202 #[serde(rename = "@id")]
204 id: Option<String>,
205 #[serde(rename = "rdfs:label")]
206 label: Option<String>,
207 #[serde(rename = "knora-api:lastModificationDate", default)]
208 last_modification_date: Option<LastModDto>,
209}
210
211#[derive(serde::Deserialize)]
212struct OntologyMetadataDto {
213 #[serde(rename = "@id")]
214 id: String,
215 #[serde(rename = "rdfs:label")]
216 label: Option<String>,
217 #[serde(rename = "knora-api:lastModificationDate", default)]
218 last_modification_date: Option<LastModDto>,
219}
220
221#[derive(serde::Deserialize)]
229struct LastModDto {
230 #[serde(rename = "@value")]
231 value: String,
232}
233
234#[derive(serde::Deserialize)]
238struct OntologyAllEntitiesResponse {
239 #[serde(rename = "@id")]
240 id: String,
241 #[serde(rename = "rdfs:label")]
242 label: Option<String>,
243 #[serde(rename = "knora-api:lastModificationDate", default)]
244 last_modification_date: Option<LastModDto>,
245 #[serde(rename = "@graph", default)]
246 graph: Vec<OntologyEntityDto>,
247 #[serde(rename = "@context", default)]
255 context: HashMap<String, serde_json::Value>,
256}
257
258#[derive(serde::Deserialize)]
278struct OntologyEntityDto {
279 #[serde(rename = "@id")]
280 id: String,
281 #[serde(rename = "rdfs:label")]
282 label: Option<String>,
283 #[serde(rename = "knora-api:isResourceClass", default)]
284 is_resource_class: bool,
285 #[serde(rename = "rdfs:subClassOf", default)]
290 sub_class_of: Vec<serde_json::Value>,
291 #[serde(rename = "knora-api:objectType")]
294 object_type: Option<ObjectTypeDto>,
295 #[serde(rename = "knora-api:isLinkProperty", default)]
297 is_link_property: bool,
298 #[serde(rename = "knora-api:isLinkValueProperty", default)]
301 is_link_value_property: bool,
302 #[serde(rename = "knora-api:isResourceProperty", default)]
304 is_resource_property: bool,
305}
306
307#[derive(serde::Deserialize, Clone)]
309struct ObjectTypeDto {
310 #[serde(rename = "@id")]
311 id: String,
312}
313
314struct ExportExists<'a> {
319 id: Option<&'a str>,
321 project_iri: Option<&'a str>,
323}
324
325impl V3ErrorBody {
326 fn export_exists(&self) -> Option<ExportExists<'_>> {
332 self.errors
333 .iter()
334 .find(|e| e.code == "export_exists")
335 .map(|e| ExportExists {
336 id: e.details.get("id").map(String::as_str),
337 project_iri: e.details.get("projectIri").map(String::as_str),
338 })
339 }
340}
341
342impl DataTaskStatusApiResponse {
343 fn into_dump_task(self) -> Result<DumpTask, Diagnostic> {
356 validate_dump_id(&self.id)?;
360
361 let status = match self.status.as_str() {
362 "in_progress" => DumpStatus::InProgress,
363 "completed" => DumpStatus::Completed,
364 "failed" => DumpStatus::Failed,
365 other => {
366 return Err(Diagnostic::ServerError(format!(
367 "server returned unknown dump status: '{other}'"
368 )));
369 }
370 };
371
372 let error_message = self.error_message.map(|raw| {
376 let truncated = if raw.chars().count() > 500 {
377 raw.chars().take(500).collect::<String>()
378 } else {
379 raw
380 };
381 tracing::trace!("dump task error_message (truncated): {}", truncated);
382 truncated
383 });
384
385 let created_at = self.created_at.and_then(|s| {
388 match chrono::DateTime::parse_from_rfc3339(&s) {
389 Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
390 Err(_) => {
391 tracing::debug!(raw = %s, "dump task createdAt could not be parsed as RFC3339; using None");
392 None
393 }
394 }
395 });
396
397 Ok(DumpTask {
398 id: self.id,
399 status,
400 error_message,
401 created_at,
402 })
403 }
404}
405
406fn identifier_key(user: &str) -> &'static str {
414 if user.starts_with("http://") || user.starts_with("https://") {
415 "iri"
416 } else if user.contains('@') {
417 "email"
418 } else {
419 "username"
420 }
421}
422
423enum ProjectIdent<'a> {
434 Iri(&'a str),
435 Shortcode(&'a str),
436 Shortname(&'a str),
437}
438
439fn classify(project: &str) -> ProjectIdent<'_> {
440 if project.starts_with("http://") || project.starts_with("https://") {
441 ProjectIdent::Iri(project)
442 } else if project.len() == 4 && project.chars().all(|c| c.is_ascii_hexdigit()) {
443 ProjectIdent::Shortcode(project)
444 } else {
445 ProjectIdent::Shortname(project)
446 }
447}
448
449fn enc(iri: &str) -> String {
455 utf8_percent_encode(iri, NON_ALPHANUMERIC).to_string()
456}
457
458fn map_unexpected_status(status: reqwest::StatusCode, url: &str) -> Diagnostic {
470 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
471 Diagnostic::AuthRequired(
475 "your token may be missing, expired, or lack permission — run \
476 `dsp auth login` to (re)authenticate"
477 .into(),
478 )
479 } else if status.is_server_error() {
480 Diagnostic::ServerError(format!("server returned {status} for {url}"))
481 } else {
482 Diagnostic::ServerError(format!("unexpected status {status} for {url}"))
483 }
484}
485
486fn validate_dump_id(id: &str) -> Result<(), Diagnostic> {
494 if id.is_empty()
495 || id.len() > 256 || !id
497 .chars()
498 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
499 {
500 let preview: String = id.chars().take(40).collect();
502 let suffix = if id.chars().count() > 40 { "…" } else { "" };
503 return Err(Diagnostic::ServerError(format!(
504 "server returned an invalid dump id: '{preview}{suffix}'"
505 )));
506 }
507 Ok(())
508}
509
510fn project_lookup_url(base: &str, project: &str) -> String {
516 match classify(project) {
517 ProjectIdent::Shortcode(code) => {
518 format!("{base}/admin/projects/shortcode/{code}")
519 }
520 ProjectIdent::Shortname(name) => {
521 format!("{base}/admin/projects/shortname/{name}")
522 }
523 ProjectIdent::Iri(iri) => {
524 format!("{base}/admin/projects/iri/{}", enc(iri))
525 }
526 }
527}
528
529fn is_safe_shortcode(s: &str) -> bool {
539 !s.is_empty() && s.len() <= 32 && s.chars().all(|c| c.is_ascii_alphanumeric())
540}
541
542fn local_name(id: &str) -> &str {
547 id.rsplit(['#', '/', ':']).next().unwrap_or(id)
548}
549
550fn expand_class_id(id: &str, prefixes: &HashMap<String, String>) -> (String, String) {
561 let name = local_name(id).to_string();
562 let iri = match id.split_once(':') {
563 Some((prefix, local)) if !local.starts_with("//") => prefixes
564 .get(prefix)
565 .map(|ns| format!("{ns}{local}"))
566 .unwrap_or_else(|| id.to_string()),
567 _ => id.to_string(), };
569 (name, iri)
570}
571
572pub(crate) fn data_model_name_from_iri(iri: &str) -> String {
584 let t = iri.trim_end_matches('/');
585 let t = t.strip_suffix("/v2").unwrap_or(t);
586 t.rsplit('/').next().unwrap_or(t).to_string()
587}
588
589const SYSTEM_PREFIXES: &[&str] = &[
598 "knora-api",
599 "knora-base",
600 "rdf",
601 "rdfs",
602 "owl",
603 "salsah-gui",
604 "standoff",
605 "xsd",
606];
607
608const FILE_VALUE_PROPS: &[(&str, Representation)] = &[
613 ("hasStillImageFileValue", Representation::StillImage),
614 ("hasMovingImageFileValue", Representation::MovingImage),
615 ("hasAudioFileValue", Representation::Audio),
616 ("hasDocumentFileValue", Representation::Document),
617 ("hasArchiveFileValue", Representation::Archive),
618 ("hasTextFileValue", Representation::Text),
619];
620
621const MAX_SIBLING_FETCHES: usize = 16;
624
625fn is_system_prefix(prefix: &str) -> bool {
631 SYSTEM_PREFIXES.contains(&prefix)
632}
633
634fn map_object_type_to_value_type(local: &str) -> ValueType {
645 match local {
646 "TextValue" => ValueType::Text,
647 "IntValue" => ValueType::Integer,
648 "DecimalValue" => ValueType::Decimal,
649 "BooleanValue" => ValueType::Boolean,
650 "DateValue" => ValueType::Date,
651 "TimeValue" => ValueType::Time,
652 "UriValue" => ValueType::Uri,
653 "ColorValue" => ValueType::Color,
654 "GeonameValue" => ValueType::Geoname,
655 "ListValue" => ValueType::ListItem,
656 "StillImageFileValue" => ValueType::StillImage,
657 "MovingImageFileValue" => ValueType::MovingImage,
658 "AudioFileValue" => ValueType::Audio,
659 "DocumentFileValue" => ValueType::Document,
660 "ArchiveFileValue" => ValueType::Archive,
661 other => ValueType::Other(object_type_to_kebab(other)),
662 }
663}
664
665fn object_type_to_kebab(local: &str) -> String {
672 let base = local.strip_suffix("Value").unwrap_or(local);
674
675 let mut result = String::with_capacity(base.len() + 4);
678 let chars: Vec<char> = base.chars().collect();
679 for (i, &ch) in chars.iter().enumerate() {
680 if i > 0 && ch.is_uppercase() {
681 if chars[i - 1].is_lowercase() {
683 result.push('-');
684 }
685 }
686 result.push(ch);
687 }
688 result.to_lowercase()
689}
690
691fn decode_cardinality(restriction: &serde_json::Value) -> Cardinality {
697 let as_u64 =
699 |key: &str| -> Option<u64> { restriction.get(key).and_then(serde_json::Value::as_u64) };
700
701 if let Some(v) = as_u64("owl:cardinality") {
702 if v == 1 {
703 return Cardinality::One;
704 }
705 tracing::warn!(
706 value = v,
707 "owl:cardinality had unexpected value (expected 1); falling back to ZeroOrMore"
708 );
709 return Cardinality::ZeroOrMore;
710 }
711
712 if let Some(v) = as_u64("owl:maxCardinality") {
713 if v == 1 {
714 return Cardinality::ZeroOrOne;
715 }
716 tracing::warn!(
717 value = v,
718 "owl:maxCardinality had unexpected value (expected 1); falling back to ZeroOrMore"
719 );
720 return Cardinality::ZeroOrMore;
721 }
722
723 if let Some(v) = as_u64("owl:minCardinality") {
724 return match v {
725 0 => Cardinality::ZeroOrMore,
726 1 => Cardinality::OneOrMore,
727 other => {
728 tracing::warn!(
729 value = other,
730 "owl:minCardinality had unexpected value (expected 0 or 1); falling back to ZeroOrMore"
731 );
732 Cardinality::ZeroOrMore
733 }
734 };
735 }
736
737 tracing::warn!("owl:Restriction has no recognized cardinality key; falling back to ZeroOrMore");
738 Cardinality::ZeroOrMore
739}
740
741fn detect_representation(restriction_prop_locals: &[&str]) -> Option<Representation> {
747 for local in restriction_prop_locals {
748 for (file_val_local, repr) in FILE_VALUE_PROPS {
749 if local == file_val_local {
750 return Some(*repr);
751 }
752 }
753 }
754 None
755}
756
757fn curie_prefix(id: &str) -> Option<&str> {
760 id.split_once(':')
761 .filter(|(_, local)| !local.starts_with("//"))
762 .map(|(prefix, _)| prefix)
763}
764
765#[derive(serde::Deserialize)]
781struct ResourceListDto {
782 #[serde(rename = "@graph", default)]
784 graph: Option<Vec<ResourceNodeDto>>,
785
786 #[serde(rename = "@id", default)]
788 id: Option<String>,
789
790 #[serde(rename = "@type", default)]
793 type_field: Option<serde_json::Value>,
794
795 #[serde(rename = "rdfs:label", default)]
797 label: Option<serde_json::Value>,
798
799 #[serde(rename = "knora-api:arkUrl", default)]
801 ark_url: Option<serde_json::Value>,
802
803 #[serde(rename = "knora-api:creationDate", default)]
805 creation_date: Option<serde_json::Value>,
806
807 #[serde(rename = "knora-api:lastModificationDate", default)]
809 last_modification_date: Option<serde_json::Value>,
810
811 #[serde(rename = "knora-api:mayHaveMoreResults", default)]
813 may_have_more_results: bool,
814}
815
816#[derive(serde::Deserialize)]
823struct ResourceNodeDto {
824 #[serde(rename = "@id")]
825 id: String,
826
827 #[serde(rename = "@type", default)]
829 type_field: Option<serde_json::Value>,
830
831 #[serde(rename = "rdfs:label", default)]
833 label: Option<serde_json::Value>,
834
835 #[serde(rename = "knora-api:arkUrl", default)]
837 ark_url: Option<serde_json::Value>,
838
839 #[serde(rename = "knora-api:creationDate", default)]
841 creation_date: Option<serde_json::Value>,
842
843 #[serde(rename = "knora-api:lastModificationDate", default)]
845 last_modification_date: Option<serde_json::Value>,
846}
847
848fn extract_string_value(v: &serde_json::Value) -> Option<String> {
853 match v {
854 serde_json::Value::String(s) => Some(s.clone()),
855 serde_json::Value::Object(map) => map
856 .get("@value")
857 .or_else(|| map.get("@id"))
858 .and_then(|inner| inner.as_str())
859 .map(str::to_owned),
860 _ => None,
861 }
862}
863
864fn extract_resource_type(type_val: Option<&serde_json::Value>) -> String {
871 match type_val {
872 None => "unknown".to_string(),
873 Some(serde_json::Value::String(s)) => local_name(s).to_string(),
874 Some(serde_json::Value::Array(arr)) => arr
875 .first()
876 .and_then(|v| v.as_str())
877 .map(|s| local_name(s).to_string())
878 .unwrap_or_else(|| "unknown".to_string()),
879 _ => "unknown".to_string(),
880 }
881}
882
883fn node_dto_to_summary(
885 id: String,
886 type_val: Option<&serde_json::Value>,
887 label_val: Option<&serde_json::Value>,
888 ark_val: Option<&serde_json::Value>,
889 creation_val: Option<&serde_json::Value>,
890 last_modification_val: Option<&serde_json::Value>,
891) -> ResourceSummary {
892 let label = label_val.and_then(extract_string_value).unwrap_or_default();
893 let resource_type = extract_resource_type(type_val);
894 let ark_url = ark_val.and_then(extract_string_value);
895 let creation_date = creation_val.and_then(extract_string_value);
903 let last_modified = last_modification_val.and_then(extract_string_value);
904 ResourceSummary {
905 label,
906 iri: id,
907 ark_url,
908 creation_date,
909 last_modified,
910 resource_type,
911 }
912}
913
914#[derive(serde::Deserialize)]
934struct ResourceDetailDto {
935 #[serde(rename = "@id")]
936 id: String,
937
938 #[serde(rename = "@type", default)]
940 type_field: Option<serde_json::Value>,
941
942 #[serde(rename = "rdfs:label", default)]
944 label: Option<serde_json::Value>,
945
946 #[serde(rename = "knora-api:arkUrl", default)]
948 ark_url: Option<serde_json::Value>,
949
950 #[serde(rename = "knora-api:creationDate", default)]
952 creation_date: Option<serde_json::Value>,
953
954 #[serde(rename = "knora-api:lastModificationDate", default)]
956 last_modification_date: Option<serde_json::Value>,
957
958 #[serde(rename = "knora-api:attachedToProject", default)]
960 attached_to_project: Option<serde_json::Value>,
961
962 #[serde(rename = "knora-api:attachedToUser", default)]
964 attached_to_user: Option<serde_json::Value>,
965
966 #[serde(rename = "knora-api:hasPermissions", default)]
969 has_permissions: Option<String>,
970
971 #[serde(rename = "knora-api:userHasPermission", default)]
974 user_has_permission: Option<String>,
975
976 #[serde(rename = "@context", default)]
983 context: Option<serde_json::Value>,
984
985 #[serde(flatten)]
993 extra: serde_json::Map<String, serde_json::Value>,
994}
995
996fn permission_rank(code: &str) -> u8 {
1002 match code {
1003 "RV" => 1,
1004 "V" => 2,
1005 "M" => 6,
1006 "D" => 7,
1007 "CR" => 8,
1008 _ => 0,
1009 }
1010}
1011
1012fn derive_access(user_has_permission: &str) -> Option<ResourceAccess> {
1022 match user_has_permission {
1023 "RV" => Some(ResourceAccess::RestrictedView),
1024 "V" => Some(ResourceAccess::View),
1025 "M" => Some(ResourceAccess::Edit),
1026 "D" => Some(ResourceAccess::Delete),
1027 "CR" => Some(ResourceAccess::Manage),
1028 _ => None,
1029 }
1030}
1031
1032fn derive_visibility(has_permissions: &str) -> Option<ResourceVisibility> {
1042 if has_permissions.trim().is_empty() {
1043 return None;
1044 }
1045
1046 let mut unknown_rank: u8 = 0;
1047 let mut known_rank: u8 = 0;
1048 let mut parsed_any = false;
1049
1050 for entry in has_permissions.split('|') {
1051 let entry = entry.trim();
1052 if entry.is_empty() {
1053 continue;
1054 }
1055 let Some((code, group_list)) = entry.split_once(' ') else {
1057 continue;
1059 };
1060 parsed_any = true;
1061 let rank = permission_rank(code);
1062 for group in group_list.split(',') {
1063 let group_local = local_name(group.trim());
1064 if group_local == "UnknownUser" {
1065 unknown_rank = unknown_rank.max(rank);
1066 } else if group_local == "KnownUser" {
1067 known_rank = known_rank.max(rank);
1068 }
1069 }
1070 }
1071
1072 if !parsed_any {
1073 return None;
1074 }
1075
1076 let v_rank = permission_rank("V");
1080 let rv_rank = permission_rank("RV");
1081
1082 if unknown_rank >= v_rank {
1083 Some(ResourceVisibility::Public)
1084 } else if unknown_rank >= rv_rank {
1085 Some(ResourceVisibility::PublicRestricted)
1087 } else if known_rank >= rv_rank {
1088 Some(ResourceVisibility::LoggedInUsers)
1089 } else {
1090 Some(ResourceVisibility::ProjectMembers)
1091 }
1092}
1093
1094pub struct HttpDspClient {
1100 client: reqwest::blocking::Client,
1103 download_client: reqwest::blocking::Client,
1108}
1109
1110impl HttpDspClient {
1111 pub fn new() -> Result<Self, Diagnostic> {
1120 let client = reqwest::blocking::Client::builder()
1121 .connect_timeout(Duration::from_secs(10))
1122 .timeout(Duration::from_secs(30))
1123 .build()
1124 .map_err(|e| Diagnostic::Internal(format!("failed to build HTTP client: {e}")))?;
1125 let download_client = reqwest::blocking::Client::builder()
1126 .connect_timeout(Some(Duration::from_secs(30)))
1127 .timeout(None)
1128 .build()
1129 .map_err(|e| {
1130 Diagnostic::Internal(format!("failed to build download HTTP client: {e}"))
1131 })?;
1132 Ok(Self {
1133 client,
1134 download_client,
1135 })
1136 }
1137
1138 fn fetch_allentities(
1148 &self,
1149 server: &str,
1150 ontology_iri: &str,
1151 token: Option<&str>,
1152 ) -> Result<OntologyAllEntitiesResponse, Diagnostic> {
1153 let url = format!(
1154 "{}/v2/ontologies/allentities/{}",
1155 server.trim_end_matches('/'),
1156 enc(ontology_iri)
1157 );
1158
1159 let req = self.client.get(&url);
1160 let req = if let Some(t) = token {
1161 req.bearer_auth(t)
1162 } else {
1163 req
1164 };
1165
1166 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
1167 let status = response.status();
1168
1169 if status.is_success() {
1170 let resp: OntologyAllEntitiesResponse = response.json().map_err(|e| {
1171 Diagnostic::ServerError(format!("data-model response could not be parsed: {e}"))
1172 })?;
1173 Ok(resp)
1174 } else {
1175 Err(map_unexpected_status(status, &url))
1176 }
1177 }
1178}
1179
1180impl HttpDspClient {
1181 fn parse_resource_values(
1193 &self,
1194 server: &str,
1195 token: Option<&str>,
1196 context_val: &Option<serde_json::Value>,
1197 extra: &serde_json::Map<String, serde_json::Value>,
1198 ) -> Vec<FieldValues> {
1199 let prefixes: HashMap<String, String> = build_prefix_map(context_val);
1201
1202 const DENYLIST: &[&str] = &[
1205 "knora-api:hasIncomingLinkValue",
1206 "knora-api:hasStandoffLinkToValue",
1207 "knora-api:hasStandoffLinkValue", ];
1209
1210 let mut field_entries: Vec<(&str, Vec<&serde_json::Value>)> = Vec::new();
1213
1214 for (key, val) in extra.iter() {
1215 if DENYLIST.contains(&key.as_str()) {
1216 continue;
1217 }
1218
1219 let objs: Vec<&serde_json::Value> = match val {
1221 serde_json::Value::Array(arr) => arr.iter().collect(),
1222 obj @ serde_json::Value::Object(_) => vec![obj],
1223 _ => continue, };
1225
1226 if objs.is_empty() {
1227 continue;
1228 }
1229
1230 let first = match objs.first() {
1233 Some(v) => v,
1234 None => continue,
1235 };
1236 if !has_value_class_type(first) {
1237 continue;
1238 }
1239
1240 field_entries.push((key.as_str(), objs));
1241 }
1242
1243 struct ParsedField<'a> {
1246 key: &'a str,
1247 is_link: bool,
1248 values: Vec<Value>,
1249 }
1250
1251 let mut parsed_fields: Vec<ParsedField> = Vec::new();
1252
1253 for (key, objs) in &field_entries {
1254 let mut contents: Vec<Value> = Vec::new();
1255 let mut any_link = false;
1256
1257 for obj in objs {
1258 if get_type_local(obj) == "DeletedValue" {
1260 continue;
1261 }
1262 let (content, is_link) = parse_value(obj);
1263 if is_link {
1264 any_link = true;
1265 }
1266 contents.push(content);
1267 }
1268
1269 if contents.is_empty() {
1270 continue;
1271 }
1272
1273 parsed_fields.push(ParsedField {
1274 key,
1275 is_link: any_link,
1276 values: contents,
1277 });
1278 }
1279
1280 let mut ontology_labels: HashMap<String, HashMap<String, String>> = HashMap::new(); let mut fetched_ontologies: HashSet<String> = HashSet::new();
1285
1286 for pf in &parsed_fields {
1287 let prefix = curie_prefix(pf.key).unwrap_or("");
1288 if is_system_prefix(prefix) || prefix.is_empty() {
1289 continue; }
1291 let namespace = match prefixes.get(prefix) {
1293 Some(ns) => ns,
1294 None => continue,
1295 };
1296 let ont_iri = namespace.trim_end_matches(['#', '/']).to_string();
1297 if fetched_ontologies.insert(ont_iri.clone()) {
1298 match self.fetch_allentities(server, &ont_iri, token) {
1302 Ok(resp) => {
1303 let mut prop_map: HashMap<String, String> = HashMap::new();
1304 let ctx_prefixes: HashMap<String, String> = resp
1305 .context
1306 .iter()
1307 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1308 .collect();
1309 for entity in resp.graph {
1310 if let Some(lbl) = entity.label {
1311 let (_, iri) = expand_class_id(&entity.id, &ctx_prefixes);
1312 prop_map.insert(iri, lbl);
1313 }
1314 }
1315 ontology_labels.insert(ont_iri, prop_map);
1316 }
1317 Err(e) => {
1318 tracing::warn!(
1320 prefix = %prefix,
1321 error = %e,
1322 "field-label ontology fetch failed; using local name as fallback"
1323 );
1324 }
1325 }
1326 }
1327 }
1328
1329 let mut node_labels: HashMap<String, Option<String>> = HashMap::new();
1331
1332 for pf in &parsed_fields {
1334 for v in &pf.values {
1335 if let ValueContent::ListItem { node_iri, .. } = &v.content {
1336 node_labels.entry(node_iri.clone()).or_insert(None);
1337 }
1338 }
1339 }
1340
1341 for (node_iri, label_slot) in node_labels.iter_mut() {
1343 let url = format!("{}/v2/node/{}", server.trim_end_matches('/'), enc(node_iri));
1347 let req = self.client.get(&url);
1348 let req = if let Some(t) = token {
1349 req.bearer_auth(t)
1350 } else {
1351 req
1352 };
1353 match req.send() {
1354 Ok(resp) if resp.status().is_success() => {
1355 match resp.json::<serde_json::Value>() {
1357 Ok(body) => {
1358 let lbl = body.get("rdfs:label").and_then(extract_string_value);
1360 *label_slot = lbl;
1361 }
1362 Err(_) => {
1363 tracing::debug!(
1364 node_iri = %node_iri,
1365 "list-node label response could not be parsed as JSON; using node IRI as fallback"
1366 );
1367 }
1368 }
1369 }
1370 Ok(resp) => {
1371 tracing::debug!(
1373 node_iri = %node_iri,
1374 status = %resp.status(),
1375 "list-node label fetch returned non-success; using node IRI as fallback"
1376 );
1377 }
1378 Err(e) => {
1379 tracing::debug!(
1380 node_iri = %node_iri,
1381 error = %e,
1382 "list-node label fetch failed; using node IRI as fallback"
1383 );
1384 }
1385 }
1386 }
1387
1388 let mut result: Vec<FieldValues> = Vec::new();
1390
1391 for pf in parsed_fields {
1392 let raw_name = local_name(pf.key).to_string();
1394 let name = if pf.is_link {
1395 raw_name
1396 .strip_suffix("Value")
1397 .unwrap_or(&raw_name)
1398 .to_string()
1399 } else {
1400 raw_name
1401 };
1402
1403 let label: Option<String> = {
1405 let prefix = curie_prefix(pf.key).unwrap_or("");
1406 if is_system_prefix(prefix) || prefix.is_empty() {
1407 None
1408 } else if let Some(ns) = prefixes.get(prefix) {
1409 let ont_iri = ns.trim_end_matches(['#', '/']).to_string();
1410 let local = local_name(pf.key);
1411 let prop_iri = format!("{}{}", ns, local);
1412 ontology_labels
1413 .get(&ont_iri)
1414 .and_then(|m| m.get(&prop_iri).cloned())
1415 } else {
1416 None
1417 }
1418 };
1419
1420 let values: Vec<Value> = pf
1422 .values
1423 .into_iter()
1424 .map(|v| match v.content {
1425 ValueContent::ListItem { node_iri, label: _ } => {
1426 let resolved = node_labels.get(&node_iri).cloned().flatten();
1427 Value {
1428 content: ValueContent::ListItem {
1429 node_iri,
1430 label: resolved,
1431 },
1432 comment: v.comment,
1433 }
1434 }
1435 other => Value {
1436 content: other,
1437 comment: v.comment,
1438 },
1439 })
1440 .collect();
1441
1442 result.push(FieldValues {
1443 name,
1444 label,
1445 values,
1446 });
1447 }
1448
1449 result
1450 }
1451}
1452
1453fn has_value_class_type(val: &serde_json::Value) -> bool {
1461 let type_local = get_type_local(val);
1462 type_local.ends_with("Value") && !type_local.is_empty() && {
1465 let raw_type = val
1467 .as_object()
1468 .and_then(|m| m.get("@type"))
1469 .and_then(|t| t.as_str())
1470 .unwrap_or("");
1471 raw_type.starts_with("knora-api:")
1472 }
1473}
1474
1475fn get_type_local(val: &serde_json::Value) -> &str {
1479 val.as_object()
1480 .and_then(|m| m.get("@type"))
1481 .and_then(|t| t.as_str())
1482 .map(local_name)
1483 .unwrap_or("")
1484}
1485
1486fn build_prefix_map(context_val: &Option<serde_json::Value>) -> HashMap<String, String> {
1492 match context_val {
1493 Some(serde_json::Value::Object(map)) => map
1494 .iter()
1495 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1496 .collect(),
1497 _ => HashMap::new(),
1498 }
1499}
1500
1501fn parse_value_content(obj: &serde_json::Value) -> (ValueContent, bool) {
1508 let type_local = get_type_local(obj);
1509
1510 match type_local {
1511 "TextValue" => {
1513 let content =
1516 if let Some(xml) = obj.get("knora-api:textValueAsXml").and_then(|v| v.as_str()) {
1517 crate::util::text::html_to_text(xml)
1518 } else {
1519 obj.get("knora-api:valueAsString")
1520 .and_then(|v| v.as_str())
1521 .unwrap_or("")
1522 .to_string()
1523 };
1524 (ValueContent::Text(content), false)
1525 }
1526
1527 "IntValue" => {
1529 let n = obj
1530 .get("knora-api:intValueAsInt")
1531 .and_then(|v| v.as_i64())
1532 .unwrap_or(0);
1533 (ValueContent::Integer(n), false)
1534 }
1535
1536 "DecimalValue" => {
1538 let s = obj
1540 .get("knora-api:decimalValueAsDecimal")
1541 .and_then(|v| {
1542 if let Some(s) = v.as_str() {
1544 Some(s.to_string())
1545 } else {
1546 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1547 }
1548 })
1549 .unwrap_or_default();
1550 (ValueContent::Decimal(s), false)
1551 }
1552
1553 "BooleanValue" => {
1555 let b = obj
1556 .get("knora-api:booleanValueAsBoolean")
1557 .and_then(|v| v.as_bool())
1558 .unwrap_or(false);
1559 (ValueContent::Boolean(b), false)
1560 }
1561
1562 "DateValue" => {
1564 let calendar = obj
1565 .get("knora-api:dateValueHasCalendar")
1566 .and_then(|v| v.as_str())
1567 .unwrap_or("GREGORIAN")
1568 .to_string();
1569
1570 let parse_point = |prefix: &str| -> DatePoint {
1571 let year_key = format!("knora-api:{prefix}Year");
1572 let month_key = format!("knora-api:{prefix}Month");
1573 let day_key = format!("knora-api:{prefix}Day");
1574 let era_key = format!("knora-api:{prefix}Era");
1575
1576 DatePoint {
1577 year: obj
1578 .get(year_key.as_str())
1579 .and_then(|v| v.as_i64())
1580 .map(|v| v as i32),
1581 month: obj
1582 .get(month_key.as_str())
1583 .and_then(|v| v.as_u64())
1584 .map(|v| v as u32),
1585 day: obj
1586 .get(day_key.as_str())
1587 .and_then(|v| v.as_u64())
1588 .map(|v| v as u32),
1589 era: obj
1590 .get(era_key.as_str())
1591 .and_then(|v| v.as_str())
1592 .map(str::to_owned),
1593 }
1594 };
1595
1596 let start = parse_point("dateValueHasStart");
1599 let end = parse_point("dateValueHasEnd");
1600
1601 if start.year.is_none() && end.year.is_none() {
1602 let raw_text = obj
1604 .get("knora-api:valueAsString")
1605 .and_then(|v| v.as_str())
1606 .unwrap_or("")
1607 .to_string();
1608 return (
1609 ValueContent::Raw {
1610 value_type: "date".to_string(),
1611 text: raw_text,
1612 },
1613 false,
1614 );
1615 }
1616
1617 (
1618 ValueContent::Date(DateValue {
1619 calendar,
1620 start,
1621 end,
1622 }),
1623 false,
1624 )
1625 }
1626
1627 "TimeValue" => {
1629 let s = obj
1630 .get("knora-api:timeValueAsTimeStamp")
1631 .and_then(|v| {
1632 if let Some(s) = v.as_str() {
1633 Some(s.to_string())
1634 } else {
1635 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1636 }
1637 })
1638 .unwrap_or_default();
1639 (ValueContent::Time(s), false)
1640 }
1641
1642 "UriValue" => {
1644 let s = obj
1645 .get("knora-api:uriValueAsUri")
1646 .and_then(|v| {
1647 if let Some(s) = v.as_str() {
1648 Some(s.to_string())
1649 } else {
1650 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1651 }
1652 })
1653 .unwrap_or_default();
1654 (ValueContent::Uri(s), false)
1655 }
1656
1657 "ColorValue" => {
1659 let s = obj
1660 .get("knora-api:colorValueAsColor")
1661 .and_then(|v| v.as_str())
1662 .unwrap_or("")
1663 .to_string();
1664 (ValueContent::Color(s), false)
1665 }
1666
1667 "GeonameValue" => {
1669 let s = obj
1670 .get("knora-api:geonameValueAsGeonameCode")
1671 .and_then(|v| v.as_str())
1672 .unwrap_or("")
1673 .to_string();
1674 (ValueContent::Geoname(s), false)
1675 }
1676
1677 "ListValue" => {
1679 let node_iri = obj
1681 .get("knora-api:listValueAsListNode")
1682 .and_then(|v| v.get("@id"))
1683 .and_then(|v| v.as_str())
1684 .unwrap_or("")
1685 .to_string();
1686 (
1687 ValueContent::ListItem {
1688 node_iri,
1689 label: None, },
1691 false,
1692 )
1693 }
1694
1695 "LinkValue" => {
1697 let (target_iri, target_label) =
1700 if let Some(target_obj) = obj.get("knora-api:linkValueHasTarget") {
1701 let iri = target_obj
1702 .get("@id")
1703 .and_then(|v| v.as_str())
1704 .unwrap_or("")
1705 .to_string();
1706 let lbl = target_obj.get("rdfs:label").and_then(extract_string_value);
1707 (iri, lbl)
1708 } else {
1709 let iri = obj
1710 .get("knora-api:linkValueHasTargetIri")
1711 .and_then(|v| v.get("@id"))
1712 .and_then(|v| v.as_str())
1713 .unwrap_or("")
1714 .to_string();
1715 (iri, None)
1716 };
1717 (
1718 ValueContent::Link {
1719 target_iri,
1720 target_label,
1721 },
1722 true, )
1724 }
1725
1726 t if t.ends_with("FileValue") => {
1729 let filename = obj
1730 .get("knora-api:fileValueHasFilename")
1731 .and_then(|v| v.as_str())
1732 .unwrap_or("")
1733 .to_string();
1734 let url_str = obj
1735 .get("knora-api:fileValueAsUrl")
1736 .and_then(|v| {
1737 if let Some(s) = v.as_str() {
1738 Some(s.to_string())
1739 } else {
1740 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1741 }
1742 })
1743 .unwrap_or_default();
1744
1745 let value_type_opt = if t.starts_with("StillImage") {
1747 Some(ValueType::StillImage)
1748 } else if t.starts_with("MovingImage") {
1749 Some(ValueType::MovingImage)
1750 } else if t.starts_with("Audio") {
1751 Some(ValueType::Audio)
1752 } else if t.starts_with("Document") || t.starts_with("Text") {
1753 Some(ValueType::Document)
1755 } else if t.starts_with("Archive") {
1756 Some(ValueType::Archive)
1757 } else {
1758 None };
1760
1761 match value_type_opt {
1762 Some(vt) => {
1763 let (width, height) = if vt == ValueType::StillImage {
1765 let w = obj
1766 .get("knora-api:stillImageFileValueHasDimX")
1767 .and_then(|v| v.as_u64())
1768 .map(|v| v as u32);
1769 let h = obj
1770 .get("knora-api:stillImageFileValueHasDimY")
1771 .and_then(|v| v.as_u64())
1772 .map(|v| v as u32);
1773 (w, h)
1774 } else {
1775 (None, None)
1776 };
1777 (
1778 ValueContent::File(FileValue {
1779 value_type: vt,
1780 filename,
1781 url: url_str,
1782 width,
1783 height,
1784 }),
1785 false,
1786 )
1787 }
1788 None => {
1789 let raw_text = obj
1791 .get("knora-api:valueAsString")
1792 .and_then(|v| v.as_str())
1793 .unwrap_or(&filename)
1794 .to_string();
1795 (
1796 ValueContent::Raw {
1797 value_type: object_type_to_kebab(t),
1798 text: raw_text,
1799 },
1800 false,
1801 )
1802 }
1803 }
1804 }
1805
1806 other => {
1808 let value_type = object_type_to_kebab(other);
1809 let raw_text = obj
1812 .get("knora-api:valueAsString")
1813 .and_then(|v| v.as_str())
1814 .map(str::to_owned)
1815 .unwrap_or_else(|| compact_value_text(obj));
1816 (
1817 ValueContent::Raw {
1818 value_type,
1819 text: raw_text,
1820 },
1821 false,
1822 )
1823 }
1824 }
1825}
1826
1827fn parse_value(obj: &serde_json::Value) -> (Value, bool) {
1833 let (content, is_link) = parse_value_content(obj);
1834 let comment = obj
1835 .get("knora-api:valueHasComment")
1836 .and_then(|v| v.as_str())
1837 .filter(|s| !s.trim().is_empty())
1838 .map(str::to_owned);
1839 (Value { content, comment }, is_link)
1840}
1841
1842const VALUE_META_KEYS: &[&str] = &[
1844 "@id",
1845 "@type",
1846 "knora-api:attachedToUser",
1847 "knora-api:hasPermissions",
1848 "knora-api:userHasPermission",
1849 "knora-api:valueCreationDate",
1850 "knora-api:valueHasComment",
1851 "knora-api:isDeleted",
1852 "knora-api:arkUrl",
1853 "knora-api:versionArkUrl",
1854 "knora-api:valueHasUUID",
1855];
1856
1857fn compact_value_text(obj: &serde_json::Value) -> String {
1862 if let Some(map) = obj.as_object() {
1863 let filtered: serde_json::Map<String, serde_json::Value> = map
1864 .iter()
1865 .filter(|(k, _)| !VALUE_META_KEYS.contains(&k.as_str()))
1866 .map(|(k, v)| (k.clone(), v.clone()))
1867 .collect();
1868 if filtered.is_empty() {
1869 String::new()
1870 } else {
1871 serde_json::to_string(&serde_json::Value::Object(filtered)).unwrap_or_default()
1872 }
1873 } else {
1874 String::new()
1875 }
1876}
1877
1878impl DspClient for HttpDspClient {
1879 fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic> {
1880 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
1881
1882 let mut body = serde_json::Map::with_capacity(2);
1883 body.insert(
1884 identifier_key(user).to_owned(),
1885 serde_json::Value::from(user),
1886 );
1887 body.insert("password".to_owned(), serde_json::Value::from(password));
1888
1889 let response = self
1890 .client
1891 .post(&url)
1892 .json(&body)
1893 .send()
1894 .map_err(|e| Diagnostic::Network(e.to_string()))?;
1895
1896 let status = response.status();
1897
1898 if status.is_success() {
1899 let api: LoginApiResponse = response.json().map_err(|e| {
1900 Diagnostic::ServerError(format!("login response could not be parsed: {e}"))
1901 })?;
1902 let expires_at = extract_exp(&api.token);
1903 Ok(LoginResponse {
1904 token: api.token,
1905 user: user.to_string(),
1906 expires_at,
1907 })
1908 } else if status == reqwest::StatusCode::UNAUTHORIZED
1909 || status == reqwest::StatusCode::FORBIDDEN
1910 {
1911 let body = response.text().unwrap_or_default();
1912 let preview: String = body.chars().take(200).collect();
1913 tracing::trace!("auth failure response body (capped): {}", preview);
1914 Err(Diagnostic::AuthRequired(format!(
1916 "Authentication failed on {server}"
1917 )))
1918 } else if status == reqwest::StatusCode::NOT_FOUND {
1919 Err(Diagnostic::NotFound(format!(
1920 "endpoint not found at {url}; check that --server resolves to a DSP-API instance, not just any HTTPS host"
1921 )))
1922 } else if status.is_server_error() {
1923 let body = response.text().unwrap_or_default();
1924 let preview: String = body.chars().take(200).collect();
1925 tracing::trace!("server error response body (capped): {}", preview);
1926 Err(Diagnostic::ServerError(format!("server returned {status}")))
1927 } else {
1928 Err(Diagnostic::ServerError(format!(
1929 "unexpected status: {status}"
1930 )))
1931 }
1932 }
1933
1934 fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic> {
1935 let base = server.trim_end_matches('/');
1936
1937 let url = project_lookup_url(base, project);
1938
1939 let response = self
1941 .client
1942 .get(&url)
1943 .send()
1944 .map_err(|e| Diagnostic::Network(e.to_string()))?;
1945
1946 let status = response.status();
1947
1948 if status.is_success() {
1949 let api: ProjectGetApiResponse = response.json().map_err(|e| {
1950 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
1951 })?;
1952 if !is_safe_shortcode(&api.project.shortcode) {
1953 return Err(Diagnostic::ServerError(
1954 "server returned a project with an unexpected shortcode".into(),
1955 ));
1956 }
1957 Ok(ProjectRef {
1958 iri: api.project.id,
1959 shortcode: api.project.shortcode,
1960 shortname: api.project.shortname,
1961 })
1962 } else if status == reqwest::StatusCode::NOT_FOUND {
1963 let display_input: String = project.chars().take(80).collect();
1965 let suffix = if project.chars().count() > 80 {
1966 "…"
1967 } else {
1968 ""
1969 };
1970 Err(Diagnostic::NotFound(format!(
1971 "project '{display_input}{suffix}' not found on {server}"
1972 )))
1973 } else {
1974 Err(map_unexpected_status(status, &url))
1975 }
1976 }
1977
1978 fn create_project_dump(
1979 &self,
1980 server: &str,
1981 project_iri: &str,
1982 skip_assets: bool,
1983 token: &str,
1984 ) -> Result<CreateDumpOutcome, Diagnostic> {
1985 let base = server.trim_end_matches('/');
1986 let url = format!(
1991 "{base}/v3/projects/{}/exports?skipAssets={skip_assets}",
1992 enc(project_iri)
1993 );
1994
1995 let response = self
1996 .client
1997 .post(&url)
1998 .bearer_auth(token)
1999 .send()
2000 .map_err(|e: reqwest::Error| Diagnostic::Network(e.to_string()))?;
2001
2002 let status = response.status();
2003
2004 match status.as_u16() {
2005 202 => {
2006 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2007 Diagnostic::ServerError(format!(
2008 "dump trigger response could not be parsed: {e}"
2009 ))
2010 })?;
2011 api.into_dump_task().map(CreateDumpOutcome::Created)
2012 }
2013 409 => {
2014 let body_text = response.text().unwrap_or_default();
2024 let error_body: Option<V3ErrorBody> = if body_text.len() <= 65536 {
2025 serde_json::from_str(&body_text).ok()
2026 } else {
2027 None
2028 };
2029 match error_body.as_ref().and_then(|b| b.export_exists()) {
2030 Some(ex) => {
2031 let id = ex.id.ok_or_else(|| {
2034 Diagnostic::ServerError(
2035 "the server's dump-conflict response was missing the dump id"
2036 .into(),
2037 )
2038 })?;
2039 validate_dump_id(id)?;
2040 match ex.project_iri {
2046 Some(owner) if owner == project_iri => {
2047 Ok(CreateDumpOutcome::Exists { id: id.to_string() })
2048 }
2049 Some(owner) => Ok(CreateDumpOutcome::ExistsForOtherProject {
2050 id: id.to_string(),
2051 project_iri: owner.to_string(),
2052 }),
2053 None => Err(Diagnostic::ServerError(
2056 "the server's dump-conflict response did not identify which \
2057project owns the existing dump; cannot safely proceed"
2058 .into(),
2059 )),
2060 }
2061 }
2062 None => Err(Diagnostic::ServerError(
2064 "server reported a 409 conflict whose detail could not be parsed".into(),
2066 )),
2067 }
2068 }
2069 401 | 403 => Err(Diagnostic::AuthRequired(
2070 "triggering a project dump requires a system-administrator token".into(),
2071 )),
2072 404 => Err(Diagnostic::NotFound(format!("project not found at {url}"))),
2073 _ => Err(map_unexpected_status(status, &url)),
2074 }
2075 }
2076
2077 fn get_project_dump_status(
2078 &self,
2079 server: &str,
2080 project_iri: &str,
2081 dump_id: &str,
2082 token: &str,
2083 ) -> Result<DumpTask, Diagnostic> {
2084 validate_dump_id(dump_id)?;
2085 let base = server.trim_end_matches('/');
2086 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2088
2089 let response = self
2090 .client
2091 .get(&url)
2092 .bearer_auth(token)
2093 .send()
2094 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2095
2096 let status = response.status();
2097
2098 match status.as_u16() {
2099 200 => {
2100 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2101 Diagnostic::ServerError(format!(
2102 "dump status response could not be parsed: {e}"
2103 ))
2104 })?;
2105 api.into_dump_task()
2106 }
2107 404 => Err(Diagnostic::NotFound(format!(
2108 "dump '{dump_id}' not found for project at {url}"
2109 ))),
2110 401 | 403 => Err(Diagnostic::AuthRequired(
2111 "fetching dump status requires a system-administrator token".into(),
2112 )),
2113 _ => Err(map_unexpected_status(status, &url)),
2114 }
2115 }
2116
2117 fn download_project_dump(
2118 &self,
2119 server: &str,
2120 project_iri: &str,
2121 dump_id: &str,
2122 token: &str,
2123 dest: &mut dyn Write,
2124 ) -> Result<u64, Diagnostic> {
2125 validate_dump_id(dump_id)?;
2126 let base = server.trim_end_matches('/');
2127 let url = format!(
2129 "{base}/v3/projects/{}/exports/{dump_id}/download",
2130 enc(project_iri)
2131 );
2132
2133 let mut response = self
2135 .download_client
2136 .get(&url)
2137 .bearer_auth(token)
2138 .send()
2139 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2140
2141 let status = response.status();
2142
2143 match status.as_u16() {
2146 200 => {
2147 let mut buf = [0u8; 64 * 1024];
2151 let mut total: u64 = 0;
2152 loop {
2153 let n = response
2154 .read(&mut buf)
2155 .map_err(|e| Diagnostic::Network(format!("download interrupted: {e}")))?;
2156 if n == 0 {
2157 break;
2158 }
2159 dest.write_all(&buf[..n]).map_err(|e| {
2160 Diagnostic::Io(format!("failed to write dump to disk: {e}"))
2161 })?;
2162 total += n as u64;
2163 }
2164 Ok(total)
2165 }
2166 409 => Err(Diagnostic::Conflict(
2167 "dump not ready — still in progress or failed".into(),
2168 )),
2169 404 => Err(Diagnostic::NotFound(format!(
2170 "dump '{dump_id}' not found at {url}"
2171 ))),
2172 401 | 403 => Err(Diagnostic::AuthRequired(
2173 "downloading a project dump requires a system-administrator token".into(),
2174 )),
2175 _ => Err(map_unexpected_status(status, &url)),
2176 }
2177 }
2178
2179 fn delete_project_dump(
2180 &self,
2181 server: &str,
2182 project_iri: &str,
2183 dump_id: &str,
2184 token: &str,
2185 ) -> Result<(), Diagnostic> {
2186 validate_dump_id(dump_id)?;
2187 let base = server.trim_end_matches('/');
2188 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2190
2191 let response = self
2192 .client
2193 .delete(&url)
2194 .bearer_auth(token)
2195 .send()
2196 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2197
2198 let status = response.status();
2199
2200 match status.as_u16() {
2201 204 => Ok(()),
2202 409 => Err(Diagnostic::Conflict(
2203 "dump is still in progress and cannot be deleted yet".into(),
2204 )),
2205 404 => Err(Diagnostic::NotFound(format!(
2206 "dump '{dump_id}' not found at {url}"
2207 ))),
2208 401 | 403 => Err(Diagnostic::AuthRequired(
2209 "deleting a project dump requires a system-administrator token".into(),
2210 )),
2211 _ => Err(map_unexpected_status(status, &url)),
2212 }
2213 }
2214
2215 fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic> {
2216 let base = server.trim_end_matches('/');
2217 let url = format!("{base}/admin/projects");
2218
2219 let req = self.client.get(&url);
2225 let req = if let Some(t) = token {
2226 req.bearer_auth(t)
2227 } else {
2228 req
2229 };
2230
2231 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2232
2233 let status = response.status();
2234
2235 if status.is_success() {
2236 let api: ProjectsListApiResponse = response.json().map_err(|e| {
2237 Diagnostic::ServerError(format!("projects list response could not be parsed: {e}"))
2238 })?;
2239 let projects = api
2240 .projects
2241 .into_iter()
2242 .map(|dto| Project {
2243 iri: dto.id,
2244 shortcode: dto.shortcode,
2245 shortname: dto.shortname,
2246 longname: dto.longname,
2247 status: if dto.status {
2251 ProjectStatus::Active
2252 } else {
2253 ProjectStatus::Inactive
2254 },
2255 data_models: dto.ontologies.len(),
2258 })
2259 .collect();
2260 Ok(projects)
2261 } else {
2262 Err(map_unexpected_status(status, &url))
2263 }
2264 }
2265
2266 fn describe_project(
2267 &self,
2268 server: &str,
2269 project: &str,
2270 token: Option<&str>,
2271 ) -> Result<ProjectDetail, Diagnostic> {
2272 let base = server.trim_end_matches('/');
2273 let url = project_lookup_url(base, project);
2274
2275 let req = self.client.get(&url);
2279 let req = if let Some(t) = token {
2280 req.bearer_auth(t)
2281 } else {
2282 req
2283 };
2284
2285 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2286
2287 let status = response.status();
2288
2289 if status.is_success() {
2290 let api: ProjectDetailApiResponse = response.json().map_err(|e| {
2291 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
2292 })?;
2293 let dto = api.project;
2294
2295 let project_status = if dto.status {
2297 ProjectStatus::Active
2298 } else {
2299 ProjectStatus::Inactive
2300 };
2301
2302 let description = dto
2304 .description
2305 .into_iter()
2306 .map(|d| ProjectDescription {
2307 value: d.value,
2308 language: d.language,
2309 })
2310 .collect();
2311
2312 let mut data_models: Vec<DataModelSummary> = dto
2314 .ontologies
2315 .into_iter()
2316 .map(|iri| {
2317 let name = data_model_name_from_iri(&iri);
2318 DataModelSummary { name, iri }
2319 })
2320 .collect();
2321 data_models.sort_by(|a, b| a.name.cmp(&b.name));
2322
2323 Ok(ProjectDetail {
2324 iri: dto.id,
2325 shortcode: dto.shortcode,
2326 shortname: dto.shortname,
2327 longname: dto.longname,
2328 status: project_status,
2329 description,
2330 keywords: dto.keywords,
2331 data_models,
2332 })
2333 } else if status == reqwest::StatusCode::NOT_FOUND {
2334 let display_input: String = project.chars().take(80).collect();
2336 let suffix = if project.chars().count() > 80 {
2337 "…"
2338 } else {
2339 ""
2340 };
2341 Err(Diagnostic::NotFound(format!(
2342 "project '{display_input}{suffix}' not found on {server}. Run `dsp vre project list --server {server}` to see available projects."
2343 )))
2344 } else {
2345 Err(map_unexpected_status(status, &url))
2346 }
2347 }
2348
2349 fn describe_data_model(
2350 &self,
2351 server: &str,
2352 data_model_iri: &str,
2353 token: Option<&str>,
2354 ) -> Result<DataModelDetail, Diagnostic> {
2355 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2356
2357 let prefixes: HashMap<String, String> = resp
2361 .context
2362 .iter()
2363 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2364 .collect();
2365
2366 let mut resource_types: Vec<ResourceTypeSummary> = resp
2367 .graph
2368 .into_iter()
2369 .filter(|dto| dto.is_resource_class)
2370 .map(|dto| {
2371 let (name, iri) = expand_class_id(&dto.id, &prefixes);
2372 ResourceTypeSummary {
2373 name,
2374 iri,
2375 label: dto.label,
2376 }
2377 })
2378 .collect();
2379
2380 resource_types.sort_by(|a, b| a.name.cmp(&b.name));
2381
2382 Ok(DataModelDetail {
2383 name: data_model_name_from_iri(&resp.id),
2384 iri: resp.id,
2385 label: resp.label,
2386 last_modified: resp.last_modification_date.map(|d| d.value),
2387 resource_types,
2388 })
2389 }
2390
2391 fn data_model_structure(
2392 &self,
2393 server: &str,
2394 data_model_iri: &str,
2395 token: Option<&str>,
2396 ) -> Result<DataModelStructure, Diagnostic> {
2397 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2399
2400 let graph_entities: Vec<OntologyEntityDto> = resp.graph;
2401
2402 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2408 let mut class_nodes: Vec<OntologyEntityDto> = Vec::new();
2409 for entity in graph_entities {
2410 if entity.is_resource_class {
2411 class_nodes.push(entity);
2412 } else if entity.object_type.is_some()
2413 || entity.is_link_property
2414 || entity.is_resource_property
2415 {
2416 prop_lookup.insert(entity.id.clone(), entity);
2417 }
2418 }
2419
2420 let mut relations: Vec<Relation> = Vec::new();
2422
2423 for class in &class_nodes {
2424 let source = local_name(&class.id).to_string();
2425
2426 for element in &class.sub_class_of {
2427 if let Some(type_val) = element.get("@type")
2428 && type_val.as_str() == Some("owl:Restriction")
2429 {
2430 let on_prop_id = match element
2432 .get("owl:onProperty")
2433 .and_then(|v| v.get("@id"))
2434 .and_then(serde_json::Value::as_str)
2435 {
2436 Some(s) => s,
2437 None => continue,
2438 };
2439
2440 let node = match prop_lookup.get(on_prop_id) {
2442 Some(n) => n,
2443 None => continue, };
2445
2446 if node.is_link_value_property {
2448 continue;
2449 }
2450
2451 if !node.is_link_property {
2453 continue;
2454 }
2455
2456 let target_id = match node.object_type.as_ref() {
2458 Some(ot) => &ot.id,
2459 None => continue, };
2461 let target = local_name(target_id).to_string();
2462
2463 let t_prefix = curie_prefix(target_id).unwrap_or("");
2464 let target_data_model = if is_system_prefix(t_prefix) || t_prefix.is_empty() {
2465 None
2466 } else {
2467 Some(t_prefix.to_string())
2468 };
2469
2470 let field_prefix = curie_prefix(on_prop_id).unwrap_or("");
2472 let is_builtin = is_system_prefix(field_prefix);
2473
2474 let field = local_name(on_prop_id).to_string();
2475
2476 relations.push(Relation {
2477 source: source.clone(),
2478 target,
2479 kind: RelationKind::Link,
2480 field: Some(field),
2481 target_data_model,
2482 is_builtin,
2483 });
2484 } else if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str)
2485 {
2486 let target = local_name(id_val).to_string();
2491
2492 let sup_prefix = curie_prefix(id_val).unwrap_or("");
2493 let is_builtin = is_system_prefix(sup_prefix);
2494 let target_data_model = if is_system_prefix(sup_prefix) || sup_prefix.is_empty()
2495 {
2496 None
2497 } else {
2498 Some(sup_prefix.to_string())
2499 };
2500
2501 relations.push(Relation {
2502 source: source.clone(),
2503 target,
2504 kind: RelationKind::Inherits,
2505 field: None,
2506 target_data_model,
2507 is_builtin,
2508 });
2509 }
2510 }
2511 }
2512
2513 relations.sort_by(|a, b| {
2517 a.source
2518 .cmp(&b.source)
2519 .then_with(|| a.kind.cmp(&b.kind))
2520 .then_with(|| a.field.cmp(&b.field))
2521 .then_with(|| a.target.cmp(&b.target))
2522 });
2523
2524 Ok(DataModelStructure {
2526 data_model: data_model_name_from_iri(data_model_iri),
2527 relations,
2528 })
2529 }
2530
2531 fn list_resources(
2532 &self,
2533 server: &str,
2534 project_iri: &str,
2535 resource_type_iri: &str,
2536 order_by: Option<&str>,
2537 page: u32,
2538 token: Option<&str>,
2539 ) -> Result<ResourcePage, Diagnostic> {
2540 let base = server.trim_end_matches('/');
2541 let url = format!("{base}/v2/resources");
2542
2543 let mut req = self.client.get(&url).query(&[
2548 ("resourceClass", resource_type_iri),
2549 ("page", &page.to_string()),
2550 ("schema", "complex"),
2551 ]);
2552 if let Some(prop_iri) = order_by {
2555 req = req.query(&[("orderByProperty", prop_iri)]);
2556 }
2557
2558 let header_value = reqwest::header::HeaderValue::from_str(project_iri).map_err(|e| {
2562 Diagnostic::Usage(format!("project IRI is not a valid HTTP header value: {e}"))
2563 })?;
2564 let req = req.header("x-knora-accept-project", header_value);
2565
2566 let req = if let Some(t) = token {
2568 req.bearer_auth(t)
2569 } else {
2570 req
2571 };
2572
2573 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2574 let status = response.status();
2575
2576 if !status.is_success() {
2577 return Err(map_unexpected_status(status, &url));
2578 }
2579
2580 let dto: ResourceListDto = response.json().map_err(|e| {
2581 Diagnostic::ServerError(format!("resource list response could not be parsed: {e}"))
2582 })?;
2583
2584 let may_have_more_results = dto.may_have_more_results;
2585
2586 let resources: Vec<ResourceSummary> = if let Some(graph) = dto.graph {
2591 graph
2592 .into_iter()
2593 .map(|node| {
2594 node_dto_to_summary(
2595 node.id,
2596 node.type_field.as_ref(),
2597 node.label.as_ref(),
2598 node.ark_url.as_ref(),
2599 node.creation_date.as_ref(),
2600 node.last_modification_date.as_ref(),
2601 )
2602 })
2603 .collect()
2604 } else if let Some(id) = dto.id {
2605 vec![node_dto_to_summary(
2607 id,
2608 dto.type_field.as_ref(),
2609 dto.label.as_ref(),
2610 dto.ark_url.as_ref(),
2611 dto.creation_date.as_ref(),
2612 dto.last_modification_date.as_ref(),
2613 )]
2614 } else {
2615 vec![]
2617 };
2618
2619 Ok(ResourcePage {
2620 resources,
2621 may_have_more_results,
2622 })
2623 }
2624
2625 fn describe_resource(
2626 &self,
2627 server: &str,
2628 resource_iri: &str,
2629 token: Option<&str>,
2630 with_values: bool,
2631 ) -> Result<ResourceDetail, Diagnostic> {
2632 let base = server.trim_end_matches('/');
2633 let url = format!("{base}/v2/resources/{}", enc(resource_iri));
2635
2636 let req = self.client.get(&url).query(&[("schema", "complex")]);
2638 let req = if let Some(t) = token {
2639 req.bearer_auth(t)
2640 } else {
2641 req
2642 };
2643
2644 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2645 let status = response.status();
2646
2647 if status.is_success() {
2648 let dto: ResourceDetailDto = response.json().map_err(|e| {
2649 Diagnostic::ServerError(format!(
2650 "resource describe response could not be parsed: {e}"
2651 ))
2652 })?;
2653
2654 let label = dto
2656 .label
2657 .as_ref()
2658 .and_then(extract_string_value)
2659 .unwrap_or_default();
2660 let resource_type = extract_resource_type(dto.type_field.as_ref());
2661 let ark_url = dto.ark_url.as_ref().and_then(extract_string_value);
2662 let creation_date = dto.creation_date.as_ref().and_then(extract_string_value);
2663 let last_modified = dto
2664 .last_modification_date
2665 .as_ref()
2666 .and_then(extract_string_value);
2667 let attached_project = dto
2668 .attached_to_project
2669 .as_ref()
2670 .and_then(extract_string_value);
2671 let owner = dto.attached_to_user.as_ref().and_then(extract_string_value);
2672 let visibility = dto.has_permissions.as_deref().and_then(derive_visibility);
2673 let your_access = dto.user_has_permission.as_deref().and_then(derive_access);
2674
2675 let values = if with_values {
2677 Some(self.parse_resource_values(server, token, &dto.context, &dto.extra))
2678 } else {
2679 None
2680 };
2681
2682 Ok(ResourceDetail {
2683 label,
2684 iri: dto.id,
2685 resource_type,
2686 ark_url,
2687 creation_date,
2688 last_modified,
2689 attached_project,
2690 owner,
2691 visibility,
2692 your_access,
2693 values,
2694 })
2695 } else if status == reqwest::StatusCode::NOT_FOUND {
2696 let display_iri: String = resource_iri.chars().take(80).collect();
2698 let iri_suffix = if resource_iri.chars().count() > 80 {
2699 "…"
2700 } else {
2701 ""
2702 };
2703 Err(Diagnostic::NotFound(format!(
2704 "resource '{display_iri}{iri_suffix}' not found"
2705 )))
2706 } else if status == reqwest::StatusCode::UNAUTHORIZED
2707 || status == reqwest::StatusCode::FORBIDDEN
2708 {
2709 let display_iri: String = resource_iri.chars().take(80).collect();
2713 let iri_suffix = if resource_iri.chars().count() > 80 {
2714 "…"
2715 } else {
2716 ""
2717 };
2718 Err(Diagnostic::AuthRequired(format!(
2719 "access denied for resource '{display_iri}{iri_suffix}' — log in to view this resource"
2720 )))
2721 } else {
2722 Err(map_unexpected_status(status, &url))
2723 }
2724 }
2725
2726 fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic> {
2727 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
2728
2729 let response = self
2730 .client
2731 .get(&url)
2732 .bearer_auth(token)
2733 .send()
2734 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2735
2736 let status = response.status();
2737
2738 if status.is_success() {
2739 let body = response.text().unwrap_or_default();
2742 let preview: String = body.chars().take(200).collect();
2743 tracing::trace!("verify_token success response body (capped): {}", preview);
2744 Ok(())
2745 } else if status == reqwest::StatusCode::UNAUTHORIZED
2746 || status == reqwest::StatusCode::FORBIDDEN
2747 {
2748 let body = response.text().unwrap_or_default();
2750 let preview: String = body.chars().take(200).collect();
2751 tracing::trace!("verify_token rejection response body (capped): {}", preview);
2752 Err(Diagnostic::AuthRequired(format!(
2754 "token rejected by {server} — it may be expired, revoked, or for a different environment"
2755 )))
2756 } else {
2757 Err(map_unexpected_status(status, &url))
2758 }
2759 }
2760
2761 fn list_data_models(
2762 &self,
2763 server: &str,
2764 project_iri: &str,
2765 token: Option<&str>,
2766 ) -> Result<Vec<DataModel>, Diagnostic> {
2767 let url = format!(
2768 "{}/v2/ontologies/metadata/{}",
2769 server.trim_end_matches('/'),
2770 enc(project_iri)
2771 );
2772
2773 let req = self.client.get(&url);
2778 let req = if let Some(t) = token {
2779 req.bearer_auth(t)
2780 } else {
2781 req
2782 };
2783
2784 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2785
2786 let status = response.status();
2787
2788 if status.is_success() {
2789 let resp: OntologyMetadataResponse = response.json().map_err(|e| {
2790 Diagnostic::ServerError(format!("data-models response could not be parsed: {e}"))
2791 })?;
2792
2793 let dtos: Vec<OntologyMetadataDto> = match resp.graph {
2797 Some(g) => g,
2798 None => match resp.id {
2799 Some(id) => vec![OntologyMetadataDto {
2800 id,
2801 label: resp.label,
2802 last_modification_date: resp.last_modification_date,
2803 }],
2804 None => vec![],
2805 },
2806 };
2807
2808 let data_models = dtos
2809 .into_iter()
2810 .map(|dto| DataModel {
2811 name: data_model_name_from_iri(&dto.id),
2812 iri: dto.id,
2813 label: dto.label,
2814 last_modified: dto.last_modification_date.map(|d| d.value),
2815 is_builtin: false,
2816 })
2817 .collect();
2818
2819 Ok(data_models)
2820 } else {
2821 Err(map_unexpected_status(status, &url))
2822 }
2823 }
2824
2825 fn describe_resource_type(
2826 &self,
2827 server: &str,
2828 data_model_iri: &str,
2829 resource_type: &str,
2830 token: Option<&str>,
2831 ) -> Result<ResourceTypeDetail, Diagnostic> {
2832 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2834
2835 let prefixes: HashMap<String, String> = resp
2837 .context
2838 .iter()
2839 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2840 .collect();
2841
2842 let queried_id = resp.id;
2846 let mut graph_entities: Vec<OntologyEntityDto> = resp.graph;
2847
2848 let target_idx = graph_entities.iter().position(|e| {
2849 if !e.is_resource_class {
2850 return false;
2851 }
2852 let (type_local, expanded_iri) = expand_class_id(&e.id, &prefixes);
2853 type_local.eq_ignore_ascii_case(resource_type) || expanded_iri == resource_type
2855 });
2856
2857 let target_idx = match target_idx {
2858 Some(i) => i,
2859 None => {
2860 let display: String = resource_type.chars().take(80).collect();
2861 let suffix = if resource_type.chars().count() > 80 {
2862 "…"
2863 } else {
2864 ""
2865 };
2866 return Err(Diagnostic::NotFound(format!(
2867 "resource-type '{display}{suffix}' not found in data-model '{}' on {server}",
2868 data_model_name_from_iri(data_model_iri)
2869 )));
2870 }
2871 };
2872
2873 let target = graph_entities.swap_remove(target_idx);
2876
2877 struct Restriction {
2879 on_property_id: String,
2880 cardinality: Cardinality,
2881 gui_order: u32,
2882 }
2883
2884 let mut restrictions: Vec<Restriction> = Vec::new();
2885 let mut super_type_ids: Vec<String> = Vec::new();
2886 let mut restriction_prop_locals: Vec<String> = Vec::new();
2887
2888 for element in &target.sub_class_of {
2889 if let Some(type_val) = element.get("@type")
2890 && type_val.as_str() == Some("owl:Restriction")
2891 {
2892 let on_prop_id = element
2894 .get("owl:onProperty")
2895 .and_then(|v| v.get("@id"))
2896 .and_then(serde_json::Value::as_str)
2897 .unwrap_or("")
2898 .to_string();
2899
2900 if on_prop_id.is_empty() {
2901 tracing::warn!("owl:Restriction missing owl:onProperty @id; skipping");
2902 continue;
2903 }
2904
2905 let cardinality = decode_cardinality(element);
2906 let gui_order = element
2907 .get("salsah-gui:guiOrder")
2908 .and_then(serde_json::Value::as_u64)
2909 .map(|v| v as u32)
2910 .unwrap_or(u32::MAX);
2911
2912 restriction_prop_locals.push(local_name(&on_prop_id).to_string());
2913
2914 restrictions.push(Restriction {
2915 on_property_id: on_prop_id,
2916 cardinality,
2917 gui_order,
2918 });
2919 continue;
2920 }
2921 if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
2923 super_type_ids.push(id_val.to_string());
2924 }
2925 }
2926
2927 let representation = detect_representation(
2929 &restriction_prop_locals
2930 .iter()
2931 .map(String::as_str)
2932 .collect::<Vec<_>>(),
2933 );
2934
2935 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2937 for entity in graph_entities {
2938 if entity.object_type.is_some()
2941 || entity.is_link_property
2942 || entity.is_resource_property
2943 {
2944 prop_lookup.insert(entity.id.clone(), entity);
2945 }
2946 }
2947
2948 let mut missing_prefixes: Vec<String> = Vec::new();
2958 let mut seen_prefixes: HashSet<String> = HashSet::new();
2959 for restriction in &restrictions {
2960 if prop_lookup.contains_key(&restriction.on_property_id) {
2961 continue;
2962 }
2963 let prefix = match curie_prefix(&restriction.on_property_id) {
2964 Some(p) => p,
2965 None => continue,
2966 };
2967 if is_system_prefix(prefix) {
2968 continue;
2969 }
2970 if seen_prefixes.insert(prefix.to_string()) {
2971 missing_prefixes.push(prefix.to_string());
2972 }
2973 }
2974
2975 let mut fetched_sibling_iris: HashSet<String> = HashSet::new();
2977 let queried_iri_trimmed = data_model_iri.trim_end_matches(['#', '/']);
2978
2979 let mut siblings_to_fetch: Vec<String> = Vec::new();
2980 for prefix in &missing_prefixes {
2981 let namespace = match prefixes.get(prefix.as_str()) {
2982 Some(ns) => ns,
2983 None => {
2984 tracing::warn!(
2985 prefix = %prefix,
2986 "missing @context entry for prefix of cross-DM field; leaving best-effort"
2987 );
2988 continue;
2989 }
2990 };
2991 let sibling_iri = namespace.trim_end_matches(['#', '/']).to_string();
2992 if sibling_iri == queried_iri_trimmed {
2993 continue;
2995 }
2996 if fetched_sibling_iris.insert(sibling_iri.clone()) {
2997 siblings_to_fetch.push(sibling_iri);
2998 }
2999 }
3000
3001 if siblings_to_fetch.len() > MAX_SIBLING_FETCHES {
3002 tracing::warn!(
3003 count = siblings_to_fetch.len(),
3004 max = MAX_SIBLING_FETCHES,
3005 "too many sibling ontologies to fetch; capping at MAX_SIBLING_FETCHES"
3006 );
3007 siblings_to_fetch.truncate(MAX_SIBLING_FETCHES);
3008 }
3009
3010 for sibling_iri in &siblings_to_fetch {
3011 match self.fetch_allentities(server, sibling_iri, token) {
3013 Ok(sibling_resp) => {
3014 for entity in sibling_resp.graph {
3015 if entity.object_type.is_some()
3016 || entity.is_link_property
3017 || entity.is_resource_property
3018 {
3019 prop_lookup.entry(entity.id.clone()).or_insert(entity);
3020 }
3021 }
3022 }
3023 Err(e) => {
3024 tracing::warn!(
3027 iri = %sibling_iri,
3028 error = %e,
3029 "sibling ontology fetch failed; affected fields left best-effort"
3030 );
3031 }
3032 }
3033 }
3034
3035 let mut fields: Vec<(u32, Field)> = Vec::new();
3037
3038 for restriction in &restrictions {
3039 let prop_id = &restriction.on_property_id;
3040
3041 let node = prop_lookup.get(prop_id.as_str());
3043
3044 if let Some(n) = node {
3046 if n.is_link_value_property {
3047 continue;
3049 }
3050 } else {
3051 let prop_local = local_name(prop_id);
3055 if let Some(base) = prop_local.strip_suffix("Value") {
3056 let base_present = restrictions
3058 .iter()
3059 .any(|r| local_name(&r.on_property_id) == base);
3060 if base_present {
3063 continue;
3064 }
3065 }
3066 }
3067
3068 let prop_prefix = curie_prefix(prop_id).unwrap_or("");
3070 let is_builtin = is_system_prefix(prop_prefix);
3071 let (prop_local, prop_iri) = expand_class_id(prop_id, &prefixes);
3072
3073 let field_data_model = if is_builtin {
3075 None
3076 } else {
3077 if prop_prefix.is_empty() {
3080 None
3081 } else {
3082 Some(prop_prefix.to_string())
3083 }
3084 };
3085
3086 let (value_type, link_target) = if let Some(n) = node {
3088 if n.is_link_property {
3089 let target_name = n
3091 .object_type
3092 .as_ref()
3093 .map(|ot| local_name(&ot.id).to_string())
3094 .unwrap_or_else(|| "unknown".to_string());
3095 (ValueType::Link, Some(target_name))
3096 } else {
3097 let obj_local = n
3098 .object_type
3099 .as_ref()
3100 .map(|ot| local_name(&ot.id))
3101 .unwrap_or("");
3102 (map_object_type_to_value_type(obj_local), None)
3103 }
3104 } else {
3105 if is_builtin {
3107 if let Some(vt) = builtin_field_value_type(&prop_local) {
3108 (vt, None)
3109 } else {
3110 (ValueType::Other("—".to_string()), None)
3111 }
3112 } else {
3113 (ValueType::Other("—".to_string()), None)
3114 }
3115 };
3116
3117 let label = node.and_then(|n| n.label.clone());
3118
3119 debug_assert!(
3121 (value_type == ValueType::Link) == link_target.is_some(),
3122 "link_target must be Some iff value_type is Link"
3123 );
3124
3125 fields.push((
3126 restriction.gui_order,
3127 Field {
3128 name: prop_local,
3129 iri: prop_iri,
3130 label,
3131 value_type,
3132 link_target,
3133 cardinality: restriction.cardinality,
3134 is_builtin,
3135 data_model: field_data_model,
3136 },
3137 ));
3138 }
3139
3140 fields.sort_by(|(order_a, field_a), (order_b, field_b)| {
3142 order_a
3143 .cmp(order_b)
3144 .then_with(|| field_a.name.cmp(&field_b.name))
3145 });
3146 let sorted_fields: Vec<Field> = fields.into_iter().map(|(_, f)| f).collect();
3147
3148 let super_types: Vec<String> = super_type_ids
3150 .iter()
3151 .filter(|id| {
3152 let prefix = curie_prefix(id).unwrap_or("");
3153 !is_system_prefix(prefix)
3154 })
3155 .map(|id| local_name(id).to_string())
3156 .collect();
3157
3158 let (class_name, class_iri) = expand_class_id(&target.id, &prefixes);
3160 let class_label = target.label;
3161 let dm_name = data_model_name_from_iri(&queried_id);
3162
3163 Ok(ResourceTypeDetail {
3164 name: class_name,
3165 iri: class_iri,
3166 label: class_label,
3167 data_model: dm_name,
3168 representation,
3169 super_types,
3170 fields: sorted_fields,
3171 })
3172 }
3173}
3174
3175#[cfg(test)]
3180mod tests {
3181 use super::*;
3182
3183 #[test]
3188 fn map_unexpected_status_401_403_are_auth_required() {
3189 for status in [
3193 reqwest::StatusCode::UNAUTHORIZED,
3194 reqwest::StatusCode::FORBIDDEN,
3195 ] {
3196 let diag = map_unexpected_status(status, "https://example.org/x");
3197 match diag {
3198 Diagnostic::AuthRequired(msg) => assert!(
3199 msg.contains("dsp auth login"),
3200 "auth message should hint at re-authentication: {msg}"
3201 ),
3202 other => panic!("expected AuthRequired for {status}, got {other:?}"),
3203 }
3204 }
3205 }
3206
3207 #[test]
3208 fn map_unexpected_status_404_and_5xx_stay_server_error() {
3209 assert!(matches!(
3212 map_unexpected_status(reqwest::StatusCode::NOT_FOUND, "u"),
3213 Diagnostic::ServerError(_)
3214 ));
3215 assert!(matches!(
3216 map_unexpected_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, "u"),
3217 Diagnostic::ServerError(_)
3218 ));
3219 }
3220
3221 #[test]
3226 fn identifier_key_email_contains_at() {
3227 assert_eq!(identifier_key("a@b.ch"), "email");
3228 }
3229
3230 #[test]
3231 fn identifier_key_bare_username() {
3232 assert_eq!(identifier_key("jdoe"), "username");
3233 }
3234
3235 #[test]
3236 fn identifier_key_http_iri() {
3237 assert_eq!(identifier_key("http://rdfh.ch/users/x"), "iri");
3238 }
3239
3240 #[test]
3241 fn identifier_key_https_iri() {
3242 assert_eq!(identifier_key("https://rdfh.ch/users/x"), "iri");
3243 }
3244
3245 #[test]
3246 fn identifier_key_iri_with_at_uses_iri_not_email() {
3247 assert_eq!(identifier_key("http://example.org/users/a@b"), "iri");
3249 }
3250
3251 #[test]
3252 fn classify_http_iri() {
3253 let ident = classify("http://rdfh.ch/projects/0001");
3254 assert!(
3255 matches!(ident, ProjectIdent::Iri(_)),
3256 "http:// prefix should classify as Iri"
3257 );
3258 }
3259
3260 #[test]
3261 fn classify_https_iri() {
3262 let ident = classify("https://rdfh.ch/projects/0001");
3263 assert!(
3264 matches!(ident, ProjectIdent::Iri(_)),
3265 "https:// prefix should classify as Iri"
3266 );
3267 }
3268
3269 #[test]
3270 fn classify_four_digit_hex_shortcode() {
3271 let ident = classify("0001");
3272 assert!(
3273 matches!(ident, ProjectIdent::Shortcode(_)),
3274 "four hex digits should classify as Shortcode"
3275 );
3276 }
3277
3278 #[test]
3279 fn classify_four_hex_letter_shortcode() {
3280 let ident = classify("beef");
3284 assert!(
3285 matches!(ident, ProjectIdent::Shortcode(_)),
3286 "4-hex-letter input 'beef' should classify as Shortcode (documented overlap)"
3287 );
3288 }
3289
3290 #[test]
3291 fn classify_mixed_case_hex_shortcode() {
3292 let ident = classify("ABCD");
3293 assert!(
3294 matches!(ident, ProjectIdent::Shortcode(_)),
3295 "upper-case hex digits should classify as Shortcode"
3296 );
3297 }
3298
3299 #[test]
3300 fn classify_shortname() {
3301 let ident = classify("incunabula");
3302 assert!(
3303 matches!(ident, ProjectIdent::Shortname(_)),
3304 "alphabetic string longer than 4 chars should classify as Shortname"
3305 );
3306 }
3307
3308 #[test]
3309 fn classify_five_digit_hex_is_shortname() {
3310 let ident = classify("00001");
3312 assert!(
3313 matches!(ident, ProjectIdent::Shortname(_)),
3314 "5-hex-digit string should classify as Shortname, not Shortcode"
3315 );
3316 }
3317
3318 #[test]
3319 fn classify_three_digit_hex_is_shortname() {
3320 let ident = classify("001");
3321 assert!(
3322 matches!(ident, ProjectIdent::Shortname(_)),
3323 "3-hex-digit string should classify as Shortname, not Shortcode"
3324 );
3325 }
3326
3327 #[test]
3328 fn classify_non_hex_four_chars_is_shortname() {
3329 let ident = classify("zzzz");
3331 assert!(
3332 matches!(ident, ProjectIdent::Shortname(_)),
3333 "4-char non-hex string should classify as Shortname"
3334 );
3335 }
3336
3337 #[test]
3342 fn validate_dump_id_valid_accepts() {
3343 assert!(super::validate_dump_id("abc123").is_ok());
3344 assert!(super::validate_dump_id("abc-123_XYZ").is_ok());
3345 let max_id = "a".repeat(256);
3347 assert!(
3348 super::validate_dump_id(&max_id).is_ok(),
3349 "256-char id must be accepted"
3350 );
3351 }
3352
3353 #[test]
3354 fn validate_dump_id_empty_is_rejected() {
3355 let result = super::validate_dump_id("");
3356 assert!(
3357 matches!(result, Err(Diagnostic::ServerError(_))),
3358 "empty id must be rejected"
3359 );
3360 }
3361
3362 #[test]
3363 fn validate_dump_id_too_long_is_rejected() {
3364 let long_id = "a".repeat(257);
3365 let result = super::validate_dump_id(&long_id);
3366 assert!(
3367 matches!(result, Err(Diagnostic::ServerError(_))),
3368 "257-char id must be rejected"
3369 );
3370 }
3371
3372 #[test]
3373 fn validate_dump_id_invalid_chars_rejected() {
3374 let result = super::validate_dump_id("abc/def");
3375 assert!(
3376 matches!(result, Err(Diagnostic::ServerError(_))),
3377 "id with '/' must be rejected"
3378 );
3379 }
3380
3381 #[test]
3386 fn into_dump_task_in_progress() {
3387 let api = DataTaskStatusApiResponse {
3388 id: "abc123".into(),
3389 status: "in_progress".into(),
3390 error_message: None,
3391 created_at: None,
3392 };
3393 let task = api.into_dump_task().expect("should parse in_progress");
3394 assert_eq!(task.id, "abc123");
3395 assert_eq!(task.status, DumpStatus::InProgress);
3396 assert!(task.error_message.is_none());
3397 assert!(task.created_at.is_none());
3398 }
3399
3400 #[test]
3401 fn into_dump_task_completed() {
3402 let api = DataTaskStatusApiResponse {
3403 id: "done42".into(),
3404 status: "completed".into(),
3405 error_message: None,
3406 created_at: None,
3407 };
3408 let task = api.into_dump_task().expect("should parse completed");
3409 assert_eq!(task.status, DumpStatus::Completed);
3410 }
3411
3412 #[test]
3413 fn into_dump_task_failed_with_message() {
3414 let api = DataTaskStatusApiResponse {
3415 id: "fail7".into(),
3416 status: "failed".into(),
3417 error_message: Some("disk full".into()),
3418 created_at: None,
3419 };
3420 let task = api.into_dump_task().expect("should parse failed");
3421 assert_eq!(task.status, DumpStatus::Failed);
3422 assert_eq!(task.error_message.as_deref(), Some("disk full"));
3423 }
3424
3425 #[test]
3426 fn into_dump_task_unknown_status_is_server_error() {
3427 let api = DataTaskStatusApiResponse {
3428 id: "x".into(),
3429 status: "pending".into(), error_message: None,
3431 created_at: None,
3432 };
3433 let result = api.into_dump_task();
3434 assert!(result.is_err(), "unknown status should yield an error");
3435 assert!(
3436 matches!(result.unwrap_err(), Diagnostic::ServerError(_)),
3437 "unknown status should yield ServerError"
3438 );
3439 }
3440
3441 #[test]
3442 fn into_dump_task_long_error_message_is_truncated() {
3443 let long_msg = "x".repeat(501);
3445 let api = DataTaskStatusApiResponse {
3446 id: "trunc".into(),
3447 status: "failed".into(),
3448 error_message: Some(long_msg),
3449 created_at: None,
3450 };
3451 let task = api
3452 .into_dump_task()
3453 .expect("should parse even with long message");
3454 let stored = task.error_message.unwrap();
3455 assert_eq!(
3456 stored.len(),
3457 500,
3458 "error_message must be truncated to ≤500 chars at the client boundary"
3459 );
3460 }
3461
3462 #[test]
3463 fn into_dump_task_exact_500_chars_not_truncated() {
3464 let exact_msg = "y".repeat(500);
3466 let api = DataTaskStatusApiResponse {
3467 id: "exact".into(),
3468 status: "failed".into(),
3469 error_message: Some(exact_msg.clone()),
3470 created_at: None,
3471 };
3472 let task = api.into_dump_task().expect("should parse");
3473 assert_eq!(task.error_message.unwrap(), exact_msg);
3474 }
3475
3476 #[test]
3481 fn into_dump_task_valid_created_at_is_parsed() {
3482 let api = DataTaskStatusApiResponse {
3483 id: "ts-test".into(),
3484 status: "completed".into(),
3485 error_message: None,
3486 created_at: Some("2026-05-20T14:03:00Z".into()),
3487 };
3488 let task = api.into_dump_task().expect("should parse with created_at");
3489 use chrono::Datelike;
3490 let ts = task.created_at.expect("created_at should be Some");
3491 assert_eq!(ts.year(), 2026);
3492 assert_eq!(ts.month(), 5);
3493 assert_eq!(ts.day(), 20);
3494 }
3495
3496 #[test]
3497 fn into_dump_task_garbage_created_at_yields_none() {
3498 let api = DataTaskStatusApiResponse {
3499 id: "ts-bad".into(),
3500 status: "in_progress".into(),
3501 error_message: None,
3502 created_at: Some("not-a-date!!".into()),
3503 };
3504 let task = api
3506 .into_dump_task()
3507 .expect("garbage created_at must not fail parse");
3508 assert!(
3509 task.created_at.is_none(),
3510 "garbage created_at must map to None"
3511 );
3512 }
3513
3514 #[test]
3519 fn export_exists_present_with_both_fields() {
3520 let body = V3ErrorBody {
3521 errors: vec![V3ErrorItem {
3522 code: "export_exists".into(),
3523 details: [
3524 ("id".to_string(), "dGVzdC1pZA".to_string()),
3525 (
3526 "projectIri".to_string(),
3527 "http://rdfh.ch/projects/0001".to_string(),
3528 ),
3529 ]
3530 .into(),
3531 }],
3532 };
3533 let ex = body.export_exists().expect("export_exists must be Some");
3534 assert_eq!(ex.id, Some("dGVzdC1pZA"));
3535 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3536 }
3537
3538 #[test]
3539 fn export_exists_wrong_code_returns_none() {
3540 let body = V3ErrorBody {
3541 errors: vec![V3ErrorItem {
3542 code: "some_other_error".into(),
3543 details: [("id".to_string(), "abc".to_string())].into(),
3544 }],
3545 };
3546 assert!(body.export_exists().is_none(), "wrong code must not match");
3547 }
3548
3549 #[test]
3550 fn export_exists_missing_details_id_returns_some_with_none_id() {
3551 let body = V3ErrorBody {
3552 errors: vec![V3ErrorItem {
3553 code: "export_exists".into(),
3554 details: [(
3555 "projectIri".to_string(),
3556 "http://rdfh.ch/projects/0001".to_string(),
3557 )]
3558 .into(),
3559 }],
3560 };
3561 let ex = body
3563 .export_exists()
3564 .expect("export_exists must be Some when code matches");
3565 assert!(ex.id.is_none(), "id must be None when 'id' key is absent");
3566 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3567 }
3568
3569 #[test]
3570 fn export_exists_empty_errors_returns_none() {
3571 let body = V3ErrorBody { errors: vec![] };
3572 assert!(body.export_exists().is_none());
3573 }
3574
3575 #[test]
3576 fn export_exists_missing_project_iri_returns_some_with_none_iri() {
3577 let body = V3ErrorBody {
3578 errors: vec![V3ErrorItem {
3579 code: "export_exists".into(),
3580 details: [("id".to_string(), "abc123".to_string())].into(),
3581 }],
3582 };
3583 let ex = body
3584 .export_exists()
3585 .expect("export_exists must be Some when code matches");
3586 assert_eq!(ex.id, Some("abc123"));
3587 assert!(
3588 ex.project_iri.is_none(),
3589 "project_iri must be None when 'projectIri' key is absent"
3590 );
3591 }
3592
3593 #[test]
3598 fn is_safe_shortcode_valid_hex_shortcode() {
3599 assert!(
3600 super::is_safe_shortcode("0001"),
3601 "4-hex-digit shortcode must be accepted"
3602 );
3603 assert!(
3604 super::is_safe_shortcode("ABCD"),
3605 "upper-case hex shortcode must be accepted"
3606 );
3607 assert!(
3608 super::is_safe_shortcode("beef"),
3609 "lower-case hex shortcode must be accepted"
3610 );
3611 }
3612
3613 #[test]
3614 fn is_safe_shortcode_alphanumeric_within_32_chars_accepted() {
3615 let long_code = "a".repeat(32);
3616 assert!(
3617 super::is_safe_shortcode(&long_code),
3618 "32-char alphanumeric must be accepted"
3619 );
3620 }
3621
3622 #[test]
3623 fn is_safe_shortcode_empty_is_rejected() {
3624 assert!(
3625 !super::is_safe_shortcode(""),
3626 "empty shortcode must be rejected"
3627 );
3628 }
3629
3630 #[test]
3631 fn is_safe_shortcode_too_long_is_rejected() {
3632 let long_code = "a".repeat(33);
3633 assert!(
3634 !super::is_safe_shortcode(&long_code),
3635 "33-char shortcode must be rejected"
3636 );
3637 }
3638
3639 #[test]
3640 fn is_safe_shortcode_slash_is_rejected() {
3641 assert!(
3642 !super::is_safe_shortcode("ab/cd"),
3643 "shortcode with '/' must be rejected"
3644 );
3645 assert!(
3646 !super::is_safe_shortcode("/evil"),
3647 "absolute path shortcode must be rejected"
3648 );
3649 }
3650
3651 #[test]
3652 fn is_safe_shortcode_dot_dot_is_rejected() {
3653 assert!(
3654 !super::is_safe_shortcode("../evil"),
3655 "path traversal shortcode must be rejected"
3656 );
3657 assert!(
3658 !super::is_safe_shortcode(".."),
3659 "'..' shortcode must be rejected"
3660 );
3661 }
3662
3663 #[test]
3664 fn is_safe_shortcode_backslash_is_rejected() {
3665 assert!(
3666 !super::is_safe_shortcode("ab\\cd"),
3667 "shortcode with '\\' must be rejected"
3668 );
3669 }
3670
3671 #[test]
3672 fn is_safe_shortcode_dot_is_rejected() {
3673 assert!(
3675 !super::is_safe_shortcode("ab.cd"),
3676 "shortcode with '.' must be rejected"
3677 );
3678 }
3679
3680 #[test]
3681 fn resolve_project_rejects_unsafe_shortcode() {
3682 let unsafe_examples = ["../evil", "/abs", "ab/cd", "a\\b", ""];
3686 for s in &unsafe_examples {
3687 assert!(
3688 !super::is_safe_shortcode(s),
3689 "is_safe_shortcode must reject '{s}' — resolve_project would have returned ServerError for this input"
3690 );
3691 }
3692 }
3693
3694 #[test]
3699 fn data_model_name_from_iri_standard_form() {
3700 assert_eq!(
3702 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2"),
3703 "beol"
3704 );
3705 }
3706
3707 #[test]
3708 fn data_model_name_from_iri_no_v2_suffix() {
3709 assert_eq!(
3711 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol"),
3712 "beol"
3713 );
3714 }
3715
3716 #[test]
3717 fn data_model_name_from_iri_trailing_slash() {
3718 assert_eq!(
3720 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2/"),
3721 "beol"
3722 );
3723 }
3724
3725 #[test]
3726 fn data_model_name_from_iri_bare_name() {
3727 assert_eq!(super::data_model_name_from_iri("beol"), "beol");
3729 }
3730
3731 #[test]
3732 fn data_model_name_from_iri_empty_string() {
3733 assert_eq!(super::data_model_name_from_iri(""), "");
3735 }
3736
3737 fn beol_prefixes() -> HashMap<String, String> {
3742 let mut m = HashMap::new();
3743 m.insert(
3744 "beol".to_string(),
3745 "http://api.dasch.swiss/ontology/0801/beol/v2#".to_string(),
3746 );
3747 m
3748 }
3749
3750 #[test]
3751 fn expand_class_id_curie_expands_with_known_prefix() {
3752 let (name, iri) = super::expand_class_id("beol:Archive", &beol_prefixes());
3754 assert_eq!(name, "Archive");
3755 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
3756 }
3757
3758 #[test]
3759 fn expand_class_id_unknown_prefix_falls_back_to_raw_id() {
3760 let (name, iri) = super::expand_class_id("urn:uuid:x", &HashMap::new());
3762 assert_eq!(name, "x");
3763 assert_eq!(iri, "urn:uuid:x");
3764 }
3765
3766 #[test]
3767 fn expand_class_id_full_iri_passes_through() {
3768 let (name, iri) = super::expand_class_id(
3771 "http://api.dasch.swiss/ontology/0801/beol/v2#Letter",
3772 &beol_prefixes(),
3773 );
3774 assert_eq!(name, "Letter");
3775 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Letter");
3776 }
3777
3778 #[test]
3779 fn expand_class_id_no_colon_degenerate() {
3780 let (name, iri) = super::expand_class_id("bare", &HashMap::new());
3782 assert_eq!(name, "bare");
3783 assert_eq!(iri, "bare");
3784 }
3785
3786 #[test]
3791 fn local_name_hash_iri() {
3792 assert_eq!(super::local_name("http://example.org/onto#Thing"), "Thing");
3793 }
3794
3795 #[test]
3796 fn local_name_slash_iri() {
3797 assert_eq!(super::local_name("http://example.org/onto/Thing"), "Thing");
3798 }
3799
3800 #[test]
3801 fn local_name_curie_colon() {
3802 assert_eq!(super::local_name("incunabula:Page"), "Page");
3803 }
3804
3805 #[test]
3806 fn local_name_bare_name_fallback() {
3807 assert_eq!(super::local_name("Page"), "Page");
3808 }
3809
3810 #[test]
3811 fn local_name_empty_string() {
3812 assert_eq!(super::local_name(""), "");
3813 }
3814
3815 #[test]
3816 fn local_name_trailing_separator() {
3817 assert_eq!(super::local_name("foo#"), "");
3820 }
3821
3822 #[test]
3827 fn object_type_to_kebab_text_value() {
3828 assert_eq!(super::object_type_to_kebab("TextValue"), "text");
3829 }
3830
3831 #[test]
3832 fn object_type_to_kebab_geom_value() {
3833 assert_eq!(super::object_type_to_kebab("GeomValue"), "geom");
3835 }
3836
3837 #[test]
3838 fn object_type_to_kebab_geo_name_value() {
3839 assert_eq!(super::object_type_to_kebab("GeoNameValue"), "geo-name");
3841 }
3842
3843 #[test]
3844 fn object_type_to_kebab_uri_value() {
3845 assert_eq!(super::object_type_to_kebab("URIValue"), "uri");
3848 }
3849
3850 #[test]
3851 fn object_type_to_kebab_interval_value() {
3852 assert_eq!(super::object_type_to_kebab("IntervalValue"), "interval");
3855 }
3856
3857 #[test]
3858 fn object_type_to_kebab_no_value_suffix() {
3859 assert_eq!(super::object_type_to_kebab("Geom"), "geom");
3861 }
3862
3863 #[test]
3864 fn map_object_type_known_text_value() {
3865 use crate::model::ValueType;
3866 assert_eq!(
3867 super::map_object_type_to_value_type("TextValue"),
3868 ValueType::Text
3869 );
3870 }
3871
3872 #[test]
3873 fn map_object_type_known_list_value() {
3874 use crate::model::ValueType;
3875 assert_eq!(
3876 super::map_object_type_to_value_type("ListValue"),
3877 ValueType::ListItem
3878 );
3879 }
3880
3881 #[test]
3882 fn map_object_type_other_geom() {
3883 use crate::model::ValueType;
3884 assert_eq!(
3886 super::map_object_type_to_value_type("GeomValue"),
3887 ValueType::Other("geom".to_string())
3888 );
3889 }
3890
3891 #[test]
3892 fn map_object_type_other_uri_value() {
3893 use crate::model::ValueType;
3894 assert_eq!(
3896 super::map_object_type_to_value_type("URIValue"),
3897 ValueType::Other("uri".to_string())
3898 );
3899 }
3900
3901 #[test]
3902 fn map_object_type_other_geo_name_value() {
3903 use crate::model::ValueType;
3904 assert_eq!(
3905 super::map_object_type_to_value_type("GeoNameValue"),
3906 ValueType::Other("geo-name".to_string())
3907 );
3908 }
3909
3910 #[test]
3915 fn decode_cardinality_owl_cardinality_1() {
3916 use crate::model::Cardinality;
3917 let v = serde_json::json!({"owl:cardinality": 1});
3918 assert_eq!(super::decode_cardinality(&v), Cardinality::One);
3919 }
3920
3921 #[test]
3922 fn decode_cardinality_owl_max_cardinality_1() {
3923 use crate::model::Cardinality;
3924 let v = serde_json::json!({"owl:maxCardinality": 1});
3925 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrOne);
3926 }
3927
3928 #[test]
3929 fn decode_cardinality_owl_min_cardinality_0() {
3930 use crate::model::Cardinality;
3931 let v = serde_json::json!({"owl:minCardinality": 0});
3932 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
3933 }
3934
3935 #[test]
3936 fn decode_cardinality_owl_min_cardinality_1() {
3937 use crate::model::Cardinality;
3938 let v = serde_json::json!({"owl:minCardinality": 1});
3939 assert_eq!(super::decode_cardinality(&v), Cardinality::OneOrMore);
3940 }
3941
3942 #[test]
3943 fn decode_cardinality_fallback_no_key() {
3944 use crate::model::Cardinality;
3945 let v = serde_json::json!({});
3947 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
3948 }
3949
3950 #[test]
3951 fn decode_cardinality_fallback_owl_cardinality_unexpected_value() {
3952 use crate::model::Cardinality;
3953 let v = serde_json::json!({"owl:cardinality": 5});
3955 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
3956 }
3957
3958 #[test]
3959 fn decode_cardinality_fallback_owl_max_cardinality_gt1() {
3960 use crate::model::Cardinality;
3961 let v = serde_json::json!({"owl:maxCardinality": 2});
3964 assert_eq!(
3965 super::decode_cardinality(&v),
3966 Cardinality::ZeroOrMore,
3967 "owl:maxCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
3968 );
3969 }
3970
3971 #[test]
3972 fn decode_cardinality_fallback_owl_min_cardinality_gt1() {
3973 use crate::model::Cardinality;
3974 let v = serde_json::json!({"owl:minCardinality": 2});
3977 assert_eq!(
3978 super::decode_cardinality(&v),
3979 Cardinality::ZeroOrMore,
3980 "owl:minCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
3981 );
3982 }
3983
3984 #[test]
3989 fn detect_representation_still_image() {
3990 use crate::model::Representation;
3991 let locals = vec!["hasStillImageFileValue"];
3992 assert_eq!(
3993 super::detect_representation(&locals),
3994 Some(Representation::StillImage)
3995 );
3996 }
3997
3998 #[test]
3999 fn detect_representation_moving_image() {
4000 use crate::model::Representation;
4001 let locals = vec!["hasMovingImageFileValue"];
4002 assert_eq!(
4003 super::detect_representation(&locals),
4004 Some(Representation::MovingImage)
4005 );
4006 }
4007
4008 #[test]
4009 fn detect_representation_audio() {
4010 use crate::model::Representation;
4011 let locals = vec!["hasAudioFileValue"];
4012 assert_eq!(
4013 super::detect_representation(&locals),
4014 Some(Representation::Audio)
4015 );
4016 }
4017
4018 #[test]
4019 fn detect_representation_none_when_absent() {
4020 let locals = vec!["hasTitle", "hasAuthor"];
4022 assert_eq!(super::detect_representation(&locals), None);
4023 }
4024
4025 #[test]
4026 fn detect_representation_takes_first() {
4027 use crate::model::Representation;
4028 let locals = vec!["hasDocumentFileValue", "hasStillImageFileValue"];
4030 assert_eq!(
4031 super::detect_representation(&locals),
4032 Some(Representation::Document)
4033 );
4034 }
4035
4036 #[test]
4041 fn is_system_prefix_knora_api() {
4042 assert!(super::is_system_prefix("knora-api"));
4043 }
4044
4045 #[test]
4046 fn is_system_prefix_rdf() {
4047 assert!(super::is_system_prefix("rdf"));
4048 }
4049
4050 #[test]
4051 fn is_system_prefix_project_prefix_is_not_system() {
4052 assert!(!super::is_system_prefix("incunabula"));
4053 assert!(!super::is_system_prefix("beol"));
4054 assert!(!super::is_system_prefix("biblio"));
4055 }
4056
4057 #[test]
4062 fn curie_prefix_returns_prefix_for_curie() {
4063 assert_eq!(super::curie_prefix("knora-api:arkUrl"), Some("knora-api"));
4064 assert_eq!(super::curie_prefix("beol:hasTitle"), Some("beol"));
4065 }
4066
4067 #[test]
4068 fn curie_prefix_returns_none_for_full_iri() {
4069 assert_eq!(
4071 super::curie_prefix("http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle"),
4072 None
4073 );
4074 }
4075
4076 #[test]
4077 fn curie_prefix_returns_none_for_no_colon() {
4078 assert_eq!(super::curie_prefix("hasTitle"), None);
4079 }
4080
4081 #[test]
4086 fn sibling_iri_trim_hash_delimiter() {
4087 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4089 let trimmed = namespace.trim_end_matches(['#', '/']);
4090 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4091 }
4092
4093 #[test]
4094 fn sibling_iri_trim_slash_delimiter() {
4095 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2/";
4097 let trimmed = namespace.trim_end_matches(['#', '/']);
4098 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4099 }
4100
4101 #[test]
4102 fn sibling_iri_self_loop_detected() {
4103 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4105 let namespace = "http://api.dasch.swiss/ontology/0801/beol/v2#";
4106 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4107 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4108 assert_eq!(sibling_iri, queried_trimmed); }
4110
4111 #[test]
4112 fn sibling_iri_different_ontology_is_not_self_loop() {
4113 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4114 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4115 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4116 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4117 assert_ne!(sibling_iri, queried_trimmed); }
4119
4120 #[test]
4121 fn missing_prefix_in_context_is_skipped() {
4122 let prefixes: HashMap<String, String> = HashMap::new();
4124 let result = prefixes.get("biblio");
4125 assert!(result.is_none()); }
4127
4128 #[test]
4133 fn derive_access_rv() {
4134 assert_eq!(
4135 super::derive_access("RV"),
4136 Some(super::ResourceAccess::RestrictedView)
4137 );
4138 }
4139
4140 #[test]
4141 fn derive_access_v() {
4142 assert_eq!(super::derive_access("V"), Some(super::ResourceAccess::View));
4143 }
4144
4145 #[test]
4146 fn derive_access_m() {
4147 assert_eq!(super::derive_access("M"), Some(super::ResourceAccess::Edit));
4148 }
4149
4150 #[test]
4151 fn derive_access_d() {
4152 assert_eq!(
4153 super::derive_access("D"),
4154 Some(super::ResourceAccess::Delete)
4155 );
4156 }
4157
4158 #[test]
4159 fn derive_access_cr() {
4160 assert_eq!(
4161 super::derive_access("CR"),
4162 Some(super::ResourceAccess::Manage)
4163 );
4164 }
4165
4166 #[test]
4167 fn derive_access_unknown_is_none() {
4168 assert_eq!(super::derive_access("XYZ"), None);
4169 }
4170
4171 #[test]
4172 fn derive_access_empty_is_none() {
4173 assert_eq!(super::derive_access(""), None);
4174 }
4175
4176 #[test]
4181 fn derive_visibility_public_when_unknown_user_has_view() {
4182 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|V knora-admin:KnownUser,knora-admin:UnknownUser";
4184 assert_eq!(
4185 super::derive_visibility(acl),
4186 Some(super::ResourceVisibility::Public)
4187 );
4188 }
4189
4190 #[test]
4191 fn derive_visibility_public_when_unknown_user_has_cr() {
4192 let acl = "CR knora-admin:UnknownUser";
4194 assert_eq!(
4195 super::derive_visibility(acl),
4196 Some(super::ResourceVisibility::Public)
4197 );
4198 }
4199
4200 #[test]
4201 fn derive_visibility_public_restricted_when_unknown_user_has_rv() {
4202 let acl = "RV knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4204 assert_eq!(
4205 super::derive_visibility(acl),
4206 Some(super::ResourceVisibility::PublicRestricted)
4207 );
4208 }
4209
4210 #[test]
4211 fn derive_visibility_logged_in_when_known_user_has_rv_unknown_absent() {
4212 let acl = "RV knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4214 assert_eq!(
4215 super::derive_visibility(acl),
4216 Some(super::ResourceVisibility::LoggedInUsers)
4217 );
4218 }
4219
4220 #[test]
4221 fn derive_visibility_logged_in_when_known_user_has_v() {
4222 let acl = "V knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4224 assert_eq!(
4225 super::derive_visibility(acl),
4226 Some(super::ResourceVisibility::LoggedInUsers)
4227 );
4228 }
4229
4230 #[test]
4231 fn derive_visibility_project_members_when_neither_world_group_granted() {
4232 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|M knora-admin:ProjectMember";
4234 assert_eq!(
4235 super::derive_visibility(acl),
4236 Some(super::ResourceVisibility::ProjectMembers)
4237 );
4238 }
4239
4240 #[test]
4241 fn derive_visibility_empty_string_is_none() {
4242 assert_eq!(super::derive_visibility(""), None);
4243 }
4244
4245 #[test]
4246 fn derive_visibility_whitespace_only_is_none() {
4247 assert_eq!(super::derive_visibility(" "), None);
4248 }
4249
4250 #[test]
4251 fn derive_visibility_malformed_entry_without_space_is_skipped() {
4252 let acl = "CRMALFORMED|CR knora-admin:ProjectAdmin";
4254 assert_eq!(
4256 super::derive_visibility(acl),
4257 Some(super::ResourceVisibility::ProjectMembers)
4258 );
4259 }
4260
4261 #[test]
4262 fn derive_visibility_unknown_code_ranks_zero_no_implicit_grant() {
4263 let acl = "BOGUS knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4265 assert_eq!(
4267 super::derive_visibility(acl),
4268 Some(super::ResourceVisibility::ProjectMembers)
4269 );
4270 }
4271
4272 #[test]
4273 fn derive_visibility_same_group_two_entries_max_wins() {
4274 let acl = "RV knora-admin:UnknownUser|V knora-admin:UnknownUser";
4276 assert_eq!(
4277 super::derive_visibility(acl),
4278 Some(super::ResourceVisibility::Public)
4279 );
4280 }
4281
4282 #[test]
4283 fn derive_visibility_both_world_groups_unknown_user_decides() {
4284 let acl = "V knora-admin:UnknownUser|CR knora-admin:KnownUser";
4287 assert_eq!(
4288 super::derive_visibility(acl),
4289 Some(super::ResourceVisibility::Public)
4290 );
4291 }
4292
4293 #[test]
4294 fn derive_visibility_super_unknown_user_does_not_match() {
4295 let acl = "CR knora-admin:SuperUnknownUser|CR knora-admin:ProjectAdmin";
4298 assert_eq!(
4300 super::derive_visibility(acl),
4301 Some(super::ResourceVisibility::ProjectMembers)
4302 );
4303 }
4304
4305 #[test]
4306 fn derive_visibility_all_malformed_entries_no_space_returns_none() {
4307 let acl = "NOSPACE|ALSONOSPACE|STILLNOSPACE";
4311 assert_eq!(
4312 super::derive_visibility(acl),
4313 None,
4314 "all-malformed ACL (no space in any entry) must return None"
4315 );
4316 }
4317
4318 use crate::model::ValueType;
4323 use crate::model::resource::{DatePoint, DateValue, FileValue, ValueContent};
4324
4325 #[test]
4328 fn parse_value_text_plain() {
4329 let obj = serde_json::json!({
4330 "@type": "knora-api:TextValue",
4331 "knora-api:valueAsString": "Hello world"
4332 });
4333 let (content, is_link) = super::parse_value_content(&obj);
4334 assert_eq!(content, ValueContent::Text("Hello world".into()));
4335 assert!(!is_link);
4336 }
4337
4338 #[test]
4339 fn parse_value_text_standoff_xml_stripped() {
4340 let obj = serde_json::json!({
4342 "@type": "knora-api:TextValue",
4343 "knora-api:textValueAsXml": "<p>Hello <b>world</b></p>",
4344 "knora-api:valueAsString": "This is ignored when xml present"
4345 });
4346 let (content, is_link) = super::parse_value_content(&obj);
4347 assert!(matches!(content, ValueContent::Text(_)));
4349 assert!(!is_link);
4350 if let ValueContent::Text(s) = content {
4351 assert!(!s.contains('<'), "no raw tags: {s:?}");
4353 assert!(s.contains("Hello"), "text retained: {s:?}");
4354 }
4355 }
4356
4357 #[test]
4360 fn parse_value_integer() {
4361 let obj = serde_json::json!({
4362 "@type": "knora-api:IntValue",
4363 "knora-api:intValueAsInt": 42
4364 });
4365 let (content, is_link) = super::parse_value_content(&obj);
4366 assert_eq!(content, ValueContent::Integer(42));
4367 assert!(!is_link);
4368 }
4369
4370 #[test]
4371 fn parse_value_integer_negative() {
4372 let obj = serde_json::json!({
4373 "@type": "knora-api:IntValue",
4374 "knora-api:intValueAsInt": -7
4375 });
4376 let (content, _) = super::parse_value_content(&obj);
4377 assert_eq!(content, ValueContent::Integer(-7));
4378 }
4379
4380 #[test]
4383 fn parse_value_decimal_object_form() {
4384 let obj = serde_json::json!({
4386 "@type": "knora-api:DecimalValue",
4387 "knora-api:decimalValueAsDecimal": {"@value": "3.14159", "@type": "xsd:decimal"}
4388 });
4389 let (content, is_link) = super::parse_value_content(&obj);
4390 assert_eq!(content, ValueContent::Decimal("3.14159".into()));
4391 assert!(!is_link);
4392 }
4393
4394 #[test]
4395 fn parse_value_decimal_bare_string_form() {
4396 let obj = serde_json::json!({
4397 "@type": "knora-api:DecimalValue",
4398 "knora-api:decimalValueAsDecimal": "2.71828"
4399 });
4400 let (content, _) = super::parse_value_content(&obj);
4401 assert_eq!(content, ValueContent::Decimal("2.71828".into()));
4402 }
4403
4404 #[test]
4407 fn parse_value_boolean_true() {
4408 let obj = serde_json::json!({
4409 "@type": "knora-api:BooleanValue",
4410 "knora-api:booleanValueAsBoolean": true
4411 });
4412 let (content, is_link) = super::parse_value_content(&obj);
4413 assert_eq!(content, ValueContent::Boolean(true));
4414 assert!(!is_link);
4415 }
4416
4417 #[test]
4418 fn parse_value_boolean_false() {
4419 let obj = serde_json::json!({
4420 "@type": "knora-api:BooleanValue",
4421 "knora-api:booleanValueAsBoolean": false
4422 });
4423 let (content, _) = super::parse_value_content(&obj);
4424 assert_eq!(content, ValueContent::Boolean(false));
4425 }
4426
4427 #[test]
4430 fn parse_value_date_single_point() {
4431 let obj = serde_json::json!({
4433 "@type": "knora-api:DateValue",
4434 "knora-api:dateValueHasCalendar": "GREGORIAN",
4435 "knora-api:dateValueHasStartYear": 1489,
4436 "knora-api:dateValueHasStartEra": "CE",
4437 "knora-api:dateValueHasEndYear": 1489,
4438 "knora-api:dateValueHasEndEra": "CE"
4439 });
4440 let (content, is_link) = super::parse_value_content(&obj);
4441 assert!(!is_link);
4442 let expected = ValueContent::Date(DateValue {
4443 calendar: "GREGORIAN".into(),
4444 start: DatePoint {
4445 year: Some(1489),
4446 month: None,
4447 day: None,
4448 era: Some("CE".into()),
4449 },
4450 end: DatePoint {
4451 year: Some(1489),
4452 month: None,
4453 day: None,
4454 era: Some("CE".into()),
4455 },
4456 });
4457 assert_eq!(content, expected);
4458 }
4459
4460 #[test]
4461 fn parse_value_date_range() {
4462 let obj = serde_json::json!({
4464 "@type": "knora-api:DateValue",
4465 "knora-api:dateValueHasCalendar": "GREGORIAN",
4466 "knora-api:dateValueHasStartYear": 1489,
4467 "knora-api:dateValueHasStartEra": "CE",
4468 "knora-api:dateValueHasEndYear": 1490,
4469 "knora-api:dateValueHasEndEra": "CE"
4470 });
4471 let (content, _) = super::parse_value_content(&obj);
4472 if let ValueContent::Date(dv) = content {
4473 assert_eq!(dv.start.year, Some(1489));
4474 assert_eq!(dv.end.year, Some(1490));
4475 assert_ne!(dv.start, dv.end, "range: start != end");
4476 } else {
4477 panic!("expected DateValue, got {content:?}");
4478 }
4479 }
4480
4481 #[test]
4482 fn parse_value_date_full_day_precision() {
4483 let obj = serde_json::json!({
4485 "@type": "knora-api:DateValue",
4486 "knora-api:dateValueHasCalendar": "JULIAN",
4487 "knora-api:dateValueHasStartYear": 1456,
4488 "knora-api:dateValueHasStartMonth": 3,
4489 "knora-api:dateValueHasStartDay": 14,
4490 "knora-api:dateValueHasStartEra": "CE",
4491 "knora-api:dateValueHasEndYear": 1456,
4492 "knora-api:dateValueHasEndMonth": 3,
4493 "knora-api:dateValueHasEndDay": 14,
4494 "knora-api:dateValueHasEndEra": "CE"
4495 });
4496 let (content, _) = super::parse_value_content(&obj);
4497 if let ValueContent::Date(dv) = content {
4498 assert_eq!(dv.calendar, "JULIAN");
4499 assert_eq!(dv.start.month, Some(3));
4500 assert_eq!(dv.start.day, Some(14));
4501 } else {
4502 panic!("expected DateValue, got {content:?}");
4503 }
4504 }
4505
4506 #[test]
4507 fn parse_value_date_no_year_falls_back_to_raw() {
4508 let obj = serde_json::json!({
4510 "@type": "knora-api:DateValue",
4511 "knora-api:dateValueHasCalendar": "GREGORIAN",
4512 "knora-api:valueAsString": "some date"
4513 });
4514 let (content, _) = super::parse_value_content(&obj);
4515 assert!(
4516 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "date"),
4517 "missing years must degrade to Raw date"
4518 );
4519 }
4520
4521 #[test]
4524 fn parse_value_time() {
4525 let obj = serde_json::json!({
4526 "@type": "knora-api:TimeValue",
4527 "knora-api:timeValueAsTimeStamp": {"@value": "2021-01-01T12:00:00Z", "@type": "xsd:dateTimeStamp"}
4528 });
4529 let (content, is_link) = super::parse_value_content(&obj);
4530 assert_eq!(content, ValueContent::Time("2021-01-01T12:00:00Z".into()));
4531 assert!(!is_link);
4532 }
4533
4534 #[test]
4535 fn parse_value_time_bare_string() {
4536 let obj = serde_json::json!({
4537 "@type": "knora-api:TimeValue",
4538 "knora-api:timeValueAsTimeStamp": "2022-06-01T00:00:00Z"
4539 });
4540 let (content, _) = super::parse_value_content(&obj);
4541 assert_eq!(content, ValueContent::Time("2022-06-01T00:00:00Z".into()));
4542 }
4543
4544 #[test]
4547 fn parse_value_uri() {
4548 let obj = serde_json::json!({
4549 "@type": "knora-api:UriValue",
4550 "knora-api:uriValueAsUri": {"@value": "https://example.com", "@type": "xsd:anyURI"}
4551 });
4552 let (content, is_link) = super::parse_value_content(&obj);
4553 assert_eq!(content, ValueContent::Uri("https://example.com".into()));
4554 assert!(!is_link);
4555 }
4556
4557 #[test]
4560 fn parse_value_color() {
4561 let obj = serde_json::json!({
4562 "@type": "knora-api:ColorValue",
4563 "knora-api:colorValueAsColor": "#ff0000"
4564 });
4565 let (content, is_link) = super::parse_value_content(&obj);
4566 assert_eq!(content, ValueContent::Color("#ff0000".into()));
4567 assert!(!is_link);
4568 }
4569
4570 #[test]
4573 fn parse_value_geoname() {
4574 let obj = serde_json::json!({
4575 "@type": "knora-api:GeonameValue",
4576 "knora-api:geonameValueAsGeonameCode": "2661552"
4577 });
4578 let (content, is_link) = super::parse_value_content(&obj);
4579 assert_eq!(content, ValueContent::Geoname("2661552".into()));
4580 assert!(!is_link);
4581 }
4582
4583 #[test]
4586 fn parse_value_list_item() {
4587 let obj = serde_json::json!({
4588 "@type": "knora-api:ListValue",
4589 "knora-api:listValueAsListNode": {"@id": "http://rdfh.ch/lists/0001/node1"}
4590 });
4591 let (content, is_link) = super::parse_value_content(&obj);
4592 assert_eq!(
4593 content,
4594 ValueContent::ListItem {
4595 node_iri: "http://rdfh.ch/lists/0001/node1".into(),
4596 label: None, }
4598 );
4599 assert!(!is_link);
4600 }
4601
4602 #[test]
4605 fn parse_value_link_with_embedded_target() {
4606 let obj = serde_json::json!({
4607 "@type": "knora-api:LinkValue",
4608 "knora-api:linkValueHasTarget": {
4609 "@id": "http://rdfh.ch/0803/res1",
4610 "@type": "incunabula:Book",
4611 "rdfs:label": "Incunabula Book 1"
4612 }
4613 });
4614 let (content, is_link) = super::parse_value_content(&obj);
4615 assert!(is_link, "LinkValue must set is_link=true");
4616 assert_eq!(
4617 content,
4618 ValueContent::Link {
4619 target_iri: "http://rdfh.ch/0803/res1".into(),
4620 target_label: Some("Incunabula Book 1".into()),
4621 }
4622 );
4623 }
4624
4625 #[test]
4626 fn parse_value_link_with_target_iri_only() {
4627 let obj = serde_json::json!({
4629 "@type": "knora-api:LinkValue",
4630 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res2"}
4631 });
4632 let (content, is_link) = super::parse_value_content(&obj);
4633 assert!(is_link);
4634 assert_eq!(
4635 content,
4636 ValueContent::Link {
4637 target_iri: "http://rdfh.ch/0803/res2".into(),
4638 target_label: None,
4639 }
4640 );
4641 }
4642
4643 #[test]
4646 fn parse_value_still_image_file() {
4647 let obj = serde_json::json!({
4648 "@type": "knora-api:StillImageFileValue",
4649 "knora-api:fileValueHasFilename": "image.jp2",
4650 "knora-api:fileValueAsUrl": {"@value": "https://iiif.example.com/image.jp2/full/max/0/default.jpg"},
4651 "knora-api:stillImageFileValueHasDimX": 1200,
4652 "knora-api:stillImageFileValueHasDimY": 800
4653 });
4654 let (content, is_link) = super::parse_value_content(&obj);
4655 assert!(!is_link);
4656 assert_eq!(
4657 content,
4658 ValueContent::File(FileValue {
4659 value_type: ValueType::StillImage,
4660 filename: "image.jp2".into(),
4661 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
4662 width: Some(1200),
4663 height: Some(800),
4664 })
4665 );
4666 }
4667
4668 #[test]
4669 fn parse_value_still_image_external_file_value() {
4670 let obj = serde_json::json!({
4672 "@type": "knora-api:StillImageExternalFileValue",
4673 "knora-api:fileValueHasFilename": "external.jpg",
4674 "knora-api:fileValueAsUrl": {"@value": "https://iiif.external.com/image.jpg"}
4675 });
4676 let (content, _) = super::parse_value_content(&obj);
4677 if let ValueContent::File(fv) = content {
4678 assert_eq!(
4679 fv.value_type,
4680 ValueType::StillImage,
4681 "StillImageExternal* → StillImage"
4682 );
4683 } else {
4684 panic!("expected File, got {content:?}");
4685 }
4686 }
4687
4688 #[test]
4691 fn parse_value_moving_image_file() {
4692 let obj = serde_json::json!({
4693 "@type": "knora-api:MovingImageFileValue",
4694 "knora-api:fileValueHasFilename": "video.mp4",
4695 "knora-api:fileValueAsUrl": {"@value": "https://example.com/video.mp4"}
4696 });
4697 let (content, is_link) = super::parse_value_content(&obj);
4698 assert!(!is_link);
4699 assert_eq!(
4700 content,
4701 ValueContent::File(FileValue {
4702 value_type: ValueType::MovingImage,
4703 filename: "video.mp4".into(),
4704 url: "https://example.com/video.mp4".into(),
4705 width: None,
4706 height: None,
4707 })
4708 );
4709 }
4710
4711 #[test]
4714 fn parse_value_audio_file() {
4715 let obj = serde_json::json!({
4716 "@type": "knora-api:AudioFileValue",
4717 "knora-api:fileValueHasFilename": "sound.wav",
4718 "knora-api:fileValueAsUrl": {"@value": "https://example.com/sound.wav"}
4719 });
4720 let (content, _) = super::parse_value_content(&obj);
4721 assert_eq!(
4722 content,
4723 ValueContent::File(FileValue {
4724 value_type: ValueType::Audio,
4725 filename: "sound.wav".into(),
4726 url: "https://example.com/sound.wav".into(),
4727 width: None,
4728 height: None,
4729 })
4730 );
4731 }
4732
4733 #[test]
4736 fn parse_value_document_file() {
4737 let obj = serde_json::json!({
4738 "@type": "knora-api:DocumentFileValue",
4739 "knora-api:fileValueHasFilename": "doc.pdf",
4740 "knora-api:fileValueAsUrl": {"@value": "https://example.com/doc.pdf"}
4741 });
4742 let (content, _) = super::parse_value_content(&obj);
4743 assert_eq!(
4744 content,
4745 ValueContent::File(FileValue {
4746 value_type: ValueType::Document,
4747 filename: "doc.pdf".into(),
4748 url: "https://example.com/doc.pdf".into(),
4749 width: None,
4750 height: None,
4751 })
4752 );
4753 }
4754
4755 #[test]
4758 fn parse_value_archive_file() {
4759 let obj = serde_json::json!({
4760 "@type": "knora-api:ArchiveFileValue",
4761 "knora-api:fileValueHasFilename": "data.zip",
4762 "knora-api:fileValueAsUrl": {"@value": "https://example.com/data.zip"}
4763 });
4764 let (content, _) = super::parse_value_content(&obj);
4765 assert_eq!(
4766 content,
4767 ValueContent::File(FileValue {
4768 value_type: ValueType::Archive,
4769 filename: "data.zip".into(),
4770 url: "https://example.com/data.zip".into(),
4771 width: None,
4772 height: None,
4773 })
4774 );
4775 }
4776
4777 #[test]
4780 fn parse_value_text_file_value_maps_to_document() {
4781 let obj = serde_json::json!({
4782 "@type": "knora-api:TextFileValue",
4783 "knora-api:fileValueHasFilename": "text.txt",
4784 "knora-api:fileValueAsUrl": {"@value": "https://example.com/text.txt"}
4785 });
4786 let (content, _) = super::parse_value_content(&obj);
4787 if let ValueContent::File(fv) = content {
4788 assert_eq!(
4789 fv.value_type,
4790 ValueType::Document,
4791 "TextFileValue → Document"
4792 );
4793 } else {
4794 panic!("expected File, got {content:?}");
4795 }
4796 }
4797
4798 #[test]
4801 fn parse_value_interval_raw_fallback() {
4802 let obj = serde_json::json!({
4803 "@type": "knora-api:IntervalValue",
4804 "knora-api:intervalValueHasStart": {"@value": "0.0", "@type": "xsd:decimal"},
4805 "knora-api:intervalValueHasEnd": {"@value": "10.5", "@type": "xsd:decimal"},
4806 "knora-api:valueAsString": "0.0 - 10.5"
4807 });
4808 let (content, is_link) = super::parse_value_content(&obj);
4809 assert!(!is_link);
4810 assert!(
4811 matches!(content, ValueContent::Raw { ref value_type, .. } if value_type == "interval"),
4812 "IntervalValue must degrade to Raw with token 'interval'"
4813 );
4814 if let ValueContent::Raw { text, .. } = content {
4815 assert_eq!(text, "0.0 - 10.5");
4816 }
4817 }
4818
4819 #[test]
4820 fn parse_value_geom_raw_fallback() {
4821 let obj = serde_json::json!({
4822 "@type": "knora-api:GeomValue",
4823 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
4824 });
4825 let (content, _) = super::parse_value_content(&obj);
4826 assert!(
4827 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "geom"),
4828 "GeomValue must degrade to Raw with token 'geom'"
4829 );
4830 }
4831
4832 #[test]
4835 fn parse_value_with_comment() {
4836 let obj = serde_json::json!({
4837 "@type": "knora-api:TextValue",
4838 "knora-api:valueAsString": "Hello world",
4839 "knora-api:valueHasComment": "reading uncertain"
4840 });
4841 let (value, is_link) = super::parse_value(&obj);
4842 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4843 assert_eq!(value.comment.as_deref(), Some("reading uncertain"));
4844 assert!(!is_link);
4845 }
4846
4847 #[test]
4848 fn parse_value_without_comment() {
4849 let obj = serde_json::json!({
4850 "@type": "knora-api:TextValue",
4851 "knora-api:valueAsString": "Hello world"
4852 });
4853 let (value, is_link) = super::parse_value(&obj);
4854 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4855 assert_eq!(value.comment, None);
4856 assert!(!is_link);
4857 }
4858
4859 #[test]
4860 fn parse_value_with_empty_comment() {
4861 let obj = serde_json::json!({
4862 "@type": "knora-api:TextValue",
4863 "knora-api:valueAsString": "Hello world",
4864 "knora-api:valueHasComment": ""
4865 });
4866 let (value, is_link) = super::parse_value(&obj);
4867 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4868 assert_eq!(value.comment, None);
4869 assert!(!is_link);
4870 }
4871
4872 #[test]
4875 fn parse_value_link_is_link_true() {
4876 let obj = serde_json::json!({
4878 "@type": "knora-api:LinkValue",
4879 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4880 });
4881 let (_, is_link) = super::parse_value_content(&obj);
4882 assert!(
4883 is_link,
4884 "LinkValue must report is_link=true for name derivation"
4885 );
4886 }
4887
4888 #[test]
4889 fn field_name_link_strips_value_suffix() {
4890 let key = "incunabula:isPartOfBookValue";
4894 let link_obj = serde_json::json!({
4895 "@type": "knora-api:LinkValue",
4896 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4897 });
4898 let (_, is_link) = super::parse_value_content(&link_obj);
4899 assert!(
4900 is_link,
4901 "LinkValue must report is_link=true for name derivation"
4902 );
4903
4904 let raw_name = super::local_name(key).to_string();
4905 let name = if is_link {
4907 raw_name
4908 .strip_suffix("Value")
4909 .unwrap_or(&raw_name)
4910 .to_string()
4911 } else {
4912 raw_name
4913 };
4914 assert_eq!(name, "isPartOfBook");
4915 }
4916
4917 #[test]
4918 fn field_name_non_link_does_not_strip_value_suffix() {
4919 let key = "incunabula:hasAValue";
4924 let text_obj = serde_json::json!({
4925 "@type": "knora-api:TextValue",
4926 "knora-api:valueAsString": "some text"
4927 });
4928 let (_, is_link) = super::parse_value_content(&text_obj);
4929 assert!(!is_link, "TextValue must report is_link=false");
4930
4931 let raw_name = super::local_name(key).to_string();
4932 let name = if is_link {
4934 raw_name
4935 .strip_suffix("Value")
4936 .unwrap_or(&raw_name)
4937 .to_string()
4938 } else {
4939 raw_name
4940 };
4941 assert_eq!(
4942 name, "hasAValue",
4943 "non-link ending in Value must NOT be stripped; is_link={is_link}"
4944 );
4945 }
4946
4947 #[test]
4950 fn has_value_class_type_rejects_xsd_any_uri() {
4951 let obj = serde_json::json!({
4953 "@value": "http://ark.dasch.swiss/ark:/…",
4954 "@type": "xsd:anyURI"
4955 });
4956 assert!(
4957 !super::has_value_class_type(&obj),
4958 "xsd:anyURI must not pass the value-class test"
4959 );
4960 }
4961
4962 #[test]
4963 fn has_value_class_type_rejects_scalar() {
4964 let obj = serde_json::json!("just a string");
4966 assert!(!super::has_value_class_type(&obj));
4967 }
4968
4969 #[test]
4970 fn has_value_class_type_accepts_text_value() {
4971 let obj = serde_json::json!({
4972 "@type": "knora-api:TextValue",
4973 "knora-api:valueAsString": "hello"
4974 });
4975 assert!(super::has_value_class_type(&obj));
4976 }
4977
4978 #[test]
4979 fn has_value_class_type_accepts_still_image_file_value() {
4980 let obj = serde_json::json!({
4981 "@type": "knora-api:StillImageFileValue",
4982 "knora-api:fileValueHasFilename": "img.jp2"
4983 });
4984 assert!(super::has_value_class_type(&obj));
4985 }
4986
4987 #[test]
4990 fn build_prefix_map_string_entries_only() {
4991 let ctx = Some(serde_json::json!({
4992 "incunabula": "http://api.dasch.swiss/ontology/0803/incunabula/v2#",
4993 "knora-api": "http://api.knora.org/ontology/knora-api/v2#",
4994 "someterm": {"@id": "http://example.com/term", "@type": "@id"}
4996 }));
4997 let map = super::build_prefix_map(&ctx);
4998 assert_eq!(
4999 map.get("incunabula").map(String::as_str),
5000 Some("http://api.dasch.swiss/ontology/0803/incunabula/v2#")
5001 );
5002 assert_eq!(
5003 map.get("knora-api").map(String::as_str),
5004 Some("http://api.knora.org/ontology/knora-api/v2#")
5005 );
5006 assert!(
5007 !map.contains_key("someterm"),
5008 "object-valued entry must be skipped"
5009 );
5010 }
5011
5012 #[test]
5013 fn build_prefix_map_empty_when_no_context() {
5014 let map = super::build_prefix_map(&None);
5015 assert!(map.is_empty());
5016 }
5017
5018 #[test]
5021 fn compact_value_text_excludes_meta_keys() {
5022 let obj = serde_json::json!({
5023 "@id": "http://rdfh.ch/0803/val1",
5024 "@type": "knora-api:GeomValue",
5025 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
5026 });
5027 let text = super::compact_value_text(&obj);
5028 assert!(
5030 text.contains("geometryValueAsGeometry"),
5031 "geometry key present: {text}"
5032 );
5033 assert!(!text.contains("@id"), "@id must be excluded: {text}");
5034 assert!(!text.contains("@type"), "@type must be excluded: {text}");
5035 }
5036
5037 #[test]
5038 fn compact_value_text_all_meta_yields_empty() {
5039 let obj = serde_json::json!({
5040 "@id": "http://rdfh.ch/0803/val1",
5041 "@type": "knora-api:IntervalValue"
5042 });
5043 let text = super::compact_value_text(&obj);
5044 assert!(
5045 text.is_empty(),
5046 "all-meta object must yield empty string: {text:?}"
5047 );
5048 }
5049}