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 {
465 if status.is_server_error() {
466 Diagnostic::ServerError(format!("server returned {status} for {url}"))
467 } else {
468 Diagnostic::ServerError(format!("unexpected status {status} for {url}"))
469 }
470}
471
472fn validate_dump_id(id: &str) -> Result<(), Diagnostic> {
480 if id.is_empty()
481 || id.len() > 256 || !id
483 .chars()
484 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
485 {
486 let preview: String = id.chars().take(40).collect();
488 let suffix = if id.chars().count() > 40 { "…" } else { "" };
489 return Err(Diagnostic::ServerError(format!(
490 "server returned an invalid dump id: '{preview}{suffix}'"
491 )));
492 }
493 Ok(())
494}
495
496fn project_lookup_url(base: &str, project: &str) -> String {
502 match classify(project) {
503 ProjectIdent::Shortcode(code) => {
504 format!("{base}/admin/projects/shortcode/{code}")
505 }
506 ProjectIdent::Shortname(name) => {
507 format!("{base}/admin/projects/shortname/{name}")
508 }
509 ProjectIdent::Iri(iri) => {
510 format!("{base}/admin/projects/iri/{}", enc(iri))
511 }
512 }
513}
514
515fn is_safe_shortcode(s: &str) -> bool {
525 !s.is_empty() && s.len() <= 32 && s.chars().all(|c| c.is_ascii_alphanumeric())
526}
527
528fn local_name(id: &str) -> &str {
533 id.rsplit(['#', '/', ':']).next().unwrap_or(id)
534}
535
536fn expand_class_id(id: &str, prefixes: &HashMap<String, String>) -> (String, String) {
547 let name = local_name(id).to_string();
548 let iri = match id.split_once(':') {
549 Some((prefix, local)) if !local.starts_with("//") => prefixes
550 .get(prefix)
551 .map(|ns| format!("{ns}{local}"))
552 .unwrap_or_else(|| id.to_string()),
553 _ => id.to_string(), };
555 (name, iri)
556}
557
558pub(crate) fn data_model_name_from_iri(iri: &str) -> String {
570 let t = iri.trim_end_matches('/');
571 let t = t.strip_suffix("/v2").unwrap_or(t);
572 t.rsplit('/').next().unwrap_or(t).to_string()
573}
574
575const SYSTEM_PREFIXES: &[&str] = &[
584 "knora-api",
585 "knora-base",
586 "rdf",
587 "rdfs",
588 "owl",
589 "salsah-gui",
590 "standoff",
591 "xsd",
592];
593
594const FILE_VALUE_PROPS: &[(&str, Representation)] = &[
599 ("hasStillImageFileValue", Representation::StillImage),
600 ("hasMovingImageFileValue", Representation::MovingImage),
601 ("hasAudioFileValue", Representation::Audio),
602 ("hasDocumentFileValue", Representation::Document),
603 ("hasArchiveFileValue", Representation::Archive),
604 ("hasTextFileValue", Representation::Text),
605];
606
607const MAX_SIBLING_FETCHES: usize = 16;
610
611fn is_system_prefix(prefix: &str) -> bool {
617 SYSTEM_PREFIXES.contains(&prefix)
618}
619
620fn map_object_type_to_value_type(local: &str) -> ValueType {
631 match local {
632 "TextValue" => ValueType::Text,
633 "IntValue" => ValueType::Integer,
634 "DecimalValue" => ValueType::Decimal,
635 "BooleanValue" => ValueType::Boolean,
636 "DateValue" => ValueType::Date,
637 "TimeValue" => ValueType::Time,
638 "UriValue" => ValueType::Uri,
639 "ColorValue" => ValueType::Color,
640 "GeonameValue" => ValueType::Geoname,
641 "ListValue" => ValueType::ListItem,
642 "StillImageFileValue" => ValueType::StillImage,
643 "MovingImageFileValue" => ValueType::MovingImage,
644 "AudioFileValue" => ValueType::Audio,
645 "DocumentFileValue" => ValueType::Document,
646 "ArchiveFileValue" => ValueType::Archive,
647 other => ValueType::Other(object_type_to_kebab(other)),
648 }
649}
650
651fn object_type_to_kebab(local: &str) -> String {
658 let base = local.strip_suffix("Value").unwrap_or(local);
660
661 let mut result = String::with_capacity(base.len() + 4);
664 let chars: Vec<char> = base.chars().collect();
665 for (i, &ch) in chars.iter().enumerate() {
666 if i > 0 && ch.is_uppercase() {
667 if chars[i - 1].is_lowercase() {
669 result.push('-');
670 }
671 }
672 result.push(ch);
673 }
674 result.to_lowercase()
675}
676
677fn decode_cardinality(restriction: &serde_json::Value) -> Cardinality {
683 let as_u64 =
685 |key: &str| -> Option<u64> { restriction.get(key).and_then(serde_json::Value::as_u64) };
686
687 if let Some(v) = as_u64("owl:cardinality") {
688 if v == 1 {
689 return Cardinality::One;
690 }
691 tracing::warn!(
692 value = v,
693 "owl:cardinality had unexpected value (expected 1); falling back to ZeroOrMore"
694 );
695 return Cardinality::ZeroOrMore;
696 }
697
698 if let Some(v) = as_u64("owl:maxCardinality") {
699 if v == 1 {
700 return Cardinality::ZeroOrOne;
701 }
702 tracing::warn!(
703 value = v,
704 "owl:maxCardinality had unexpected value (expected 1); falling back to ZeroOrMore"
705 );
706 return Cardinality::ZeroOrMore;
707 }
708
709 if let Some(v) = as_u64("owl:minCardinality") {
710 return match v {
711 0 => Cardinality::ZeroOrMore,
712 1 => Cardinality::OneOrMore,
713 other => {
714 tracing::warn!(
715 value = other,
716 "owl:minCardinality had unexpected value (expected 0 or 1); falling back to ZeroOrMore"
717 );
718 Cardinality::ZeroOrMore
719 }
720 };
721 }
722
723 tracing::warn!("owl:Restriction has no recognized cardinality key; falling back to ZeroOrMore");
724 Cardinality::ZeroOrMore
725}
726
727fn detect_representation(restriction_prop_locals: &[&str]) -> Option<Representation> {
733 for local in restriction_prop_locals {
734 for (file_val_local, repr) in FILE_VALUE_PROPS {
735 if local == file_val_local {
736 return Some(*repr);
737 }
738 }
739 }
740 None
741}
742
743fn curie_prefix(id: &str) -> Option<&str> {
746 id.split_once(':')
747 .filter(|(_, local)| !local.starts_with("//"))
748 .map(|(prefix, _)| prefix)
749}
750
751#[derive(serde::Deserialize)]
767struct ResourceListDto {
768 #[serde(rename = "@graph", default)]
770 graph: Option<Vec<ResourceNodeDto>>,
771
772 #[serde(rename = "@id", default)]
774 id: Option<String>,
775
776 #[serde(rename = "@type", default)]
779 type_field: Option<serde_json::Value>,
780
781 #[serde(rename = "rdfs:label", default)]
783 label: Option<serde_json::Value>,
784
785 #[serde(rename = "knora-api:arkUrl", default)]
787 ark_url: Option<serde_json::Value>,
788
789 #[serde(rename = "knora-api:creationDate", default)]
791 creation_date: Option<serde_json::Value>,
792
793 #[serde(rename = "knora-api:lastModificationDate", default)]
795 last_modification_date: Option<serde_json::Value>,
796
797 #[serde(rename = "knora-api:mayHaveMoreResults", default)]
799 may_have_more_results: bool,
800}
801
802#[derive(serde::Deserialize)]
809struct ResourceNodeDto {
810 #[serde(rename = "@id")]
811 id: String,
812
813 #[serde(rename = "@type", default)]
815 type_field: Option<serde_json::Value>,
816
817 #[serde(rename = "rdfs:label", default)]
819 label: Option<serde_json::Value>,
820
821 #[serde(rename = "knora-api:arkUrl", default)]
823 ark_url: Option<serde_json::Value>,
824
825 #[serde(rename = "knora-api:creationDate", default)]
827 creation_date: Option<serde_json::Value>,
828
829 #[serde(rename = "knora-api:lastModificationDate", default)]
831 last_modification_date: Option<serde_json::Value>,
832}
833
834fn extract_string_value(v: &serde_json::Value) -> Option<String> {
839 match v {
840 serde_json::Value::String(s) => Some(s.clone()),
841 serde_json::Value::Object(map) => map
842 .get("@value")
843 .or_else(|| map.get("@id"))
844 .and_then(|inner| inner.as_str())
845 .map(str::to_owned),
846 _ => None,
847 }
848}
849
850fn extract_resource_type(type_val: Option<&serde_json::Value>) -> String {
857 match type_val {
858 None => "unknown".to_string(),
859 Some(serde_json::Value::String(s)) => local_name(s).to_string(),
860 Some(serde_json::Value::Array(arr)) => arr
861 .first()
862 .and_then(|v| v.as_str())
863 .map(|s| local_name(s).to_string())
864 .unwrap_or_else(|| "unknown".to_string()),
865 _ => "unknown".to_string(),
866 }
867}
868
869fn node_dto_to_summary(
871 id: String,
872 type_val: Option<&serde_json::Value>,
873 label_val: Option<&serde_json::Value>,
874 ark_val: Option<&serde_json::Value>,
875 creation_val: Option<&serde_json::Value>,
876 last_modification_val: Option<&serde_json::Value>,
877) -> ResourceSummary {
878 let label = label_val.and_then(extract_string_value).unwrap_or_default();
879 let resource_type = extract_resource_type(type_val);
880 let ark_url = ark_val.and_then(extract_string_value);
881 let creation_date = creation_val.and_then(extract_string_value);
889 let last_modified = last_modification_val.and_then(extract_string_value);
890 ResourceSummary {
891 label,
892 iri: id,
893 ark_url,
894 creation_date,
895 last_modified,
896 resource_type,
897 }
898}
899
900#[derive(serde::Deserialize)]
920struct ResourceDetailDto {
921 #[serde(rename = "@id")]
922 id: String,
923
924 #[serde(rename = "@type", default)]
926 type_field: Option<serde_json::Value>,
927
928 #[serde(rename = "rdfs:label", default)]
930 label: Option<serde_json::Value>,
931
932 #[serde(rename = "knora-api:arkUrl", default)]
934 ark_url: Option<serde_json::Value>,
935
936 #[serde(rename = "knora-api:creationDate", default)]
938 creation_date: Option<serde_json::Value>,
939
940 #[serde(rename = "knora-api:lastModificationDate", default)]
942 last_modification_date: Option<serde_json::Value>,
943
944 #[serde(rename = "knora-api:attachedToProject", default)]
946 attached_to_project: Option<serde_json::Value>,
947
948 #[serde(rename = "knora-api:attachedToUser", default)]
950 attached_to_user: Option<serde_json::Value>,
951
952 #[serde(rename = "knora-api:hasPermissions", default)]
955 has_permissions: Option<String>,
956
957 #[serde(rename = "knora-api:userHasPermission", default)]
960 user_has_permission: Option<String>,
961
962 #[serde(rename = "@context", default)]
969 context: Option<serde_json::Value>,
970
971 #[serde(flatten)]
979 extra: serde_json::Map<String, serde_json::Value>,
980}
981
982fn permission_rank(code: &str) -> u8 {
988 match code {
989 "RV" => 1,
990 "V" => 2,
991 "M" => 6,
992 "D" => 7,
993 "CR" => 8,
994 _ => 0,
995 }
996}
997
998fn derive_access(user_has_permission: &str) -> Option<ResourceAccess> {
1008 match user_has_permission {
1009 "RV" => Some(ResourceAccess::RestrictedView),
1010 "V" => Some(ResourceAccess::View),
1011 "M" => Some(ResourceAccess::Edit),
1012 "D" => Some(ResourceAccess::Delete),
1013 "CR" => Some(ResourceAccess::Manage),
1014 _ => None,
1015 }
1016}
1017
1018fn derive_visibility(has_permissions: &str) -> Option<ResourceVisibility> {
1028 if has_permissions.trim().is_empty() {
1029 return None;
1030 }
1031
1032 let mut unknown_rank: u8 = 0;
1033 let mut known_rank: u8 = 0;
1034 let mut parsed_any = false;
1035
1036 for entry in has_permissions.split('|') {
1037 let entry = entry.trim();
1038 if entry.is_empty() {
1039 continue;
1040 }
1041 let Some((code, group_list)) = entry.split_once(' ') else {
1043 continue;
1045 };
1046 parsed_any = true;
1047 let rank = permission_rank(code);
1048 for group in group_list.split(',') {
1049 let group_local = local_name(group.trim());
1050 if group_local == "UnknownUser" {
1051 unknown_rank = unknown_rank.max(rank);
1052 } else if group_local == "KnownUser" {
1053 known_rank = known_rank.max(rank);
1054 }
1055 }
1056 }
1057
1058 if !parsed_any {
1059 return None;
1060 }
1061
1062 let v_rank = permission_rank("V");
1066 let rv_rank = permission_rank("RV");
1067
1068 if unknown_rank >= v_rank {
1069 Some(ResourceVisibility::Public)
1070 } else if unknown_rank >= rv_rank {
1071 Some(ResourceVisibility::PublicRestricted)
1073 } else if known_rank >= rv_rank {
1074 Some(ResourceVisibility::LoggedInUsers)
1075 } else {
1076 Some(ResourceVisibility::ProjectMembers)
1077 }
1078}
1079
1080pub struct HttpDspClient {
1086 client: reqwest::blocking::Client,
1089 download_client: reqwest::blocking::Client,
1094}
1095
1096impl HttpDspClient {
1097 pub fn new() -> Result<Self, Diagnostic> {
1106 let client = reqwest::blocking::Client::builder()
1107 .connect_timeout(Duration::from_secs(10))
1108 .timeout(Duration::from_secs(30))
1109 .build()
1110 .map_err(|e| Diagnostic::Internal(format!("failed to build HTTP client: {e}")))?;
1111 let download_client = reqwest::blocking::Client::builder()
1112 .connect_timeout(Some(Duration::from_secs(30)))
1113 .timeout(None)
1114 .build()
1115 .map_err(|e| {
1116 Diagnostic::Internal(format!("failed to build download HTTP client: {e}"))
1117 })?;
1118 Ok(Self {
1119 client,
1120 download_client,
1121 })
1122 }
1123
1124 fn fetch_allentities(
1134 &self,
1135 server: &str,
1136 ontology_iri: &str,
1137 token: Option<&str>,
1138 ) -> Result<OntologyAllEntitiesResponse, Diagnostic> {
1139 let url = format!(
1140 "{}/v2/ontologies/allentities/{}",
1141 server.trim_end_matches('/'),
1142 enc(ontology_iri)
1143 );
1144
1145 let req = self.client.get(&url);
1146 let req = if let Some(t) = token {
1147 req.bearer_auth(t)
1148 } else {
1149 req
1150 };
1151
1152 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
1153 let status = response.status();
1154
1155 if status.is_success() {
1156 let resp: OntologyAllEntitiesResponse = response.json().map_err(|e| {
1157 Diagnostic::ServerError(format!("data-model response could not be parsed: {e}"))
1158 })?;
1159 Ok(resp)
1160 } else {
1161 Err(map_unexpected_status(status, &url))
1162 }
1163 }
1164}
1165
1166impl HttpDspClient {
1167 fn parse_resource_values(
1179 &self,
1180 server: &str,
1181 token: Option<&str>,
1182 context_val: &Option<serde_json::Value>,
1183 extra: &serde_json::Map<String, serde_json::Value>,
1184 ) -> Vec<FieldValues> {
1185 let prefixes: HashMap<String, String> = build_prefix_map(context_val);
1187
1188 const DENYLIST: &[&str] = &[
1191 "knora-api:hasIncomingLinkValue",
1192 "knora-api:hasStandoffLinkToValue",
1193 "knora-api:hasStandoffLinkValue", ];
1195
1196 let mut field_entries: Vec<(&str, Vec<&serde_json::Value>)> = Vec::new();
1199
1200 for (key, val) in extra.iter() {
1201 if DENYLIST.contains(&key.as_str()) {
1202 continue;
1203 }
1204
1205 let objs: Vec<&serde_json::Value> = match val {
1207 serde_json::Value::Array(arr) => arr.iter().collect(),
1208 obj @ serde_json::Value::Object(_) => vec![obj],
1209 _ => continue, };
1211
1212 if objs.is_empty() {
1213 continue;
1214 }
1215
1216 let first = match objs.first() {
1219 Some(v) => v,
1220 None => continue,
1221 };
1222 if !has_value_class_type(first) {
1223 continue;
1224 }
1225
1226 field_entries.push((key.as_str(), objs));
1227 }
1228
1229 struct ParsedField<'a> {
1232 key: &'a str,
1233 is_link: bool,
1234 values: Vec<Value>,
1235 }
1236
1237 let mut parsed_fields: Vec<ParsedField> = Vec::new();
1238
1239 for (key, objs) in &field_entries {
1240 let mut contents: Vec<Value> = Vec::new();
1241 let mut any_link = false;
1242
1243 for obj in objs {
1244 if get_type_local(obj) == "DeletedValue" {
1246 continue;
1247 }
1248 let (content, is_link) = parse_value(obj);
1249 if is_link {
1250 any_link = true;
1251 }
1252 contents.push(content);
1253 }
1254
1255 if contents.is_empty() {
1256 continue;
1257 }
1258
1259 parsed_fields.push(ParsedField {
1260 key,
1261 is_link: any_link,
1262 values: contents,
1263 });
1264 }
1265
1266 let mut ontology_labels: HashMap<String, HashMap<String, String>> = HashMap::new(); let mut fetched_ontologies: HashSet<String> = HashSet::new();
1271
1272 for pf in &parsed_fields {
1273 let prefix = curie_prefix(pf.key).unwrap_or("");
1274 if is_system_prefix(prefix) || prefix.is_empty() {
1275 continue; }
1277 let namespace = match prefixes.get(prefix) {
1279 Some(ns) => ns,
1280 None => continue,
1281 };
1282 let ont_iri = namespace.trim_end_matches(['#', '/']).to_string();
1283 if fetched_ontologies.insert(ont_iri.clone()) {
1284 match self.fetch_allentities(server, &ont_iri, token) {
1288 Ok(resp) => {
1289 let mut prop_map: HashMap<String, String> = HashMap::new();
1290 let ctx_prefixes: HashMap<String, String> = resp
1291 .context
1292 .iter()
1293 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1294 .collect();
1295 for entity in resp.graph {
1296 if let Some(lbl) = entity.label {
1297 let (_, iri) = expand_class_id(&entity.id, &ctx_prefixes);
1298 prop_map.insert(iri, lbl);
1299 }
1300 }
1301 ontology_labels.insert(ont_iri, prop_map);
1302 }
1303 Err(e) => {
1304 tracing::warn!(
1306 prefix = %prefix,
1307 error = %e,
1308 "field-label ontology fetch failed; using local name as fallback"
1309 );
1310 }
1311 }
1312 }
1313 }
1314
1315 let mut node_labels: HashMap<String, Option<String>> = HashMap::new();
1317
1318 for pf in &parsed_fields {
1320 for v in &pf.values {
1321 if let ValueContent::ListItem { node_iri, .. } = &v.content {
1322 node_labels.entry(node_iri.clone()).or_insert(None);
1323 }
1324 }
1325 }
1326
1327 for (node_iri, label_slot) in node_labels.iter_mut() {
1329 let url = format!("{}/v2/node/{}", server.trim_end_matches('/'), enc(node_iri));
1333 let req = self.client.get(&url);
1334 let req = if let Some(t) = token {
1335 req.bearer_auth(t)
1336 } else {
1337 req
1338 };
1339 match req.send() {
1340 Ok(resp) if resp.status().is_success() => {
1341 match resp.json::<serde_json::Value>() {
1343 Ok(body) => {
1344 let lbl = body.get("rdfs:label").and_then(extract_string_value);
1346 *label_slot = lbl;
1347 }
1348 Err(_) => {
1349 tracing::debug!(
1350 node_iri = %node_iri,
1351 "list-node label response could not be parsed as JSON; using node IRI as fallback"
1352 );
1353 }
1354 }
1355 }
1356 Ok(resp) => {
1357 tracing::debug!(
1359 node_iri = %node_iri,
1360 status = %resp.status(),
1361 "list-node label fetch returned non-success; using node IRI as fallback"
1362 );
1363 }
1364 Err(e) => {
1365 tracing::debug!(
1366 node_iri = %node_iri,
1367 error = %e,
1368 "list-node label fetch failed; using node IRI as fallback"
1369 );
1370 }
1371 }
1372 }
1373
1374 let mut result: Vec<FieldValues> = Vec::new();
1376
1377 for pf in parsed_fields {
1378 let raw_name = local_name(pf.key).to_string();
1380 let name = if pf.is_link {
1381 raw_name
1382 .strip_suffix("Value")
1383 .unwrap_or(&raw_name)
1384 .to_string()
1385 } else {
1386 raw_name
1387 };
1388
1389 let label: Option<String> = {
1391 let prefix = curie_prefix(pf.key).unwrap_or("");
1392 if is_system_prefix(prefix) || prefix.is_empty() {
1393 None
1394 } else if let Some(ns) = prefixes.get(prefix) {
1395 let ont_iri = ns.trim_end_matches(['#', '/']).to_string();
1396 let local = local_name(pf.key);
1397 let prop_iri = format!("{}{}", ns, local);
1398 ontology_labels
1399 .get(&ont_iri)
1400 .and_then(|m| m.get(&prop_iri).cloned())
1401 } else {
1402 None
1403 }
1404 };
1405
1406 let values: Vec<Value> = pf
1408 .values
1409 .into_iter()
1410 .map(|v| match v.content {
1411 ValueContent::ListItem { node_iri, label: _ } => {
1412 let resolved = node_labels.get(&node_iri).cloned().flatten();
1413 Value {
1414 content: ValueContent::ListItem {
1415 node_iri,
1416 label: resolved,
1417 },
1418 comment: v.comment,
1419 }
1420 }
1421 other => Value {
1422 content: other,
1423 comment: v.comment,
1424 },
1425 })
1426 .collect();
1427
1428 result.push(FieldValues {
1429 name,
1430 label,
1431 values,
1432 });
1433 }
1434
1435 result
1436 }
1437}
1438
1439fn has_value_class_type(val: &serde_json::Value) -> bool {
1447 let type_local = get_type_local(val);
1448 type_local.ends_with("Value") && !type_local.is_empty() && {
1451 let raw_type = val
1453 .as_object()
1454 .and_then(|m| m.get("@type"))
1455 .and_then(|t| t.as_str())
1456 .unwrap_or("");
1457 raw_type.starts_with("knora-api:")
1458 }
1459}
1460
1461fn get_type_local(val: &serde_json::Value) -> &str {
1465 val.as_object()
1466 .and_then(|m| m.get("@type"))
1467 .and_then(|t| t.as_str())
1468 .map(local_name)
1469 .unwrap_or("")
1470}
1471
1472fn build_prefix_map(context_val: &Option<serde_json::Value>) -> HashMap<String, String> {
1478 match context_val {
1479 Some(serde_json::Value::Object(map)) => map
1480 .iter()
1481 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1482 .collect(),
1483 _ => HashMap::new(),
1484 }
1485}
1486
1487fn parse_value_content(obj: &serde_json::Value) -> (ValueContent, bool) {
1494 let type_local = get_type_local(obj);
1495
1496 match type_local {
1497 "TextValue" => {
1499 let content =
1502 if let Some(xml) = obj.get("knora-api:textValueAsXml").and_then(|v| v.as_str()) {
1503 crate::util::text::html_to_text(xml)
1504 } else {
1505 obj.get("knora-api:valueAsString")
1506 .and_then(|v| v.as_str())
1507 .unwrap_or("")
1508 .to_string()
1509 };
1510 (ValueContent::Text(content), false)
1511 }
1512
1513 "IntValue" => {
1515 let n = obj
1516 .get("knora-api:intValueAsInt")
1517 .and_then(|v| v.as_i64())
1518 .unwrap_or(0);
1519 (ValueContent::Integer(n), false)
1520 }
1521
1522 "DecimalValue" => {
1524 let s = obj
1526 .get("knora-api:decimalValueAsDecimal")
1527 .and_then(|v| {
1528 if let Some(s) = v.as_str() {
1530 Some(s.to_string())
1531 } else {
1532 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1533 }
1534 })
1535 .unwrap_or_default();
1536 (ValueContent::Decimal(s), false)
1537 }
1538
1539 "BooleanValue" => {
1541 let b = obj
1542 .get("knora-api:booleanValueAsBoolean")
1543 .and_then(|v| v.as_bool())
1544 .unwrap_or(false);
1545 (ValueContent::Boolean(b), false)
1546 }
1547
1548 "DateValue" => {
1550 let calendar = obj
1551 .get("knora-api:dateValueHasCalendar")
1552 .and_then(|v| v.as_str())
1553 .unwrap_or("GREGORIAN")
1554 .to_string();
1555
1556 let parse_point = |prefix: &str| -> DatePoint {
1557 let year_key = format!("knora-api:{prefix}Year");
1558 let month_key = format!("knora-api:{prefix}Month");
1559 let day_key = format!("knora-api:{prefix}Day");
1560 let era_key = format!("knora-api:{prefix}Era");
1561
1562 DatePoint {
1563 year: obj
1564 .get(year_key.as_str())
1565 .and_then(|v| v.as_i64())
1566 .map(|v| v as i32),
1567 month: obj
1568 .get(month_key.as_str())
1569 .and_then(|v| v.as_u64())
1570 .map(|v| v as u32),
1571 day: obj
1572 .get(day_key.as_str())
1573 .and_then(|v| v.as_u64())
1574 .map(|v| v as u32),
1575 era: obj
1576 .get(era_key.as_str())
1577 .and_then(|v| v.as_str())
1578 .map(str::to_owned),
1579 }
1580 };
1581
1582 let start = parse_point("dateValueHasStart");
1585 let end = parse_point("dateValueHasEnd");
1586
1587 if start.year.is_none() && end.year.is_none() {
1588 let raw_text = obj
1590 .get("knora-api:valueAsString")
1591 .and_then(|v| v.as_str())
1592 .unwrap_or("")
1593 .to_string();
1594 return (
1595 ValueContent::Raw {
1596 value_type: "date".to_string(),
1597 text: raw_text,
1598 },
1599 false,
1600 );
1601 }
1602
1603 (
1604 ValueContent::Date(DateValue {
1605 calendar,
1606 start,
1607 end,
1608 }),
1609 false,
1610 )
1611 }
1612
1613 "TimeValue" => {
1615 let s = obj
1616 .get("knora-api:timeValueAsTimeStamp")
1617 .and_then(|v| {
1618 if let Some(s) = v.as_str() {
1619 Some(s.to_string())
1620 } else {
1621 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1622 }
1623 })
1624 .unwrap_or_default();
1625 (ValueContent::Time(s), false)
1626 }
1627
1628 "UriValue" => {
1630 let s = obj
1631 .get("knora-api:uriValueAsUri")
1632 .and_then(|v| {
1633 if let Some(s) = v.as_str() {
1634 Some(s.to_string())
1635 } else {
1636 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1637 }
1638 })
1639 .unwrap_or_default();
1640 (ValueContent::Uri(s), false)
1641 }
1642
1643 "ColorValue" => {
1645 let s = obj
1646 .get("knora-api:colorValueAsColor")
1647 .and_then(|v| v.as_str())
1648 .unwrap_or("")
1649 .to_string();
1650 (ValueContent::Color(s), false)
1651 }
1652
1653 "GeonameValue" => {
1655 let s = obj
1656 .get("knora-api:geonameValueAsGeonameCode")
1657 .and_then(|v| v.as_str())
1658 .unwrap_or("")
1659 .to_string();
1660 (ValueContent::Geoname(s), false)
1661 }
1662
1663 "ListValue" => {
1665 let node_iri = obj
1667 .get("knora-api:listValueAsListNode")
1668 .and_then(|v| v.get("@id"))
1669 .and_then(|v| v.as_str())
1670 .unwrap_or("")
1671 .to_string();
1672 (
1673 ValueContent::ListItem {
1674 node_iri,
1675 label: None, },
1677 false,
1678 )
1679 }
1680
1681 "LinkValue" => {
1683 let (target_iri, target_label) =
1686 if let Some(target_obj) = obj.get("knora-api:linkValueHasTarget") {
1687 let iri = target_obj
1688 .get("@id")
1689 .and_then(|v| v.as_str())
1690 .unwrap_or("")
1691 .to_string();
1692 let lbl = target_obj.get("rdfs:label").and_then(extract_string_value);
1693 (iri, lbl)
1694 } else {
1695 let iri = obj
1696 .get("knora-api:linkValueHasTargetIri")
1697 .and_then(|v| v.get("@id"))
1698 .and_then(|v| v.as_str())
1699 .unwrap_or("")
1700 .to_string();
1701 (iri, None)
1702 };
1703 (
1704 ValueContent::Link {
1705 target_iri,
1706 target_label,
1707 },
1708 true, )
1710 }
1711
1712 t if t.ends_with("FileValue") => {
1715 let filename = obj
1716 .get("knora-api:fileValueHasFilename")
1717 .and_then(|v| v.as_str())
1718 .unwrap_or("")
1719 .to_string();
1720 let url_str = obj
1721 .get("knora-api:fileValueAsUrl")
1722 .and_then(|v| {
1723 if let Some(s) = v.as_str() {
1724 Some(s.to_string())
1725 } else {
1726 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1727 }
1728 })
1729 .unwrap_or_default();
1730
1731 let value_type_opt = if t.starts_with("StillImage") {
1733 Some(ValueType::StillImage)
1734 } else if t.starts_with("MovingImage") {
1735 Some(ValueType::MovingImage)
1736 } else if t.starts_with("Audio") {
1737 Some(ValueType::Audio)
1738 } else if t.starts_with("Document") || t.starts_with("Text") {
1739 Some(ValueType::Document)
1741 } else if t.starts_with("Archive") {
1742 Some(ValueType::Archive)
1743 } else {
1744 None };
1746
1747 match value_type_opt {
1748 Some(vt) => {
1749 let (width, height) = if vt == ValueType::StillImage {
1751 let w = obj
1752 .get("knora-api:stillImageFileValueHasDimX")
1753 .and_then(|v| v.as_u64())
1754 .map(|v| v as u32);
1755 let h = obj
1756 .get("knora-api:stillImageFileValueHasDimY")
1757 .and_then(|v| v.as_u64())
1758 .map(|v| v as u32);
1759 (w, h)
1760 } else {
1761 (None, None)
1762 };
1763 (
1764 ValueContent::File(FileValue {
1765 value_type: vt,
1766 filename,
1767 url: url_str,
1768 width,
1769 height,
1770 }),
1771 false,
1772 )
1773 }
1774 None => {
1775 let raw_text = obj
1777 .get("knora-api:valueAsString")
1778 .and_then(|v| v.as_str())
1779 .unwrap_or(&filename)
1780 .to_string();
1781 (
1782 ValueContent::Raw {
1783 value_type: object_type_to_kebab(t),
1784 text: raw_text,
1785 },
1786 false,
1787 )
1788 }
1789 }
1790 }
1791
1792 other => {
1794 let value_type = object_type_to_kebab(other);
1795 let raw_text = obj
1798 .get("knora-api:valueAsString")
1799 .and_then(|v| v.as_str())
1800 .map(str::to_owned)
1801 .unwrap_or_else(|| compact_value_text(obj));
1802 (
1803 ValueContent::Raw {
1804 value_type,
1805 text: raw_text,
1806 },
1807 false,
1808 )
1809 }
1810 }
1811}
1812
1813fn parse_value(obj: &serde_json::Value) -> (Value, bool) {
1819 let (content, is_link) = parse_value_content(obj);
1820 let comment = obj
1821 .get("knora-api:valueHasComment")
1822 .and_then(|v| v.as_str())
1823 .filter(|s| !s.trim().is_empty())
1824 .map(str::to_owned);
1825 (Value { content, comment }, is_link)
1826}
1827
1828const VALUE_META_KEYS: &[&str] = &[
1830 "@id",
1831 "@type",
1832 "knora-api:attachedToUser",
1833 "knora-api:hasPermissions",
1834 "knora-api:userHasPermission",
1835 "knora-api:valueCreationDate",
1836 "knora-api:valueHasComment",
1837 "knora-api:isDeleted",
1838 "knora-api:arkUrl",
1839 "knora-api:versionArkUrl",
1840 "knora-api:valueHasUUID",
1841];
1842
1843fn compact_value_text(obj: &serde_json::Value) -> String {
1848 if let Some(map) = obj.as_object() {
1849 let filtered: serde_json::Map<String, serde_json::Value> = map
1850 .iter()
1851 .filter(|(k, _)| !VALUE_META_KEYS.contains(&k.as_str()))
1852 .map(|(k, v)| (k.clone(), v.clone()))
1853 .collect();
1854 if filtered.is_empty() {
1855 String::new()
1856 } else {
1857 serde_json::to_string(&serde_json::Value::Object(filtered)).unwrap_or_default()
1858 }
1859 } else {
1860 String::new()
1861 }
1862}
1863
1864impl DspClient for HttpDspClient {
1865 fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic> {
1866 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
1867
1868 let mut body = serde_json::Map::with_capacity(2);
1869 body.insert(
1870 identifier_key(user).to_owned(),
1871 serde_json::Value::from(user),
1872 );
1873 body.insert("password".to_owned(), serde_json::Value::from(password));
1874
1875 let response = self
1876 .client
1877 .post(&url)
1878 .json(&body)
1879 .send()
1880 .map_err(|e| Diagnostic::Network(e.to_string()))?;
1881
1882 let status = response.status();
1883
1884 if status.is_success() {
1885 let api: LoginApiResponse = response.json().map_err(|e| {
1886 Diagnostic::ServerError(format!("login response could not be parsed: {e}"))
1887 })?;
1888 let expires_at = extract_exp(&api.token);
1889 Ok(LoginResponse {
1890 token: api.token,
1891 user: user.to_string(),
1892 expires_at,
1893 })
1894 } else if status == reqwest::StatusCode::UNAUTHORIZED
1895 || status == reqwest::StatusCode::FORBIDDEN
1896 {
1897 let body = response.text().unwrap_or_default();
1898 let preview: String = body.chars().take(200).collect();
1899 tracing::trace!("auth failure response body (capped): {}", preview);
1900 Err(Diagnostic::AuthRequired(format!(
1902 "Authentication failed on {server}"
1903 )))
1904 } else if status == reqwest::StatusCode::NOT_FOUND {
1905 Err(Diagnostic::NotFound(format!(
1906 "endpoint not found at {url}; check that --server resolves to a DSP-API instance, not just any HTTPS host"
1907 )))
1908 } else if status.is_server_error() {
1909 let body = response.text().unwrap_or_default();
1910 let preview: String = body.chars().take(200).collect();
1911 tracing::trace!("server error response body (capped): {}", preview);
1912 Err(Diagnostic::ServerError(format!("server returned {status}")))
1913 } else {
1914 Err(Diagnostic::ServerError(format!(
1915 "unexpected status: {status}"
1916 )))
1917 }
1918 }
1919
1920 fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic> {
1921 let base = server.trim_end_matches('/');
1922
1923 let url = project_lookup_url(base, project);
1924
1925 let response = self
1927 .client
1928 .get(&url)
1929 .send()
1930 .map_err(|e| Diagnostic::Network(e.to_string()))?;
1931
1932 let status = response.status();
1933
1934 if status.is_success() {
1935 let api: ProjectGetApiResponse = response.json().map_err(|e| {
1936 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
1937 })?;
1938 if !is_safe_shortcode(&api.project.shortcode) {
1939 return Err(Diagnostic::ServerError(
1940 "server returned a project with an unexpected shortcode".into(),
1941 ));
1942 }
1943 Ok(ProjectRef {
1944 iri: api.project.id,
1945 shortcode: api.project.shortcode,
1946 shortname: api.project.shortname,
1947 })
1948 } else if status == reqwest::StatusCode::NOT_FOUND {
1949 let display_input: String = project.chars().take(80).collect();
1951 let suffix = if project.chars().count() > 80 {
1952 "…"
1953 } else {
1954 ""
1955 };
1956 Err(Diagnostic::NotFound(format!(
1957 "project '{display_input}{suffix}' not found on {server}"
1958 )))
1959 } else {
1960 Err(map_unexpected_status(status, &url))
1961 }
1962 }
1963
1964 fn create_project_dump(
1965 &self,
1966 server: &str,
1967 project_iri: &str,
1968 skip_assets: bool,
1969 token: &str,
1970 ) -> Result<CreateDumpOutcome, Diagnostic> {
1971 let base = server.trim_end_matches('/');
1972 let url = format!(
1977 "{base}/v3/projects/{}/exports?skipAssets={skip_assets}",
1978 enc(project_iri)
1979 );
1980
1981 let response = self
1982 .client
1983 .post(&url)
1984 .bearer_auth(token)
1985 .send()
1986 .map_err(|e: reqwest::Error| Diagnostic::Network(e.to_string()))?;
1987
1988 let status = response.status();
1989
1990 match status.as_u16() {
1991 202 => {
1992 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
1993 Diagnostic::ServerError(format!(
1994 "dump trigger response could not be parsed: {e}"
1995 ))
1996 })?;
1997 api.into_dump_task().map(CreateDumpOutcome::Created)
1998 }
1999 409 => {
2000 let body_text = response.text().unwrap_or_default();
2010 let error_body: Option<V3ErrorBody> = if body_text.len() <= 65536 {
2011 serde_json::from_str(&body_text).ok()
2012 } else {
2013 None
2014 };
2015 match error_body.as_ref().and_then(|b| b.export_exists()) {
2016 Some(ex) => {
2017 let id = ex.id.ok_or_else(|| {
2020 Diagnostic::ServerError(
2021 "the server's dump-conflict response was missing the dump id"
2022 .into(),
2023 )
2024 })?;
2025 validate_dump_id(id)?;
2026 match ex.project_iri {
2032 Some(owner) if owner == project_iri => {
2033 Ok(CreateDumpOutcome::Exists { id: id.to_string() })
2034 }
2035 Some(owner) => Ok(CreateDumpOutcome::ExistsForOtherProject {
2036 id: id.to_string(),
2037 project_iri: owner.to_string(),
2038 }),
2039 None => Err(Diagnostic::ServerError(
2042 "the server's dump-conflict response did not identify which \
2043project owns the existing dump; cannot safely proceed"
2044 .into(),
2045 )),
2046 }
2047 }
2048 None => Err(Diagnostic::ServerError(
2050 "server reported a 409 conflict whose detail could not be parsed".into(),
2052 )),
2053 }
2054 }
2055 401 | 403 => Err(Diagnostic::AuthRequired(
2056 "triggering a project dump requires a system-administrator token".into(),
2057 )),
2058 404 => Err(Diagnostic::NotFound(format!("project not found at {url}"))),
2059 _ => Err(map_unexpected_status(status, &url)),
2060 }
2061 }
2062
2063 fn get_project_dump_status(
2064 &self,
2065 server: &str,
2066 project_iri: &str,
2067 dump_id: &str,
2068 token: &str,
2069 ) -> Result<DumpTask, Diagnostic> {
2070 validate_dump_id(dump_id)?;
2071 let base = server.trim_end_matches('/');
2072 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2074
2075 let response = self
2076 .client
2077 .get(&url)
2078 .bearer_auth(token)
2079 .send()
2080 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2081
2082 let status = response.status();
2083
2084 match status.as_u16() {
2085 200 => {
2086 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2087 Diagnostic::ServerError(format!(
2088 "dump status response could not be parsed: {e}"
2089 ))
2090 })?;
2091 api.into_dump_task()
2092 }
2093 404 => Err(Diagnostic::NotFound(format!(
2094 "dump '{dump_id}' not found for project at {url}"
2095 ))),
2096 401 | 403 => Err(Diagnostic::AuthRequired(
2097 "fetching dump status requires a system-administrator token".into(),
2098 )),
2099 _ => Err(map_unexpected_status(status, &url)),
2100 }
2101 }
2102
2103 fn download_project_dump(
2104 &self,
2105 server: &str,
2106 project_iri: &str,
2107 dump_id: &str,
2108 token: &str,
2109 dest: &mut dyn Write,
2110 ) -> Result<u64, Diagnostic> {
2111 validate_dump_id(dump_id)?;
2112 let base = server.trim_end_matches('/');
2113 let url = format!(
2115 "{base}/v3/projects/{}/exports/{dump_id}/download",
2116 enc(project_iri)
2117 );
2118
2119 let mut response = self
2121 .download_client
2122 .get(&url)
2123 .bearer_auth(token)
2124 .send()
2125 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2126
2127 let status = response.status();
2128
2129 match status.as_u16() {
2132 200 => {
2133 let mut buf = [0u8; 64 * 1024];
2137 let mut total: u64 = 0;
2138 loop {
2139 let n = response
2140 .read(&mut buf)
2141 .map_err(|e| Diagnostic::Network(format!("download interrupted: {e}")))?;
2142 if n == 0 {
2143 break;
2144 }
2145 dest.write_all(&buf[..n]).map_err(|e| {
2146 Diagnostic::Io(format!("failed to write dump to disk: {e}"))
2147 })?;
2148 total += n as u64;
2149 }
2150 Ok(total)
2151 }
2152 409 => Err(Diagnostic::Conflict(
2153 "dump not ready — still in progress or failed".into(),
2154 )),
2155 404 => Err(Diagnostic::NotFound(format!(
2156 "dump '{dump_id}' not found at {url}"
2157 ))),
2158 401 | 403 => Err(Diagnostic::AuthRequired(
2159 "downloading a project dump requires a system-administrator token".into(),
2160 )),
2161 _ => Err(map_unexpected_status(status, &url)),
2162 }
2163 }
2164
2165 fn delete_project_dump(
2166 &self,
2167 server: &str,
2168 project_iri: &str,
2169 dump_id: &str,
2170 token: &str,
2171 ) -> Result<(), Diagnostic> {
2172 validate_dump_id(dump_id)?;
2173 let base = server.trim_end_matches('/');
2174 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2176
2177 let response = self
2178 .client
2179 .delete(&url)
2180 .bearer_auth(token)
2181 .send()
2182 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2183
2184 let status = response.status();
2185
2186 match status.as_u16() {
2187 204 => Ok(()),
2188 409 => Err(Diagnostic::Conflict(
2189 "dump is still in progress and cannot be deleted yet".into(),
2190 )),
2191 404 => Err(Diagnostic::NotFound(format!(
2192 "dump '{dump_id}' not found at {url}"
2193 ))),
2194 401 | 403 => Err(Diagnostic::AuthRequired(
2195 "deleting a project dump requires a system-administrator token".into(),
2196 )),
2197 _ => Err(map_unexpected_status(status, &url)),
2198 }
2199 }
2200
2201 fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic> {
2202 let base = server.trim_end_matches('/');
2203 let url = format!("{base}/admin/projects");
2204
2205 let req = self.client.get(&url);
2211 let req = if let Some(t) = token {
2212 req.bearer_auth(t)
2213 } else {
2214 req
2215 };
2216
2217 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2218
2219 let status = response.status();
2220
2221 if status.is_success() {
2222 let api: ProjectsListApiResponse = response.json().map_err(|e| {
2223 Diagnostic::ServerError(format!("projects list response could not be parsed: {e}"))
2224 })?;
2225 let projects = api
2226 .projects
2227 .into_iter()
2228 .map(|dto| Project {
2229 iri: dto.id,
2230 shortcode: dto.shortcode,
2231 shortname: dto.shortname,
2232 longname: dto.longname,
2233 status: if dto.status {
2237 ProjectStatus::Active
2238 } else {
2239 ProjectStatus::Inactive
2240 },
2241 data_models: dto.ontologies.len(),
2244 })
2245 .collect();
2246 Ok(projects)
2247 } else {
2248 Err(map_unexpected_status(status, &url))
2249 }
2250 }
2251
2252 fn describe_project(
2253 &self,
2254 server: &str,
2255 project: &str,
2256 token: Option<&str>,
2257 ) -> Result<ProjectDetail, Diagnostic> {
2258 let base = server.trim_end_matches('/');
2259 let url = project_lookup_url(base, project);
2260
2261 let req = self.client.get(&url);
2265 let req = if let Some(t) = token {
2266 req.bearer_auth(t)
2267 } else {
2268 req
2269 };
2270
2271 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2272
2273 let status = response.status();
2274
2275 if status.is_success() {
2276 let api: ProjectDetailApiResponse = response.json().map_err(|e| {
2277 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
2278 })?;
2279 let dto = api.project;
2280
2281 let project_status = if dto.status {
2283 ProjectStatus::Active
2284 } else {
2285 ProjectStatus::Inactive
2286 };
2287
2288 let description = dto
2290 .description
2291 .into_iter()
2292 .map(|d| ProjectDescription {
2293 value: d.value,
2294 language: d.language,
2295 })
2296 .collect();
2297
2298 let mut data_models: Vec<DataModelSummary> = dto
2300 .ontologies
2301 .into_iter()
2302 .map(|iri| {
2303 let name = data_model_name_from_iri(&iri);
2304 DataModelSummary { name, iri }
2305 })
2306 .collect();
2307 data_models.sort_by(|a, b| a.name.cmp(&b.name));
2308
2309 Ok(ProjectDetail {
2310 iri: dto.id,
2311 shortcode: dto.shortcode,
2312 shortname: dto.shortname,
2313 longname: dto.longname,
2314 status: project_status,
2315 description,
2316 keywords: dto.keywords,
2317 data_models,
2318 })
2319 } else if status == reqwest::StatusCode::NOT_FOUND {
2320 let display_input: String = project.chars().take(80).collect();
2322 let suffix = if project.chars().count() > 80 {
2323 "…"
2324 } else {
2325 ""
2326 };
2327 Err(Diagnostic::NotFound(format!(
2328 "project '{display_input}{suffix}' not found on {server}. Run `dsp vre project list --server {server}` to see available projects."
2329 )))
2330 } else {
2331 Err(map_unexpected_status(status, &url))
2332 }
2333 }
2334
2335 fn describe_data_model(
2336 &self,
2337 server: &str,
2338 data_model_iri: &str,
2339 token: Option<&str>,
2340 ) -> Result<DataModelDetail, Diagnostic> {
2341 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2342
2343 let prefixes: HashMap<String, String> = resp
2347 .context
2348 .iter()
2349 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2350 .collect();
2351
2352 let mut resource_types: Vec<ResourceTypeSummary> = resp
2353 .graph
2354 .into_iter()
2355 .filter(|dto| dto.is_resource_class)
2356 .map(|dto| {
2357 let (name, iri) = expand_class_id(&dto.id, &prefixes);
2358 ResourceTypeSummary {
2359 name,
2360 iri,
2361 label: dto.label,
2362 }
2363 })
2364 .collect();
2365
2366 resource_types.sort_by(|a, b| a.name.cmp(&b.name));
2367
2368 Ok(DataModelDetail {
2369 name: data_model_name_from_iri(&resp.id),
2370 iri: resp.id,
2371 label: resp.label,
2372 last_modified: resp.last_modification_date.map(|d| d.value),
2373 resource_types,
2374 })
2375 }
2376
2377 fn data_model_structure(
2378 &self,
2379 server: &str,
2380 data_model_iri: &str,
2381 token: Option<&str>,
2382 ) -> Result<DataModelStructure, Diagnostic> {
2383 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2385
2386 let graph_entities: Vec<OntologyEntityDto> = resp.graph;
2387
2388 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2394 let mut class_nodes: Vec<OntologyEntityDto> = Vec::new();
2395 for entity in graph_entities {
2396 if entity.is_resource_class {
2397 class_nodes.push(entity);
2398 } else if entity.object_type.is_some()
2399 || entity.is_link_property
2400 || entity.is_resource_property
2401 {
2402 prop_lookup.insert(entity.id.clone(), entity);
2403 }
2404 }
2405
2406 let mut relations: Vec<Relation> = Vec::new();
2408
2409 for class in &class_nodes {
2410 let source = local_name(&class.id).to_string();
2411
2412 for element in &class.sub_class_of {
2413 if let Some(type_val) = element.get("@type")
2414 && type_val.as_str() == Some("owl:Restriction")
2415 {
2416 let on_prop_id = match element
2418 .get("owl:onProperty")
2419 .and_then(|v| v.get("@id"))
2420 .and_then(serde_json::Value::as_str)
2421 {
2422 Some(s) => s,
2423 None => continue,
2424 };
2425
2426 let node = match prop_lookup.get(on_prop_id) {
2428 Some(n) => n,
2429 None => continue, };
2431
2432 if node.is_link_value_property {
2434 continue;
2435 }
2436
2437 if !node.is_link_property {
2439 continue;
2440 }
2441
2442 let target_id = match node.object_type.as_ref() {
2444 Some(ot) => &ot.id,
2445 None => continue, };
2447 let target = local_name(target_id).to_string();
2448
2449 let t_prefix = curie_prefix(target_id).unwrap_or("");
2450 let target_data_model = if is_system_prefix(t_prefix) || t_prefix.is_empty() {
2451 None
2452 } else {
2453 Some(t_prefix.to_string())
2454 };
2455
2456 let field_prefix = curie_prefix(on_prop_id).unwrap_or("");
2458 let is_builtin = is_system_prefix(field_prefix);
2459
2460 let field = local_name(on_prop_id).to_string();
2461
2462 relations.push(Relation {
2463 source: source.clone(),
2464 target,
2465 kind: RelationKind::Link,
2466 field: Some(field),
2467 target_data_model,
2468 is_builtin,
2469 });
2470 } else if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str)
2471 {
2472 let target = local_name(id_val).to_string();
2477
2478 let sup_prefix = curie_prefix(id_val).unwrap_or("");
2479 let is_builtin = is_system_prefix(sup_prefix);
2480 let target_data_model = if is_system_prefix(sup_prefix) || sup_prefix.is_empty()
2481 {
2482 None
2483 } else {
2484 Some(sup_prefix.to_string())
2485 };
2486
2487 relations.push(Relation {
2488 source: source.clone(),
2489 target,
2490 kind: RelationKind::Inherits,
2491 field: None,
2492 target_data_model,
2493 is_builtin,
2494 });
2495 }
2496 }
2497 }
2498
2499 relations.sort_by(|a, b| {
2503 a.source
2504 .cmp(&b.source)
2505 .then_with(|| a.kind.cmp(&b.kind))
2506 .then_with(|| a.field.cmp(&b.field))
2507 .then_with(|| a.target.cmp(&b.target))
2508 });
2509
2510 Ok(DataModelStructure {
2512 data_model: data_model_name_from_iri(data_model_iri),
2513 relations,
2514 })
2515 }
2516
2517 fn list_resources(
2518 &self,
2519 server: &str,
2520 project_iri: &str,
2521 resource_type_iri: &str,
2522 order_by: Option<&str>,
2523 page: u32,
2524 token: Option<&str>,
2525 ) -> Result<ResourcePage, Diagnostic> {
2526 let base = server.trim_end_matches('/');
2527 let url = format!("{base}/v2/resources");
2528
2529 let mut req = self.client.get(&url).query(&[
2534 ("resourceClass", resource_type_iri),
2535 ("page", &page.to_string()),
2536 ("schema", "complex"),
2537 ]);
2538 if let Some(prop_iri) = order_by {
2541 req = req.query(&[("orderByProperty", prop_iri)]);
2542 }
2543
2544 let header_value = reqwest::header::HeaderValue::from_str(project_iri).map_err(|e| {
2548 Diagnostic::Usage(format!("project IRI is not a valid HTTP header value: {e}"))
2549 })?;
2550 let req = req.header("x-knora-accept-project", header_value);
2551
2552 let req = if let Some(t) = token {
2554 req.bearer_auth(t)
2555 } else {
2556 req
2557 };
2558
2559 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2560 let status = response.status();
2561
2562 if !status.is_success() {
2563 return Err(map_unexpected_status(status, &url));
2564 }
2565
2566 let dto: ResourceListDto = response.json().map_err(|e| {
2567 Diagnostic::ServerError(format!("resource list response could not be parsed: {e}"))
2568 })?;
2569
2570 let may_have_more_results = dto.may_have_more_results;
2571
2572 let resources: Vec<ResourceSummary> = if let Some(graph) = dto.graph {
2577 graph
2578 .into_iter()
2579 .map(|node| {
2580 node_dto_to_summary(
2581 node.id,
2582 node.type_field.as_ref(),
2583 node.label.as_ref(),
2584 node.ark_url.as_ref(),
2585 node.creation_date.as_ref(),
2586 node.last_modification_date.as_ref(),
2587 )
2588 })
2589 .collect()
2590 } else if let Some(id) = dto.id {
2591 vec![node_dto_to_summary(
2593 id,
2594 dto.type_field.as_ref(),
2595 dto.label.as_ref(),
2596 dto.ark_url.as_ref(),
2597 dto.creation_date.as_ref(),
2598 dto.last_modification_date.as_ref(),
2599 )]
2600 } else {
2601 vec![]
2603 };
2604
2605 Ok(ResourcePage {
2606 resources,
2607 may_have_more_results,
2608 })
2609 }
2610
2611 fn describe_resource(
2612 &self,
2613 server: &str,
2614 resource_iri: &str,
2615 token: Option<&str>,
2616 with_values: bool,
2617 ) -> Result<ResourceDetail, Diagnostic> {
2618 let base = server.trim_end_matches('/');
2619 let url = format!("{base}/v2/resources/{}", enc(resource_iri));
2621
2622 let req = self.client.get(&url).query(&[("schema", "complex")]);
2624 let req = if let Some(t) = token {
2625 req.bearer_auth(t)
2626 } else {
2627 req
2628 };
2629
2630 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2631 let status = response.status();
2632
2633 if status.is_success() {
2634 let dto: ResourceDetailDto = response.json().map_err(|e| {
2635 Diagnostic::ServerError(format!(
2636 "resource describe response could not be parsed: {e}"
2637 ))
2638 })?;
2639
2640 let label = dto
2642 .label
2643 .as_ref()
2644 .and_then(extract_string_value)
2645 .unwrap_or_default();
2646 let resource_type = extract_resource_type(dto.type_field.as_ref());
2647 let ark_url = dto.ark_url.as_ref().and_then(extract_string_value);
2648 let creation_date = dto.creation_date.as_ref().and_then(extract_string_value);
2649 let last_modified = dto
2650 .last_modification_date
2651 .as_ref()
2652 .and_then(extract_string_value);
2653 let attached_project = dto
2654 .attached_to_project
2655 .as_ref()
2656 .and_then(extract_string_value);
2657 let owner = dto.attached_to_user.as_ref().and_then(extract_string_value);
2658 let visibility = dto.has_permissions.as_deref().and_then(derive_visibility);
2659 let your_access = dto.user_has_permission.as_deref().and_then(derive_access);
2660
2661 let values = if with_values {
2663 Some(self.parse_resource_values(server, token, &dto.context, &dto.extra))
2664 } else {
2665 None
2666 };
2667
2668 Ok(ResourceDetail {
2669 label,
2670 iri: dto.id,
2671 resource_type,
2672 ark_url,
2673 creation_date,
2674 last_modified,
2675 attached_project,
2676 owner,
2677 visibility,
2678 your_access,
2679 values,
2680 })
2681 } else if status == reqwest::StatusCode::NOT_FOUND {
2682 let display_iri: String = resource_iri.chars().take(80).collect();
2684 let iri_suffix = if resource_iri.chars().count() > 80 {
2685 "…"
2686 } else {
2687 ""
2688 };
2689 Err(Diagnostic::NotFound(format!(
2690 "resource '{display_iri}{iri_suffix}' not found"
2691 )))
2692 } else if status == reqwest::StatusCode::UNAUTHORIZED
2693 || status == reqwest::StatusCode::FORBIDDEN
2694 {
2695 let display_iri: String = resource_iri.chars().take(80).collect();
2699 let iri_suffix = if resource_iri.chars().count() > 80 {
2700 "…"
2701 } else {
2702 ""
2703 };
2704 Err(Diagnostic::AuthRequired(format!(
2705 "access denied for resource '{display_iri}{iri_suffix}' — log in to view this resource"
2706 )))
2707 } else {
2708 Err(map_unexpected_status(status, &url))
2709 }
2710 }
2711
2712 fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic> {
2713 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
2714
2715 let response = self
2716 .client
2717 .get(&url)
2718 .bearer_auth(token)
2719 .send()
2720 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2721
2722 let status = response.status();
2723
2724 if status.is_success() {
2725 let body = response.text().unwrap_or_default();
2728 let preview: String = body.chars().take(200).collect();
2729 tracing::trace!("verify_token success response body (capped): {}", preview);
2730 Ok(())
2731 } else if status == reqwest::StatusCode::UNAUTHORIZED
2732 || status == reqwest::StatusCode::FORBIDDEN
2733 {
2734 let body = response.text().unwrap_or_default();
2736 let preview: String = body.chars().take(200).collect();
2737 tracing::trace!("verify_token rejection response body (capped): {}", preview);
2738 Err(Diagnostic::AuthRequired(format!(
2740 "token rejected by {server} — it may be expired, revoked, or for a different environment"
2741 )))
2742 } else {
2743 Err(map_unexpected_status(status, &url))
2744 }
2745 }
2746
2747 fn list_data_models(
2748 &self,
2749 server: &str,
2750 project_iri: &str,
2751 token: Option<&str>,
2752 ) -> Result<Vec<DataModel>, Diagnostic> {
2753 let url = format!(
2754 "{}/v2/ontologies/metadata/{}",
2755 server.trim_end_matches('/'),
2756 enc(project_iri)
2757 );
2758
2759 let req = self.client.get(&url);
2764 let req = if let Some(t) = token {
2765 req.bearer_auth(t)
2766 } else {
2767 req
2768 };
2769
2770 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2771
2772 let status = response.status();
2773
2774 if status.is_success() {
2775 let resp: OntologyMetadataResponse = response.json().map_err(|e| {
2776 Diagnostic::ServerError(format!("data-models response could not be parsed: {e}"))
2777 })?;
2778
2779 let dtos: Vec<OntologyMetadataDto> = match resp.graph {
2783 Some(g) => g,
2784 None => match resp.id {
2785 Some(id) => vec![OntologyMetadataDto {
2786 id,
2787 label: resp.label,
2788 last_modification_date: resp.last_modification_date,
2789 }],
2790 None => vec![],
2791 },
2792 };
2793
2794 let data_models = dtos
2795 .into_iter()
2796 .map(|dto| DataModel {
2797 name: data_model_name_from_iri(&dto.id),
2798 iri: dto.id,
2799 label: dto.label,
2800 last_modified: dto.last_modification_date.map(|d| d.value),
2801 is_builtin: false,
2802 })
2803 .collect();
2804
2805 Ok(data_models)
2806 } else {
2807 Err(map_unexpected_status(status, &url))
2808 }
2809 }
2810
2811 fn describe_resource_type(
2812 &self,
2813 server: &str,
2814 data_model_iri: &str,
2815 resource_type: &str,
2816 token: Option<&str>,
2817 ) -> Result<ResourceTypeDetail, Diagnostic> {
2818 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2820
2821 let prefixes: HashMap<String, String> = resp
2823 .context
2824 .iter()
2825 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2826 .collect();
2827
2828 let queried_id = resp.id;
2832 let mut graph_entities: Vec<OntologyEntityDto> = resp.graph;
2833
2834 let target_idx = graph_entities.iter().position(|e| {
2835 if !e.is_resource_class {
2836 return false;
2837 }
2838 let (type_local, expanded_iri) = expand_class_id(&e.id, &prefixes);
2839 type_local.eq_ignore_ascii_case(resource_type) || expanded_iri == resource_type
2841 });
2842
2843 let target_idx = match target_idx {
2844 Some(i) => i,
2845 None => {
2846 let display: String = resource_type.chars().take(80).collect();
2847 let suffix = if resource_type.chars().count() > 80 {
2848 "…"
2849 } else {
2850 ""
2851 };
2852 return Err(Diagnostic::NotFound(format!(
2853 "resource-type '{display}{suffix}' not found in data-model '{}' on {server}",
2854 data_model_name_from_iri(data_model_iri)
2855 )));
2856 }
2857 };
2858
2859 let target = graph_entities.swap_remove(target_idx);
2862
2863 struct Restriction {
2865 on_property_id: String,
2866 cardinality: Cardinality,
2867 gui_order: u32,
2868 }
2869
2870 let mut restrictions: Vec<Restriction> = Vec::new();
2871 let mut super_type_ids: Vec<String> = Vec::new();
2872 let mut restriction_prop_locals: Vec<String> = Vec::new();
2873
2874 for element in &target.sub_class_of {
2875 if let Some(type_val) = element.get("@type")
2876 && type_val.as_str() == Some("owl:Restriction")
2877 {
2878 let on_prop_id = element
2880 .get("owl:onProperty")
2881 .and_then(|v| v.get("@id"))
2882 .and_then(serde_json::Value::as_str)
2883 .unwrap_or("")
2884 .to_string();
2885
2886 if on_prop_id.is_empty() {
2887 tracing::warn!("owl:Restriction missing owl:onProperty @id; skipping");
2888 continue;
2889 }
2890
2891 let cardinality = decode_cardinality(element);
2892 let gui_order = element
2893 .get("salsah-gui:guiOrder")
2894 .and_then(serde_json::Value::as_u64)
2895 .map(|v| v as u32)
2896 .unwrap_or(u32::MAX);
2897
2898 restriction_prop_locals.push(local_name(&on_prop_id).to_string());
2899
2900 restrictions.push(Restriction {
2901 on_property_id: on_prop_id,
2902 cardinality,
2903 gui_order,
2904 });
2905 continue;
2906 }
2907 if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
2909 super_type_ids.push(id_val.to_string());
2910 }
2911 }
2912
2913 let representation = detect_representation(
2915 &restriction_prop_locals
2916 .iter()
2917 .map(String::as_str)
2918 .collect::<Vec<_>>(),
2919 );
2920
2921 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2923 for entity in graph_entities {
2924 if entity.object_type.is_some()
2927 || entity.is_link_property
2928 || entity.is_resource_property
2929 {
2930 prop_lookup.insert(entity.id.clone(), entity);
2931 }
2932 }
2933
2934 let mut missing_prefixes: Vec<String> = Vec::new();
2944 let mut seen_prefixes: HashSet<String> = HashSet::new();
2945 for restriction in &restrictions {
2946 if prop_lookup.contains_key(&restriction.on_property_id) {
2947 continue;
2948 }
2949 let prefix = match curie_prefix(&restriction.on_property_id) {
2950 Some(p) => p,
2951 None => continue,
2952 };
2953 if is_system_prefix(prefix) {
2954 continue;
2955 }
2956 if seen_prefixes.insert(prefix.to_string()) {
2957 missing_prefixes.push(prefix.to_string());
2958 }
2959 }
2960
2961 let mut fetched_sibling_iris: HashSet<String> = HashSet::new();
2963 let queried_iri_trimmed = data_model_iri.trim_end_matches(['#', '/']);
2964
2965 let mut siblings_to_fetch: Vec<String> = Vec::new();
2966 for prefix in &missing_prefixes {
2967 let namespace = match prefixes.get(prefix.as_str()) {
2968 Some(ns) => ns,
2969 None => {
2970 tracing::warn!(
2971 prefix = %prefix,
2972 "missing @context entry for prefix of cross-DM field; leaving best-effort"
2973 );
2974 continue;
2975 }
2976 };
2977 let sibling_iri = namespace.trim_end_matches(['#', '/']).to_string();
2978 if sibling_iri == queried_iri_trimmed {
2979 continue;
2981 }
2982 if fetched_sibling_iris.insert(sibling_iri.clone()) {
2983 siblings_to_fetch.push(sibling_iri);
2984 }
2985 }
2986
2987 if siblings_to_fetch.len() > MAX_SIBLING_FETCHES {
2988 tracing::warn!(
2989 count = siblings_to_fetch.len(),
2990 max = MAX_SIBLING_FETCHES,
2991 "too many sibling ontologies to fetch; capping at MAX_SIBLING_FETCHES"
2992 );
2993 siblings_to_fetch.truncate(MAX_SIBLING_FETCHES);
2994 }
2995
2996 for sibling_iri in &siblings_to_fetch {
2997 match self.fetch_allentities(server, sibling_iri, token) {
2999 Ok(sibling_resp) => {
3000 for entity in sibling_resp.graph {
3001 if entity.object_type.is_some()
3002 || entity.is_link_property
3003 || entity.is_resource_property
3004 {
3005 prop_lookup.entry(entity.id.clone()).or_insert(entity);
3006 }
3007 }
3008 }
3009 Err(e) => {
3010 tracing::warn!(
3013 iri = %sibling_iri,
3014 error = %e,
3015 "sibling ontology fetch failed; affected fields left best-effort"
3016 );
3017 }
3018 }
3019 }
3020
3021 let mut fields: Vec<(u32, Field)> = Vec::new();
3023
3024 for restriction in &restrictions {
3025 let prop_id = &restriction.on_property_id;
3026
3027 let node = prop_lookup.get(prop_id.as_str());
3029
3030 if let Some(n) = node {
3032 if n.is_link_value_property {
3033 continue;
3035 }
3036 } else {
3037 let prop_local = local_name(prop_id);
3041 if let Some(base) = prop_local.strip_suffix("Value") {
3042 let base_present = restrictions
3044 .iter()
3045 .any(|r| local_name(&r.on_property_id) == base);
3046 if base_present {
3049 continue;
3050 }
3051 }
3052 }
3053
3054 let prop_prefix = curie_prefix(prop_id).unwrap_or("");
3056 let is_builtin = is_system_prefix(prop_prefix);
3057 let (prop_local, prop_iri) = expand_class_id(prop_id, &prefixes);
3058
3059 let field_data_model = if is_builtin {
3061 None
3062 } else {
3063 if prop_prefix.is_empty() {
3066 None
3067 } else {
3068 Some(prop_prefix.to_string())
3069 }
3070 };
3071
3072 let (value_type, link_target) = if let Some(n) = node {
3074 if n.is_link_property {
3075 let target_name = n
3077 .object_type
3078 .as_ref()
3079 .map(|ot| local_name(&ot.id).to_string())
3080 .unwrap_or_else(|| "unknown".to_string());
3081 (ValueType::Link, Some(target_name))
3082 } else {
3083 let obj_local = n
3084 .object_type
3085 .as_ref()
3086 .map(|ot| local_name(&ot.id))
3087 .unwrap_or("");
3088 (map_object_type_to_value_type(obj_local), None)
3089 }
3090 } else {
3091 if is_builtin {
3093 if let Some(vt) = builtin_field_value_type(&prop_local) {
3094 (vt, None)
3095 } else {
3096 (ValueType::Other("—".to_string()), None)
3097 }
3098 } else {
3099 (ValueType::Other("—".to_string()), None)
3100 }
3101 };
3102
3103 let label = node.and_then(|n| n.label.clone());
3104
3105 debug_assert!(
3107 (value_type == ValueType::Link) == link_target.is_some(),
3108 "link_target must be Some iff value_type is Link"
3109 );
3110
3111 fields.push((
3112 restriction.gui_order,
3113 Field {
3114 name: prop_local,
3115 iri: prop_iri,
3116 label,
3117 value_type,
3118 link_target,
3119 cardinality: restriction.cardinality,
3120 is_builtin,
3121 data_model: field_data_model,
3122 },
3123 ));
3124 }
3125
3126 fields.sort_by(|(order_a, field_a), (order_b, field_b)| {
3128 order_a
3129 .cmp(order_b)
3130 .then_with(|| field_a.name.cmp(&field_b.name))
3131 });
3132 let sorted_fields: Vec<Field> = fields.into_iter().map(|(_, f)| f).collect();
3133
3134 let super_types: Vec<String> = super_type_ids
3136 .iter()
3137 .filter(|id| {
3138 let prefix = curie_prefix(id).unwrap_or("");
3139 !is_system_prefix(prefix)
3140 })
3141 .map(|id| local_name(id).to_string())
3142 .collect();
3143
3144 let (class_name, class_iri) = expand_class_id(&target.id, &prefixes);
3146 let class_label = target.label;
3147 let dm_name = data_model_name_from_iri(&queried_id);
3148
3149 Ok(ResourceTypeDetail {
3150 name: class_name,
3151 iri: class_iri,
3152 label: class_label,
3153 data_model: dm_name,
3154 representation,
3155 super_types,
3156 fields: sorted_fields,
3157 })
3158 }
3159}
3160
3161#[cfg(test)]
3166mod tests {
3167 use super::*;
3168
3169 #[test]
3174 fn identifier_key_email_contains_at() {
3175 assert_eq!(identifier_key("a@b.ch"), "email");
3176 }
3177
3178 #[test]
3179 fn identifier_key_bare_username() {
3180 assert_eq!(identifier_key("jdoe"), "username");
3181 }
3182
3183 #[test]
3184 fn identifier_key_http_iri() {
3185 assert_eq!(identifier_key("http://rdfh.ch/users/x"), "iri");
3186 }
3187
3188 #[test]
3189 fn identifier_key_https_iri() {
3190 assert_eq!(identifier_key("https://rdfh.ch/users/x"), "iri");
3191 }
3192
3193 #[test]
3194 fn identifier_key_iri_with_at_uses_iri_not_email() {
3195 assert_eq!(identifier_key("http://example.org/users/a@b"), "iri");
3197 }
3198
3199 #[test]
3200 fn classify_http_iri() {
3201 let ident = classify("http://rdfh.ch/projects/0001");
3202 assert!(
3203 matches!(ident, ProjectIdent::Iri(_)),
3204 "http:// prefix should classify as Iri"
3205 );
3206 }
3207
3208 #[test]
3209 fn classify_https_iri() {
3210 let ident = classify("https://rdfh.ch/projects/0001");
3211 assert!(
3212 matches!(ident, ProjectIdent::Iri(_)),
3213 "https:// prefix should classify as Iri"
3214 );
3215 }
3216
3217 #[test]
3218 fn classify_four_digit_hex_shortcode() {
3219 let ident = classify("0001");
3220 assert!(
3221 matches!(ident, ProjectIdent::Shortcode(_)),
3222 "four hex digits should classify as Shortcode"
3223 );
3224 }
3225
3226 #[test]
3227 fn classify_four_hex_letter_shortcode() {
3228 let ident = classify("beef");
3232 assert!(
3233 matches!(ident, ProjectIdent::Shortcode(_)),
3234 "4-hex-letter input 'beef' should classify as Shortcode (documented overlap)"
3235 );
3236 }
3237
3238 #[test]
3239 fn classify_mixed_case_hex_shortcode() {
3240 let ident = classify("ABCD");
3241 assert!(
3242 matches!(ident, ProjectIdent::Shortcode(_)),
3243 "upper-case hex digits should classify as Shortcode"
3244 );
3245 }
3246
3247 #[test]
3248 fn classify_shortname() {
3249 let ident = classify("incunabula");
3250 assert!(
3251 matches!(ident, ProjectIdent::Shortname(_)),
3252 "alphabetic string longer than 4 chars should classify as Shortname"
3253 );
3254 }
3255
3256 #[test]
3257 fn classify_five_digit_hex_is_shortname() {
3258 let ident = classify("00001");
3260 assert!(
3261 matches!(ident, ProjectIdent::Shortname(_)),
3262 "5-hex-digit string should classify as Shortname, not Shortcode"
3263 );
3264 }
3265
3266 #[test]
3267 fn classify_three_digit_hex_is_shortname() {
3268 let ident = classify("001");
3269 assert!(
3270 matches!(ident, ProjectIdent::Shortname(_)),
3271 "3-hex-digit string should classify as Shortname, not Shortcode"
3272 );
3273 }
3274
3275 #[test]
3276 fn classify_non_hex_four_chars_is_shortname() {
3277 let ident = classify("zzzz");
3279 assert!(
3280 matches!(ident, ProjectIdent::Shortname(_)),
3281 "4-char non-hex string should classify as Shortname"
3282 );
3283 }
3284
3285 #[test]
3290 fn validate_dump_id_valid_accepts() {
3291 assert!(super::validate_dump_id("abc123").is_ok());
3292 assert!(super::validate_dump_id("abc-123_XYZ").is_ok());
3293 let max_id = "a".repeat(256);
3295 assert!(
3296 super::validate_dump_id(&max_id).is_ok(),
3297 "256-char id must be accepted"
3298 );
3299 }
3300
3301 #[test]
3302 fn validate_dump_id_empty_is_rejected() {
3303 let result = super::validate_dump_id("");
3304 assert!(
3305 matches!(result, Err(Diagnostic::ServerError(_))),
3306 "empty id must be rejected"
3307 );
3308 }
3309
3310 #[test]
3311 fn validate_dump_id_too_long_is_rejected() {
3312 let long_id = "a".repeat(257);
3313 let result = super::validate_dump_id(&long_id);
3314 assert!(
3315 matches!(result, Err(Diagnostic::ServerError(_))),
3316 "257-char id must be rejected"
3317 );
3318 }
3319
3320 #[test]
3321 fn validate_dump_id_invalid_chars_rejected() {
3322 let result = super::validate_dump_id("abc/def");
3323 assert!(
3324 matches!(result, Err(Diagnostic::ServerError(_))),
3325 "id with '/' must be rejected"
3326 );
3327 }
3328
3329 #[test]
3334 fn into_dump_task_in_progress() {
3335 let api = DataTaskStatusApiResponse {
3336 id: "abc123".into(),
3337 status: "in_progress".into(),
3338 error_message: None,
3339 created_at: None,
3340 };
3341 let task = api.into_dump_task().expect("should parse in_progress");
3342 assert_eq!(task.id, "abc123");
3343 assert_eq!(task.status, DumpStatus::InProgress);
3344 assert!(task.error_message.is_none());
3345 assert!(task.created_at.is_none());
3346 }
3347
3348 #[test]
3349 fn into_dump_task_completed() {
3350 let api = DataTaskStatusApiResponse {
3351 id: "done42".into(),
3352 status: "completed".into(),
3353 error_message: None,
3354 created_at: None,
3355 };
3356 let task = api.into_dump_task().expect("should parse completed");
3357 assert_eq!(task.status, DumpStatus::Completed);
3358 }
3359
3360 #[test]
3361 fn into_dump_task_failed_with_message() {
3362 let api = DataTaskStatusApiResponse {
3363 id: "fail7".into(),
3364 status: "failed".into(),
3365 error_message: Some("disk full".into()),
3366 created_at: None,
3367 };
3368 let task = api.into_dump_task().expect("should parse failed");
3369 assert_eq!(task.status, DumpStatus::Failed);
3370 assert_eq!(task.error_message.as_deref(), Some("disk full"));
3371 }
3372
3373 #[test]
3374 fn into_dump_task_unknown_status_is_server_error() {
3375 let api = DataTaskStatusApiResponse {
3376 id: "x".into(),
3377 status: "pending".into(), error_message: None,
3379 created_at: None,
3380 };
3381 let result = api.into_dump_task();
3382 assert!(result.is_err(), "unknown status should yield an error");
3383 assert!(
3384 matches!(result.unwrap_err(), Diagnostic::ServerError(_)),
3385 "unknown status should yield ServerError"
3386 );
3387 }
3388
3389 #[test]
3390 fn into_dump_task_long_error_message_is_truncated() {
3391 let long_msg = "x".repeat(501);
3393 let api = DataTaskStatusApiResponse {
3394 id: "trunc".into(),
3395 status: "failed".into(),
3396 error_message: Some(long_msg),
3397 created_at: None,
3398 };
3399 let task = api
3400 .into_dump_task()
3401 .expect("should parse even with long message");
3402 let stored = task.error_message.unwrap();
3403 assert_eq!(
3404 stored.len(),
3405 500,
3406 "error_message must be truncated to ≤500 chars at the client boundary"
3407 );
3408 }
3409
3410 #[test]
3411 fn into_dump_task_exact_500_chars_not_truncated() {
3412 let exact_msg = "y".repeat(500);
3414 let api = DataTaskStatusApiResponse {
3415 id: "exact".into(),
3416 status: "failed".into(),
3417 error_message: Some(exact_msg.clone()),
3418 created_at: None,
3419 };
3420 let task = api.into_dump_task().expect("should parse");
3421 assert_eq!(task.error_message.unwrap(), exact_msg);
3422 }
3423
3424 #[test]
3429 fn into_dump_task_valid_created_at_is_parsed() {
3430 let api = DataTaskStatusApiResponse {
3431 id: "ts-test".into(),
3432 status: "completed".into(),
3433 error_message: None,
3434 created_at: Some("2026-05-20T14:03:00Z".into()),
3435 };
3436 let task = api.into_dump_task().expect("should parse with created_at");
3437 use chrono::Datelike;
3438 let ts = task.created_at.expect("created_at should be Some");
3439 assert_eq!(ts.year(), 2026);
3440 assert_eq!(ts.month(), 5);
3441 assert_eq!(ts.day(), 20);
3442 }
3443
3444 #[test]
3445 fn into_dump_task_garbage_created_at_yields_none() {
3446 let api = DataTaskStatusApiResponse {
3447 id: "ts-bad".into(),
3448 status: "in_progress".into(),
3449 error_message: None,
3450 created_at: Some("not-a-date!!".into()),
3451 };
3452 let task = api
3454 .into_dump_task()
3455 .expect("garbage created_at must not fail parse");
3456 assert!(
3457 task.created_at.is_none(),
3458 "garbage created_at must map to None"
3459 );
3460 }
3461
3462 #[test]
3467 fn export_exists_present_with_both_fields() {
3468 let body = V3ErrorBody {
3469 errors: vec![V3ErrorItem {
3470 code: "export_exists".into(),
3471 details: [
3472 ("id".to_string(), "dGVzdC1pZA".to_string()),
3473 (
3474 "projectIri".to_string(),
3475 "http://rdfh.ch/projects/0001".to_string(),
3476 ),
3477 ]
3478 .into(),
3479 }],
3480 };
3481 let ex = body.export_exists().expect("export_exists must be Some");
3482 assert_eq!(ex.id, Some("dGVzdC1pZA"));
3483 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3484 }
3485
3486 #[test]
3487 fn export_exists_wrong_code_returns_none() {
3488 let body = V3ErrorBody {
3489 errors: vec![V3ErrorItem {
3490 code: "some_other_error".into(),
3491 details: [("id".to_string(), "abc".to_string())].into(),
3492 }],
3493 };
3494 assert!(body.export_exists().is_none(), "wrong code must not match");
3495 }
3496
3497 #[test]
3498 fn export_exists_missing_details_id_returns_some_with_none_id() {
3499 let body = V3ErrorBody {
3500 errors: vec![V3ErrorItem {
3501 code: "export_exists".into(),
3502 details: [(
3503 "projectIri".to_string(),
3504 "http://rdfh.ch/projects/0001".to_string(),
3505 )]
3506 .into(),
3507 }],
3508 };
3509 let ex = body
3511 .export_exists()
3512 .expect("export_exists must be Some when code matches");
3513 assert!(ex.id.is_none(), "id must be None when 'id' key is absent");
3514 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3515 }
3516
3517 #[test]
3518 fn export_exists_empty_errors_returns_none() {
3519 let body = V3ErrorBody { errors: vec![] };
3520 assert!(body.export_exists().is_none());
3521 }
3522
3523 #[test]
3524 fn export_exists_missing_project_iri_returns_some_with_none_iri() {
3525 let body = V3ErrorBody {
3526 errors: vec![V3ErrorItem {
3527 code: "export_exists".into(),
3528 details: [("id".to_string(), "abc123".to_string())].into(),
3529 }],
3530 };
3531 let ex = body
3532 .export_exists()
3533 .expect("export_exists must be Some when code matches");
3534 assert_eq!(ex.id, Some("abc123"));
3535 assert!(
3536 ex.project_iri.is_none(),
3537 "project_iri must be None when 'projectIri' key is absent"
3538 );
3539 }
3540
3541 #[test]
3546 fn is_safe_shortcode_valid_hex_shortcode() {
3547 assert!(
3548 super::is_safe_shortcode("0001"),
3549 "4-hex-digit shortcode must be accepted"
3550 );
3551 assert!(
3552 super::is_safe_shortcode("ABCD"),
3553 "upper-case hex shortcode must be accepted"
3554 );
3555 assert!(
3556 super::is_safe_shortcode("beef"),
3557 "lower-case hex shortcode must be accepted"
3558 );
3559 }
3560
3561 #[test]
3562 fn is_safe_shortcode_alphanumeric_within_32_chars_accepted() {
3563 let long_code = "a".repeat(32);
3564 assert!(
3565 super::is_safe_shortcode(&long_code),
3566 "32-char alphanumeric must be accepted"
3567 );
3568 }
3569
3570 #[test]
3571 fn is_safe_shortcode_empty_is_rejected() {
3572 assert!(
3573 !super::is_safe_shortcode(""),
3574 "empty shortcode must be rejected"
3575 );
3576 }
3577
3578 #[test]
3579 fn is_safe_shortcode_too_long_is_rejected() {
3580 let long_code = "a".repeat(33);
3581 assert!(
3582 !super::is_safe_shortcode(&long_code),
3583 "33-char shortcode must be rejected"
3584 );
3585 }
3586
3587 #[test]
3588 fn is_safe_shortcode_slash_is_rejected() {
3589 assert!(
3590 !super::is_safe_shortcode("ab/cd"),
3591 "shortcode with '/' must be rejected"
3592 );
3593 assert!(
3594 !super::is_safe_shortcode("/evil"),
3595 "absolute path shortcode must be rejected"
3596 );
3597 }
3598
3599 #[test]
3600 fn is_safe_shortcode_dot_dot_is_rejected() {
3601 assert!(
3602 !super::is_safe_shortcode("../evil"),
3603 "path traversal shortcode must be rejected"
3604 );
3605 assert!(
3606 !super::is_safe_shortcode(".."),
3607 "'..' shortcode must be rejected"
3608 );
3609 }
3610
3611 #[test]
3612 fn is_safe_shortcode_backslash_is_rejected() {
3613 assert!(
3614 !super::is_safe_shortcode("ab\\cd"),
3615 "shortcode with '\\' must be rejected"
3616 );
3617 }
3618
3619 #[test]
3620 fn is_safe_shortcode_dot_is_rejected() {
3621 assert!(
3623 !super::is_safe_shortcode("ab.cd"),
3624 "shortcode with '.' must be rejected"
3625 );
3626 }
3627
3628 #[test]
3629 fn resolve_project_rejects_unsafe_shortcode() {
3630 let unsafe_examples = ["../evil", "/abs", "ab/cd", "a\\b", ""];
3634 for s in &unsafe_examples {
3635 assert!(
3636 !super::is_safe_shortcode(s),
3637 "is_safe_shortcode must reject '{s}' — resolve_project would have returned ServerError for this input"
3638 );
3639 }
3640 }
3641
3642 #[test]
3647 fn data_model_name_from_iri_standard_form() {
3648 assert_eq!(
3650 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2"),
3651 "beol"
3652 );
3653 }
3654
3655 #[test]
3656 fn data_model_name_from_iri_no_v2_suffix() {
3657 assert_eq!(
3659 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol"),
3660 "beol"
3661 );
3662 }
3663
3664 #[test]
3665 fn data_model_name_from_iri_trailing_slash() {
3666 assert_eq!(
3668 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2/"),
3669 "beol"
3670 );
3671 }
3672
3673 #[test]
3674 fn data_model_name_from_iri_bare_name() {
3675 assert_eq!(super::data_model_name_from_iri("beol"), "beol");
3677 }
3678
3679 #[test]
3680 fn data_model_name_from_iri_empty_string() {
3681 assert_eq!(super::data_model_name_from_iri(""), "");
3683 }
3684
3685 fn beol_prefixes() -> HashMap<String, String> {
3690 let mut m = HashMap::new();
3691 m.insert(
3692 "beol".to_string(),
3693 "http://api.dasch.swiss/ontology/0801/beol/v2#".to_string(),
3694 );
3695 m
3696 }
3697
3698 #[test]
3699 fn expand_class_id_curie_expands_with_known_prefix() {
3700 let (name, iri) = super::expand_class_id("beol:Archive", &beol_prefixes());
3702 assert_eq!(name, "Archive");
3703 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
3704 }
3705
3706 #[test]
3707 fn expand_class_id_unknown_prefix_falls_back_to_raw_id() {
3708 let (name, iri) = super::expand_class_id("urn:uuid:x", &HashMap::new());
3710 assert_eq!(name, "x");
3711 assert_eq!(iri, "urn:uuid:x");
3712 }
3713
3714 #[test]
3715 fn expand_class_id_full_iri_passes_through() {
3716 let (name, iri) = super::expand_class_id(
3719 "http://api.dasch.swiss/ontology/0801/beol/v2#Letter",
3720 &beol_prefixes(),
3721 );
3722 assert_eq!(name, "Letter");
3723 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Letter");
3724 }
3725
3726 #[test]
3727 fn expand_class_id_no_colon_degenerate() {
3728 let (name, iri) = super::expand_class_id("bare", &HashMap::new());
3730 assert_eq!(name, "bare");
3731 assert_eq!(iri, "bare");
3732 }
3733
3734 #[test]
3739 fn local_name_hash_iri() {
3740 assert_eq!(super::local_name("http://example.org/onto#Thing"), "Thing");
3741 }
3742
3743 #[test]
3744 fn local_name_slash_iri() {
3745 assert_eq!(super::local_name("http://example.org/onto/Thing"), "Thing");
3746 }
3747
3748 #[test]
3749 fn local_name_curie_colon() {
3750 assert_eq!(super::local_name("incunabula:Page"), "Page");
3751 }
3752
3753 #[test]
3754 fn local_name_bare_name_fallback() {
3755 assert_eq!(super::local_name("Page"), "Page");
3756 }
3757
3758 #[test]
3759 fn local_name_empty_string() {
3760 assert_eq!(super::local_name(""), "");
3761 }
3762
3763 #[test]
3764 fn local_name_trailing_separator() {
3765 assert_eq!(super::local_name("foo#"), "");
3768 }
3769
3770 #[test]
3775 fn object_type_to_kebab_text_value() {
3776 assert_eq!(super::object_type_to_kebab("TextValue"), "text");
3777 }
3778
3779 #[test]
3780 fn object_type_to_kebab_geom_value() {
3781 assert_eq!(super::object_type_to_kebab("GeomValue"), "geom");
3783 }
3784
3785 #[test]
3786 fn object_type_to_kebab_geo_name_value() {
3787 assert_eq!(super::object_type_to_kebab("GeoNameValue"), "geo-name");
3789 }
3790
3791 #[test]
3792 fn object_type_to_kebab_uri_value() {
3793 assert_eq!(super::object_type_to_kebab("URIValue"), "uri");
3796 }
3797
3798 #[test]
3799 fn object_type_to_kebab_interval_value() {
3800 assert_eq!(super::object_type_to_kebab("IntervalValue"), "interval");
3803 }
3804
3805 #[test]
3806 fn object_type_to_kebab_no_value_suffix() {
3807 assert_eq!(super::object_type_to_kebab("Geom"), "geom");
3809 }
3810
3811 #[test]
3812 fn map_object_type_known_text_value() {
3813 use crate::model::ValueType;
3814 assert_eq!(
3815 super::map_object_type_to_value_type("TextValue"),
3816 ValueType::Text
3817 );
3818 }
3819
3820 #[test]
3821 fn map_object_type_known_list_value() {
3822 use crate::model::ValueType;
3823 assert_eq!(
3824 super::map_object_type_to_value_type("ListValue"),
3825 ValueType::ListItem
3826 );
3827 }
3828
3829 #[test]
3830 fn map_object_type_other_geom() {
3831 use crate::model::ValueType;
3832 assert_eq!(
3834 super::map_object_type_to_value_type("GeomValue"),
3835 ValueType::Other("geom".to_string())
3836 );
3837 }
3838
3839 #[test]
3840 fn map_object_type_other_uri_value() {
3841 use crate::model::ValueType;
3842 assert_eq!(
3844 super::map_object_type_to_value_type("URIValue"),
3845 ValueType::Other("uri".to_string())
3846 );
3847 }
3848
3849 #[test]
3850 fn map_object_type_other_geo_name_value() {
3851 use crate::model::ValueType;
3852 assert_eq!(
3853 super::map_object_type_to_value_type("GeoNameValue"),
3854 ValueType::Other("geo-name".to_string())
3855 );
3856 }
3857
3858 #[test]
3863 fn decode_cardinality_owl_cardinality_1() {
3864 use crate::model::Cardinality;
3865 let v = serde_json::json!({"owl:cardinality": 1});
3866 assert_eq!(super::decode_cardinality(&v), Cardinality::One);
3867 }
3868
3869 #[test]
3870 fn decode_cardinality_owl_max_cardinality_1() {
3871 use crate::model::Cardinality;
3872 let v = serde_json::json!({"owl:maxCardinality": 1});
3873 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrOne);
3874 }
3875
3876 #[test]
3877 fn decode_cardinality_owl_min_cardinality_0() {
3878 use crate::model::Cardinality;
3879 let v = serde_json::json!({"owl:minCardinality": 0});
3880 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
3881 }
3882
3883 #[test]
3884 fn decode_cardinality_owl_min_cardinality_1() {
3885 use crate::model::Cardinality;
3886 let v = serde_json::json!({"owl:minCardinality": 1});
3887 assert_eq!(super::decode_cardinality(&v), Cardinality::OneOrMore);
3888 }
3889
3890 #[test]
3891 fn decode_cardinality_fallback_no_key() {
3892 use crate::model::Cardinality;
3893 let v = serde_json::json!({});
3895 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
3896 }
3897
3898 #[test]
3899 fn decode_cardinality_fallback_owl_cardinality_unexpected_value() {
3900 use crate::model::Cardinality;
3901 let v = serde_json::json!({"owl:cardinality": 5});
3903 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
3904 }
3905
3906 #[test]
3907 fn decode_cardinality_fallback_owl_max_cardinality_gt1() {
3908 use crate::model::Cardinality;
3909 let v = serde_json::json!({"owl:maxCardinality": 2});
3912 assert_eq!(
3913 super::decode_cardinality(&v),
3914 Cardinality::ZeroOrMore,
3915 "owl:maxCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
3916 );
3917 }
3918
3919 #[test]
3920 fn decode_cardinality_fallback_owl_min_cardinality_gt1() {
3921 use crate::model::Cardinality;
3922 let v = serde_json::json!({"owl:minCardinality": 2});
3925 assert_eq!(
3926 super::decode_cardinality(&v),
3927 Cardinality::ZeroOrMore,
3928 "owl:minCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
3929 );
3930 }
3931
3932 #[test]
3937 fn detect_representation_still_image() {
3938 use crate::model::Representation;
3939 let locals = vec!["hasStillImageFileValue"];
3940 assert_eq!(
3941 super::detect_representation(&locals),
3942 Some(Representation::StillImage)
3943 );
3944 }
3945
3946 #[test]
3947 fn detect_representation_moving_image() {
3948 use crate::model::Representation;
3949 let locals = vec!["hasMovingImageFileValue"];
3950 assert_eq!(
3951 super::detect_representation(&locals),
3952 Some(Representation::MovingImage)
3953 );
3954 }
3955
3956 #[test]
3957 fn detect_representation_audio() {
3958 use crate::model::Representation;
3959 let locals = vec!["hasAudioFileValue"];
3960 assert_eq!(
3961 super::detect_representation(&locals),
3962 Some(Representation::Audio)
3963 );
3964 }
3965
3966 #[test]
3967 fn detect_representation_none_when_absent() {
3968 let locals = vec!["hasTitle", "hasAuthor"];
3970 assert_eq!(super::detect_representation(&locals), None);
3971 }
3972
3973 #[test]
3974 fn detect_representation_takes_first() {
3975 use crate::model::Representation;
3976 let locals = vec!["hasDocumentFileValue", "hasStillImageFileValue"];
3978 assert_eq!(
3979 super::detect_representation(&locals),
3980 Some(Representation::Document)
3981 );
3982 }
3983
3984 #[test]
3989 fn is_system_prefix_knora_api() {
3990 assert!(super::is_system_prefix("knora-api"));
3991 }
3992
3993 #[test]
3994 fn is_system_prefix_rdf() {
3995 assert!(super::is_system_prefix("rdf"));
3996 }
3997
3998 #[test]
3999 fn is_system_prefix_project_prefix_is_not_system() {
4000 assert!(!super::is_system_prefix("incunabula"));
4001 assert!(!super::is_system_prefix("beol"));
4002 assert!(!super::is_system_prefix("biblio"));
4003 }
4004
4005 #[test]
4010 fn curie_prefix_returns_prefix_for_curie() {
4011 assert_eq!(super::curie_prefix("knora-api:arkUrl"), Some("knora-api"));
4012 assert_eq!(super::curie_prefix("beol:hasTitle"), Some("beol"));
4013 }
4014
4015 #[test]
4016 fn curie_prefix_returns_none_for_full_iri() {
4017 assert_eq!(
4019 super::curie_prefix("http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle"),
4020 None
4021 );
4022 }
4023
4024 #[test]
4025 fn curie_prefix_returns_none_for_no_colon() {
4026 assert_eq!(super::curie_prefix("hasTitle"), None);
4027 }
4028
4029 #[test]
4034 fn sibling_iri_trim_hash_delimiter() {
4035 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4037 let trimmed = namespace.trim_end_matches(['#', '/']);
4038 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4039 }
4040
4041 #[test]
4042 fn sibling_iri_trim_slash_delimiter() {
4043 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2/";
4045 let trimmed = namespace.trim_end_matches(['#', '/']);
4046 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4047 }
4048
4049 #[test]
4050 fn sibling_iri_self_loop_detected() {
4051 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4053 let namespace = "http://api.dasch.swiss/ontology/0801/beol/v2#";
4054 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4055 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4056 assert_eq!(sibling_iri, queried_trimmed); }
4058
4059 #[test]
4060 fn sibling_iri_different_ontology_is_not_self_loop() {
4061 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4062 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4063 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4064 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4065 assert_ne!(sibling_iri, queried_trimmed); }
4067
4068 #[test]
4069 fn missing_prefix_in_context_is_skipped() {
4070 let prefixes: HashMap<String, String> = HashMap::new();
4072 let result = prefixes.get("biblio");
4073 assert!(result.is_none()); }
4075
4076 #[test]
4081 fn derive_access_rv() {
4082 assert_eq!(
4083 super::derive_access("RV"),
4084 Some(super::ResourceAccess::RestrictedView)
4085 );
4086 }
4087
4088 #[test]
4089 fn derive_access_v() {
4090 assert_eq!(super::derive_access("V"), Some(super::ResourceAccess::View));
4091 }
4092
4093 #[test]
4094 fn derive_access_m() {
4095 assert_eq!(super::derive_access("M"), Some(super::ResourceAccess::Edit));
4096 }
4097
4098 #[test]
4099 fn derive_access_d() {
4100 assert_eq!(
4101 super::derive_access("D"),
4102 Some(super::ResourceAccess::Delete)
4103 );
4104 }
4105
4106 #[test]
4107 fn derive_access_cr() {
4108 assert_eq!(
4109 super::derive_access("CR"),
4110 Some(super::ResourceAccess::Manage)
4111 );
4112 }
4113
4114 #[test]
4115 fn derive_access_unknown_is_none() {
4116 assert_eq!(super::derive_access("XYZ"), None);
4117 }
4118
4119 #[test]
4120 fn derive_access_empty_is_none() {
4121 assert_eq!(super::derive_access(""), None);
4122 }
4123
4124 #[test]
4129 fn derive_visibility_public_when_unknown_user_has_view() {
4130 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|V knora-admin:KnownUser,knora-admin:UnknownUser";
4132 assert_eq!(
4133 super::derive_visibility(acl),
4134 Some(super::ResourceVisibility::Public)
4135 );
4136 }
4137
4138 #[test]
4139 fn derive_visibility_public_when_unknown_user_has_cr() {
4140 let acl = "CR knora-admin:UnknownUser";
4142 assert_eq!(
4143 super::derive_visibility(acl),
4144 Some(super::ResourceVisibility::Public)
4145 );
4146 }
4147
4148 #[test]
4149 fn derive_visibility_public_restricted_when_unknown_user_has_rv() {
4150 let acl = "RV knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4152 assert_eq!(
4153 super::derive_visibility(acl),
4154 Some(super::ResourceVisibility::PublicRestricted)
4155 );
4156 }
4157
4158 #[test]
4159 fn derive_visibility_logged_in_when_known_user_has_rv_unknown_absent() {
4160 let acl = "RV knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4162 assert_eq!(
4163 super::derive_visibility(acl),
4164 Some(super::ResourceVisibility::LoggedInUsers)
4165 );
4166 }
4167
4168 #[test]
4169 fn derive_visibility_logged_in_when_known_user_has_v() {
4170 let acl = "V knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4172 assert_eq!(
4173 super::derive_visibility(acl),
4174 Some(super::ResourceVisibility::LoggedInUsers)
4175 );
4176 }
4177
4178 #[test]
4179 fn derive_visibility_project_members_when_neither_world_group_granted() {
4180 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|M knora-admin:ProjectMember";
4182 assert_eq!(
4183 super::derive_visibility(acl),
4184 Some(super::ResourceVisibility::ProjectMembers)
4185 );
4186 }
4187
4188 #[test]
4189 fn derive_visibility_empty_string_is_none() {
4190 assert_eq!(super::derive_visibility(""), None);
4191 }
4192
4193 #[test]
4194 fn derive_visibility_whitespace_only_is_none() {
4195 assert_eq!(super::derive_visibility(" "), None);
4196 }
4197
4198 #[test]
4199 fn derive_visibility_malformed_entry_without_space_is_skipped() {
4200 let acl = "CRMALFORMED|CR knora-admin:ProjectAdmin";
4202 assert_eq!(
4204 super::derive_visibility(acl),
4205 Some(super::ResourceVisibility::ProjectMembers)
4206 );
4207 }
4208
4209 #[test]
4210 fn derive_visibility_unknown_code_ranks_zero_no_implicit_grant() {
4211 let acl = "BOGUS knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4213 assert_eq!(
4215 super::derive_visibility(acl),
4216 Some(super::ResourceVisibility::ProjectMembers)
4217 );
4218 }
4219
4220 #[test]
4221 fn derive_visibility_same_group_two_entries_max_wins() {
4222 let acl = "RV knora-admin:UnknownUser|V knora-admin:UnknownUser";
4224 assert_eq!(
4225 super::derive_visibility(acl),
4226 Some(super::ResourceVisibility::Public)
4227 );
4228 }
4229
4230 #[test]
4231 fn derive_visibility_both_world_groups_unknown_user_decides() {
4232 let acl = "V knora-admin:UnknownUser|CR knora-admin:KnownUser";
4235 assert_eq!(
4236 super::derive_visibility(acl),
4237 Some(super::ResourceVisibility::Public)
4238 );
4239 }
4240
4241 #[test]
4242 fn derive_visibility_super_unknown_user_does_not_match() {
4243 let acl = "CR knora-admin:SuperUnknownUser|CR knora-admin:ProjectAdmin";
4246 assert_eq!(
4248 super::derive_visibility(acl),
4249 Some(super::ResourceVisibility::ProjectMembers)
4250 );
4251 }
4252
4253 #[test]
4254 fn derive_visibility_all_malformed_entries_no_space_returns_none() {
4255 let acl = "NOSPACE|ALSONOSPACE|STILLNOSPACE";
4259 assert_eq!(
4260 super::derive_visibility(acl),
4261 None,
4262 "all-malformed ACL (no space in any entry) must return None"
4263 );
4264 }
4265
4266 use crate::model::ValueType;
4271 use crate::model::resource::{DatePoint, DateValue, FileValue, ValueContent};
4272
4273 #[test]
4276 fn parse_value_text_plain() {
4277 let obj = serde_json::json!({
4278 "@type": "knora-api:TextValue",
4279 "knora-api:valueAsString": "Hello world"
4280 });
4281 let (content, is_link) = super::parse_value_content(&obj);
4282 assert_eq!(content, ValueContent::Text("Hello world".into()));
4283 assert!(!is_link);
4284 }
4285
4286 #[test]
4287 fn parse_value_text_standoff_xml_stripped() {
4288 let obj = serde_json::json!({
4290 "@type": "knora-api:TextValue",
4291 "knora-api:textValueAsXml": "<p>Hello <b>world</b></p>",
4292 "knora-api:valueAsString": "This is ignored when xml present"
4293 });
4294 let (content, is_link) = super::parse_value_content(&obj);
4295 assert!(matches!(content, ValueContent::Text(_)));
4297 assert!(!is_link);
4298 if let ValueContent::Text(s) = content {
4299 assert!(!s.contains('<'), "no raw tags: {s:?}");
4301 assert!(s.contains("Hello"), "text retained: {s:?}");
4302 }
4303 }
4304
4305 #[test]
4308 fn parse_value_integer() {
4309 let obj = serde_json::json!({
4310 "@type": "knora-api:IntValue",
4311 "knora-api:intValueAsInt": 42
4312 });
4313 let (content, is_link) = super::parse_value_content(&obj);
4314 assert_eq!(content, ValueContent::Integer(42));
4315 assert!(!is_link);
4316 }
4317
4318 #[test]
4319 fn parse_value_integer_negative() {
4320 let obj = serde_json::json!({
4321 "@type": "knora-api:IntValue",
4322 "knora-api:intValueAsInt": -7
4323 });
4324 let (content, _) = super::parse_value_content(&obj);
4325 assert_eq!(content, ValueContent::Integer(-7));
4326 }
4327
4328 #[test]
4331 fn parse_value_decimal_object_form() {
4332 let obj = serde_json::json!({
4334 "@type": "knora-api:DecimalValue",
4335 "knora-api:decimalValueAsDecimal": {"@value": "3.14159", "@type": "xsd:decimal"}
4336 });
4337 let (content, is_link) = super::parse_value_content(&obj);
4338 assert_eq!(content, ValueContent::Decimal("3.14159".into()));
4339 assert!(!is_link);
4340 }
4341
4342 #[test]
4343 fn parse_value_decimal_bare_string_form() {
4344 let obj = serde_json::json!({
4345 "@type": "knora-api:DecimalValue",
4346 "knora-api:decimalValueAsDecimal": "2.71828"
4347 });
4348 let (content, _) = super::parse_value_content(&obj);
4349 assert_eq!(content, ValueContent::Decimal("2.71828".into()));
4350 }
4351
4352 #[test]
4355 fn parse_value_boolean_true() {
4356 let obj = serde_json::json!({
4357 "@type": "knora-api:BooleanValue",
4358 "knora-api:booleanValueAsBoolean": true
4359 });
4360 let (content, is_link) = super::parse_value_content(&obj);
4361 assert_eq!(content, ValueContent::Boolean(true));
4362 assert!(!is_link);
4363 }
4364
4365 #[test]
4366 fn parse_value_boolean_false() {
4367 let obj = serde_json::json!({
4368 "@type": "knora-api:BooleanValue",
4369 "knora-api:booleanValueAsBoolean": false
4370 });
4371 let (content, _) = super::parse_value_content(&obj);
4372 assert_eq!(content, ValueContent::Boolean(false));
4373 }
4374
4375 #[test]
4378 fn parse_value_date_single_point() {
4379 let obj = serde_json::json!({
4381 "@type": "knora-api:DateValue",
4382 "knora-api:dateValueHasCalendar": "GREGORIAN",
4383 "knora-api:dateValueHasStartYear": 1489,
4384 "knora-api:dateValueHasStartEra": "CE",
4385 "knora-api:dateValueHasEndYear": 1489,
4386 "knora-api:dateValueHasEndEra": "CE"
4387 });
4388 let (content, is_link) = super::parse_value_content(&obj);
4389 assert!(!is_link);
4390 let expected = ValueContent::Date(DateValue {
4391 calendar: "GREGORIAN".into(),
4392 start: DatePoint {
4393 year: Some(1489),
4394 month: None,
4395 day: None,
4396 era: Some("CE".into()),
4397 },
4398 end: DatePoint {
4399 year: Some(1489),
4400 month: None,
4401 day: None,
4402 era: Some("CE".into()),
4403 },
4404 });
4405 assert_eq!(content, expected);
4406 }
4407
4408 #[test]
4409 fn parse_value_date_range() {
4410 let obj = serde_json::json!({
4412 "@type": "knora-api:DateValue",
4413 "knora-api:dateValueHasCalendar": "GREGORIAN",
4414 "knora-api:dateValueHasStartYear": 1489,
4415 "knora-api:dateValueHasStartEra": "CE",
4416 "knora-api:dateValueHasEndYear": 1490,
4417 "knora-api:dateValueHasEndEra": "CE"
4418 });
4419 let (content, _) = super::parse_value_content(&obj);
4420 if let ValueContent::Date(dv) = content {
4421 assert_eq!(dv.start.year, Some(1489));
4422 assert_eq!(dv.end.year, Some(1490));
4423 assert_ne!(dv.start, dv.end, "range: start != end");
4424 } else {
4425 panic!("expected DateValue, got {content:?}");
4426 }
4427 }
4428
4429 #[test]
4430 fn parse_value_date_full_day_precision() {
4431 let obj = serde_json::json!({
4433 "@type": "knora-api:DateValue",
4434 "knora-api:dateValueHasCalendar": "JULIAN",
4435 "knora-api:dateValueHasStartYear": 1456,
4436 "knora-api:dateValueHasStartMonth": 3,
4437 "knora-api:dateValueHasStartDay": 14,
4438 "knora-api:dateValueHasStartEra": "CE",
4439 "knora-api:dateValueHasEndYear": 1456,
4440 "knora-api:dateValueHasEndMonth": 3,
4441 "knora-api:dateValueHasEndDay": 14,
4442 "knora-api:dateValueHasEndEra": "CE"
4443 });
4444 let (content, _) = super::parse_value_content(&obj);
4445 if let ValueContent::Date(dv) = content {
4446 assert_eq!(dv.calendar, "JULIAN");
4447 assert_eq!(dv.start.month, Some(3));
4448 assert_eq!(dv.start.day, Some(14));
4449 } else {
4450 panic!("expected DateValue, got {content:?}");
4451 }
4452 }
4453
4454 #[test]
4455 fn parse_value_date_no_year_falls_back_to_raw() {
4456 let obj = serde_json::json!({
4458 "@type": "knora-api:DateValue",
4459 "knora-api:dateValueHasCalendar": "GREGORIAN",
4460 "knora-api:valueAsString": "some date"
4461 });
4462 let (content, _) = super::parse_value_content(&obj);
4463 assert!(
4464 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "date"),
4465 "missing years must degrade to Raw date"
4466 );
4467 }
4468
4469 #[test]
4472 fn parse_value_time() {
4473 let obj = serde_json::json!({
4474 "@type": "knora-api:TimeValue",
4475 "knora-api:timeValueAsTimeStamp": {"@value": "2021-01-01T12:00:00Z", "@type": "xsd:dateTimeStamp"}
4476 });
4477 let (content, is_link) = super::parse_value_content(&obj);
4478 assert_eq!(content, ValueContent::Time("2021-01-01T12:00:00Z".into()));
4479 assert!(!is_link);
4480 }
4481
4482 #[test]
4483 fn parse_value_time_bare_string() {
4484 let obj = serde_json::json!({
4485 "@type": "knora-api:TimeValue",
4486 "knora-api:timeValueAsTimeStamp": "2022-06-01T00:00:00Z"
4487 });
4488 let (content, _) = super::parse_value_content(&obj);
4489 assert_eq!(content, ValueContent::Time("2022-06-01T00:00:00Z".into()));
4490 }
4491
4492 #[test]
4495 fn parse_value_uri() {
4496 let obj = serde_json::json!({
4497 "@type": "knora-api:UriValue",
4498 "knora-api:uriValueAsUri": {"@value": "https://example.com", "@type": "xsd:anyURI"}
4499 });
4500 let (content, is_link) = super::parse_value_content(&obj);
4501 assert_eq!(content, ValueContent::Uri("https://example.com".into()));
4502 assert!(!is_link);
4503 }
4504
4505 #[test]
4508 fn parse_value_color() {
4509 let obj = serde_json::json!({
4510 "@type": "knora-api:ColorValue",
4511 "knora-api:colorValueAsColor": "#ff0000"
4512 });
4513 let (content, is_link) = super::parse_value_content(&obj);
4514 assert_eq!(content, ValueContent::Color("#ff0000".into()));
4515 assert!(!is_link);
4516 }
4517
4518 #[test]
4521 fn parse_value_geoname() {
4522 let obj = serde_json::json!({
4523 "@type": "knora-api:GeonameValue",
4524 "knora-api:geonameValueAsGeonameCode": "2661552"
4525 });
4526 let (content, is_link) = super::parse_value_content(&obj);
4527 assert_eq!(content, ValueContent::Geoname("2661552".into()));
4528 assert!(!is_link);
4529 }
4530
4531 #[test]
4534 fn parse_value_list_item() {
4535 let obj = serde_json::json!({
4536 "@type": "knora-api:ListValue",
4537 "knora-api:listValueAsListNode": {"@id": "http://rdfh.ch/lists/0001/node1"}
4538 });
4539 let (content, is_link) = super::parse_value_content(&obj);
4540 assert_eq!(
4541 content,
4542 ValueContent::ListItem {
4543 node_iri: "http://rdfh.ch/lists/0001/node1".into(),
4544 label: None, }
4546 );
4547 assert!(!is_link);
4548 }
4549
4550 #[test]
4553 fn parse_value_link_with_embedded_target() {
4554 let obj = serde_json::json!({
4555 "@type": "knora-api:LinkValue",
4556 "knora-api:linkValueHasTarget": {
4557 "@id": "http://rdfh.ch/0803/res1",
4558 "@type": "incunabula:Book",
4559 "rdfs:label": "Incunabula Book 1"
4560 }
4561 });
4562 let (content, is_link) = super::parse_value_content(&obj);
4563 assert!(is_link, "LinkValue must set is_link=true");
4564 assert_eq!(
4565 content,
4566 ValueContent::Link {
4567 target_iri: "http://rdfh.ch/0803/res1".into(),
4568 target_label: Some("Incunabula Book 1".into()),
4569 }
4570 );
4571 }
4572
4573 #[test]
4574 fn parse_value_link_with_target_iri_only() {
4575 let obj = serde_json::json!({
4577 "@type": "knora-api:LinkValue",
4578 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res2"}
4579 });
4580 let (content, is_link) = super::parse_value_content(&obj);
4581 assert!(is_link);
4582 assert_eq!(
4583 content,
4584 ValueContent::Link {
4585 target_iri: "http://rdfh.ch/0803/res2".into(),
4586 target_label: None,
4587 }
4588 );
4589 }
4590
4591 #[test]
4594 fn parse_value_still_image_file() {
4595 let obj = serde_json::json!({
4596 "@type": "knora-api:StillImageFileValue",
4597 "knora-api:fileValueHasFilename": "image.jp2",
4598 "knora-api:fileValueAsUrl": {"@value": "https://iiif.example.com/image.jp2/full/max/0/default.jpg"},
4599 "knora-api:stillImageFileValueHasDimX": 1200,
4600 "knora-api:stillImageFileValueHasDimY": 800
4601 });
4602 let (content, is_link) = super::parse_value_content(&obj);
4603 assert!(!is_link);
4604 assert_eq!(
4605 content,
4606 ValueContent::File(FileValue {
4607 value_type: ValueType::StillImage,
4608 filename: "image.jp2".into(),
4609 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
4610 width: Some(1200),
4611 height: Some(800),
4612 })
4613 );
4614 }
4615
4616 #[test]
4617 fn parse_value_still_image_external_file_value() {
4618 let obj = serde_json::json!({
4620 "@type": "knora-api:StillImageExternalFileValue",
4621 "knora-api:fileValueHasFilename": "external.jpg",
4622 "knora-api:fileValueAsUrl": {"@value": "https://iiif.external.com/image.jpg"}
4623 });
4624 let (content, _) = super::parse_value_content(&obj);
4625 if let ValueContent::File(fv) = content {
4626 assert_eq!(
4627 fv.value_type,
4628 ValueType::StillImage,
4629 "StillImageExternal* → StillImage"
4630 );
4631 } else {
4632 panic!("expected File, got {content:?}");
4633 }
4634 }
4635
4636 #[test]
4639 fn parse_value_moving_image_file() {
4640 let obj = serde_json::json!({
4641 "@type": "knora-api:MovingImageFileValue",
4642 "knora-api:fileValueHasFilename": "video.mp4",
4643 "knora-api:fileValueAsUrl": {"@value": "https://example.com/video.mp4"}
4644 });
4645 let (content, is_link) = super::parse_value_content(&obj);
4646 assert!(!is_link);
4647 assert_eq!(
4648 content,
4649 ValueContent::File(FileValue {
4650 value_type: ValueType::MovingImage,
4651 filename: "video.mp4".into(),
4652 url: "https://example.com/video.mp4".into(),
4653 width: None,
4654 height: None,
4655 })
4656 );
4657 }
4658
4659 #[test]
4662 fn parse_value_audio_file() {
4663 let obj = serde_json::json!({
4664 "@type": "knora-api:AudioFileValue",
4665 "knora-api:fileValueHasFilename": "sound.wav",
4666 "knora-api:fileValueAsUrl": {"@value": "https://example.com/sound.wav"}
4667 });
4668 let (content, _) = super::parse_value_content(&obj);
4669 assert_eq!(
4670 content,
4671 ValueContent::File(FileValue {
4672 value_type: ValueType::Audio,
4673 filename: "sound.wav".into(),
4674 url: "https://example.com/sound.wav".into(),
4675 width: None,
4676 height: None,
4677 })
4678 );
4679 }
4680
4681 #[test]
4684 fn parse_value_document_file() {
4685 let obj = serde_json::json!({
4686 "@type": "knora-api:DocumentFileValue",
4687 "knora-api:fileValueHasFilename": "doc.pdf",
4688 "knora-api:fileValueAsUrl": {"@value": "https://example.com/doc.pdf"}
4689 });
4690 let (content, _) = super::parse_value_content(&obj);
4691 assert_eq!(
4692 content,
4693 ValueContent::File(FileValue {
4694 value_type: ValueType::Document,
4695 filename: "doc.pdf".into(),
4696 url: "https://example.com/doc.pdf".into(),
4697 width: None,
4698 height: None,
4699 })
4700 );
4701 }
4702
4703 #[test]
4706 fn parse_value_archive_file() {
4707 let obj = serde_json::json!({
4708 "@type": "knora-api:ArchiveFileValue",
4709 "knora-api:fileValueHasFilename": "data.zip",
4710 "knora-api:fileValueAsUrl": {"@value": "https://example.com/data.zip"}
4711 });
4712 let (content, _) = super::parse_value_content(&obj);
4713 assert_eq!(
4714 content,
4715 ValueContent::File(FileValue {
4716 value_type: ValueType::Archive,
4717 filename: "data.zip".into(),
4718 url: "https://example.com/data.zip".into(),
4719 width: None,
4720 height: None,
4721 })
4722 );
4723 }
4724
4725 #[test]
4728 fn parse_value_text_file_value_maps_to_document() {
4729 let obj = serde_json::json!({
4730 "@type": "knora-api:TextFileValue",
4731 "knora-api:fileValueHasFilename": "text.txt",
4732 "knora-api:fileValueAsUrl": {"@value": "https://example.com/text.txt"}
4733 });
4734 let (content, _) = super::parse_value_content(&obj);
4735 if let ValueContent::File(fv) = content {
4736 assert_eq!(
4737 fv.value_type,
4738 ValueType::Document,
4739 "TextFileValue → Document"
4740 );
4741 } else {
4742 panic!("expected File, got {content:?}");
4743 }
4744 }
4745
4746 #[test]
4749 fn parse_value_interval_raw_fallback() {
4750 let obj = serde_json::json!({
4751 "@type": "knora-api:IntervalValue",
4752 "knora-api:intervalValueHasStart": {"@value": "0.0", "@type": "xsd:decimal"},
4753 "knora-api:intervalValueHasEnd": {"@value": "10.5", "@type": "xsd:decimal"},
4754 "knora-api:valueAsString": "0.0 - 10.5"
4755 });
4756 let (content, is_link) = super::parse_value_content(&obj);
4757 assert!(!is_link);
4758 assert!(
4759 matches!(content, ValueContent::Raw { ref value_type, .. } if value_type == "interval"),
4760 "IntervalValue must degrade to Raw with token 'interval'"
4761 );
4762 if let ValueContent::Raw { text, .. } = content {
4763 assert_eq!(text, "0.0 - 10.5");
4764 }
4765 }
4766
4767 #[test]
4768 fn parse_value_geom_raw_fallback() {
4769 let obj = serde_json::json!({
4770 "@type": "knora-api:GeomValue",
4771 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
4772 });
4773 let (content, _) = super::parse_value_content(&obj);
4774 assert!(
4775 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "geom"),
4776 "GeomValue must degrade to Raw with token 'geom'"
4777 );
4778 }
4779
4780 #[test]
4783 fn parse_value_with_comment() {
4784 let obj = serde_json::json!({
4785 "@type": "knora-api:TextValue",
4786 "knora-api:valueAsString": "Hello world",
4787 "knora-api:valueHasComment": "reading uncertain"
4788 });
4789 let (value, is_link) = super::parse_value(&obj);
4790 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4791 assert_eq!(value.comment.as_deref(), Some("reading uncertain"));
4792 assert!(!is_link);
4793 }
4794
4795 #[test]
4796 fn parse_value_without_comment() {
4797 let obj = serde_json::json!({
4798 "@type": "knora-api:TextValue",
4799 "knora-api:valueAsString": "Hello world"
4800 });
4801 let (value, is_link) = super::parse_value(&obj);
4802 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4803 assert_eq!(value.comment, None);
4804 assert!(!is_link);
4805 }
4806
4807 #[test]
4808 fn parse_value_with_empty_comment() {
4809 let obj = serde_json::json!({
4810 "@type": "knora-api:TextValue",
4811 "knora-api:valueAsString": "Hello world",
4812 "knora-api:valueHasComment": ""
4813 });
4814 let (value, is_link) = super::parse_value(&obj);
4815 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4816 assert_eq!(value.comment, None);
4817 assert!(!is_link);
4818 }
4819
4820 #[test]
4823 fn parse_value_link_is_link_true() {
4824 let obj = serde_json::json!({
4826 "@type": "knora-api:LinkValue",
4827 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4828 });
4829 let (_, is_link) = super::parse_value_content(&obj);
4830 assert!(
4831 is_link,
4832 "LinkValue must report is_link=true for name derivation"
4833 );
4834 }
4835
4836 #[test]
4837 fn field_name_link_strips_value_suffix() {
4838 let key = "incunabula:isPartOfBookValue";
4842 let link_obj = serde_json::json!({
4843 "@type": "knora-api:LinkValue",
4844 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4845 });
4846 let (_, is_link) = super::parse_value_content(&link_obj);
4847 assert!(
4848 is_link,
4849 "LinkValue must report is_link=true for name derivation"
4850 );
4851
4852 let raw_name = super::local_name(key).to_string();
4853 let name = if is_link {
4855 raw_name
4856 .strip_suffix("Value")
4857 .unwrap_or(&raw_name)
4858 .to_string()
4859 } else {
4860 raw_name
4861 };
4862 assert_eq!(name, "isPartOfBook");
4863 }
4864
4865 #[test]
4866 fn field_name_non_link_does_not_strip_value_suffix() {
4867 let key = "incunabula:hasAValue";
4872 let text_obj = serde_json::json!({
4873 "@type": "knora-api:TextValue",
4874 "knora-api:valueAsString": "some text"
4875 });
4876 let (_, is_link) = super::parse_value_content(&text_obj);
4877 assert!(!is_link, "TextValue must report is_link=false");
4878
4879 let raw_name = super::local_name(key).to_string();
4880 let name = if is_link {
4882 raw_name
4883 .strip_suffix("Value")
4884 .unwrap_or(&raw_name)
4885 .to_string()
4886 } else {
4887 raw_name
4888 };
4889 assert_eq!(
4890 name, "hasAValue",
4891 "non-link ending in Value must NOT be stripped; is_link={is_link}"
4892 );
4893 }
4894
4895 #[test]
4898 fn has_value_class_type_rejects_xsd_any_uri() {
4899 let obj = serde_json::json!({
4901 "@value": "http://ark.dasch.swiss/ark:/…",
4902 "@type": "xsd:anyURI"
4903 });
4904 assert!(
4905 !super::has_value_class_type(&obj),
4906 "xsd:anyURI must not pass the value-class test"
4907 );
4908 }
4909
4910 #[test]
4911 fn has_value_class_type_rejects_scalar() {
4912 let obj = serde_json::json!("just a string");
4914 assert!(!super::has_value_class_type(&obj));
4915 }
4916
4917 #[test]
4918 fn has_value_class_type_accepts_text_value() {
4919 let obj = serde_json::json!({
4920 "@type": "knora-api:TextValue",
4921 "knora-api:valueAsString": "hello"
4922 });
4923 assert!(super::has_value_class_type(&obj));
4924 }
4925
4926 #[test]
4927 fn has_value_class_type_accepts_still_image_file_value() {
4928 let obj = serde_json::json!({
4929 "@type": "knora-api:StillImageFileValue",
4930 "knora-api:fileValueHasFilename": "img.jp2"
4931 });
4932 assert!(super::has_value_class_type(&obj));
4933 }
4934
4935 #[test]
4938 fn build_prefix_map_string_entries_only() {
4939 let ctx = Some(serde_json::json!({
4940 "incunabula": "http://api.dasch.swiss/ontology/0803/incunabula/v2#",
4941 "knora-api": "http://api.knora.org/ontology/knora-api/v2#",
4942 "someterm": {"@id": "http://example.com/term", "@type": "@id"}
4944 }));
4945 let map = super::build_prefix_map(&ctx);
4946 assert_eq!(
4947 map.get("incunabula").map(String::as_str),
4948 Some("http://api.dasch.swiss/ontology/0803/incunabula/v2#")
4949 );
4950 assert_eq!(
4951 map.get("knora-api").map(String::as_str),
4952 Some("http://api.knora.org/ontology/knora-api/v2#")
4953 );
4954 assert!(
4955 !map.contains_key("someterm"),
4956 "object-valued entry must be skipped"
4957 );
4958 }
4959
4960 #[test]
4961 fn build_prefix_map_empty_when_no_context() {
4962 let map = super::build_prefix_map(&None);
4963 assert!(map.is_empty());
4964 }
4965
4966 #[test]
4969 fn compact_value_text_excludes_meta_keys() {
4970 let obj = serde_json::json!({
4971 "@id": "http://rdfh.ch/0803/val1",
4972 "@type": "knora-api:GeomValue",
4973 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
4974 });
4975 let text = super::compact_value_text(&obj);
4976 assert!(
4978 text.contains("geometryValueAsGeometry"),
4979 "geometry key present: {text}"
4980 );
4981 assert!(!text.contains("@id"), "@id must be excluded: {text}");
4982 assert!(!text.contains("@type"), "@type must be excluded: {text}");
4983 }
4984
4985 #[test]
4986 fn compact_value_text_all_meta_yields_empty() {
4987 let obj = serde_json::json!({
4988 "@id": "http://rdfh.ch/0803/val1",
4989 "@type": "knora-api:IntervalValue"
4990 });
4991 let text = super::compact_value_text(&obj);
4992 assert!(
4993 text.is_empty(),
4994 "all-meta object must yield empty string: {text:?}"
4995 );
4996 }
4997}