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, LocalizedText, Project, ProjectDescription,
44 ProjectDetail, ProjectRef, ProjectStatus, Relation, RelationKind, Representation,
45 ResourceAccess, ResourceDetail, ResourcePage, ResourceSummary, ResourceTypeDetail,
46 ResourceTypeSummary, ResourceVisibility, ValueType, Vocabulary, VocabularyHeader,
47 VocabularyNode, VocabularyTree,
48};
49
50#[derive(serde::Deserialize)]
59struct LoginApiResponse {
60 token: String,
61}
62
63#[derive(serde::Deserialize)]
69struct ProjectGetApiResponse {
70 project: ProjectApiDto,
71}
72
73#[derive(serde::Deserialize)]
74struct ProjectApiDto {
75 id: String,
76 shortcode: String,
77 shortname: String,
78}
79
80#[derive(serde::Deserialize)]
87struct DataTaskStatusApiResponse {
88 id: String,
89 status: String,
90 #[serde(default, rename = "errorMessage")]
91 error_message: Option<String>,
92 #[serde(default, rename = "createdAt")]
97 created_at: Option<String>,
98}
99
100#[derive(serde::Deserialize)]
108struct V3ErrorBody {
109 #[serde(default)]
110 errors: Vec<V3ErrorItem>,
111}
112
113#[derive(serde::Deserialize)]
114struct V3ErrorItem {
115 code: String,
116 #[serde(default)]
117 details: std::collections::HashMap<String, String>,
118}
119
120#[derive(serde::Deserialize)]
129struct OntologyAndResourceClassesDto {
130 #[serde(rename = "classesAndCount", default)]
131 classes_and_count: Vec<ClassAndCountDto>,
132}
133
134#[derive(serde::Deserialize)]
141struct ClassAndCountDto {
142 #[serde(rename = "resourceClass")]
143 resource_class: ResourceClassRefDto,
144 #[serde(rename = "itemCount")]
145 item_count: u64,
146}
147
148#[derive(serde::Deserialize)]
151struct ResourceClassRefDto {
152 iri: String,
153}
154
155#[derive(serde::Deserialize)]
160struct ProjectsListApiResponse {
161 projects: Vec<ProjectListItemDto>,
162}
163
164#[derive(serde::Deserialize)]
169struct ProjectListItemDto {
170 id: String,
171 shortname: String,
172 shortcode: String,
173 #[serde(default)]
174 longname: Option<String>,
175 status: bool,
181 #[serde(default)]
182 ontologies: Vec<String>,
183}
184
185#[derive(serde::Deserialize)]
194struct ProjectDetailApiResponse {
195 project: ProjectDetailApiDto,
196}
197
198#[derive(serde::Deserialize)]
199struct ProjectDetailApiDto {
200 id: String,
201 shortcode: String,
202 shortname: String,
203 #[serde(default)]
204 longname: Option<String>,
205 status: bool,
208 #[serde(default)]
209 description: Vec<ProjectDescriptionDto>,
210 #[serde(default)]
211 keywords: Vec<String>,
212 #[serde(default)]
213 ontologies: Vec<String>,
214}
215
216#[derive(serde::Deserialize)]
217struct ProjectDescriptionDto {
218 value: String,
219 #[serde(default)]
220 language: Option<String>,
221}
222
223#[derive(serde::Deserialize)]
235struct OntologyMetadataResponse {
236 #[serde(rename = "@graph")]
237 graph: Option<Vec<OntologyMetadataDto>>,
238 #[serde(rename = "@id")]
240 id: Option<String>,
241 #[serde(rename = "rdfs:label")]
242 label: Option<String>,
243 #[serde(rename = "knora-api:lastModificationDate", default)]
244 last_modification_date: Option<LastModDto>,
245}
246
247#[derive(serde::Deserialize)]
248struct OntologyMetadataDto {
249 #[serde(rename = "@id")]
250 id: String,
251 #[serde(rename = "rdfs:label")]
252 label: Option<String>,
253 #[serde(rename = "knora-api:lastModificationDate", default)]
254 last_modification_date: Option<LastModDto>,
255}
256
257#[derive(serde::Deserialize)]
265struct LastModDto {
266 #[serde(rename = "@value")]
267 value: String,
268}
269
270#[derive(serde::Deserialize)]
274struct OntologyAllEntitiesResponse {
275 #[serde(rename = "@id")]
276 id: String,
277 #[serde(rename = "rdfs:label")]
278 label: Option<String>,
279 #[serde(rename = "knora-api:lastModificationDate", default)]
280 last_modification_date: Option<LastModDto>,
281 #[serde(rename = "@graph", default)]
282 graph: Vec<OntologyEntityDto>,
283 #[serde(rename = "@context", default)]
291 context: HashMap<String, serde_json::Value>,
292}
293
294#[derive(serde::Deserialize)]
314struct OntologyEntityDto {
315 #[serde(rename = "@id")]
316 id: String,
317 #[serde(rename = "rdfs:label")]
318 label: Option<String>,
319 #[serde(rename = "knora-api:isResourceClass", default)]
320 is_resource_class: bool,
321 #[serde(rename = "rdfs:subClassOf", default)]
326 sub_class_of: Vec<serde_json::Value>,
327 #[serde(rename = "knora-api:objectType")]
330 object_type: Option<ObjectTypeDto>,
331 #[serde(rename = "knora-api:isLinkProperty", default)]
333 is_link_property: bool,
334 #[serde(rename = "knora-api:isLinkValueProperty", default)]
337 is_link_value_property: bool,
338 #[serde(rename = "knora-api:isResourceProperty", default)]
340 is_resource_property: bool,
341}
342
343#[derive(serde::Deserialize, Clone)]
345struct ObjectTypeDto {
346 #[serde(rename = "@id")]
347 id: String,
348}
349
350struct ExportExists<'a> {
355 id: Option<&'a str>,
357 project_iri: Option<&'a str>,
359}
360
361impl V3ErrorBody {
362 fn export_exists(&self) -> Option<ExportExists<'_>> {
368 self.errors
369 .iter()
370 .find(|e| e.code == "export_exists")
371 .map(|e| ExportExists {
372 id: e.details.get("id").map(String::as_str),
373 project_iri: e.details.get("projectIri").map(String::as_str),
374 })
375 }
376}
377
378impl DataTaskStatusApiResponse {
379 fn into_dump_task(self) -> Result<DumpTask, Diagnostic> {
392 validate_dump_id(&self.id)?;
396
397 let status = match self.status.as_str() {
398 "in_progress" => DumpStatus::InProgress,
399 "completed" => DumpStatus::Completed,
400 "failed" => DumpStatus::Failed,
401 other => {
402 return Err(Diagnostic::ServerError(format!(
403 "server returned unknown dump status: '{other}'"
404 )));
405 }
406 };
407
408 let error_message = self.error_message.map(|raw| {
412 let truncated = if raw.chars().count() > 500 {
413 raw.chars().take(500).collect::<String>()
414 } else {
415 raw
416 };
417 tracing::trace!("dump task error_message (truncated): {}", truncated);
418 truncated
419 });
420
421 let created_at = self.created_at.and_then(|s| {
424 match chrono::DateTime::parse_from_rfc3339(&s) {
425 Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
426 Err(_) => {
427 tracing::debug!(raw = %s, "dump task createdAt could not be parsed as RFC3339; using None");
428 None
429 }
430 }
431 });
432
433 Ok(DumpTask {
434 id: self.id,
435 status,
436 error_message,
437 created_at,
438 })
439 }
440}
441
442#[derive(serde::Deserialize)]
448struct ListsListApiResponse {
449 lists: Vec<ListSummaryDto>,
450}
451
452#[derive(serde::Deserialize)]
457struct ListSummaryDto {
458 id: String,
459 #[serde(default)]
460 name: Option<String>,
461 #[serde(default)]
462 labels: Vec<ListLabelDto>,
463 #[serde(default)]
464 comments: Vec<ListLabelDto>,
465}
466
467#[derive(serde::Deserialize, Clone)]
471struct ListLabelDto {
472 value: String,
473 #[serde(default)]
474 language: Option<String>,
475}
476
477#[derive(serde::Deserialize)]
487#[serde(untagged)]
488enum ListGetResponseDto {
489 Root(ListRootResponseDto),
490 Node(ListNodeGetResponseDto),
491}
492
493#[derive(serde::Deserialize)]
494struct ListRootResponseDto {
495 list: ListRootDto,
496}
497
498#[derive(serde::Deserialize)]
499struct ListRootDto {
500 listinfo: ListInfoDto,
501 #[serde(default)]
502 children: Vec<ListNodeDto>,
503}
504
505#[derive(serde::Deserialize)]
509struct ListInfoDto {
510 id: String,
511 #[serde(rename = "projectIri")]
512 project_iri: String,
513 #[serde(default)]
514 name: Option<String>,
515 #[serde(default)]
516 labels: Vec<ListLabelDto>,
517 #[serde(default)]
518 comments: Vec<ListLabelDto>,
519}
520
521#[derive(serde::Deserialize)]
522struct ListNodeGetResponseDto {
523 node: ListNodeGetDto,
524}
525
526#[derive(serde::Deserialize)]
529struct ListNodeGetDto {
530 nodeinfo: ListNodeInfoDto,
531}
532
533#[derive(serde::Deserialize)]
534struct ListNodeInfoDto {
535 #[serde(rename = "hasRootNode")]
536 has_root_node: String,
537}
538
539#[derive(serde::Deserialize)]
545struct ListNodeDto {
546 id: String,
547 #[serde(default)]
548 name: Option<String>,
549 #[serde(default)]
550 labels: Vec<ListLabelDto>,
551 #[serde(default)]
552 comments: Vec<ListLabelDto>,
553 position: i32,
554 #[serde(default)]
555 children: Vec<ListNodeDto>,
556}
557
558fn into_localized_texts(dtos: Vec<ListLabelDto>) -> Vec<LocalizedText> {
562 dtos.into_iter()
563 .map(|d| LocalizedText {
564 value: d.value,
565 language: d.language,
566 })
567 .collect()
568}
569
570fn build_vocabulary_tree(list: ListRootDto, requested_node: Option<String>) -> VocabularyTree {
576 VocabularyTree {
577 root: VocabularyHeader {
578 iri: list.listinfo.id,
579 name: list.listinfo.name,
580 labels: into_localized_texts(list.listinfo.labels),
581 comments: into_localized_texts(list.listinfo.comments),
582 },
583 children: convert_list_nodes(list.children),
584 project_iri: list.listinfo.project_iri,
585 requested_node,
586 }
587}
588
589struct ListNodeConversionFrame {
592 header: VocabularyHeader,
593 position: i32,
594 remaining_children: std::collections::VecDeque<ListNodeDto>,
596 converted_children: Vec<VocabularyNode>,
598}
599
600fn convert_list_nodes(dtos: Vec<ListNodeDto>) -> Vec<VocabularyNode> {
611 fn dto_to_frame(dto: ListNodeDto) -> ListNodeConversionFrame {
612 let mut children = dto.children;
613 children.sort_by_key(|c| c.position);
614 ListNodeConversionFrame {
615 header: VocabularyHeader {
616 iri: dto.id,
617 name: dto.name,
618 labels: into_localized_texts(dto.labels),
619 comments: into_localized_texts(dto.comments),
620 },
621 position: dto.position,
622 remaining_children: children.into(),
623 converted_children: Vec::new(),
624 }
625 }
626
627 let mut top_level = dtos;
628 top_level.sort_by_key(|d| d.position);
629 let mut top_level: std::collections::VecDeque<ListNodeDto> = top_level.into();
630
631 let mut result: Vec<VocabularyNode> = Vec::new();
632 let mut stack: Vec<ListNodeConversionFrame> = Vec::new();
633
634 loop {
635 let next_dto = match stack.last_mut() {
638 Some(frame) => frame.remaining_children.pop_front(),
639 None => top_level.pop_front(),
640 };
641
642 match next_dto {
643 Some(dto) => stack.push(dto_to_frame(dto)),
644 None => {
645 match stack.pop() {
649 Some(frame) => {
650 let node = VocabularyNode {
651 header: frame.header,
652 position: frame.position,
653 children: frame.converted_children,
654 };
655 match stack.last_mut() {
656 Some(parent) => parent.converted_children.push(node),
657 None => result.push(node),
658 }
659 }
660 None => break,
662 }
663 }
664 }
665 }
666
667 result
668}
669
670fn identifier_key(user: &str) -> &'static str {
678 if user.starts_with("http://") || user.starts_with("https://") {
679 "iri"
680 } else if user.contains('@') {
681 "email"
682 } else {
683 "username"
684 }
685}
686
687enum ProjectIdent<'a> {
698 Iri(&'a str),
699 Shortcode(&'a str),
700 Shortname(&'a str),
701}
702
703fn classify(project: &str) -> ProjectIdent<'_> {
704 if project.starts_with("http://") || project.starts_with("https://") {
705 ProjectIdent::Iri(project)
706 } else if project.len() == 4 && project.chars().all(|c| c.is_ascii_hexdigit()) {
707 ProjectIdent::Shortcode(project)
708 } else {
709 ProjectIdent::Shortname(project)
710 }
711}
712
713fn enc(iri: &str) -> String {
719 utf8_percent_encode(iri, NON_ALPHANUMERIC).to_string()
720}
721
722fn map_unexpected_status(status: reqwest::StatusCode, url: &str) -> Diagnostic {
734 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
735 Diagnostic::AuthRequired(
739 "your token may be missing, expired, or lack permission — run \
740 `dsp auth login` to (re)authenticate"
741 .into(),
742 )
743 } else if status.is_server_error() {
744 Diagnostic::ServerError(format!("server returned {status} for {url}"))
745 } else {
746 Diagnostic::ServerError(format!("unexpected status {status} for {url}"))
747 }
748}
749
750fn validate_dump_id(id: &str) -> Result<(), Diagnostic> {
758 if id.is_empty()
759 || id.len() > 256 || !id
761 .chars()
762 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
763 {
764 let preview: String = id.chars().take(40).collect();
766 let suffix = if id.chars().count() > 40 { "…" } else { "" };
767 return Err(Diagnostic::ServerError(format!(
768 "server returned an invalid dump id: '{preview}{suffix}'"
769 )));
770 }
771 Ok(())
772}
773
774fn project_lookup_url(base: &str, project: &str) -> String {
780 match classify(project) {
781 ProjectIdent::Shortcode(code) => {
782 format!("{base}/admin/projects/shortcode/{code}")
783 }
784 ProjectIdent::Shortname(name) => {
785 format!("{base}/admin/projects/shortname/{name}")
786 }
787 ProjectIdent::Iri(iri) => {
788 format!("{base}/admin/projects/iri/{}", enc(iri))
789 }
790 }
791}
792
793fn is_safe_shortcode(s: &str) -> bool {
803 !s.is_empty() && s.len() <= 32 && s.chars().all(|c| c.is_ascii_alphanumeric())
804}
805
806fn local_name(id: &str) -> &str {
811 id.rsplit(['#', '/', ':']).next().unwrap_or(id)
812}
813
814fn expand_class_id(id: &str, prefixes: &HashMap<String, String>) -> (String, String) {
825 let name = local_name(id).to_string();
826 let iri = match id.split_once(':') {
827 Some((prefix, local)) if !local.starts_with("//") => prefixes
828 .get(prefix)
829 .map(|ns| format!("{ns}{local}"))
830 .unwrap_or_else(|| id.to_string()),
831 _ => id.to_string(), };
833 (name, iri)
834}
835
836pub(crate) fn data_model_name_from_iri(iri: &str) -> String {
848 let t = iri.trim_end_matches('/');
849 let t = t.strip_suffix("/v2").unwrap_or(t);
850 t.rsplit('/').next().unwrap_or(t).to_string()
851}
852
853const SYSTEM_PREFIXES: &[&str] = &[
862 "knora-api",
863 "knora-base",
864 "rdf",
865 "rdfs",
866 "owl",
867 "salsah-gui",
868 "standoff",
869 "xsd",
870];
871
872const FILE_VALUE_PROPS: &[(&str, Representation)] = &[
877 ("hasStillImageFileValue", Representation::StillImage),
878 ("hasMovingImageFileValue", Representation::MovingImage),
879 ("hasAudioFileValue", Representation::Audio),
880 ("hasDocumentFileValue", Representation::Document),
881 ("hasArchiveFileValue", Representation::Archive),
882 ("hasTextFileValue", Representation::Text),
883];
884
885const MAX_SIBLING_FETCHES: usize = 16;
888
889fn is_system_prefix(prefix: &str) -> bool {
895 SYSTEM_PREFIXES.contains(&prefix)
896}
897
898fn map_object_type_to_value_type(local: &str) -> ValueType {
909 match local {
910 "TextValue" => ValueType::Text,
911 "IntValue" => ValueType::Integer,
912 "DecimalValue" => ValueType::Decimal,
913 "BooleanValue" => ValueType::Boolean,
914 "DateValue" => ValueType::Date,
915 "TimeValue" => ValueType::Time,
916 "UriValue" => ValueType::Uri,
917 "ColorValue" => ValueType::Color,
918 "GeonameValue" => ValueType::Geoname,
919 "ListValue" => ValueType::VocabularyItem,
920 "StillImageFileValue" => ValueType::StillImage,
921 "MovingImageFileValue" => ValueType::MovingImage,
922 "AudioFileValue" => ValueType::Audio,
923 "DocumentFileValue" => ValueType::Document,
924 "ArchiveFileValue" => ValueType::Archive,
925 other => ValueType::Other(object_type_to_kebab(other)),
926 }
927}
928
929fn object_type_to_kebab(local: &str) -> String {
936 let base = local.strip_suffix("Value").unwrap_or(local);
938
939 let mut result = String::with_capacity(base.len() + 4);
942 let chars: Vec<char> = base.chars().collect();
943 for (i, &ch) in chars.iter().enumerate() {
944 if i > 0 && ch.is_uppercase() {
945 if chars[i - 1].is_lowercase() {
947 result.push('-');
948 }
949 }
950 result.push(ch);
951 }
952 result.to_lowercase()
953}
954
955fn decode_cardinality(restriction: &serde_json::Value) -> Cardinality {
961 let as_u64 =
963 |key: &str| -> Option<u64> { restriction.get(key).and_then(serde_json::Value::as_u64) };
964
965 if let Some(v) = as_u64("owl:cardinality") {
966 if v == 1 {
967 return Cardinality::One;
968 }
969 tracing::warn!(
970 value = v,
971 "owl:cardinality had unexpected value (expected 1); falling back to ZeroOrMore"
972 );
973 return Cardinality::ZeroOrMore;
974 }
975
976 if let Some(v) = as_u64("owl:maxCardinality") {
977 if v == 1 {
978 return Cardinality::ZeroOrOne;
979 }
980 tracing::warn!(
981 value = v,
982 "owl:maxCardinality had unexpected value (expected 1); falling back to ZeroOrMore"
983 );
984 return Cardinality::ZeroOrMore;
985 }
986
987 if let Some(v) = as_u64("owl:minCardinality") {
988 return match v {
989 0 => Cardinality::ZeroOrMore,
990 1 => Cardinality::OneOrMore,
991 other => {
992 tracing::warn!(
993 value = other,
994 "owl:minCardinality had unexpected value (expected 0 or 1); falling back to ZeroOrMore"
995 );
996 Cardinality::ZeroOrMore
997 }
998 };
999 }
1000
1001 tracing::warn!("owl:Restriction has no recognized cardinality key; falling back to ZeroOrMore");
1002 Cardinality::ZeroOrMore
1003}
1004
1005fn detect_representation(restriction_prop_locals: &[&str]) -> Option<Representation> {
1011 for local in restriction_prop_locals {
1012 for (file_val_local, repr) in FILE_VALUE_PROPS {
1013 if local == file_val_local {
1014 return Some(*repr);
1015 }
1016 }
1017 }
1018 None
1019}
1020
1021fn curie_prefix(id: &str) -> Option<&str> {
1024 id.split_once(':')
1025 .filter(|(_, local)| !local.starts_with("//"))
1026 .map(|(prefix, _)| prefix)
1027}
1028
1029#[derive(serde::Deserialize)]
1045struct ResourceListDto {
1046 #[serde(rename = "@graph", default)]
1048 graph: Option<Vec<ResourceNodeDto>>,
1049
1050 #[serde(rename = "@id", default)]
1052 id: Option<String>,
1053
1054 #[serde(rename = "@type", default)]
1057 type_field: Option<serde_json::Value>,
1058
1059 #[serde(rename = "rdfs:label", default)]
1061 label: Option<serde_json::Value>,
1062
1063 #[serde(rename = "knora-api:arkUrl", default)]
1065 ark_url: Option<serde_json::Value>,
1066
1067 #[serde(rename = "knora-api:creationDate", default)]
1069 creation_date: Option<serde_json::Value>,
1070
1071 #[serde(rename = "knora-api:lastModificationDate", default)]
1073 last_modification_date: Option<serde_json::Value>,
1074
1075 #[serde(rename = "knora-api:mayHaveMoreResults", default)]
1077 may_have_more_results: bool,
1078}
1079
1080#[derive(serde::Deserialize)]
1087struct ResourceNodeDto {
1088 #[serde(rename = "@id")]
1089 id: String,
1090
1091 #[serde(rename = "@type", default)]
1093 type_field: Option<serde_json::Value>,
1094
1095 #[serde(rename = "rdfs:label", default)]
1097 label: Option<serde_json::Value>,
1098
1099 #[serde(rename = "knora-api:arkUrl", default)]
1101 ark_url: Option<serde_json::Value>,
1102
1103 #[serde(rename = "knora-api:creationDate", default)]
1105 creation_date: Option<serde_json::Value>,
1106
1107 #[serde(rename = "knora-api:lastModificationDate", default)]
1109 last_modification_date: Option<serde_json::Value>,
1110}
1111
1112fn extract_string_value(v: &serde_json::Value) -> Option<String> {
1117 match v {
1118 serde_json::Value::String(s) => Some(s.clone()),
1119 serde_json::Value::Object(map) => map
1120 .get("@value")
1121 .or_else(|| map.get("@id"))
1122 .and_then(|inner| inner.as_str())
1123 .map(str::to_owned),
1124 _ => None,
1125 }
1126}
1127
1128fn extract_resource_type(type_val: Option<&serde_json::Value>) -> String {
1135 match type_val {
1136 None => "unknown".to_string(),
1137 Some(serde_json::Value::String(s)) => local_name(s).to_string(),
1138 Some(serde_json::Value::Array(arr)) => arr
1139 .first()
1140 .and_then(|v| v.as_str())
1141 .map(|s| local_name(s).to_string())
1142 .unwrap_or_else(|| "unknown".to_string()),
1143 _ => "unknown".to_string(),
1144 }
1145}
1146
1147fn node_dto_to_summary(
1149 id: String,
1150 type_val: Option<&serde_json::Value>,
1151 label_val: Option<&serde_json::Value>,
1152 ark_val: Option<&serde_json::Value>,
1153 creation_val: Option<&serde_json::Value>,
1154 last_modification_val: Option<&serde_json::Value>,
1155) -> ResourceSummary {
1156 let label = label_val.and_then(extract_string_value).unwrap_or_default();
1157 let resource_type = extract_resource_type(type_val);
1158 let ark_url = ark_val.and_then(extract_string_value);
1159 let creation_date = creation_val.and_then(extract_string_value);
1167 let last_modified = last_modification_val.and_then(extract_string_value);
1168 ResourceSummary {
1169 label,
1170 iri: id,
1171 ark_url,
1172 creation_date,
1173 last_modified,
1174 resource_type,
1175 }
1176}
1177
1178#[derive(serde::Deserialize)]
1198struct ResourceDetailDto {
1199 #[serde(rename = "@id")]
1200 id: String,
1201
1202 #[serde(rename = "@type", default)]
1204 type_field: Option<serde_json::Value>,
1205
1206 #[serde(rename = "rdfs:label", default)]
1208 label: Option<serde_json::Value>,
1209
1210 #[serde(rename = "knora-api:arkUrl", default)]
1212 ark_url: Option<serde_json::Value>,
1213
1214 #[serde(rename = "knora-api:creationDate", default)]
1216 creation_date: Option<serde_json::Value>,
1217
1218 #[serde(rename = "knora-api:lastModificationDate", default)]
1220 last_modification_date: Option<serde_json::Value>,
1221
1222 #[serde(rename = "knora-api:attachedToProject", default)]
1224 attached_to_project: Option<serde_json::Value>,
1225
1226 #[serde(rename = "knora-api:attachedToUser", default)]
1228 attached_to_user: Option<serde_json::Value>,
1229
1230 #[serde(rename = "knora-api:hasPermissions", default)]
1233 has_permissions: Option<String>,
1234
1235 #[serde(rename = "knora-api:userHasPermission", default)]
1238 user_has_permission: Option<String>,
1239
1240 #[serde(rename = "@context", default)]
1247 context: Option<serde_json::Value>,
1248
1249 #[serde(flatten)]
1257 extra: serde_json::Map<String, serde_json::Value>,
1258}
1259
1260fn permission_rank(code: &str) -> u8 {
1266 match code {
1267 "RV" => 1,
1268 "V" => 2,
1269 "M" => 6,
1270 "D" => 7,
1271 "CR" => 8,
1272 _ => 0,
1273 }
1274}
1275
1276fn derive_access(user_has_permission: &str) -> Option<ResourceAccess> {
1286 match user_has_permission {
1287 "RV" => Some(ResourceAccess::RestrictedView),
1288 "V" => Some(ResourceAccess::View),
1289 "M" => Some(ResourceAccess::Edit),
1290 "D" => Some(ResourceAccess::Delete),
1291 "CR" => Some(ResourceAccess::Manage),
1292 _ => None,
1293 }
1294}
1295
1296fn derive_visibility(has_permissions: &str) -> Option<ResourceVisibility> {
1306 if has_permissions.trim().is_empty() {
1307 return None;
1308 }
1309
1310 let mut unknown_rank: u8 = 0;
1311 let mut known_rank: u8 = 0;
1312 let mut parsed_any = false;
1313
1314 for entry in has_permissions.split('|') {
1315 let entry = entry.trim();
1316 if entry.is_empty() {
1317 continue;
1318 }
1319 let Some((code, group_list)) = entry.split_once(' ') else {
1321 continue;
1323 };
1324 parsed_any = true;
1325 let rank = permission_rank(code);
1326 for group in group_list.split(',') {
1327 let group_local = local_name(group.trim());
1328 if group_local == "UnknownUser" {
1329 unknown_rank = unknown_rank.max(rank);
1330 } else if group_local == "KnownUser" {
1331 known_rank = known_rank.max(rank);
1332 }
1333 }
1334 }
1335
1336 if !parsed_any {
1337 return None;
1338 }
1339
1340 let v_rank = permission_rank("V");
1344 let rv_rank = permission_rank("RV");
1345
1346 if unknown_rank >= v_rank {
1347 Some(ResourceVisibility::Public)
1348 } else if unknown_rank >= rv_rank {
1349 Some(ResourceVisibility::PublicRestricted)
1351 } else if known_rank >= rv_rank {
1352 Some(ResourceVisibility::LoggedInUsers)
1353 } else {
1354 Some(ResourceVisibility::ProjectMembers)
1355 }
1356}
1357
1358pub struct HttpDspClient {
1364 client: reqwest::blocking::Client,
1367 download_client: reqwest::blocking::Client,
1372}
1373
1374impl HttpDspClient {
1375 pub fn new() -> Result<Self, Diagnostic> {
1384 let client = reqwest::blocking::Client::builder()
1385 .connect_timeout(Duration::from_secs(10))
1386 .timeout(Duration::from_secs(30))
1387 .user_agent(crate::util::USER_AGENT)
1388 .build()
1389 .map_err(|e| Diagnostic::Internal(format!("failed to build HTTP client: {e}")))?;
1390 let download_client = reqwest::blocking::Client::builder()
1391 .connect_timeout(Some(Duration::from_secs(30)))
1392 .timeout(None)
1393 .user_agent(crate::util::USER_AGENT)
1394 .build()
1395 .map_err(|e| {
1396 Diagnostic::Internal(format!("failed to build download HTTP client: {e}"))
1397 })?;
1398 Ok(Self {
1401 client,
1402 download_client,
1403 })
1404 }
1405
1406 fn fetch_allentities(
1416 &self,
1417 server: &str,
1418 ontology_iri: &str,
1419 token: Option<&str>,
1420 ) -> Result<OntologyAllEntitiesResponse, Diagnostic> {
1421 let url = format!(
1422 "{}/v2/ontologies/allentities/{}",
1423 server.trim_end_matches('/'),
1424 enc(ontology_iri)
1425 );
1426
1427 let req = self.client.get(&url);
1428 let req = if let Some(t) = token {
1429 req.bearer_auth(t)
1430 } else {
1431 req
1432 };
1433
1434 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
1435 let status = response.status();
1436
1437 if status.is_success() {
1438 let resp: OntologyAllEntitiesResponse = response.json().map_err(|e| {
1439 Diagnostic::ServerError(format!("data-model response could not be parsed: {e}"))
1440 })?;
1441 Ok(resp)
1442 } else {
1443 Err(map_unexpected_status(status, &url))
1444 }
1445 }
1446
1447 fn fetch_list_get(
1454 &self,
1455 server: &str,
1456 iri: &str,
1457 token: Option<&str>,
1458 ) -> Result<ListGetResponseDto, Diagnostic> {
1459 let url = format!("{}/admin/lists/{}", server.trim_end_matches('/'), enc(iri));
1460
1461 let req = self.client.get(&url);
1462 let req = if let Some(t) = token {
1463 req.bearer_auth(t)
1464 } else {
1465 req
1466 };
1467
1468 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
1469 let status = response.status();
1470
1471 if status.is_success() {
1472 response.json::<ListGetResponseDto>().map_err(|e| {
1473 Diagnostic::ServerError(format!("vocabulary response could not be parsed: {e}"))
1474 })
1475 } else {
1476 Err(map_unexpected_status(status, &url))
1477 }
1478 }
1479}
1480
1481impl HttpDspClient {
1482 fn parse_resource_values(
1494 &self,
1495 server: &str,
1496 token: Option<&str>,
1497 context_val: &Option<serde_json::Value>,
1498 extra: &serde_json::Map<String, serde_json::Value>,
1499 ) -> Vec<FieldValues> {
1500 let prefixes: HashMap<String, String> = build_prefix_map(context_val);
1502
1503 const DENYLIST: &[&str] = &[
1506 "knora-api:hasIncomingLinkValue",
1507 "knora-api:hasStandoffLinkToValue",
1508 "knora-api:hasStandoffLinkValue", ];
1510
1511 let mut field_entries: Vec<(&str, Vec<&serde_json::Value>)> = Vec::new();
1514
1515 for (key, val) in extra.iter() {
1516 if DENYLIST.contains(&key.as_str()) {
1517 continue;
1518 }
1519
1520 let objs: Vec<&serde_json::Value> = match val {
1522 serde_json::Value::Array(arr) => arr.iter().collect(),
1523 obj @ serde_json::Value::Object(_) => vec![obj],
1524 _ => continue, };
1526
1527 if objs.is_empty() {
1528 continue;
1529 }
1530
1531 let first = match objs.first() {
1534 Some(v) => v,
1535 None => continue,
1536 };
1537 if !has_value_class_type(first) {
1538 continue;
1539 }
1540
1541 field_entries.push((key.as_str(), objs));
1542 }
1543
1544 struct ParsedField<'a> {
1547 key: &'a str,
1548 is_link: bool,
1549 values: Vec<Value>,
1550 }
1551
1552 let mut parsed_fields: Vec<ParsedField> = Vec::new();
1553
1554 for (key, objs) in &field_entries {
1555 let mut contents: Vec<Value> = Vec::new();
1556 let mut any_link = false;
1557
1558 for obj in objs {
1559 if get_type_local(obj) == "DeletedValue" {
1561 continue;
1562 }
1563 let (content, is_link) = parse_value(obj);
1564 if is_link {
1565 any_link = true;
1566 }
1567 contents.push(content);
1568 }
1569
1570 if contents.is_empty() {
1571 continue;
1572 }
1573
1574 parsed_fields.push(ParsedField {
1575 key,
1576 is_link: any_link,
1577 values: contents,
1578 });
1579 }
1580
1581 let mut ontology_labels: HashMap<String, HashMap<String, String>> = HashMap::new(); let mut fetched_ontologies: HashSet<String> = HashSet::new();
1586
1587 for pf in &parsed_fields {
1588 let prefix = curie_prefix(pf.key).unwrap_or("");
1589 if is_system_prefix(prefix) || prefix.is_empty() {
1590 continue; }
1592 let namespace = match prefixes.get(prefix) {
1594 Some(ns) => ns,
1595 None => continue,
1596 };
1597 let ont_iri = namespace.trim_end_matches(['#', '/']).to_string();
1598 if fetched_ontologies.insert(ont_iri.clone()) {
1599 match self.fetch_allentities(server, &ont_iri, token) {
1603 Ok(resp) => {
1604 let mut prop_map: HashMap<String, String> = HashMap::new();
1605 let ctx_prefixes: HashMap<String, String> = resp
1606 .context
1607 .iter()
1608 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1609 .collect();
1610 for entity in resp.graph {
1611 if let Some(lbl) = entity.label {
1612 let (_, iri) = expand_class_id(&entity.id, &ctx_prefixes);
1613 prop_map.insert(iri, lbl);
1614 }
1615 }
1616 ontology_labels.insert(ont_iri, prop_map);
1617 }
1618 Err(e) => {
1619 tracing::warn!(
1621 prefix = %prefix,
1622 error = %e,
1623 "field-label ontology fetch failed; using local name as fallback"
1624 );
1625 }
1626 }
1627 }
1628 }
1629
1630 let mut node_labels: HashMap<String, Option<String>> = HashMap::new();
1632
1633 for pf in &parsed_fields {
1635 for v in &pf.values {
1636 if let ValueContent::VocabularyItem { node_iri, .. } = &v.content {
1637 node_labels.entry(node_iri.clone()).or_insert(None);
1638 }
1639 }
1640 }
1641
1642 for (node_iri, label_slot) in node_labels.iter_mut() {
1644 let url = format!("{}/v2/node/{}", server.trim_end_matches('/'), enc(node_iri));
1648 let req = self.client.get(&url);
1649 let req = if let Some(t) = token {
1650 req.bearer_auth(t)
1651 } else {
1652 req
1653 };
1654 match req.send() {
1655 Ok(resp) if resp.status().is_success() => {
1656 match resp.json::<serde_json::Value>() {
1658 Ok(body) => {
1659 let lbl = body.get("rdfs:label").and_then(extract_string_value);
1661 *label_slot = lbl;
1662 }
1663 Err(_) => {
1664 tracing::debug!(
1665 node_iri = %node_iri,
1666 "list-node label response could not be parsed as JSON; using node IRI as fallback"
1667 );
1668 }
1669 }
1670 }
1671 Ok(resp) => {
1672 tracing::debug!(
1674 node_iri = %node_iri,
1675 status = %resp.status(),
1676 "list-node label fetch returned non-success; using node IRI as fallback"
1677 );
1678 }
1679 Err(e) => {
1680 tracing::debug!(
1681 node_iri = %node_iri,
1682 error = %e,
1683 "list-node label fetch failed; using node IRI as fallback"
1684 );
1685 }
1686 }
1687 }
1688
1689 let mut result: Vec<FieldValues> = Vec::new();
1691
1692 for pf in parsed_fields {
1693 let raw_name = local_name(pf.key).to_string();
1695 let name = if pf.is_link {
1696 raw_name
1697 .strip_suffix("Value")
1698 .unwrap_or(&raw_name)
1699 .to_string()
1700 } else {
1701 raw_name
1702 };
1703
1704 let label: Option<String> = {
1706 let prefix = curie_prefix(pf.key).unwrap_or("");
1707 if is_system_prefix(prefix) || prefix.is_empty() {
1708 None
1709 } else if let Some(ns) = prefixes.get(prefix) {
1710 let ont_iri = ns.trim_end_matches(['#', '/']).to_string();
1711 let local = local_name(pf.key);
1712 let prop_iri = format!("{}{}", ns, local);
1713 ontology_labels
1714 .get(&ont_iri)
1715 .and_then(|m| m.get(&prop_iri).cloned())
1716 } else {
1717 None
1718 }
1719 };
1720
1721 let values: Vec<Value> = pf
1723 .values
1724 .into_iter()
1725 .map(|v| match v.content {
1726 ValueContent::VocabularyItem { node_iri, label: _ } => {
1727 let resolved = node_labels.get(&node_iri).cloned().flatten();
1728 Value {
1729 content: ValueContent::VocabularyItem {
1730 node_iri,
1731 label: resolved,
1732 },
1733 comment: v.comment,
1734 }
1735 }
1736 other => Value {
1737 content: other,
1738 comment: v.comment,
1739 },
1740 })
1741 .collect();
1742
1743 result.push(FieldValues {
1744 name,
1745 label,
1746 values,
1747 });
1748 }
1749
1750 result
1751 }
1752}
1753
1754fn has_value_class_type(val: &serde_json::Value) -> bool {
1762 let type_local = get_type_local(val);
1763 type_local.ends_with("Value") && !type_local.is_empty() && {
1766 let raw_type = val
1768 .as_object()
1769 .and_then(|m| m.get("@type"))
1770 .and_then(|t| t.as_str())
1771 .unwrap_or("");
1772 raw_type.starts_with("knora-api:")
1773 }
1774}
1775
1776fn get_type_local(val: &serde_json::Value) -> &str {
1780 val.as_object()
1781 .and_then(|m| m.get("@type"))
1782 .and_then(|t| t.as_str())
1783 .map(local_name)
1784 .unwrap_or("")
1785}
1786
1787fn build_prefix_map(context_val: &Option<serde_json::Value>) -> HashMap<String, String> {
1793 match context_val {
1794 Some(serde_json::Value::Object(map)) => map
1795 .iter()
1796 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1797 .collect(),
1798 _ => HashMap::new(),
1799 }
1800}
1801
1802fn parse_value_content(obj: &serde_json::Value) -> (ValueContent, bool) {
1809 let type_local = get_type_local(obj);
1810
1811 match type_local {
1812 "TextValue" => {
1814 let content =
1817 if let Some(xml) = obj.get("knora-api:textValueAsXml").and_then(|v| v.as_str()) {
1818 crate::util::text::html_to_text(xml)
1819 } else {
1820 obj.get("knora-api:valueAsString")
1821 .and_then(|v| v.as_str())
1822 .unwrap_or("")
1823 .to_string()
1824 };
1825 (ValueContent::Text(content), false)
1826 }
1827
1828 "IntValue" => {
1830 let n = obj
1831 .get("knora-api:intValueAsInt")
1832 .and_then(|v| v.as_i64())
1833 .unwrap_or(0);
1834 (ValueContent::Integer(n), false)
1835 }
1836
1837 "DecimalValue" => {
1839 let s = obj
1841 .get("knora-api:decimalValueAsDecimal")
1842 .and_then(|v| {
1843 if let Some(s) = v.as_str() {
1845 Some(s.to_string())
1846 } else {
1847 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1848 }
1849 })
1850 .unwrap_or_default();
1851 (ValueContent::Decimal(s), false)
1852 }
1853
1854 "BooleanValue" => {
1856 let b = obj
1857 .get("knora-api:booleanValueAsBoolean")
1858 .and_then(|v| v.as_bool())
1859 .unwrap_or(false);
1860 (ValueContent::Boolean(b), false)
1861 }
1862
1863 "DateValue" => {
1865 let calendar = obj
1866 .get("knora-api:dateValueHasCalendar")
1867 .and_then(|v| v.as_str())
1868 .unwrap_or("GREGORIAN")
1869 .to_string();
1870
1871 let parse_point = |prefix: &str| -> DatePoint {
1872 let year_key = format!("knora-api:{prefix}Year");
1873 let month_key = format!("knora-api:{prefix}Month");
1874 let day_key = format!("knora-api:{prefix}Day");
1875 let era_key = format!("knora-api:{prefix}Era");
1876
1877 DatePoint {
1878 year: obj
1879 .get(year_key.as_str())
1880 .and_then(|v| v.as_i64())
1881 .map(|v| v as i32),
1882 month: obj
1883 .get(month_key.as_str())
1884 .and_then(|v| v.as_u64())
1885 .map(|v| v as u32),
1886 day: obj
1887 .get(day_key.as_str())
1888 .and_then(|v| v.as_u64())
1889 .map(|v| v as u32),
1890 era: obj
1891 .get(era_key.as_str())
1892 .and_then(|v| v.as_str())
1893 .map(str::to_owned),
1894 }
1895 };
1896
1897 let start = parse_point("dateValueHasStart");
1900 let end = parse_point("dateValueHasEnd");
1901
1902 if start.year.is_none() && end.year.is_none() {
1903 let raw_text = obj
1905 .get("knora-api:valueAsString")
1906 .and_then(|v| v.as_str())
1907 .unwrap_or("")
1908 .to_string();
1909 return (
1910 ValueContent::Raw {
1911 value_type: "date".to_string(),
1912 text: raw_text,
1913 },
1914 false,
1915 );
1916 }
1917
1918 (
1919 ValueContent::Date(DateValue {
1920 calendar,
1921 start,
1922 end,
1923 }),
1924 false,
1925 )
1926 }
1927
1928 "TimeValue" => {
1930 let s = obj
1931 .get("knora-api:timeValueAsTimeStamp")
1932 .and_then(|v| {
1933 if let Some(s) = v.as_str() {
1934 Some(s.to_string())
1935 } else {
1936 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1937 }
1938 })
1939 .unwrap_or_default();
1940 (ValueContent::Time(s), false)
1941 }
1942
1943 "UriValue" => {
1945 let s = obj
1946 .get("knora-api:uriValueAsUri")
1947 .and_then(|v| {
1948 if let Some(s) = v.as_str() {
1949 Some(s.to_string())
1950 } else {
1951 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1952 }
1953 })
1954 .unwrap_or_default();
1955 (ValueContent::Uri(s), false)
1956 }
1957
1958 "ColorValue" => {
1960 let s = obj
1961 .get("knora-api:colorValueAsColor")
1962 .and_then(|v| v.as_str())
1963 .unwrap_or("")
1964 .to_string();
1965 (ValueContent::Color(s), false)
1966 }
1967
1968 "GeonameValue" => {
1970 let s = obj
1971 .get("knora-api:geonameValueAsGeonameCode")
1972 .and_then(|v| v.as_str())
1973 .unwrap_or("")
1974 .to_string();
1975 (ValueContent::Geoname(s), false)
1976 }
1977
1978 "ListValue" => {
1980 let node_iri = obj
1982 .get("knora-api:listValueAsListNode")
1983 .and_then(|v| v.get("@id"))
1984 .and_then(|v| v.as_str())
1985 .unwrap_or("")
1986 .to_string();
1987 (
1988 ValueContent::VocabularyItem {
1989 node_iri,
1990 label: None, },
1992 false,
1993 )
1994 }
1995
1996 "LinkValue" => {
1998 let (target_iri, target_label) =
2001 if let Some(target_obj) = obj.get("knora-api:linkValueHasTarget") {
2002 let iri = target_obj
2003 .get("@id")
2004 .and_then(|v| v.as_str())
2005 .unwrap_or("")
2006 .to_string();
2007 let lbl = target_obj.get("rdfs:label").and_then(extract_string_value);
2008 (iri, lbl)
2009 } else {
2010 let iri = obj
2011 .get("knora-api:linkValueHasTargetIri")
2012 .and_then(|v| v.get("@id"))
2013 .and_then(|v| v.as_str())
2014 .unwrap_or("")
2015 .to_string();
2016 (iri, None)
2017 };
2018 (
2019 ValueContent::Link {
2020 target_iri,
2021 target_label,
2022 },
2023 true, )
2025 }
2026
2027 t if t.ends_with("FileValue") => {
2030 let filename = obj
2031 .get("knora-api:fileValueHasFilename")
2032 .and_then(|v| v.as_str())
2033 .unwrap_or("")
2034 .to_string();
2035 let url_str = obj
2036 .get("knora-api:fileValueAsUrl")
2037 .and_then(|v| {
2038 if let Some(s) = v.as_str() {
2039 Some(s.to_string())
2040 } else {
2041 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
2042 }
2043 })
2044 .unwrap_or_default();
2045
2046 let value_type_opt = if t.starts_with("StillImage") {
2048 Some(ValueType::StillImage)
2049 } else if t.starts_with("MovingImage") {
2050 Some(ValueType::MovingImage)
2051 } else if t.starts_with("Audio") {
2052 Some(ValueType::Audio)
2053 } else if t.starts_with("Document") || t.starts_with("Text") {
2054 Some(ValueType::Document)
2056 } else if t.starts_with("Archive") {
2057 Some(ValueType::Archive)
2058 } else {
2059 None };
2061
2062 match value_type_opt {
2063 Some(vt) => {
2064 let (width, height) = if vt == ValueType::StillImage {
2066 let w = obj
2067 .get("knora-api:stillImageFileValueHasDimX")
2068 .and_then(|v| v.as_u64())
2069 .map(|v| v as u32);
2070 let h = obj
2071 .get("knora-api:stillImageFileValueHasDimY")
2072 .and_then(|v| v.as_u64())
2073 .map(|v| v as u32);
2074 (w, h)
2075 } else {
2076 (None, None)
2077 };
2078 (
2079 ValueContent::File(FileValue {
2080 value_type: vt,
2081 filename,
2082 url: url_str,
2083 width,
2084 height,
2085 }),
2086 false,
2087 )
2088 }
2089 None => {
2090 let raw_text = obj
2092 .get("knora-api:valueAsString")
2093 .and_then(|v| v.as_str())
2094 .unwrap_or(&filename)
2095 .to_string();
2096 (
2097 ValueContent::Raw {
2098 value_type: object_type_to_kebab(t),
2099 text: raw_text,
2100 },
2101 false,
2102 )
2103 }
2104 }
2105 }
2106
2107 other => {
2109 let value_type = object_type_to_kebab(other);
2110 let raw_text = obj
2113 .get("knora-api:valueAsString")
2114 .and_then(|v| v.as_str())
2115 .map(str::to_owned)
2116 .unwrap_or_else(|| compact_value_text(obj));
2117 (
2118 ValueContent::Raw {
2119 value_type,
2120 text: raw_text,
2121 },
2122 false,
2123 )
2124 }
2125 }
2126}
2127
2128fn parse_value(obj: &serde_json::Value) -> (Value, bool) {
2134 let (content, is_link) = parse_value_content(obj);
2135 let comment = obj
2136 .get("knora-api:valueHasComment")
2137 .and_then(|v| v.as_str())
2138 .filter(|s| !s.trim().is_empty())
2139 .map(str::to_owned);
2140 (Value { content, comment }, is_link)
2141}
2142
2143const VALUE_META_KEYS: &[&str] = &[
2145 "@id",
2146 "@type",
2147 "knora-api:attachedToUser",
2148 "knora-api:hasPermissions",
2149 "knora-api:userHasPermission",
2150 "knora-api:valueCreationDate",
2151 "knora-api:valueHasComment",
2152 "knora-api:isDeleted",
2153 "knora-api:arkUrl",
2154 "knora-api:versionArkUrl",
2155 "knora-api:valueHasUUID",
2156];
2157
2158fn compact_value_text(obj: &serde_json::Value) -> String {
2163 if let Some(map) = obj.as_object() {
2164 let filtered: serde_json::Map<String, serde_json::Value> = map
2165 .iter()
2166 .filter(|(k, _)| !VALUE_META_KEYS.contains(&k.as_str()))
2167 .map(|(k, v)| (k.clone(), v.clone()))
2168 .collect();
2169 if filtered.is_empty() {
2170 String::new()
2171 } else {
2172 serde_json::to_string(&serde_json::Value::Object(filtered)).unwrap_or_default()
2173 }
2174 } else {
2175 String::new()
2176 }
2177}
2178
2179impl DspClient for HttpDspClient {
2180 fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic> {
2181 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
2182
2183 let mut body = serde_json::Map::with_capacity(2);
2184 body.insert(
2185 identifier_key(user).to_owned(),
2186 serde_json::Value::from(user),
2187 );
2188 body.insert("password".to_owned(), serde_json::Value::from(password));
2189
2190 let response = self
2191 .client
2192 .post(&url)
2193 .json(&body)
2194 .send()
2195 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2196
2197 let status = response.status();
2198
2199 if status.is_success() {
2200 let api: LoginApiResponse = response.json().map_err(|e| {
2201 Diagnostic::ServerError(format!("login response could not be parsed: {e}"))
2202 })?;
2203 let expires_at = extract_exp(&api.token);
2204 Ok(LoginResponse {
2205 token: api.token,
2206 user: user.to_string(),
2207 expires_at,
2208 })
2209 } else if status == reqwest::StatusCode::UNAUTHORIZED
2210 || status == reqwest::StatusCode::FORBIDDEN
2211 {
2212 let body = response.text().unwrap_or_default();
2213 let preview: String = body.chars().take(200).collect();
2214 tracing::trace!("auth failure response body (capped): {}", preview);
2215 Err(Diagnostic::AuthRequired(format!(
2217 "Authentication failed on {server}"
2218 )))
2219 } else if status == reqwest::StatusCode::NOT_FOUND {
2220 Err(Diagnostic::NotFound(format!(
2221 "endpoint not found at {url}; check that --server resolves to a DSP-API instance, not just any HTTPS host"
2222 )))
2223 } else if status.is_server_error() {
2224 let body = response.text().unwrap_or_default();
2225 let preview: String = body.chars().take(200).collect();
2226 tracing::trace!("server error response body (capped): {}", preview);
2227 Err(Diagnostic::ServerError(format!("server returned {status}")))
2228 } else {
2229 Err(Diagnostic::ServerError(format!(
2230 "unexpected status: {status}"
2231 )))
2232 }
2233 }
2234
2235 fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic> {
2236 let base = server.trim_end_matches('/');
2237
2238 let url = project_lookup_url(base, project);
2239
2240 let response = self
2242 .client
2243 .get(&url)
2244 .send()
2245 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2246
2247 let status = response.status();
2248
2249 if status.is_success() {
2250 let api: ProjectGetApiResponse = response.json().map_err(|e| {
2251 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
2252 })?;
2253 if !is_safe_shortcode(&api.project.shortcode) {
2254 return Err(Diagnostic::ServerError(
2255 "server returned a project with an unexpected shortcode".into(),
2256 ));
2257 }
2258 Ok(ProjectRef {
2259 iri: api.project.id,
2260 shortcode: api.project.shortcode,
2261 shortname: api.project.shortname,
2262 })
2263 } else if status == reqwest::StatusCode::NOT_FOUND {
2264 let display_input: String = project.chars().take(80).collect();
2266 let suffix = if project.chars().count() > 80 {
2267 "…"
2268 } else {
2269 ""
2270 };
2271 Err(Diagnostic::NotFound(format!(
2272 "project '{display_input}{suffix}' not found on {server}"
2273 )))
2274 } else {
2275 Err(map_unexpected_status(status, &url))
2276 }
2277 }
2278
2279 fn create_project_dump(
2280 &self,
2281 server: &str,
2282 project_iri: &str,
2283 skip_assets: bool,
2284 token: &str,
2285 ) -> Result<CreateDumpOutcome, Diagnostic> {
2286 let base = server.trim_end_matches('/');
2287 let url = format!(
2292 "{base}/v3/projects/{}/exports?skipAssets={skip_assets}",
2293 enc(project_iri)
2294 );
2295
2296 let response = self
2297 .client
2298 .post(&url)
2299 .bearer_auth(token)
2300 .send()
2301 .map_err(|e: reqwest::Error| Diagnostic::Network(e.to_string()))?;
2302
2303 let status = response.status();
2304
2305 match status.as_u16() {
2306 202 => {
2307 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2308 Diagnostic::ServerError(format!(
2309 "dump trigger response could not be parsed: {e}"
2310 ))
2311 })?;
2312 api.into_dump_task().map(CreateDumpOutcome::Created)
2313 }
2314 409 => {
2315 let body_text = response.text().unwrap_or_default();
2325 let error_body: Option<V3ErrorBody> = if body_text.len() <= 65536 {
2326 serde_json::from_str(&body_text).ok()
2327 } else {
2328 None
2329 };
2330 match error_body.as_ref().and_then(|b| b.export_exists()) {
2331 Some(ex) => {
2332 let id = ex.id.ok_or_else(|| {
2335 Diagnostic::ServerError(
2336 "the server's dump-conflict response was missing the dump id"
2337 .into(),
2338 )
2339 })?;
2340 validate_dump_id(id)?;
2341 match ex.project_iri {
2347 Some(owner) if owner == project_iri => {
2348 Ok(CreateDumpOutcome::Exists { id: id.to_string() })
2349 }
2350 Some(owner) => Ok(CreateDumpOutcome::ExistsForOtherProject {
2351 id: id.to_string(),
2352 project_iri: owner.to_string(),
2353 }),
2354 None => Err(Diagnostic::ServerError(
2357 "the server's dump-conflict response did not identify which \
2358project owns the existing dump; cannot safely proceed"
2359 .into(),
2360 )),
2361 }
2362 }
2363 None => Err(Diagnostic::ServerError(
2365 "server reported a 409 conflict whose detail could not be parsed".into(),
2367 )),
2368 }
2369 }
2370 401 | 403 => Err(Diagnostic::AuthRequired(
2371 "triggering a project dump requires a system-administrator token".into(),
2372 )),
2373 404 => Err(Diagnostic::NotFound(format!("project not found at {url}"))),
2374 _ => Err(map_unexpected_status(status, &url)),
2375 }
2376 }
2377
2378 fn get_project_dump_status(
2379 &self,
2380 server: &str,
2381 project_iri: &str,
2382 dump_id: &str,
2383 token: &str,
2384 ) -> Result<DumpTask, Diagnostic> {
2385 validate_dump_id(dump_id)?;
2386 let base = server.trim_end_matches('/');
2387 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2389
2390 let response = self
2391 .client
2392 .get(&url)
2393 .bearer_auth(token)
2394 .send()
2395 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2396
2397 let status = response.status();
2398
2399 match status.as_u16() {
2400 200 => {
2401 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2402 Diagnostic::ServerError(format!(
2403 "dump status response could not be parsed: {e}"
2404 ))
2405 })?;
2406 api.into_dump_task()
2407 }
2408 404 => Err(Diagnostic::NotFound(format!(
2409 "dump '{dump_id}' not found for project at {url}"
2410 ))),
2411 401 | 403 => Err(Diagnostic::AuthRequired(
2412 "fetching dump status requires a system-administrator token".into(),
2413 )),
2414 _ => Err(map_unexpected_status(status, &url)),
2415 }
2416 }
2417
2418 fn download_project_dump(
2419 &self,
2420 server: &str,
2421 project_iri: &str,
2422 dump_id: &str,
2423 token: &str,
2424 dest: &mut dyn Write,
2425 ) -> Result<u64, Diagnostic> {
2426 validate_dump_id(dump_id)?;
2427 let base = server.trim_end_matches('/');
2428 let url = format!(
2430 "{base}/v3/projects/{}/exports/{dump_id}/download",
2431 enc(project_iri)
2432 );
2433
2434 let mut response = self
2436 .download_client
2437 .get(&url)
2438 .bearer_auth(token)
2439 .send()
2440 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2441
2442 let status = response.status();
2443
2444 match status.as_u16() {
2447 200 => {
2448 let mut buf = [0u8; 64 * 1024];
2452 let mut total: u64 = 0;
2453 loop {
2454 let n = response
2455 .read(&mut buf)
2456 .map_err(|e| Diagnostic::Network(format!("download interrupted: {e}")))?;
2457 if n == 0 {
2458 break;
2459 }
2460 dest.write_all(&buf[..n]).map_err(|e| {
2461 Diagnostic::Io(format!("failed to write dump to disk: {e}"))
2462 })?;
2463 total += n as u64;
2464 }
2465 Ok(total)
2466 }
2467 409 => Err(Diagnostic::Conflict(
2468 "dump not ready — still in progress or failed".into(),
2469 )),
2470 404 => Err(Diagnostic::NotFound(format!(
2471 "dump '{dump_id}' not found at {url}"
2472 ))),
2473 401 | 403 => Err(Diagnostic::AuthRequired(
2474 "downloading a project dump requires a system-administrator token".into(),
2475 )),
2476 _ => Err(map_unexpected_status(status, &url)),
2477 }
2478 }
2479
2480 fn delete_project_dump(
2481 &self,
2482 server: &str,
2483 project_iri: &str,
2484 dump_id: &str,
2485 token: &str,
2486 ) -> Result<(), Diagnostic> {
2487 validate_dump_id(dump_id)?;
2488 let base = server.trim_end_matches('/');
2489 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2491
2492 let response = self
2493 .client
2494 .delete(&url)
2495 .bearer_auth(token)
2496 .send()
2497 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2498
2499 let status = response.status();
2500
2501 match status.as_u16() {
2502 204 => Ok(()),
2503 409 => Err(Diagnostic::Conflict(
2504 "dump is still in progress and cannot be deleted yet".into(),
2505 )),
2506 404 => Err(Diagnostic::NotFound(format!(
2507 "dump '{dump_id}' not found at {url}"
2508 ))),
2509 401 | 403 => Err(Diagnostic::AuthRequired(
2510 "deleting a project dump requires a system-administrator token".into(),
2511 )),
2512 _ => Err(map_unexpected_status(status, &url)),
2513 }
2514 }
2515
2516 fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic> {
2517 let base = server.trim_end_matches('/');
2518 let url = format!("{base}/admin/projects");
2519
2520 let req = self.client.get(&url);
2526 let req = if let Some(t) = token {
2527 req.bearer_auth(t)
2528 } else {
2529 req
2530 };
2531
2532 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2533
2534 let status = response.status();
2535
2536 if status.is_success() {
2537 let api: ProjectsListApiResponse = response.json().map_err(|e| {
2538 Diagnostic::ServerError(format!("projects list response could not be parsed: {e}"))
2539 })?;
2540 let projects = api
2541 .projects
2542 .into_iter()
2543 .map(|dto| Project {
2544 iri: dto.id,
2545 shortcode: dto.shortcode,
2546 shortname: dto.shortname,
2547 longname: dto.longname,
2548 status: if dto.status {
2552 ProjectStatus::Active
2553 } else {
2554 ProjectStatus::Inactive
2555 },
2556 data_models: dto.ontologies.len(),
2559 })
2560 .collect();
2561 Ok(projects)
2562 } else {
2563 Err(map_unexpected_status(status, &url))
2564 }
2565 }
2566
2567 fn describe_project(
2568 &self,
2569 server: &str,
2570 project: &str,
2571 token: Option<&str>,
2572 ) -> Result<ProjectDetail, Diagnostic> {
2573 let base = server.trim_end_matches('/');
2574 let url = project_lookup_url(base, project);
2575
2576 let req = self.client.get(&url);
2580 let req = if let Some(t) = token {
2581 req.bearer_auth(t)
2582 } else {
2583 req
2584 };
2585
2586 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2587
2588 let status = response.status();
2589
2590 if status.is_success() {
2591 let api: ProjectDetailApiResponse = response.json().map_err(|e| {
2592 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
2593 })?;
2594 let dto = api.project;
2595
2596 let project_status = if dto.status {
2598 ProjectStatus::Active
2599 } else {
2600 ProjectStatus::Inactive
2601 };
2602
2603 let description = dto
2605 .description
2606 .into_iter()
2607 .map(|d| ProjectDescription {
2608 value: d.value,
2609 language: d.language,
2610 })
2611 .collect();
2612
2613 let mut data_models: Vec<DataModelSummary> = dto
2615 .ontologies
2616 .into_iter()
2617 .map(|iri| {
2618 let name = data_model_name_from_iri(&iri);
2619 DataModelSummary { name, iri }
2620 })
2621 .collect();
2622 data_models.sort_by(|a, b| a.name.cmp(&b.name));
2623
2624 Ok(ProjectDetail {
2625 iri: dto.id,
2626 shortcode: dto.shortcode,
2627 shortname: dto.shortname,
2628 longname: dto.longname,
2629 status: project_status,
2630 description,
2631 keywords: dto.keywords,
2632 data_models,
2633 })
2634 } else if status == reqwest::StatusCode::NOT_FOUND {
2635 let display_input: String = project.chars().take(80).collect();
2637 let suffix = if project.chars().count() > 80 {
2638 "…"
2639 } else {
2640 ""
2641 };
2642 Err(Diagnostic::NotFound(format!(
2643 "project '{display_input}{suffix}' not found on {server}. Run `dsp vre project list --server {server}` to see available projects."
2644 )))
2645 } else {
2646 Err(map_unexpected_status(status, &url))
2647 }
2648 }
2649
2650 fn describe_data_model(
2651 &self,
2652 server: &str,
2653 data_model_iri: &str,
2654 token: Option<&str>,
2655 ) -> Result<DataModelDetail, Diagnostic> {
2656 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2657
2658 let prefixes: HashMap<String, String> = resp
2662 .context
2663 .iter()
2664 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2665 .collect();
2666
2667 let mut resource_types: Vec<ResourceTypeSummary> = resp
2668 .graph
2669 .into_iter()
2670 .filter(|dto| dto.is_resource_class)
2671 .map(|dto| {
2672 let (name, iri) = expand_class_id(&dto.id, &prefixes);
2673 ResourceTypeSummary {
2674 name,
2675 iri,
2676 label: dto.label,
2677 }
2678 })
2679 .collect();
2680
2681 resource_types.sort_by(|a, b| a.name.cmp(&b.name));
2682
2683 Ok(DataModelDetail {
2684 name: data_model_name_from_iri(&resp.id),
2685 iri: resp.id,
2686 label: resp.label,
2687 last_modified: resp.last_modification_date.map(|d| d.value),
2688 resource_types,
2689 })
2690 }
2691
2692 fn data_model_structure(
2693 &self,
2694 server: &str,
2695 data_model_iri: &str,
2696 token: Option<&str>,
2697 ) -> Result<DataModelStructure, Diagnostic> {
2698 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2700
2701 let graph_entities: Vec<OntologyEntityDto> = resp.graph;
2702
2703 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2709 let mut class_nodes: Vec<OntologyEntityDto> = Vec::new();
2710 for entity in graph_entities {
2711 if entity.is_resource_class {
2712 class_nodes.push(entity);
2713 } else if entity.object_type.is_some()
2714 || entity.is_link_property
2715 || entity.is_resource_property
2716 {
2717 prop_lookup.insert(entity.id.clone(), entity);
2718 }
2719 }
2720
2721 let mut relations: Vec<Relation> = Vec::new();
2723
2724 for class in &class_nodes {
2725 let source = local_name(&class.id).to_string();
2726
2727 for element in &class.sub_class_of {
2728 if let Some(type_val) = element.get("@type")
2729 && type_val.as_str() == Some("owl:Restriction")
2730 {
2731 let on_prop_id = match element
2733 .get("owl:onProperty")
2734 .and_then(|v| v.get("@id"))
2735 .and_then(serde_json::Value::as_str)
2736 {
2737 Some(s) => s,
2738 None => continue,
2739 };
2740
2741 let node = match prop_lookup.get(on_prop_id) {
2743 Some(n) => n,
2744 None => continue, };
2746
2747 if node.is_link_value_property {
2749 continue;
2750 }
2751
2752 if !node.is_link_property {
2754 continue;
2755 }
2756
2757 let target_id = match node.object_type.as_ref() {
2759 Some(ot) => &ot.id,
2760 None => continue, };
2762 let target = local_name(target_id).to_string();
2763
2764 let t_prefix = curie_prefix(target_id).unwrap_or("");
2765 let target_data_model = if is_system_prefix(t_prefix) || t_prefix.is_empty() {
2766 None
2767 } else {
2768 Some(t_prefix.to_string())
2769 };
2770
2771 let field_prefix = curie_prefix(on_prop_id).unwrap_or("");
2773 let is_builtin = is_system_prefix(field_prefix);
2774
2775 let field = local_name(on_prop_id).to_string();
2776
2777 relations.push(Relation {
2778 source: source.clone(),
2779 target,
2780 kind: RelationKind::Link,
2781 field: Some(field),
2782 target_data_model,
2783 is_builtin,
2784 });
2785 } else if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str)
2786 {
2787 let target = local_name(id_val).to_string();
2792
2793 let sup_prefix = curie_prefix(id_val).unwrap_or("");
2794 let is_builtin = is_system_prefix(sup_prefix);
2795 let target_data_model = if is_system_prefix(sup_prefix) || sup_prefix.is_empty()
2796 {
2797 None
2798 } else {
2799 Some(sup_prefix.to_string())
2800 };
2801
2802 relations.push(Relation {
2803 source: source.clone(),
2804 target,
2805 kind: RelationKind::Inherits,
2806 field: None,
2807 target_data_model,
2808 is_builtin,
2809 });
2810 }
2811 }
2812 }
2813
2814 relations.sort_by(|a, b| {
2818 a.source
2819 .cmp(&b.source)
2820 .then_with(|| a.kind.cmp(&b.kind))
2821 .then_with(|| a.field.cmp(&b.field))
2822 .then_with(|| a.target.cmp(&b.target))
2823 });
2824
2825 Ok(DataModelStructure {
2827 data_model: data_model_name_from_iri(data_model_iri),
2828 relations,
2829 })
2830 }
2831
2832 fn list_resources(
2833 &self,
2834 server: &str,
2835 project_iri: &str,
2836 resource_type_iri: &str,
2837 order_by: Option<&str>,
2838 page: u32,
2839 token: Option<&str>,
2840 ) -> Result<ResourcePage, Diagnostic> {
2841 let base = server.trim_end_matches('/');
2842 let url = format!("{base}/v2/resources");
2843
2844 let mut req = self.client.get(&url).query(&[
2849 ("resourceClass", resource_type_iri),
2850 ("page", &page.to_string()),
2851 ("schema", "complex"),
2852 ]);
2853 if let Some(prop_iri) = order_by {
2856 req = req.query(&[("orderByProperty", prop_iri)]);
2857 }
2858
2859 let header_value = reqwest::header::HeaderValue::from_str(project_iri).map_err(|e| {
2863 Diagnostic::Usage(format!("project IRI is not a valid HTTP header value: {e}"))
2864 })?;
2865 let req = req.header("x-knora-accept-project", header_value);
2866
2867 let req = if let Some(t) = token {
2869 req.bearer_auth(t)
2870 } else {
2871 req
2872 };
2873
2874 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2875 let status = response.status();
2876
2877 if !status.is_success() {
2878 return Err(map_unexpected_status(status, &url));
2879 }
2880
2881 let dto: ResourceListDto = response.json().map_err(|e| {
2882 Diagnostic::ServerError(format!("resource list response could not be parsed: {e}"))
2883 })?;
2884
2885 let may_have_more_results = dto.may_have_more_results;
2886
2887 let resources: Vec<ResourceSummary> = if let Some(graph) = dto.graph {
2892 graph
2893 .into_iter()
2894 .map(|node| {
2895 node_dto_to_summary(
2896 node.id,
2897 node.type_field.as_ref(),
2898 node.label.as_ref(),
2899 node.ark_url.as_ref(),
2900 node.creation_date.as_ref(),
2901 node.last_modification_date.as_ref(),
2902 )
2903 })
2904 .collect()
2905 } else if let Some(id) = dto.id {
2906 vec![node_dto_to_summary(
2908 id,
2909 dto.type_field.as_ref(),
2910 dto.label.as_ref(),
2911 dto.ark_url.as_ref(),
2912 dto.creation_date.as_ref(),
2913 dto.last_modification_date.as_ref(),
2914 )]
2915 } else {
2916 vec![]
2918 };
2919
2920 Ok(ResourcePage {
2921 resources,
2922 may_have_more_results,
2923 })
2924 }
2925
2926 fn describe_resource(
2927 &self,
2928 server: &str,
2929 resource_iri: &str,
2930 token: Option<&str>,
2931 with_values: bool,
2932 ) -> Result<ResourceDetail, Diagnostic> {
2933 let base = server.trim_end_matches('/');
2934 let url = format!("{base}/v2/resources/{}", enc(resource_iri));
2936
2937 let req = self.client.get(&url).query(&[("schema", "complex")]);
2939 let req = if let Some(t) = token {
2940 req.bearer_auth(t)
2941 } else {
2942 req
2943 };
2944
2945 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2946 let status = response.status();
2947
2948 if status.is_success() {
2949 let dto: ResourceDetailDto = response.json().map_err(|e| {
2950 Diagnostic::ServerError(format!(
2951 "resource describe response could not be parsed: {e}"
2952 ))
2953 })?;
2954
2955 let label = dto
2957 .label
2958 .as_ref()
2959 .and_then(extract_string_value)
2960 .unwrap_or_default();
2961 let resource_type = extract_resource_type(dto.type_field.as_ref());
2962 let ark_url = dto.ark_url.as_ref().and_then(extract_string_value);
2963 let creation_date = dto.creation_date.as_ref().and_then(extract_string_value);
2964 let last_modified = dto
2965 .last_modification_date
2966 .as_ref()
2967 .and_then(extract_string_value);
2968 let attached_project = dto
2969 .attached_to_project
2970 .as_ref()
2971 .and_then(extract_string_value);
2972 let owner = dto.attached_to_user.as_ref().and_then(extract_string_value);
2973 let visibility = dto.has_permissions.as_deref().and_then(derive_visibility);
2974 let your_access = dto.user_has_permission.as_deref().and_then(derive_access);
2975
2976 let values = if with_values {
2978 Some(self.parse_resource_values(server, token, &dto.context, &dto.extra))
2979 } else {
2980 None
2981 };
2982
2983 Ok(ResourceDetail {
2984 label,
2985 iri: dto.id,
2986 resource_type,
2987 ark_url,
2988 creation_date,
2989 last_modified,
2990 attached_project,
2991 owner,
2992 visibility,
2993 your_access,
2994 values,
2995 })
2996 } else if status == reqwest::StatusCode::NOT_FOUND {
2997 let display_iri: String = resource_iri.chars().take(80).collect();
2999 let iri_suffix = if resource_iri.chars().count() > 80 {
3000 "…"
3001 } else {
3002 ""
3003 };
3004 Err(Diagnostic::NotFound(format!(
3005 "resource '{display_iri}{iri_suffix}' not found"
3006 )))
3007 } else if status == reqwest::StatusCode::UNAUTHORIZED
3008 || status == reqwest::StatusCode::FORBIDDEN
3009 {
3010 let display_iri: String = resource_iri.chars().take(80).collect();
3014 let iri_suffix = if resource_iri.chars().count() > 80 {
3015 "…"
3016 } else {
3017 ""
3018 };
3019 Err(Diagnostic::AuthRequired(format!(
3020 "access denied for resource '{display_iri}{iri_suffix}' — log in to view this resource"
3021 )))
3022 } else {
3023 Err(map_unexpected_status(status, &url))
3024 }
3025 }
3026
3027 fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic> {
3028 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
3029
3030 let response = self
3031 .client
3032 .get(&url)
3033 .bearer_auth(token)
3034 .send()
3035 .map_err(|e| Diagnostic::Network(e.to_string()))?;
3036
3037 let status = response.status();
3038
3039 if status.is_success() {
3040 let body = response.text().unwrap_or_default();
3043 let preview: String = body.chars().take(200).collect();
3044 tracing::trace!("verify_token success response body (capped): {}", preview);
3045 Ok(())
3046 } else if status == reqwest::StatusCode::UNAUTHORIZED
3047 || status == reqwest::StatusCode::FORBIDDEN
3048 {
3049 let body = response.text().unwrap_or_default();
3051 let preview: String = body.chars().take(200).collect();
3052 tracing::trace!("verify_token rejection response body (capped): {}", preview);
3053 Err(Diagnostic::AuthRequired(format!(
3055 "token rejected by {server} — it may be expired, revoked, or for a different environment"
3056 )))
3057 } else {
3058 Err(map_unexpected_status(status, &url))
3059 }
3060 }
3061
3062 fn list_data_models(
3063 &self,
3064 server: &str,
3065 project_iri: &str,
3066 token: Option<&str>,
3067 ) -> Result<Vec<DataModel>, Diagnostic> {
3068 let url = format!(
3069 "{}/v2/ontologies/metadata/{}",
3070 server.trim_end_matches('/'),
3071 enc(project_iri)
3072 );
3073
3074 let req = self.client.get(&url);
3079 let req = if let Some(t) = token {
3080 req.bearer_auth(t)
3081 } else {
3082 req
3083 };
3084
3085 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
3086
3087 let status = response.status();
3088
3089 if status.is_success() {
3090 let resp: OntologyMetadataResponse = response.json().map_err(|e| {
3091 Diagnostic::ServerError(format!("data-models response could not be parsed: {e}"))
3092 })?;
3093
3094 let dtos: Vec<OntologyMetadataDto> = match resp.graph {
3098 Some(g) => g,
3099 None => match resp.id {
3100 Some(id) => vec![OntologyMetadataDto {
3101 id,
3102 label: resp.label,
3103 last_modification_date: resp.last_modification_date,
3104 }],
3105 None => vec![],
3106 },
3107 };
3108
3109 let data_models = dtos
3110 .into_iter()
3111 .map(|dto| DataModel {
3112 name: data_model_name_from_iri(&dto.id),
3113 iri: dto.id,
3114 label: dto.label,
3115 last_modified: dto.last_modification_date.map(|d| d.value),
3116 is_builtin: false,
3117 })
3118 .collect();
3119
3120 Ok(data_models)
3121 } else {
3122 Err(map_unexpected_status(status, &url))
3123 }
3124 }
3125
3126 fn describe_resource_type(
3127 &self,
3128 server: &str,
3129 data_model_iri: &str,
3130 resource_type: &str,
3131 token: Option<&str>,
3132 ) -> Result<ResourceTypeDetail, Diagnostic> {
3133 let resp = self.fetch_allentities(server, data_model_iri, token)?;
3135
3136 let prefixes: HashMap<String, String> = resp
3138 .context
3139 .iter()
3140 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
3141 .collect();
3142
3143 let queried_id = resp.id;
3147 let mut graph_entities: Vec<OntologyEntityDto> = resp.graph;
3148
3149 let target_idx = graph_entities.iter().position(|e| {
3150 if !e.is_resource_class {
3151 return false;
3152 }
3153 let (type_local, expanded_iri) = expand_class_id(&e.id, &prefixes);
3154 type_local.eq_ignore_ascii_case(resource_type) || expanded_iri == resource_type
3156 });
3157
3158 let target_idx = match target_idx {
3159 Some(i) => i,
3160 None => {
3161 let display: String = resource_type.chars().take(80).collect();
3162 let suffix = if resource_type.chars().count() > 80 {
3163 "…"
3164 } else {
3165 ""
3166 };
3167 return Err(Diagnostic::NotFound(format!(
3168 "resource-type '{display}{suffix}' not found in data-model '{}' on {server}",
3169 data_model_name_from_iri(data_model_iri)
3170 )));
3171 }
3172 };
3173
3174 let target = graph_entities.swap_remove(target_idx);
3177
3178 struct Restriction {
3180 on_property_id: String,
3181 cardinality: Cardinality,
3182 gui_order: u32,
3183 }
3184
3185 let mut restrictions: Vec<Restriction> = Vec::new();
3186 let mut super_type_ids: Vec<String> = Vec::new();
3187 let mut restriction_prop_locals: Vec<String> = Vec::new();
3188
3189 for element in &target.sub_class_of {
3190 if let Some(type_val) = element.get("@type")
3191 && type_val.as_str() == Some("owl:Restriction")
3192 {
3193 let on_prop_id = element
3195 .get("owl:onProperty")
3196 .and_then(|v| v.get("@id"))
3197 .and_then(serde_json::Value::as_str)
3198 .unwrap_or("")
3199 .to_string();
3200
3201 if on_prop_id.is_empty() {
3202 tracing::warn!("owl:Restriction missing owl:onProperty @id; skipping");
3203 continue;
3204 }
3205
3206 let cardinality = decode_cardinality(element);
3207 let gui_order = element
3208 .get("salsah-gui:guiOrder")
3209 .and_then(serde_json::Value::as_u64)
3210 .map(|v| v as u32)
3211 .unwrap_or(u32::MAX);
3212
3213 restriction_prop_locals.push(local_name(&on_prop_id).to_string());
3214
3215 restrictions.push(Restriction {
3216 on_property_id: on_prop_id,
3217 cardinality,
3218 gui_order,
3219 });
3220 continue;
3221 }
3222 if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
3224 super_type_ids.push(id_val.to_string());
3225 }
3226 }
3227
3228 let representation = detect_representation(
3230 &restriction_prop_locals
3231 .iter()
3232 .map(String::as_str)
3233 .collect::<Vec<_>>(),
3234 );
3235
3236 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
3238 for entity in graph_entities {
3239 if entity.object_type.is_some()
3242 || entity.is_link_property
3243 || entity.is_resource_property
3244 {
3245 prop_lookup.insert(entity.id.clone(), entity);
3246 }
3247 }
3248
3249 let mut missing_prefixes: Vec<String> = Vec::new();
3259 let mut seen_prefixes: HashSet<String> = HashSet::new();
3260 for restriction in &restrictions {
3261 if prop_lookup.contains_key(&restriction.on_property_id) {
3262 continue;
3263 }
3264 let prefix = match curie_prefix(&restriction.on_property_id) {
3265 Some(p) => p,
3266 None => continue,
3267 };
3268 if is_system_prefix(prefix) {
3269 continue;
3270 }
3271 if seen_prefixes.insert(prefix.to_string()) {
3272 missing_prefixes.push(prefix.to_string());
3273 }
3274 }
3275
3276 let mut fetched_sibling_iris: HashSet<String> = HashSet::new();
3278 let queried_iri_trimmed = data_model_iri.trim_end_matches(['#', '/']);
3279
3280 let mut siblings_to_fetch: Vec<String> = Vec::new();
3281 for prefix in &missing_prefixes {
3282 let namespace = match prefixes.get(prefix.as_str()) {
3283 Some(ns) => ns,
3284 None => {
3285 tracing::warn!(
3286 prefix = %prefix,
3287 "missing @context entry for prefix of cross-DM field; leaving best-effort"
3288 );
3289 continue;
3290 }
3291 };
3292 let sibling_iri = namespace.trim_end_matches(['#', '/']).to_string();
3293 if sibling_iri == queried_iri_trimmed {
3294 continue;
3296 }
3297 if fetched_sibling_iris.insert(sibling_iri.clone()) {
3298 siblings_to_fetch.push(sibling_iri);
3299 }
3300 }
3301
3302 if siblings_to_fetch.len() > MAX_SIBLING_FETCHES {
3303 tracing::warn!(
3304 count = siblings_to_fetch.len(),
3305 max = MAX_SIBLING_FETCHES,
3306 "too many sibling ontologies to fetch; capping at MAX_SIBLING_FETCHES"
3307 );
3308 siblings_to_fetch.truncate(MAX_SIBLING_FETCHES);
3309 }
3310
3311 for sibling_iri in &siblings_to_fetch {
3312 match self.fetch_allentities(server, sibling_iri, token) {
3314 Ok(sibling_resp) => {
3315 for entity in sibling_resp.graph {
3316 if entity.object_type.is_some()
3317 || entity.is_link_property
3318 || entity.is_resource_property
3319 {
3320 prop_lookup.entry(entity.id.clone()).or_insert(entity);
3321 }
3322 }
3323 }
3324 Err(e) => {
3325 tracing::warn!(
3328 iri = %sibling_iri,
3329 error = %e,
3330 "sibling ontology fetch failed; affected fields left best-effort"
3331 );
3332 }
3333 }
3334 }
3335
3336 let mut fields: Vec<(u32, Field)> = Vec::new();
3338
3339 for restriction in &restrictions {
3340 let prop_id = &restriction.on_property_id;
3341
3342 let node = prop_lookup.get(prop_id.as_str());
3344
3345 if let Some(n) = node {
3347 if n.is_link_value_property {
3348 continue;
3350 }
3351 } else {
3352 let prop_local = local_name(prop_id);
3356 if let Some(base) = prop_local.strip_suffix("Value") {
3357 let base_present = restrictions
3359 .iter()
3360 .any(|r| local_name(&r.on_property_id) == base);
3361 if base_present {
3364 continue;
3365 }
3366 }
3367 }
3368
3369 let prop_prefix = curie_prefix(prop_id).unwrap_or("");
3371 let is_builtin = is_system_prefix(prop_prefix);
3372 let (prop_local, prop_iri) = expand_class_id(prop_id, &prefixes);
3373
3374 let field_data_model = if is_builtin {
3376 None
3377 } else {
3378 if prop_prefix.is_empty() {
3381 None
3382 } else {
3383 Some(prop_prefix.to_string())
3384 }
3385 };
3386
3387 let (value_type, link_target) = if let Some(n) = node {
3389 if n.is_link_property {
3390 let target_name = n
3392 .object_type
3393 .as_ref()
3394 .map(|ot| local_name(&ot.id).to_string())
3395 .unwrap_or_else(|| "unknown".to_string());
3396 (ValueType::Link, Some(target_name))
3397 } else {
3398 let obj_local = n
3399 .object_type
3400 .as_ref()
3401 .map(|ot| local_name(&ot.id))
3402 .unwrap_or("");
3403 (map_object_type_to_value_type(obj_local), None)
3404 }
3405 } else {
3406 if is_builtin {
3408 if let Some(vt) = builtin_field_value_type(&prop_local) {
3409 (vt, None)
3410 } else {
3411 (ValueType::Other("—".to_string()), None)
3412 }
3413 } else {
3414 (ValueType::Other("—".to_string()), None)
3415 }
3416 };
3417
3418 let label = node.and_then(|n| n.label.clone());
3419
3420 debug_assert!(
3422 (value_type == ValueType::Link) == link_target.is_some(),
3423 "link_target must be Some iff value_type is Link"
3424 );
3425
3426 fields.push((
3427 restriction.gui_order,
3428 Field {
3429 name: prop_local,
3430 iri: prop_iri,
3431 label,
3432 value_type,
3433 link_target,
3434 cardinality: restriction.cardinality,
3435 is_builtin,
3436 data_model: field_data_model,
3437 },
3438 ));
3439 }
3440
3441 fields.sort_by(|(order_a, field_a), (order_b, field_b)| {
3443 order_a
3444 .cmp(order_b)
3445 .then_with(|| field_a.name.cmp(&field_b.name))
3446 });
3447 let sorted_fields: Vec<Field> = fields.into_iter().map(|(_, f)| f).collect();
3448
3449 let super_types: Vec<String> = super_type_ids
3451 .iter()
3452 .filter(|id| {
3453 let prefix = curie_prefix(id).unwrap_or("");
3454 !is_system_prefix(prefix)
3455 })
3456 .map(|id| local_name(id).to_string())
3457 .collect();
3458
3459 let (class_name, class_iri) = expand_class_id(&target.id, &prefixes);
3461 let class_label = target.label;
3462 let dm_name = data_model_name_from_iri(&queried_id);
3463
3464 Ok(ResourceTypeDetail {
3465 name: class_name,
3466 iri: class_iri,
3467 label: class_label,
3468 data_model: dm_name,
3469 representation,
3470 super_types,
3471 fields: sorted_fields,
3472 count: None,
3473 })
3474 }
3475
3476 fn resource_counts(
3477 &self,
3478 server: &str,
3479 project_iri: &str,
3480 token: Option<&str>,
3481 ) -> Result<HashMap<String, u64>, Diagnostic> {
3482 let url = format!(
3483 "{}/v3/projects/{}/resourcesPerOntology",
3484 server.trim_end_matches('/'),
3485 enc(project_iri)
3486 );
3487
3488 let req = self.client.get(&url);
3491 let req = if let Some(t) = token {
3492 req.bearer_auth(t)
3493 } else {
3494 req
3495 };
3496
3497 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
3498 let status = response.status();
3499
3500 if status.is_success() {
3501 let entries: Vec<OntologyAndResourceClassesDto> = response.json().map_err(|e| {
3502 Diagnostic::ServerError(format!(
3503 "resource-counts response could not be parsed: {e}"
3504 ))
3505 })?;
3506
3507 let mut counts = HashMap::new();
3508 for entry in entries {
3509 for cc in entry.classes_and_count {
3510 counts.insert(cc.resource_class.iri, cc.item_count);
3511 }
3512 }
3513 Ok(counts)
3514 } else if status == reqwest::StatusCode::NOT_FOUND {
3515 Err(Diagnostic::NotFound(format!("project not found at {url}")))
3516 } else {
3517 Err(map_unexpected_status(status, &url))
3518 }
3519 }
3520
3521 fn list_vocabularies(
3522 &self,
3523 server: &str,
3524 project_iri: &str,
3525 token: Option<&str>,
3526 ) -> Result<Vec<Vocabulary>, Diagnostic> {
3527 let url = format!(
3528 "{}/admin/lists?projectIri={}",
3529 server.trim_end_matches('/'),
3530 enc(project_iri)
3531 );
3532
3533 let req = self.client.get(&url);
3536 let req = if let Some(t) = token {
3537 req.bearer_auth(t)
3538 } else {
3539 req
3540 };
3541
3542 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
3543 let status = response.status();
3544
3545 if status.is_success() {
3546 let resp: ListsListApiResponse = response.json().map_err(|e| {
3547 Diagnostic::ServerError(format!(
3548 "vocabulary list response could not be parsed: {e}"
3549 ))
3550 })?;
3551
3552 Ok(resp
3553 .lists
3554 .into_iter()
3555 .map(|dto| Vocabulary {
3556 header: VocabularyHeader {
3557 iri: dto.id,
3558 name: dto.name,
3559 labels: into_localized_texts(dto.labels),
3560 comments: into_localized_texts(dto.comments),
3561 },
3562 node_count: None,
3565 depth: None,
3566 })
3567 .collect())
3568 } else {
3569 Err(map_unexpected_status(status, &url))
3570 }
3571 }
3572
3573 fn describe_vocabulary(
3574 &self,
3575 server: &str,
3576 iri: &str,
3577 token: Option<&str>,
3578 ) -> Result<VocabularyTree, Diagnostic> {
3579 match self.fetch_list_get(server, iri, token)? {
3580 ListGetResponseDto::Root(root) => Ok(build_vocabulary_tree(root.list, None)),
3581 ListGetResponseDto::Node(node) => {
3582 let root_iri = node.node.nodeinfo.has_root_node;
3586 match self.fetch_list_get(server, &root_iri, token)? {
3587 ListGetResponseDto::Root(root) => {
3588 Ok(build_vocabulary_tree(root.list, Some(iri.to_string())))
3589 }
3590 ListGetResponseDto::Node(_) => Err(Diagnostic::ServerError(format!(
3593 "resolving vocabulary node {iri} to its root ({root_iri}) returned \
3594 another node, not a root"
3595 ))),
3596 }
3597 }
3598 }
3599 }
3600
3601 fn sparql_query(
3602 &self,
3603 server: &str,
3604 token: &str,
3605 query: &str,
3606 accept: &str,
3607 timeout_secs: u64,
3608 ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
3609 let url = format!("{}/admin/sparql/query", server.trim_end_matches('/'));
3610
3611 tracing::debug!(method = "POST", url = %url, "sparql_query: sending request");
3612
3613 let sparql_client = reqwest::blocking::Client::builder()
3635 .connect_timeout(Duration::from_secs(10))
3636 .timeout(Duration::from_secs(timeout_secs))
3637 .redirect(reqwest::redirect::Policy::none())
3638 .user_agent(crate::util::USER_AGENT)
3639 .build()
3640 .map_err(|e| {
3641 Diagnostic::Internal(format!("failed to build SPARQL HTTP client: {e}"))
3642 })?;
3643
3644 let req = sparql_client
3645 .post(&url)
3646 .bearer_auth(token)
3647 .header(reqwest::header::CONTENT_TYPE, "application/sparql-query")
3648 .header(reqwest::header::ACCEPT, accept)
3649 .body(query.to_string());
3650
3651 let response = req.send().map_err(|e| {
3652 if e.is_timeout() {
3659 Diagnostic::Network(format!(
3660 "SPARQL request to {} timed out on the client side \
3662 after {timeout_secs}s (--timeout) — this is distinct from \
3663 the server's own passthrough timeout, which would come \
3664 back as an HTTP 504: {e}",
3665 crate::util::text::sanitise_and_cap(&url)
3666 ))
3667 } else {
3668 Diagnostic::Network(e.to_string())
3669 }
3670 })?;
3671
3672 let status = response.status();
3673 let content_type = response
3674 .headers()
3675 .get(reqwest::header::CONTENT_TYPE)
3676 .and_then(|v| v.to_str().ok())
3677 .map(|s| s.to_string());
3678 let body = response
3679 .bytes()
3680 .map_err(|e| Diagnostic::Network(e.to_string()))?;
3681
3682 tracing::debug!(
3683 status = status.as_u16(),
3684 content_type = content_type.as_deref().unwrap_or(""),
3685 "sparql_query: received response"
3686 );
3687
3688 match classify_sparql_status(status.as_u16(), content_type.as_deref(), &body, &url) {
3689 SparqlOutcome::DspApiError(diag) => Err(diag),
3690 SparqlOutcome::Relay => {
3691 if !status.is_success() {
3692 tracing::trace!(
3698 "sparql_query: non-2xx relay body preview (capped): {}",
3699 crate::util::text::sanitise_bytes_for_prose(&body)
3700 );
3701 }
3702 Ok(crate::client::sparql::SparqlResponse {
3703 status: status.as_u16(),
3704 content_type,
3705 body: body.to_vec(),
3706 })
3707 }
3708 }
3709 }
3710}
3711
3712#[derive(serde::Deserialize)]
3714struct SparqlErrorBody {
3715 message: String,
3716}
3717
3718enum SparqlOutcome {
3725 DspApiError(Diagnostic),
3726 Relay,
3727}
3728
3729fn classify_sparql_status(
3738 status: u16,
3739 content_type: Option<&str>,
3740 body: &[u8],
3741 url: &str,
3742) -> SparqlOutcome {
3743 match status {
3744 401 => SparqlOutcome::DspApiError(Diagnostic::AuthRequired(
3745 "authentication is required — run `dsp auth login`".into(),
3746 )),
3747 403 => SparqlOutcome::DspApiError(Diagnostic::AuthRequired(
3748 "your token is valid but is not a system administrator; \
3749 re-running `dsp auth login` will not help — the SPARQL \
3750 passthrough endpoint requires a SystemAdmin account"
3751 .into(),
3752 )),
3753 404 => SparqlOutcome::DspApiError(Diagnostic::NotFound(format!(
3754 "the SPARQL passthrough is not available at {}. Any of these \
3758 looks identical from here: the endpoint is off on this deployment \
3759 (it is off by default — allow-sparql-passthrough), the server \
3760 predates the endpoint, the store's dataset is misconfigured, or \
3761 --server is wrong.",
3762 crate::util::text::sanitise_and_cap(url)
3763 ))),
3764 413 => SparqlOutcome::DspApiError(Diagnostic::Usage(
3765 "the SPARQL query text exceeds the server's request-body size \
3766 limit"
3767 .into(),
3768 )),
3769 415 => SparqlOutcome::DspApiError(Diagnostic::Internal(
3770 "the server rejected dsp-cli's own Content-Type \
3771 (application/sparql-query) with 415 — this is either a dsp-cli \
3772 bug or an unexpected server"
3773 .into(),
3774 )),
3775 500 | 502 | 503 | 504 => {
3776 let detail = parse_sparql_error_message(content_type, body)
3777 .unwrap_or_else(|| crate::util::text::sanitise_bytes_for_prose(body));
3778 let message = if detail.trim().is_empty() {
3784 format!("the server returned HTTP {status} with no usable message")
3785 } else {
3786 format!("the server returned HTTP {status}: {detail}")
3787 };
3788 SparqlOutcome::DspApiError(Diagnostic::ServerError(message))
3789 }
3790 _ => SparqlOutcome::Relay,
3791 }
3792}
3793
3794fn parse_sparql_error_message(content_type: Option<&str>, body: &[u8]) -> Option<String> {
3806 if !content_type.unwrap_or("").starts_with("application/json") {
3807 return None;
3808 }
3809 serde_json::from_slice::<SparqlErrorBody>(body)
3810 .ok()
3811 .map(|b| crate::util::text::sanitise_and_cap(&b.message))
3812}
3813
3814#[cfg(test)]
3819mod tests {
3820 use super::*;
3821
3822 #[test]
3827 fn map_unexpected_status_401_403_are_auth_required() {
3828 for status in [
3832 reqwest::StatusCode::UNAUTHORIZED,
3833 reqwest::StatusCode::FORBIDDEN,
3834 ] {
3835 let diag = map_unexpected_status(status, "https://example.org/x");
3836 match diag {
3837 Diagnostic::AuthRequired(msg) => assert!(
3838 msg.contains("dsp auth login"),
3839 "auth message should hint at re-authentication: {msg}"
3840 ),
3841 other => panic!("expected AuthRequired for {status}, got {other:?}"),
3842 }
3843 }
3844 }
3845
3846 #[test]
3847 fn map_unexpected_status_404_and_5xx_stay_server_error() {
3848 assert!(matches!(
3851 map_unexpected_status(reqwest::StatusCode::NOT_FOUND, "u"),
3852 Diagnostic::ServerError(_)
3853 ));
3854 assert!(matches!(
3855 map_unexpected_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, "u"),
3856 Diagnostic::ServerError(_)
3857 ));
3858 }
3859
3860 #[test]
3865 fn identifier_key_email_contains_at() {
3866 assert_eq!(identifier_key("a@b.ch"), "email");
3867 }
3868
3869 #[test]
3870 fn identifier_key_bare_username() {
3871 assert_eq!(identifier_key("jdoe"), "username");
3872 }
3873
3874 #[test]
3875 fn identifier_key_http_iri() {
3876 assert_eq!(identifier_key("http://rdfh.ch/users/x"), "iri");
3877 }
3878
3879 #[test]
3880 fn identifier_key_https_iri() {
3881 assert_eq!(identifier_key("https://rdfh.ch/users/x"), "iri");
3882 }
3883
3884 #[test]
3885 fn identifier_key_iri_with_at_uses_iri_not_email() {
3886 assert_eq!(identifier_key("http://example.org/users/a@b"), "iri");
3888 }
3889
3890 #[test]
3891 fn classify_http_iri() {
3892 let ident = classify("http://rdfh.ch/projects/0001");
3893 assert!(
3894 matches!(ident, ProjectIdent::Iri(_)),
3895 "http:// prefix should classify as Iri"
3896 );
3897 }
3898
3899 #[test]
3900 fn classify_https_iri() {
3901 let ident = classify("https://rdfh.ch/projects/0001");
3902 assert!(
3903 matches!(ident, ProjectIdent::Iri(_)),
3904 "https:// prefix should classify as Iri"
3905 );
3906 }
3907
3908 #[test]
3909 fn classify_four_digit_hex_shortcode() {
3910 let ident = classify("0001");
3911 assert!(
3912 matches!(ident, ProjectIdent::Shortcode(_)),
3913 "four hex digits should classify as Shortcode"
3914 );
3915 }
3916
3917 #[test]
3918 fn classify_four_hex_letter_shortcode() {
3919 let ident = classify("beef");
3923 assert!(
3924 matches!(ident, ProjectIdent::Shortcode(_)),
3925 "4-hex-letter input 'beef' should classify as Shortcode (documented overlap)"
3926 );
3927 }
3928
3929 #[test]
3930 fn classify_mixed_case_hex_shortcode() {
3931 let ident = classify("ABCD");
3932 assert!(
3933 matches!(ident, ProjectIdent::Shortcode(_)),
3934 "upper-case hex digits should classify as Shortcode"
3935 );
3936 }
3937
3938 #[test]
3939 fn classify_shortname() {
3940 let ident = classify("incunabula");
3941 assert!(
3942 matches!(ident, ProjectIdent::Shortname(_)),
3943 "alphabetic string longer than 4 chars should classify as Shortname"
3944 );
3945 }
3946
3947 #[test]
3948 fn classify_five_digit_hex_is_shortname() {
3949 let ident = classify("00001");
3951 assert!(
3952 matches!(ident, ProjectIdent::Shortname(_)),
3953 "5-hex-digit string should classify as Shortname, not Shortcode"
3954 );
3955 }
3956
3957 #[test]
3958 fn classify_three_digit_hex_is_shortname() {
3959 let ident = classify("001");
3960 assert!(
3961 matches!(ident, ProjectIdent::Shortname(_)),
3962 "3-hex-digit string should classify as Shortname, not Shortcode"
3963 );
3964 }
3965
3966 #[test]
3967 fn classify_non_hex_four_chars_is_shortname() {
3968 let ident = classify("zzzz");
3970 assert!(
3971 matches!(ident, ProjectIdent::Shortname(_)),
3972 "4-char non-hex string should classify as Shortname"
3973 );
3974 }
3975
3976 #[test]
3981 fn validate_dump_id_valid_accepts() {
3982 assert!(super::validate_dump_id("abc123").is_ok());
3983 assert!(super::validate_dump_id("abc-123_XYZ").is_ok());
3984 let max_id = "a".repeat(256);
3986 assert!(
3987 super::validate_dump_id(&max_id).is_ok(),
3988 "256-char id must be accepted"
3989 );
3990 }
3991
3992 #[test]
3993 fn validate_dump_id_empty_is_rejected() {
3994 let result = super::validate_dump_id("");
3995 assert!(
3996 matches!(result, Err(Diagnostic::ServerError(_))),
3997 "empty id must be rejected"
3998 );
3999 }
4000
4001 #[test]
4002 fn validate_dump_id_too_long_is_rejected() {
4003 let long_id = "a".repeat(257);
4004 let result = super::validate_dump_id(&long_id);
4005 assert!(
4006 matches!(result, Err(Diagnostic::ServerError(_))),
4007 "257-char id must be rejected"
4008 );
4009 }
4010
4011 #[test]
4012 fn validate_dump_id_invalid_chars_rejected() {
4013 let result = super::validate_dump_id("abc/def");
4014 assert!(
4015 matches!(result, Err(Diagnostic::ServerError(_))),
4016 "id with '/' must be rejected"
4017 );
4018 }
4019
4020 #[test]
4025 fn into_dump_task_in_progress() {
4026 let api = DataTaskStatusApiResponse {
4027 id: "abc123".into(),
4028 status: "in_progress".into(),
4029 error_message: None,
4030 created_at: None,
4031 };
4032 let task = api.into_dump_task().expect("should parse in_progress");
4033 assert_eq!(task.id, "abc123");
4034 assert_eq!(task.status, DumpStatus::InProgress);
4035 assert!(task.error_message.is_none());
4036 assert!(task.created_at.is_none());
4037 }
4038
4039 #[test]
4040 fn into_dump_task_completed() {
4041 let api = DataTaskStatusApiResponse {
4042 id: "done42".into(),
4043 status: "completed".into(),
4044 error_message: None,
4045 created_at: None,
4046 };
4047 let task = api.into_dump_task().expect("should parse completed");
4048 assert_eq!(task.status, DumpStatus::Completed);
4049 }
4050
4051 #[test]
4052 fn into_dump_task_failed_with_message() {
4053 let api = DataTaskStatusApiResponse {
4054 id: "fail7".into(),
4055 status: "failed".into(),
4056 error_message: Some("disk full".into()),
4057 created_at: None,
4058 };
4059 let task = api.into_dump_task().expect("should parse failed");
4060 assert_eq!(task.status, DumpStatus::Failed);
4061 assert_eq!(task.error_message.as_deref(), Some("disk full"));
4062 }
4063
4064 #[test]
4065 fn into_dump_task_unknown_status_is_server_error() {
4066 let api = DataTaskStatusApiResponse {
4067 id: "x".into(),
4068 status: "pending".into(), error_message: None,
4070 created_at: None,
4071 };
4072 let result = api.into_dump_task();
4073 assert!(result.is_err(), "unknown status should yield an error");
4074 assert!(
4075 matches!(result.unwrap_err(), Diagnostic::ServerError(_)),
4076 "unknown status should yield ServerError"
4077 );
4078 }
4079
4080 #[test]
4081 fn into_dump_task_long_error_message_is_truncated() {
4082 let long_msg = "x".repeat(501);
4084 let api = DataTaskStatusApiResponse {
4085 id: "trunc".into(),
4086 status: "failed".into(),
4087 error_message: Some(long_msg),
4088 created_at: None,
4089 };
4090 let task = api
4091 .into_dump_task()
4092 .expect("should parse even with long message");
4093 let stored = task.error_message.unwrap();
4094 assert_eq!(
4095 stored.len(),
4096 500,
4097 "error_message must be truncated to ≤500 chars at the client boundary"
4098 );
4099 }
4100
4101 #[test]
4102 fn into_dump_task_exact_500_chars_not_truncated() {
4103 let exact_msg = "y".repeat(500);
4105 let api = DataTaskStatusApiResponse {
4106 id: "exact".into(),
4107 status: "failed".into(),
4108 error_message: Some(exact_msg.clone()),
4109 created_at: None,
4110 };
4111 let task = api.into_dump_task().expect("should parse");
4112 assert_eq!(task.error_message.unwrap(), exact_msg);
4113 }
4114
4115 #[test]
4120 fn into_dump_task_valid_created_at_is_parsed() {
4121 let api = DataTaskStatusApiResponse {
4122 id: "ts-test".into(),
4123 status: "completed".into(),
4124 error_message: None,
4125 created_at: Some("2026-05-20T14:03:00Z".into()),
4126 };
4127 let task = api.into_dump_task().expect("should parse with created_at");
4128 use chrono::Datelike;
4129 let ts = task.created_at.expect("created_at should be Some");
4130 assert_eq!(ts.year(), 2026);
4131 assert_eq!(ts.month(), 5);
4132 assert_eq!(ts.day(), 20);
4133 }
4134
4135 #[test]
4136 fn into_dump_task_garbage_created_at_yields_none() {
4137 let api = DataTaskStatusApiResponse {
4138 id: "ts-bad".into(),
4139 status: "in_progress".into(),
4140 error_message: None,
4141 created_at: Some("not-a-date!!".into()),
4142 };
4143 let task = api
4145 .into_dump_task()
4146 .expect("garbage created_at must not fail parse");
4147 assert!(
4148 task.created_at.is_none(),
4149 "garbage created_at must map to None"
4150 );
4151 }
4152
4153 #[test]
4158 fn export_exists_present_with_both_fields() {
4159 let body = V3ErrorBody {
4160 errors: vec![V3ErrorItem {
4161 code: "export_exists".into(),
4162 details: [
4163 ("id".to_string(), "dGVzdC1pZA".to_string()),
4164 (
4165 "projectIri".to_string(),
4166 "http://rdfh.ch/projects/0001".to_string(),
4167 ),
4168 ]
4169 .into(),
4170 }],
4171 };
4172 let ex = body.export_exists().expect("export_exists must be Some");
4173 assert_eq!(ex.id, Some("dGVzdC1pZA"));
4174 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
4175 }
4176
4177 #[test]
4178 fn export_exists_wrong_code_returns_none() {
4179 let body = V3ErrorBody {
4180 errors: vec![V3ErrorItem {
4181 code: "some_other_error".into(),
4182 details: [("id".to_string(), "abc".to_string())].into(),
4183 }],
4184 };
4185 assert!(body.export_exists().is_none(), "wrong code must not match");
4186 }
4187
4188 #[test]
4189 fn export_exists_missing_details_id_returns_some_with_none_id() {
4190 let body = V3ErrorBody {
4191 errors: vec![V3ErrorItem {
4192 code: "export_exists".into(),
4193 details: [(
4194 "projectIri".to_string(),
4195 "http://rdfh.ch/projects/0001".to_string(),
4196 )]
4197 .into(),
4198 }],
4199 };
4200 let ex = body
4202 .export_exists()
4203 .expect("export_exists must be Some when code matches");
4204 assert!(ex.id.is_none(), "id must be None when 'id' key is absent");
4205 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
4206 }
4207
4208 #[test]
4209 fn export_exists_empty_errors_returns_none() {
4210 let body = V3ErrorBody { errors: vec![] };
4211 assert!(body.export_exists().is_none());
4212 }
4213
4214 #[test]
4215 fn export_exists_missing_project_iri_returns_some_with_none_iri() {
4216 let body = V3ErrorBody {
4217 errors: vec![V3ErrorItem {
4218 code: "export_exists".into(),
4219 details: [("id".to_string(), "abc123".to_string())].into(),
4220 }],
4221 };
4222 let ex = body
4223 .export_exists()
4224 .expect("export_exists must be Some when code matches");
4225 assert_eq!(ex.id, Some("abc123"));
4226 assert!(
4227 ex.project_iri.is_none(),
4228 "project_iri must be None when 'projectIri' key is absent"
4229 );
4230 }
4231
4232 #[test]
4237 fn is_safe_shortcode_valid_hex_shortcode() {
4238 assert!(
4239 super::is_safe_shortcode("0001"),
4240 "4-hex-digit shortcode must be accepted"
4241 );
4242 assert!(
4243 super::is_safe_shortcode("ABCD"),
4244 "upper-case hex shortcode must be accepted"
4245 );
4246 assert!(
4247 super::is_safe_shortcode("beef"),
4248 "lower-case hex shortcode must be accepted"
4249 );
4250 }
4251
4252 #[test]
4253 fn is_safe_shortcode_alphanumeric_within_32_chars_accepted() {
4254 let long_code = "a".repeat(32);
4255 assert!(
4256 super::is_safe_shortcode(&long_code),
4257 "32-char alphanumeric must be accepted"
4258 );
4259 }
4260
4261 #[test]
4262 fn is_safe_shortcode_empty_is_rejected() {
4263 assert!(
4264 !super::is_safe_shortcode(""),
4265 "empty shortcode must be rejected"
4266 );
4267 }
4268
4269 #[test]
4270 fn is_safe_shortcode_too_long_is_rejected() {
4271 let long_code = "a".repeat(33);
4272 assert!(
4273 !super::is_safe_shortcode(&long_code),
4274 "33-char shortcode must be rejected"
4275 );
4276 }
4277
4278 #[test]
4279 fn is_safe_shortcode_slash_is_rejected() {
4280 assert!(
4281 !super::is_safe_shortcode("ab/cd"),
4282 "shortcode with '/' must be rejected"
4283 );
4284 assert!(
4285 !super::is_safe_shortcode("/evil"),
4286 "absolute path shortcode must be rejected"
4287 );
4288 }
4289
4290 #[test]
4291 fn is_safe_shortcode_dot_dot_is_rejected() {
4292 assert!(
4293 !super::is_safe_shortcode("../evil"),
4294 "path traversal shortcode must be rejected"
4295 );
4296 assert!(
4297 !super::is_safe_shortcode(".."),
4298 "'..' shortcode must be rejected"
4299 );
4300 }
4301
4302 #[test]
4303 fn is_safe_shortcode_backslash_is_rejected() {
4304 assert!(
4305 !super::is_safe_shortcode("ab\\cd"),
4306 "shortcode with '\\' must be rejected"
4307 );
4308 }
4309
4310 #[test]
4311 fn is_safe_shortcode_dot_is_rejected() {
4312 assert!(
4314 !super::is_safe_shortcode("ab.cd"),
4315 "shortcode with '.' must be rejected"
4316 );
4317 }
4318
4319 #[test]
4320 fn resolve_project_rejects_unsafe_shortcode() {
4321 let unsafe_examples = ["../evil", "/abs", "ab/cd", "a\\b", ""];
4325 for s in &unsafe_examples {
4326 assert!(
4327 !super::is_safe_shortcode(s),
4328 "is_safe_shortcode must reject '{s}' — resolve_project would have returned ServerError for this input"
4329 );
4330 }
4331 }
4332
4333 #[test]
4338 fn data_model_name_from_iri_standard_form() {
4339 assert_eq!(
4341 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2"),
4342 "beol"
4343 );
4344 }
4345
4346 #[test]
4347 fn data_model_name_from_iri_no_v2_suffix() {
4348 assert_eq!(
4350 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol"),
4351 "beol"
4352 );
4353 }
4354
4355 #[test]
4356 fn data_model_name_from_iri_trailing_slash() {
4357 assert_eq!(
4359 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2/"),
4360 "beol"
4361 );
4362 }
4363
4364 #[test]
4365 fn data_model_name_from_iri_bare_name() {
4366 assert_eq!(super::data_model_name_from_iri("beol"), "beol");
4368 }
4369
4370 #[test]
4371 fn data_model_name_from_iri_empty_string() {
4372 assert_eq!(super::data_model_name_from_iri(""), "");
4374 }
4375
4376 fn beol_prefixes() -> HashMap<String, String> {
4381 let mut m = HashMap::new();
4382 m.insert(
4383 "beol".to_string(),
4384 "http://api.dasch.swiss/ontology/0801/beol/v2#".to_string(),
4385 );
4386 m
4387 }
4388
4389 #[test]
4390 fn expand_class_id_curie_expands_with_known_prefix() {
4391 let (name, iri) = super::expand_class_id("beol:Archive", &beol_prefixes());
4393 assert_eq!(name, "Archive");
4394 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
4395 }
4396
4397 #[test]
4398 fn expand_class_id_unknown_prefix_falls_back_to_raw_id() {
4399 let (name, iri) = super::expand_class_id("urn:uuid:x", &HashMap::new());
4401 assert_eq!(name, "x");
4402 assert_eq!(iri, "urn:uuid:x");
4403 }
4404
4405 #[test]
4406 fn expand_class_id_full_iri_passes_through() {
4407 let (name, iri) = super::expand_class_id(
4410 "http://api.dasch.swiss/ontology/0801/beol/v2#Letter",
4411 &beol_prefixes(),
4412 );
4413 assert_eq!(name, "Letter");
4414 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Letter");
4415 }
4416
4417 #[test]
4418 fn expand_class_id_no_colon_degenerate() {
4419 let (name, iri) = super::expand_class_id("bare", &HashMap::new());
4421 assert_eq!(name, "bare");
4422 assert_eq!(iri, "bare");
4423 }
4424
4425 #[test]
4430 fn local_name_hash_iri() {
4431 assert_eq!(super::local_name("http://example.org/onto#Thing"), "Thing");
4432 }
4433
4434 #[test]
4435 fn local_name_slash_iri() {
4436 assert_eq!(super::local_name("http://example.org/onto/Thing"), "Thing");
4437 }
4438
4439 #[test]
4440 fn local_name_curie_colon() {
4441 assert_eq!(super::local_name("incunabula:Page"), "Page");
4442 }
4443
4444 #[test]
4445 fn local_name_bare_name_fallback() {
4446 assert_eq!(super::local_name("Page"), "Page");
4447 }
4448
4449 #[test]
4450 fn local_name_empty_string() {
4451 assert_eq!(super::local_name(""), "");
4452 }
4453
4454 #[test]
4455 fn local_name_trailing_separator() {
4456 assert_eq!(super::local_name("foo#"), "");
4459 }
4460
4461 #[test]
4466 fn object_type_to_kebab_text_value() {
4467 assert_eq!(super::object_type_to_kebab("TextValue"), "text");
4468 }
4469
4470 #[test]
4471 fn object_type_to_kebab_geom_value() {
4472 assert_eq!(super::object_type_to_kebab("GeomValue"), "geom");
4474 }
4475
4476 #[test]
4477 fn object_type_to_kebab_geo_name_value() {
4478 assert_eq!(super::object_type_to_kebab("GeoNameValue"), "geo-name");
4480 }
4481
4482 #[test]
4483 fn object_type_to_kebab_uri_value() {
4484 assert_eq!(super::object_type_to_kebab("URIValue"), "uri");
4487 }
4488
4489 #[test]
4490 fn object_type_to_kebab_interval_value() {
4491 assert_eq!(super::object_type_to_kebab("IntervalValue"), "interval");
4494 }
4495
4496 #[test]
4497 fn object_type_to_kebab_no_value_suffix() {
4498 assert_eq!(super::object_type_to_kebab("Geom"), "geom");
4500 }
4501
4502 #[test]
4503 fn map_object_type_known_text_value() {
4504 use crate::model::ValueType;
4505 assert_eq!(
4506 super::map_object_type_to_value_type("TextValue"),
4507 ValueType::Text
4508 );
4509 }
4510
4511 #[test]
4512 fn map_object_type_known_list_value() {
4513 use crate::model::ValueType;
4514 assert_eq!(
4515 super::map_object_type_to_value_type("ListValue"),
4516 ValueType::VocabularyItem
4517 );
4518 }
4519
4520 #[test]
4521 fn map_object_type_other_geom() {
4522 use crate::model::ValueType;
4523 assert_eq!(
4525 super::map_object_type_to_value_type("GeomValue"),
4526 ValueType::Other("geom".to_string())
4527 );
4528 }
4529
4530 #[test]
4531 fn map_object_type_other_uri_value() {
4532 use crate::model::ValueType;
4533 assert_eq!(
4535 super::map_object_type_to_value_type("URIValue"),
4536 ValueType::Other("uri".to_string())
4537 );
4538 }
4539
4540 #[test]
4541 fn map_object_type_other_geo_name_value() {
4542 use crate::model::ValueType;
4543 assert_eq!(
4544 super::map_object_type_to_value_type("GeoNameValue"),
4545 ValueType::Other("geo-name".to_string())
4546 );
4547 }
4548
4549 #[test]
4554 fn decode_cardinality_owl_cardinality_1() {
4555 use crate::model::Cardinality;
4556 let v = serde_json::json!({"owl:cardinality": 1});
4557 assert_eq!(super::decode_cardinality(&v), Cardinality::One);
4558 }
4559
4560 #[test]
4561 fn decode_cardinality_owl_max_cardinality_1() {
4562 use crate::model::Cardinality;
4563 let v = serde_json::json!({"owl:maxCardinality": 1});
4564 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrOne);
4565 }
4566
4567 #[test]
4568 fn decode_cardinality_owl_min_cardinality_0() {
4569 use crate::model::Cardinality;
4570 let v = serde_json::json!({"owl:minCardinality": 0});
4571 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4572 }
4573
4574 #[test]
4575 fn decode_cardinality_owl_min_cardinality_1() {
4576 use crate::model::Cardinality;
4577 let v = serde_json::json!({"owl:minCardinality": 1});
4578 assert_eq!(super::decode_cardinality(&v), Cardinality::OneOrMore);
4579 }
4580
4581 #[test]
4582 fn decode_cardinality_fallback_no_key() {
4583 use crate::model::Cardinality;
4584 let v = serde_json::json!({});
4586 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4587 }
4588
4589 #[test]
4590 fn decode_cardinality_fallback_owl_cardinality_unexpected_value() {
4591 use crate::model::Cardinality;
4592 let v = serde_json::json!({"owl:cardinality": 5});
4594 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4595 }
4596
4597 #[test]
4598 fn decode_cardinality_fallback_owl_max_cardinality_gt1() {
4599 use crate::model::Cardinality;
4600 let v = serde_json::json!({"owl:maxCardinality": 2});
4603 assert_eq!(
4604 super::decode_cardinality(&v),
4605 Cardinality::ZeroOrMore,
4606 "owl:maxCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4607 );
4608 }
4609
4610 #[test]
4611 fn decode_cardinality_fallback_owl_min_cardinality_gt1() {
4612 use crate::model::Cardinality;
4613 let v = serde_json::json!({"owl:minCardinality": 2});
4616 assert_eq!(
4617 super::decode_cardinality(&v),
4618 Cardinality::ZeroOrMore,
4619 "owl:minCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4620 );
4621 }
4622
4623 #[test]
4628 fn detect_representation_still_image() {
4629 use crate::model::Representation;
4630 let locals = vec!["hasStillImageFileValue"];
4631 assert_eq!(
4632 super::detect_representation(&locals),
4633 Some(Representation::StillImage)
4634 );
4635 }
4636
4637 #[test]
4638 fn detect_representation_moving_image() {
4639 use crate::model::Representation;
4640 let locals = vec!["hasMovingImageFileValue"];
4641 assert_eq!(
4642 super::detect_representation(&locals),
4643 Some(Representation::MovingImage)
4644 );
4645 }
4646
4647 #[test]
4648 fn detect_representation_audio() {
4649 use crate::model::Representation;
4650 let locals = vec!["hasAudioFileValue"];
4651 assert_eq!(
4652 super::detect_representation(&locals),
4653 Some(Representation::Audio)
4654 );
4655 }
4656
4657 #[test]
4658 fn detect_representation_none_when_absent() {
4659 let locals = vec!["hasTitle", "hasAuthor"];
4661 assert_eq!(super::detect_representation(&locals), None);
4662 }
4663
4664 #[test]
4665 fn detect_representation_takes_first() {
4666 use crate::model::Representation;
4667 let locals = vec!["hasDocumentFileValue", "hasStillImageFileValue"];
4669 assert_eq!(
4670 super::detect_representation(&locals),
4671 Some(Representation::Document)
4672 );
4673 }
4674
4675 #[test]
4680 fn is_system_prefix_knora_api() {
4681 assert!(super::is_system_prefix("knora-api"));
4682 }
4683
4684 #[test]
4685 fn is_system_prefix_rdf() {
4686 assert!(super::is_system_prefix("rdf"));
4687 }
4688
4689 #[test]
4690 fn is_system_prefix_project_prefix_is_not_system() {
4691 assert!(!super::is_system_prefix("incunabula"));
4692 assert!(!super::is_system_prefix("beol"));
4693 assert!(!super::is_system_prefix("biblio"));
4694 }
4695
4696 #[test]
4701 fn curie_prefix_returns_prefix_for_curie() {
4702 assert_eq!(super::curie_prefix("knora-api:arkUrl"), Some("knora-api"));
4703 assert_eq!(super::curie_prefix("beol:hasTitle"), Some("beol"));
4704 }
4705
4706 #[test]
4707 fn curie_prefix_returns_none_for_full_iri() {
4708 assert_eq!(
4710 super::curie_prefix("http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle"),
4711 None
4712 );
4713 }
4714
4715 #[test]
4716 fn curie_prefix_returns_none_for_no_colon() {
4717 assert_eq!(super::curie_prefix("hasTitle"), None);
4718 }
4719
4720 #[test]
4725 fn sibling_iri_trim_hash_delimiter() {
4726 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4728 let trimmed = namespace.trim_end_matches(['#', '/']);
4729 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4730 }
4731
4732 #[test]
4733 fn sibling_iri_trim_slash_delimiter() {
4734 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2/";
4736 let trimmed = namespace.trim_end_matches(['#', '/']);
4737 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4738 }
4739
4740 #[test]
4741 fn sibling_iri_self_loop_detected() {
4742 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4744 let namespace = "http://api.dasch.swiss/ontology/0801/beol/v2#";
4745 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4746 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4747 assert_eq!(sibling_iri, queried_trimmed); }
4749
4750 #[test]
4751 fn sibling_iri_different_ontology_is_not_self_loop() {
4752 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4753 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4754 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4755 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4756 assert_ne!(sibling_iri, queried_trimmed); }
4758
4759 #[test]
4760 fn missing_prefix_in_context_is_skipped() {
4761 let prefixes: HashMap<String, String> = HashMap::new();
4763 let result = prefixes.get("biblio");
4764 assert!(result.is_none()); }
4766
4767 #[test]
4772 fn derive_access_rv() {
4773 assert_eq!(
4774 super::derive_access("RV"),
4775 Some(super::ResourceAccess::RestrictedView)
4776 );
4777 }
4778
4779 #[test]
4780 fn derive_access_v() {
4781 assert_eq!(super::derive_access("V"), Some(super::ResourceAccess::View));
4782 }
4783
4784 #[test]
4785 fn derive_access_m() {
4786 assert_eq!(super::derive_access("M"), Some(super::ResourceAccess::Edit));
4787 }
4788
4789 #[test]
4790 fn derive_access_d() {
4791 assert_eq!(
4792 super::derive_access("D"),
4793 Some(super::ResourceAccess::Delete)
4794 );
4795 }
4796
4797 #[test]
4798 fn derive_access_cr() {
4799 assert_eq!(
4800 super::derive_access("CR"),
4801 Some(super::ResourceAccess::Manage)
4802 );
4803 }
4804
4805 #[test]
4806 fn derive_access_unknown_is_none() {
4807 assert_eq!(super::derive_access("XYZ"), None);
4808 }
4809
4810 #[test]
4811 fn derive_access_empty_is_none() {
4812 assert_eq!(super::derive_access(""), None);
4813 }
4814
4815 #[test]
4820 fn derive_visibility_public_when_unknown_user_has_view() {
4821 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|V knora-admin:KnownUser,knora-admin:UnknownUser";
4823 assert_eq!(
4824 super::derive_visibility(acl),
4825 Some(super::ResourceVisibility::Public)
4826 );
4827 }
4828
4829 #[test]
4830 fn derive_visibility_public_when_unknown_user_has_cr() {
4831 let acl = "CR knora-admin:UnknownUser";
4833 assert_eq!(
4834 super::derive_visibility(acl),
4835 Some(super::ResourceVisibility::Public)
4836 );
4837 }
4838
4839 #[test]
4840 fn derive_visibility_public_restricted_when_unknown_user_has_rv() {
4841 let acl = "RV knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4843 assert_eq!(
4844 super::derive_visibility(acl),
4845 Some(super::ResourceVisibility::PublicRestricted)
4846 );
4847 }
4848
4849 #[test]
4850 fn derive_visibility_logged_in_when_known_user_has_rv_unknown_absent() {
4851 let acl = "RV knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4853 assert_eq!(
4854 super::derive_visibility(acl),
4855 Some(super::ResourceVisibility::LoggedInUsers)
4856 );
4857 }
4858
4859 #[test]
4860 fn derive_visibility_logged_in_when_known_user_has_v() {
4861 let acl = "V knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4863 assert_eq!(
4864 super::derive_visibility(acl),
4865 Some(super::ResourceVisibility::LoggedInUsers)
4866 );
4867 }
4868
4869 #[test]
4870 fn derive_visibility_project_members_when_neither_world_group_granted() {
4871 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|M knora-admin:ProjectMember";
4873 assert_eq!(
4874 super::derive_visibility(acl),
4875 Some(super::ResourceVisibility::ProjectMembers)
4876 );
4877 }
4878
4879 #[test]
4880 fn derive_visibility_empty_string_is_none() {
4881 assert_eq!(super::derive_visibility(""), None);
4882 }
4883
4884 #[test]
4885 fn derive_visibility_whitespace_only_is_none() {
4886 assert_eq!(super::derive_visibility(" "), None);
4887 }
4888
4889 #[test]
4890 fn derive_visibility_malformed_entry_without_space_is_skipped() {
4891 let acl = "CRMALFORMED|CR knora-admin:ProjectAdmin";
4893 assert_eq!(
4895 super::derive_visibility(acl),
4896 Some(super::ResourceVisibility::ProjectMembers)
4897 );
4898 }
4899
4900 #[test]
4901 fn derive_visibility_unknown_code_ranks_zero_no_implicit_grant() {
4902 let acl = "BOGUS knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4904 assert_eq!(
4906 super::derive_visibility(acl),
4907 Some(super::ResourceVisibility::ProjectMembers)
4908 );
4909 }
4910
4911 #[test]
4912 fn derive_visibility_same_group_two_entries_max_wins() {
4913 let acl = "RV knora-admin:UnknownUser|V knora-admin:UnknownUser";
4915 assert_eq!(
4916 super::derive_visibility(acl),
4917 Some(super::ResourceVisibility::Public)
4918 );
4919 }
4920
4921 #[test]
4922 fn derive_visibility_both_world_groups_unknown_user_decides() {
4923 let acl = "V knora-admin:UnknownUser|CR knora-admin:KnownUser";
4926 assert_eq!(
4927 super::derive_visibility(acl),
4928 Some(super::ResourceVisibility::Public)
4929 );
4930 }
4931
4932 #[test]
4933 fn derive_visibility_super_unknown_user_does_not_match() {
4934 let acl = "CR knora-admin:SuperUnknownUser|CR knora-admin:ProjectAdmin";
4937 assert_eq!(
4939 super::derive_visibility(acl),
4940 Some(super::ResourceVisibility::ProjectMembers)
4941 );
4942 }
4943
4944 #[test]
4945 fn derive_visibility_all_malformed_entries_no_space_returns_none() {
4946 let acl = "NOSPACE|ALSONOSPACE|STILLNOSPACE";
4950 assert_eq!(
4951 super::derive_visibility(acl),
4952 None,
4953 "all-malformed ACL (no space in any entry) must return None"
4954 );
4955 }
4956
4957 use crate::model::ValueType;
4962 use crate::model::resource::{DatePoint, DateValue, FileValue, ValueContent};
4963
4964 #[test]
4967 fn parse_value_text_plain() {
4968 let obj = serde_json::json!({
4969 "@type": "knora-api:TextValue",
4970 "knora-api:valueAsString": "Hello world"
4971 });
4972 let (content, is_link) = super::parse_value_content(&obj);
4973 assert_eq!(content, ValueContent::Text("Hello world".into()));
4974 assert!(!is_link);
4975 }
4976
4977 #[test]
4978 fn parse_value_text_standoff_xml_stripped() {
4979 let obj = serde_json::json!({
4981 "@type": "knora-api:TextValue",
4982 "knora-api:textValueAsXml": "<p>Hello <b>world</b></p>",
4983 "knora-api:valueAsString": "This is ignored when xml present"
4984 });
4985 let (content, is_link) = super::parse_value_content(&obj);
4986 assert!(matches!(content, ValueContent::Text(_)));
4988 assert!(!is_link);
4989 if let ValueContent::Text(s) = content {
4990 assert!(!s.contains('<'), "no raw tags: {s:?}");
4992 assert!(s.contains("Hello"), "text retained: {s:?}");
4993 }
4994 }
4995
4996 #[test]
4999 fn parse_value_integer() {
5000 let obj = serde_json::json!({
5001 "@type": "knora-api:IntValue",
5002 "knora-api:intValueAsInt": 42
5003 });
5004 let (content, is_link) = super::parse_value_content(&obj);
5005 assert_eq!(content, ValueContent::Integer(42));
5006 assert!(!is_link);
5007 }
5008
5009 #[test]
5010 fn parse_value_integer_negative() {
5011 let obj = serde_json::json!({
5012 "@type": "knora-api:IntValue",
5013 "knora-api:intValueAsInt": -7
5014 });
5015 let (content, _) = super::parse_value_content(&obj);
5016 assert_eq!(content, ValueContent::Integer(-7));
5017 }
5018
5019 #[test]
5022 fn parse_value_decimal_object_form() {
5023 let obj = serde_json::json!({
5025 "@type": "knora-api:DecimalValue",
5026 "knora-api:decimalValueAsDecimal": {"@value": "3.14159", "@type": "xsd:decimal"}
5027 });
5028 let (content, is_link) = super::parse_value_content(&obj);
5029 assert_eq!(content, ValueContent::Decimal("3.14159".into()));
5030 assert!(!is_link);
5031 }
5032
5033 #[test]
5034 fn parse_value_decimal_bare_string_form() {
5035 let obj = serde_json::json!({
5036 "@type": "knora-api:DecimalValue",
5037 "knora-api:decimalValueAsDecimal": "2.71828"
5038 });
5039 let (content, _) = super::parse_value_content(&obj);
5040 assert_eq!(content, ValueContent::Decimal("2.71828".into()));
5041 }
5042
5043 #[test]
5046 fn parse_value_boolean_true() {
5047 let obj = serde_json::json!({
5048 "@type": "knora-api:BooleanValue",
5049 "knora-api:booleanValueAsBoolean": true
5050 });
5051 let (content, is_link) = super::parse_value_content(&obj);
5052 assert_eq!(content, ValueContent::Boolean(true));
5053 assert!(!is_link);
5054 }
5055
5056 #[test]
5057 fn parse_value_boolean_false() {
5058 let obj = serde_json::json!({
5059 "@type": "knora-api:BooleanValue",
5060 "knora-api:booleanValueAsBoolean": false
5061 });
5062 let (content, _) = super::parse_value_content(&obj);
5063 assert_eq!(content, ValueContent::Boolean(false));
5064 }
5065
5066 #[test]
5069 fn parse_value_date_single_point() {
5070 let obj = serde_json::json!({
5072 "@type": "knora-api:DateValue",
5073 "knora-api:dateValueHasCalendar": "GREGORIAN",
5074 "knora-api:dateValueHasStartYear": 1489,
5075 "knora-api:dateValueHasStartEra": "CE",
5076 "knora-api:dateValueHasEndYear": 1489,
5077 "knora-api:dateValueHasEndEra": "CE"
5078 });
5079 let (content, is_link) = super::parse_value_content(&obj);
5080 assert!(!is_link);
5081 let expected = ValueContent::Date(DateValue {
5082 calendar: "GREGORIAN".into(),
5083 start: DatePoint {
5084 year: Some(1489),
5085 month: None,
5086 day: None,
5087 era: Some("CE".into()),
5088 },
5089 end: DatePoint {
5090 year: Some(1489),
5091 month: None,
5092 day: None,
5093 era: Some("CE".into()),
5094 },
5095 });
5096 assert_eq!(content, expected);
5097 }
5098
5099 #[test]
5100 fn parse_value_date_range() {
5101 let obj = serde_json::json!({
5103 "@type": "knora-api:DateValue",
5104 "knora-api:dateValueHasCalendar": "GREGORIAN",
5105 "knora-api:dateValueHasStartYear": 1489,
5106 "knora-api:dateValueHasStartEra": "CE",
5107 "knora-api:dateValueHasEndYear": 1490,
5108 "knora-api:dateValueHasEndEra": "CE"
5109 });
5110 let (content, _) = super::parse_value_content(&obj);
5111 if let ValueContent::Date(dv) = content {
5112 assert_eq!(dv.start.year, Some(1489));
5113 assert_eq!(dv.end.year, Some(1490));
5114 assert_ne!(dv.start, dv.end, "range: start != end");
5115 } else {
5116 panic!("expected DateValue, got {content:?}");
5117 }
5118 }
5119
5120 #[test]
5121 fn parse_value_date_full_day_precision() {
5122 let obj = serde_json::json!({
5124 "@type": "knora-api:DateValue",
5125 "knora-api:dateValueHasCalendar": "JULIAN",
5126 "knora-api:dateValueHasStartYear": 1456,
5127 "knora-api:dateValueHasStartMonth": 3,
5128 "knora-api:dateValueHasStartDay": 14,
5129 "knora-api:dateValueHasStartEra": "CE",
5130 "knora-api:dateValueHasEndYear": 1456,
5131 "knora-api:dateValueHasEndMonth": 3,
5132 "knora-api:dateValueHasEndDay": 14,
5133 "knora-api:dateValueHasEndEra": "CE"
5134 });
5135 let (content, _) = super::parse_value_content(&obj);
5136 if let ValueContent::Date(dv) = content {
5137 assert_eq!(dv.calendar, "JULIAN");
5138 assert_eq!(dv.start.month, Some(3));
5139 assert_eq!(dv.start.day, Some(14));
5140 } else {
5141 panic!("expected DateValue, got {content:?}");
5142 }
5143 }
5144
5145 #[test]
5146 fn parse_value_date_no_year_falls_back_to_raw() {
5147 let obj = serde_json::json!({
5149 "@type": "knora-api:DateValue",
5150 "knora-api:dateValueHasCalendar": "GREGORIAN",
5151 "knora-api:valueAsString": "some date"
5152 });
5153 let (content, _) = super::parse_value_content(&obj);
5154 assert!(
5155 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "date"),
5156 "missing years must degrade to Raw date"
5157 );
5158 }
5159
5160 #[test]
5163 fn parse_value_time() {
5164 let obj = serde_json::json!({
5165 "@type": "knora-api:TimeValue",
5166 "knora-api:timeValueAsTimeStamp": {"@value": "2021-01-01T12:00:00Z", "@type": "xsd:dateTimeStamp"}
5167 });
5168 let (content, is_link) = super::parse_value_content(&obj);
5169 assert_eq!(content, ValueContent::Time("2021-01-01T12:00:00Z".into()));
5170 assert!(!is_link);
5171 }
5172
5173 #[test]
5174 fn parse_value_time_bare_string() {
5175 let obj = serde_json::json!({
5176 "@type": "knora-api:TimeValue",
5177 "knora-api:timeValueAsTimeStamp": "2022-06-01T00:00:00Z"
5178 });
5179 let (content, _) = super::parse_value_content(&obj);
5180 assert_eq!(content, ValueContent::Time("2022-06-01T00:00:00Z".into()));
5181 }
5182
5183 #[test]
5186 fn parse_value_uri() {
5187 let obj = serde_json::json!({
5188 "@type": "knora-api:UriValue",
5189 "knora-api:uriValueAsUri": {"@value": "https://example.com", "@type": "xsd:anyURI"}
5190 });
5191 let (content, is_link) = super::parse_value_content(&obj);
5192 assert_eq!(content, ValueContent::Uri("https://example.com".into()));
5193 assert!(!is_link);
5194 }
5195
5196 #[test]
5199 fn parse_value_color() {
5200 let obj = serde_json::json!({
5201 "@type": "knora-api:ColorValue",
5202 "knora-api:colorValueAsColor": "#ff0000"
5203 });
5204 let (content, is_link) = super::parse_value_content(&obj);
5205 assert_eq!(content, ValueContent::Color("#ff0000".into()));
5206 assert!(!is_link);
5207 }
5208
5209 #[test]
5212 fn parse_value_geoname() {
5213 let obj = serde_json::json!({
5214 "@type": "knora-api:GeonameValue",
5215 "knora-api:geonameValueAsGeonameCode": "2661552"
5216 });
5217 let (content, is_link) = super::parse_value_content(&obj);
5218 assert_eq!(content, ValueContent::Geoname("2661552".into()));
5219 assert!(!is_link);
5220 }
5221
5222 #[test]
5225 fn parse_value_vocabulary_item() {
5226 let obj = serde_json::json!({
5227 "@type": "knora-api:ListValue",
5228 "knora-api:listValueAsListNode": {"@id": "http://rdfh.ch/lists/0001/node1"}
5229 });
5230 let (content, is_link) = super::parse_value_content(&obj);
5231 assert_eq!(
5232 content,
5233 ValueContent::VocabularyItem {
5234 node_iri: "http://rdfh.ch/lists/0001/node1".into(),
5235 label: None, }
5237 );
5238 assert!(!is_link);
5239 }
5240
5241 #[test]
5244 fn parse_value_link_with_embedded_target() {
5245 let obj = serde_json::json!({
5246 "@type": "knora-api:LinkValue",
5247 "knora-api:linkValueHasTarget": {
5248 "@id": "http://rdfh.ch/0803/res1",
5249 "@type": "incunabula:Book",
5250 "rdfs:label": "Incunabula Book 1"
5251 }
5252 });
5253 let (content, is_link) = super::parse_value_content(&obj);
5254 assert!(is_link, "LinkValue must set is_link=true");
5255 assert_eq!(
5256 content,
5257 ValueContent::Link {
5258 target_iri: "http://rdfh.ch/0803/res1".into(),
5259 target_label: Some("Incunabula Book 1".into()),
5260 }
5261 );
5262 }
5263
5264 #[test]
5265 fn parse_value_link_with_target_iri_only() {
5266 let obj = serde_json::json!({
5268 "@type": "knora-api:LinkValue",
5269 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res2"}
5270 });
5271 let (content, is_link) = super::parse_value_content(&obj);
5272 assert!(is_link);
5273 assert_eq!(
5274 content,
5275 ValueContent::Link {
5276 target_iri: "http://rdfh.ch/0803/res2".into(),
5277 target_label: None,
5278 }
5279 );
5280 }
5281
5282 #[test]
5285 fn parse_value_still_image_file() {
5286 let obj = serde_json::json!({
5287 "@type": "knora-api:StillImageFileValue",
5288 "knora-api:fileValueHasFilename": "image.jp2",
5289 "knora-api:fileValueAsUrl": {"@value": "https://iiif.example.com/image.jp2/full/max/0/default.jpg"},
5290 "knora-api:stillImageFileValueHasDimX": 1200,
5291 "knora-api:stillImageFileValueHasDimY": 800
5292 });
5293 let (content, is_link) = super::parse_value_content(&obj);
5294 assert!(!is_link);
5295 assert_eq!(
5296 content,
5297 ValueContent::File(FileValue {
5298 value_type: ValueType::StillImage,
5299 filename: "image.jp2".into(),
5300 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
5301 width: Some(1200),
5302 height: Some(800),
5303 })
5304 );
5305 }
5306
5307 #[test]
5308 fn parse_value_still_image_external_file_value() {
5309 let obj = serde_json::json!({
5311 "@type": "knora-api:StillImageExternalFileValue",
5312 "knora-api:fileValueHasFilename": "external.jpg",
5313 "knora-api:fileValueAsUrl": {"@value": "https://iiif.external.com/image.jpg"}
5314 });
5315 let (content, _) = super::parse_value_content(&obj);
5316 if let ValueContent::File(fv) = content {
5317 assert_eq!(
5318 fv.value_type,
5319 ValueType::StillImage,
5320 "StillImageExternal* → StillImage"
5321 );
5322 } else {
5323 panic!("expected File, got {content:?}");
5324 }
5325 }
5326
5327 #[test]
5330 fn parse_value_moving_image_file() {
5331 let obj = serde_json::json!({
5332 "@type": "knora-api:MovingImageFileValue",
5333 "knora-api:fileValueHasFilename": "video.mp4",
5334 "knora-api:fileValueAsUrl": {"@value": "https://example.com/video.mp4"}
5335 });
5336 let (content, is_link) = super::parse_value_content(&obj);
5337 assert!(!is_link);
5338 assert_eq!(
5339 content,
5340 ValueContent::File(FileValue {
5341 value_type: ValueType::MovingImage,
5342 filename: "video.mp4".into(),
5343 url: "https://example.com/video.mp4".into(),
5344 width: None,
5345 height: None,
5346 })
5347 );
5348 }
5349
5350 #[test]
5353 fn parse_value_audio_file() {
5354 let obj = serde_json::json!({
5355 "@type": "knora-api:AudioFileValue",
5356 "knora-api:fileValueHasFilename": "sound.wav",
5357 "knora-api:fileValueAsUrl": {"@value": "https://example.com/sound.wav"}
5358 });
5359 let (content, _) = super::parse_value_content(&obj);
5360 assert_eq!(
5361 content,
5362 ValueContent::File(FileValue {
5363 value_type: ValueType::Audio,
5364 filename: "sound.wav".into(),
5365 url: "https://example.com/sound.wav".into(),
5366 width: None,
5367 height: None,
5368 })
5369 );
5370 }
5371
5372 #[test]
5375 fn parse_value_document_file() {
5376 let obj = serde_json::json!({
5377 "@type": "knora-api:DocumentFileValue",
5378 "knora-api:fileValueHasFilename": "doc.pdf",
5379 "knora-api:fileValueAsUrl": {"@value": "https://example.com/doc.pdf"}
5380 });
5381 let (content, _) = super::parse_value_content(&obj);
5382 assert_eq!(
5383 content,
5384 ValueContent::File(FileValue {
5385 value_type: ValueType::Document,
5386 filename: "doc.pdf".into(),
5387 url: "https://example.com/doc.pdf".into(),
5388 width: None,
5389 height: None,
5390 })
5391 );
5392 }
5393
5394 #[test]
5397 fn parse_value_archive_file() {
5398 let obj = serde_json::json!({
5399 "@type": "knora-api:ArchiveFileValue",
5400 "knora-api:fileValueHasFilename": "data.zip",
5401 "knora-api:fileValueAsUrl": {"@value": "https://example.com/data.zip"}
5402 });
5403 let (content, _) = super::parse_value_content(&obj);
5404 assert_eq!(
5405 content,
5406 ValueContent::File(FileValue {
5407 value_type: ValueType::Archive,
5408 filename: "data.zip".into(),
5409 url: "https://example.com/data.zip".into(),
5410 width: None,
5411 height: None,
5412 })
5413 );
5414 }
5415
5416 #[test]
5419 fn parse_value_text_file_value_maps_to_document() {
5420 let obj = serde_json::json!({
5421 "@type": "knora-api:TextFileValue",
5422 "knora-api:fileValueHasFilename": "text.txt",
5423 "knora-api:fileValueAsUrl": {"@value": "https://example.com/text.txt"}
5424 });
5425 let (content, _) = super::parse_value_content(&obj);
5426 if let ValueContent::File(fv) = content {
5427 assert_eq!(
5428 fv.value_type,
5429 ValueType::Document,
5430 "TextFileValue → Document"
5431 );
5432 } else {
5433 panic!("expected File, got {content:?}");
5434 }
5435 }
5436
5437 #[test]
5440 fn parse_value_interval_raw_fallback() {
5441 let obj = serde_json::json!({
5442 "@type": "knora-api:IntervalValue",
5443 "knora-api:intervalValueHasStart": {"@value": "0.0", "@type": "xsd:decimal"},
5444 "knora-api:intervalValueHasEnd": {"@value": "10.5", "@type": "xsd:decimal"},
5445 "knora-api:valueAsString": "0.0 - 10.5"
5446 });
5447 let (content, is_link) = super::parse_value_content(&obj);
5448 assert!(!is_link);
5449 assert!(
5450 matches!(content, ValueContent::Raw { ref value_type, .. } if value_type == "interval"),
5451 "IntervalValue must degrade to Raw with token 'interval'"
5452 );
5453 if let ValueContent::Raw { text, .. } = content {
5454 assert_eq!(text, "0.0 - 10.5");
5455 }
5456 }
5457
5458 #[test]
5459 fn parse_value_geom_raw_fallback() {
5460 let obj = serde_json::json!({
5461 "@type": "knora-api:GeomValue",
5462 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
5463 });
5464 let (content, _) = super::parse_value_content(&obj);
5465 assert!(
5466 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "geom"),
5467 "GeomValue must degrade to Raw with token 'geom'"
5468 );
5469 }
5470
5471 #[test]
5474 fn parse_value_with_comment() {
5475 let obj = serde_json::json!({
5476 "@type": "knora-api:TextValue",
5477 "knora-api:valueAsString": "Hello world",
5478 "knora-api:valueHasComment": "reading uncertain"
5479 });
5480 let (value, is_link) = super::parse_value(&obj);
5481 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
5482 assert_eq!(value.comment.as_deref(), Some("reading uncertain"));
5483 assert!(!is_link);
5484 }
5485
5486 #[test]
5487 fn parse_value_without_comment() {
5488 let obj = serde_json::json!({
5489 "@type": "knora-api:TextValue",
5490 "knora-api:valueAsString": "Hello world"
5491 });
5492 let (value, is_link) = super::parse_value(&obj);
5493 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
5494 assert_eq!(value.comment, None);
5495 assert!(!is_link);
5496 }
5497
5498 #[test]
5499 fn parse_value_with_empty_comment() {
5500 let obj = serde_json::json!({
5501 "@type": "knora-api:TextValue",
5502 "knora-api:valueAsString": "Hello world",
5503 "knora-api:valueHasComment": ""
5504 });
5505 let (value, is_link) = super::parse_value(&obj);
5506 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
5507 assert_eq!(value.comment, None);
5508 assert!(!is_link);
5509 }
5510
5511 #[test]
5514 fn parse_value_link_is_link_true() {
5515 let obj = serde_json::json!({
5517 "@type": "knora-api:LinkValue",
5518 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
5519 });
5520 let (_, is_link) = super::parse_value_content(&obj);
5521 assert!(
5522 is_link,
5523 "LinkValue must report is_link=true for name derivation"
5524 );
5525 }
5526
5527 #[test]
5528 fn field_name_link_strips_value_suffix() {
5529 let key = "incunabula:isPartOfBookValue";
5533 let link_obj = serde_json::json!({
5534 "@type": "knora-api:LinkValue",
5535 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
5536 });
5537 let (_, is_link) = super::parse_value_content(&link_obj);
5538 assert!(
5539 is_link,
5540 "LinkValue must report is_link=true for name derivation"
5541 );
5542
5543 let raw_name = super::local_name(key).to_string();
5544 let name = if is_link {
5546 raw_name
5547 .strip_suffix("Value")
5548 .unwrap_or(&raw_name)
5549 .to_string()
5550 } else {
5551 raw_name
5552 };
5553 assert_eq!(name, "isPartOfBook");
5554 }
5555
5556 #[test]
5557 fn field_name_non_link_does_not_strip_value_suffix() {
5558 let key = "incunabula:hasAValue";
5563 let text_obj = serde_json::json!({
5564 "@type": "knora-api:TextValue",
5565 "knora-api:valueAsString": "some text"
5566 });
5567 let (_, is_link) = super::parse_value_content(&text_obj);
5568 assert!(!is_link, "TextValue must report is_link=false");
5569
5570 let raw_name = super::local_name(key).to_string();
5571 let name = if is_link {
5573 raw_name
5574 .strip_suffix("Value")
5575 .unwrap_or(&raw_name)
5576 .to_string()
5577 } else {
5578 raw_name
5579 };
5580 assert_eq!(
5581 name, "hasAValue",
5582 "non-link ending in Value must NOT be stripped; is_link={is_link}"
5583 );
5584 }
5585
5586 #[test]
5589 fn has_value_class_type_rejects_xsd_any_uri() {
5590 let obj = serde_json::json!({
5592 "@value": "http://ark.dasch.swiss/ark:/…",
5593 "@type": "xsd:anyURI"
5594 });
5595 assert!(
5596 !super::has_value_class_type(&obj),
5597 "xsd:anyURI must not pass the value-class test"
5598 );
5599 }
5600
5601 #[test]
5602 fn has_value_class_type_rejects_scalar() {
5603 let obj = serde_json::json!("just a string");
5605 assert!(!super::has_value_class_type(&obj));
5606 }
5607
5608 #[test]
5609 fn has_value_class_type_accepts_text_value() {
5610 let obj = serde_json::json!({
5611 "@type": "knora-api:TextValue",
5612 "knora-api:valueAsString": "hello"
5613 });
5614 assert!(super::has_value_class_type(&obj));
5615 }
5616
5617 #[test]
5618 fn has_value_class_type_accepts_still_image_file_value() {
5619 let obj = serde_json::json!({
5620 "@type": "knora-api:StillImageFileValue",
5621 "knora-api:fileValueHasFilename": "img.jp2"
5622 });
5623 assert!(super::has_value_class_type(&obj));
5624 }
5625
5626 #[test]
5629 fn build_prefix_map_string_entries_only() {
5630 let ctx = Some(serde_json::json!({
5631 "incunabula": "http://api.dasch.swiss/ontology/0803/incunabula/v2#",
5632 "knora-api": "http://api.knora.org/ontology/knora-api/v2#",
5633 "someterm": {"@id": "http://example.com/term", "@type": "@id"}
5635 }));
5636 let map = super::build_prefix_map(&ctx);
5637 assert_eq!(
5638 map.get("incunabula").map(String::as_str),
5639 Some("http://api.dasch.swiss/ontology/0803/incunabula/v2#")
5640 );
5641 assert_eq!(
5642 map.get("knora-api").map(String::as_str),
5643 Some("http://api.knora.org/ontology/knora-api/v2#")
5644 );
5645 assert!(
5646 !map.contains_key("someterm"),
5647 "object-valued entry must be skipped"
5648 );
5649 }
5650
5651 #[test]
5652 fn build_prefix_map_empty_when_no_context() {
5653 let map = super::build_prefix_map(&None);
5654 assert!(map.is_empty());
5655 }
5656
5657 #[test]
5660 fn compact_value_text_excludes_meta_keys() {
5661 let obj = serde_json::json!({
5662 "@id": "http://rdfh.ch/0803/val1",
5663 "@type": "knora-api:GeomValue",
5664 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
5665 });
5666 let text = super::compact_value_text(&obj);
5667 assert!(
5669 text.contains("geometryValueAsGeometry"),
5670 "geometry key present: {text}"
5671 );
5672 assert!(!text.contains("@id"), "@id must be excluded: {text}");
5673 assert!(!text.contains("@type"), "@type must be excluded: {text}");
5674 }
5675
5676 #[test]
5677 fn compact_value_text_all_meta_yields_empty() {
5678 let obj = serde_json::json!({
5679 "@id": "http://rdfh.ch/0803/val1",
5680 "@type": "knora-api:IntervalValue"
5681 });
5682 let text = super::compact_value_text(&obj);
5683 assert!(
5684 text.is_empty(),
5685 "all-meta object must yield empty string: {text:?}"
5686 );
5687 }
5688
5689 #[test]
5692 fn list_get_response_root_shape_parses_as_root_variant() {
5693 let json = serde_json::json!({
5696 "type": "ListGetResponseADM",
5697 "list": {
5698 "listinfo": {
5699 "id": "http://rdfh.ch/lists/0001/root",
5700 "projectIri": "http://rdfh.ch/projects/0001",
5701 "name": "root-name",
5702 "labels": [
5703 {"value": "Root EN", "language": "en"},
5704 {"value": "Root DE", "language": "de"}
5705 ],
5706 "comments": []
5707 },
5708 "children": [
5709 {"id": "n2", "name": "n2", "labels": [], "comments": [], "position": 1, "children": []},
5710 {"id": "n1", "name": "n1", "labels": [], "comments": [], "position": 0, "children": [
5711 {"id": "n1a", "name": "n1a", "labels": [], "comments": [], "position": 0, "children": []}
5712 ]}
5713 ]
5714 }
5715 });
5716
5717 let parsed: ListGetResponseDto =
5718 serde_json::from_value(json).expect("root shape must parse");
5719 let root = match parsed {
5720 ListGetResponseDto::Root(root) => root,
5721 ListGetResponseDto::Node(_) => panic!("expected Root variant, got Node"),
5722 };
5723
5724 let tree = build_vocabulary_tree(root.list, None);
5725 assert_eq!(tree.root.iri, "http://rdfh.ch/lists/0001/root");
5726 assert_eq!(tree.root.name.as_deref(), Some("root-name"));
5727 assert_eq!(tree.root.labels.len(), 2, "both languages kept (D4)");
5728 assert_eq!(tree.project_iri, "http://rdfh.ch/projects/0001");
5729 assert_eq!(tree.requested_node, None);
5730
5731 assert_eq!(tree.children.len(), 2);
5734 assert_eq!(tree.children[0].header.iri, "n1");
5735 assert_eq!(tree.children[1].header.iri, "n2");
5736 assert_eq!(tree.children[0].children.len(), 1);
5737 assert_eq!(tree.children[0].children[0].header.iri, "n1a");
5738 }
5739
5740 #[test]
5741 fn list_get_response_node_shape_parses_as_node_variant_and_extracts_has_root_node() {
5742 let json = serde_json::json!({
5744 "type": "ListNodeGetResponseADM",
5745 "node": {
5746 "nodeinfo": {
5747 "id": "http://rdfh.ch/lists/0001/n1",
5748 "name": "n1",
5749 "labels": [{"value": "N1", "language": "en"}],
5750 "comments": [],
5751 "position": 0,
5752 "hasRootNode": "http://rdfh.ch/lists/0001/root"
5753 },
5754 "children": []
5755 }
5756 });
5757
5758 let parsed: ListGetResponseDto =
5759 serde_json::from_value(json).expect("node shape must parse");
5760 match parsed {
5761 ListGetResponseDto::Node(node) => {
5762 assert_eq!(
5763 node.node.nodeinfo.has_root_node,
5764 "http://rdfh.ch/lists/0001/root"
5765 );
5766 }
5767 ListGetResponseDto::Root(_) => panic!("expected Node variant, got Root"),
5768 }
5769 }
5770
5771 #[test]
5772 fn list_get_response_neither_key_fails_parse() {
5773 let json = serde_json::json!({"type": "SomethingUnexpected", "foo": "bar"});
5777 let parsed = serde_json::from_value::<ListGetResponseDto>(json);
5778 assert!(
5779 parsed.is_err(),
5780 "a response with neither `list` nor `node` must fail to parse"
5781 );
5782 }
5783
5784 #[test]
5785 fn into_localized_texts_keeps_all_languages_no_filtering() {
5786 let dtos = vec![
5788 ListLabelDto {
5789 value: "a".into(),
5790 language: Some("en".into()),
5791 },
5792 ListLabelDto {
5793 value: "b".into(),
5794 language: None,
5795 },
5796 ];
5797 let texts = into_localized_texts(dtos);
5798 assert_eq!(texts.len(), 2);
5799 assert_eq!(texts[0].value, "a");
5800 assert_eq!(texts[0].language.as_deref(), Some("en"));
5801 assert_eq!(texts[1].value, "b");
5802 assert_eq!(texts[1].language, None);
5803 }
5804
5805 #[test]
5806 fn convert_list_nodes_sorts_and_nests_out_of_order_input() {
5807 let leaf_2b1 = ListNodeDto {
5810 id: "2b1".into(),
5811 name: None,
5812 labels: vec![],
5813 comments: vec![],
5814 position: 0,
5815 children: vec![],
5816 };
5817 let node_2b = ListNodeDto {
5818 id: "2b".into(),
5819 name: None,
5820 labels: vec![],
5821 comments: vec![],
5822 position: 1,
5823 children: vec![leaf_2b1],
5824 };
5825 let node_2a = ListNodeDto {
5826 id: "2a".into(),
5827 name: None,
5828 labels: vec![],
5829 comments: vec![],
5830 position: 0,
5831 children: vec![],
5832 };
5833 let node_2 = ListNodeDto {
5835 id: "2".into(),
5836 name: None,
5837 labels: vec![],
5838 comments: vec![],
5839 position: 1,
5840 children: vec![node_2b, node_2a],
5841 };
5842 let node_1 = ListNodeDto {
5843 id: "1".into(),
5844 name: None,
5845 labels: vec![],
5846 comments: vec![],
5847 position: 0,
5848 children: vec![],
5849 };
5850 let converted = convert_list_nodes(vec![node_2, node_1]);
5852
5853 assert_eq!(converted.len(), 2);
5854 assert_eq!(converted[0].header.iri, "1");
5855 assert_eq!(converted[0].position, 0);
5856 assert_eq!(converted[1].header.iri, "2");
5857 assert_eq!(converted[1].position, 1);
5858
5859 let node2_children = &converted[1].children;
5860 assert_eq!(node2_children.len(), 2);
5861 assert_eq!(node2_children[0].header.iri, "2a");
5862 assert_eq!(node2_children[1].header.iri, "2b");
5863 assert_eq!(node2_children[1].children.len(), 1);
5864 assert_eq!(node2_children[1].children[0].header.iri, "2b1");
5865 }
5866
5867 #[test]
5868 fn build_vocabulary_tree_sets_requested_node_when_provided() {
5869 let list = ListRootDto {
5870 listinfo: ListInfoDto {
5871 id: "root".into(),
5872 project_iri: "proj".into(),
5873 name: Some("Root".into()),
5874 labels: vec![],
5875 comments: vec![],
5876 },
5877 children: vec![],
5878 };
5879 let tree = build_vocabulary_tree(list, Some("node-iri".into()));
5880 assert_eq!(tree.requested_node.as_deref(), Some("node-iri"));
5881 assert_eq!(tree.root.iri, "root");
5882 assert_eq!(tree.project_iri, "proj");
5883 assert!(tree.children.is_empty());
5884 }
5885}