1use std::collections::{HashMap, HashSet};
29use std::io::{Read, Write};
30use std::time::Duration;
31
32use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
33
34use crate::client::DspClient;
35use crate::client::builtins::builtin_field_value_type;
36use crate::client::jwt::extract_exp;
37use crate::diagnostic::Diagnostic;
38use crate::model::auth::LoginResponse;
39use crate::model::resource::{DatePoint, DateValue, FieldValues, FileValue, Value, ValueContent};
40use crate::model::{
41 Cardinality, CreateDumpOutcome, DataModel, DataModelDetail, DataModelStructure, DataModelSummary, DumpStatus,
42 DumpTask, Field, LocalizedText, Project, ProjectDescription, ProjectDetail, ProjectRef, Relation, RelationKind,
43 Representation, ResourceAccess, ResourceDetail, ResourcePage, ResourceSummary, ResourceTypeDetail,
44 ResourceTypeSummary, ResourceVisibility, ValueType, Vocabulary, VocabularyHeader, VocabularyNode, VocabularyTree,
45};
46
47#[derive(serde::Deserialize)]
56struct LoginApiResponse {
57 token: String,
58}
59
60#[derive(serde::Deserialize)]
66struct ProjectGetApiResponse {
67 project: ProjectApiDto,
68}
69
70#[derive(serde::Deserialize)]
71struct ProjectApiDto {
72 id: String,
73 shortcode: String,
74 shortname: String,
75}
76
77#[derive(serde::Deserialize)]
84struct DataTaskStatusApiResponse {
85 id: String,
86 status: String,
87 #[serde(default, rename = "errorMessage")]
88 error_message: Option<String>,
89 #[serde(default, rename = "createdAt")]
94 created_at: Option<String>,
95}
96
97#[derive(serde::Deserialize)]
105struct V3ErrorBody {
106 #[serde(default)]
107 errors: Vec<V3ErrorItem>,
108}
109
110#[derive(serde::Deserialize)]
111struct V3ErrorItem {
112 code: String,
113 #[serde(default)]
114 details: std::collections::HashMap<String, String>,
115}
116
117#[derive(serde::Deserialize)]
126struct OntologyAndResourceClassesDto {
127 #[serde(rename = "classesAndCount", default)]
128 classes_and_count: Vec<ClassAndCountDto>,
129}
130
131#[derive(serde::Deserialize)]
138struct ClassAndCountDto {
139 #[serde(rename = "resourceClass")]
140 resource_class: ResourceClassRefDto,
141 #[serde(rename = "itemCount")]
142 item_count: u64,
143}
144
145#[derive(serde::Deserialize)]
148struct ResourceClassRefDto {
149 iri: String,
150}
151
152#[derive(serde::Deserialize)]
157struct ProjectsListApiResponse {
158 projects: Vec<ProjectListItemDto>,
159}
160
161#[derive(serde::Deserialize)]
166struct ProjectListItemDto {
167 id: String,
168 shortname: String,
169 shortcode: String,
170 #[serde(default)]
171 longname: Option<String>,
172 #[serde(default)]
173 ontologies: Vec<String>,
174}
175
176#[derive(serde::Deserialize)]
185struct ProjectDetailApiResponse {
186 project: ProjectDetailApiDto,
187}
188
189#[derive(serde::Deserialize)]
190struct ProjectDetailApiDto {
191 id: String,
192 shortcode: String,
193 shortname: String,
194 #[serde(default)]
195 longname: Option<String>,
196 #[serde(default)]
197 description: Vec<ProjectDescriptionDto>,
198 #[serde(default)]
199 keywords: Vec<String>,
200 #[serde(default)]
201 ontologies: Vec<String>,
202}
203
204#[derive(serde::Deserialize)]
205struct ProjectDescriptionDto {
206 value: String,
207 #[serde(default)]
208 language: Option<String>,
209}
210
211#[derive(serde::Deserialize)]
223struct OntologyMetadataResponse {
224 #[serde(rename = "@graph")]
225 graph: Option<Vec<OntologyMetadataDto>>,
226 #[serde(rename = "@id")]
228 id: Option<String>,
229 #[serde(rename = "rdfs:label")]
230 label: Option<String>,
231 #[serde(rename = "knora-api:lastModificationDate", default)]
232 last_modification_date: Option<LastModDto>,
233}
234
235#[derive(serde::Deserialize)]
236struct OntologyMetadataDto {
237 #[serde(rename = "@id")]
238 id: String,
239 #[serde(rename = "rdfs:label")]
240 label: Option<String>,
241 #[serde(rename = "knora-api:lastModificationDate", default)]
242 last_modification_date: Option<LastModDto>,
243}
244
245#[derive(serde::Deserialize)]
253struct LastModDto {
254 #[serde(rename = "@value")]
255 value: String,
256}
257
258#[derive(serde::Deserialize)]
262struct OntologyAllEntitiesResponse {
263 #[serde(rename = "@id")]
264 id: String,
265 #[serde(rename = "rdfs:label")]
266 label: Option<String>,
267 #[serde(rename = "knora-api:lastModificationDate", default)]
268 last_modification_date: Option<LastModDto>,
269 #[serde(rename = "@graph", default)]
270 graph: Vec<OntologyEntityDto>,
271 #[serde(rename = "@context", default)]
279 context: HashMap<String, serde_json::Value>,
280}
281
282#[derive(serde::Deserialize)]
300struct OntologyEntityDto {
301 #[serde(rename = "@id")]
302 id: String,
303 #[serde(rename = "rdfs:label")]
304 label: Option<String>,
305 #[serde(rename = "knora-api:isResourceClass", default)]
306 is_resource_class: bool,
307 #[serde(rename = "rdfs:subClassOf", default)]
312 sub_class_of: Vec<serde_json::Value>,
313 #[serde(rename = "knora-api:objectType")]
316 object_type: Option<ObjectTypeDto>,
317 #[serde(rename = "knora-api:isLinkProperty", default)]
319 is_link_property: bool,
320 #[serde(rename = "knora-api:isLinkValueProperty", default)]
323 is_link_value_property: bool,
324 #[serde(rename = "knora-api:isResourceProperty", default)]
326 is_resource_property: bool,
327}
328
329#[derive(serde::Deserialize, Clone)]
331struct ObjectTypeDto {
332 #[serde(rename = "@id")]
333 id: String,
334}
335
336struct ExportExists<'a> {
341 id: Option<&'a str>,
343 project_iri: Option<&'a str>,
345}
346
347impl V3ErrorBody {
348 fn export_exists(&self) -> Option<ExportExists<'_>> {
354 self.errors.iter().find(|e| e.code == "export_exists").map(|e| ExportExists {
355 id: e.details.get("id").map(String::as_str),
356 project_iri: e.details.get("projectIri").map(String::as_str),
357 })
358 }
359}
360
361impl DataTaskStatusApiResponse {
362 fn into_dump_task(self) -> Result<DumpTask, Diagnostic> {
375 validate_dump_id(&self.id)?;
379
380 let status = match self.status.as_str() {
381 "in_progress" => DumpStatus::InProgress,
382 "completed" => DumpStatus::Completed,
383 "failed" => DumpStatus::Failed,
384 other => {
385 return Err(Diagnostic::ServerError(format!(
386 "server returned unknown dump status: '{other}'"
387 )));
388 }
389 };
390
391 let error_message = self.error_message.map(|raw| {
395 let truncated = if raw.chars().count() > 500 {
396 raw.chars().take(500).collect::<String>()
397 } else {
398 raw
399 };
400 tracing::trace!("dump task error_message (truncated): {}", truncated);
401 truncated
402 });
403
404 let created_at = self.created_at.and_then(|s| match chrono::DateTime::parse_from_rfc3339(&s) {
407 Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
408 Err(_) => {
409 tracing::debug!(raw = %s, "dump task createdAt could not be parsed as RFC3339; using None");
410 None
411 }
412 });
413
414 Ok(DumpTask { id: self.id, status, error_message, created_at })
415 }
416}
417
418#[derive(serde::Deserialize)]
424struct ListsListApiResponse {
425 lists: Vec<ListSummaryDto>,
426}
427
428#[derive(serde::Deserialize)]
433struct ListSummaryDto {
434 id: String,
435 #[serde(default)]
436 name: Option<String>,
437 #[serde(default)]
438 labels: Vec<ListLabelDto>,
439 #[serde(default)]
440 comments: Vec<ListLabelDto>,
441}
442
443#[derive(serde::Deserialize, Clone)]
447struct ListLabelDto {
448 value: String,
449 #[serde(default)]
450 language: Option<String>,
451}
452
453#[derive(serde::Deserialize)]
463#[serde(untagged)]
464enum ListGetResponseDto {
465 Root(ListRootResponseDto),
466 Node(ListNodeGetResponseDto),
467}
468
469#[derive(serde::Deserialize)]
470struct ListRootResponseDto {
471 list: ListRootDto,
472}
473
474#[derive(serde::Deserialize)]
475struct ListRootDto {
476 listinfo: ListInfoDto,
477 #[serde(default)]
478 children: Vec<ListNodeDto>,
479}
480
481#[derive(serde::Deserialize)]
485struct ListInfoDto {
486 id: String,
487 #[serde(rename = "projectIri")]
488 project_iri: String,
489 #[serde(default)]
490 name: Option<String>,
491 #[serde(default)]
492 labels: Vec<ListLabelDto>,
493 #[serde(default)]
494 comments: Vec<ListLabelDto>,
495}
496
497#[derive(serde::Deserialize)]
498struct ListNodeGetResponseDto {
499 node: ListNodeGetDto,
500}
501
502#[derive(serde::Deserialize)]
505struct ListNodeGetDto {
506 nodeinfo: ListNodeInfoDto,
507}
508
509#[derive(serde::Deserialize)]
510struct ListNodeInfoDto {
511 #[serde(rename = "hasRootNode")]
512 has_root_node: String,
513}
514
515#[derive(serde::Deserialize)]
521struct ListNodeDto {
522 id: String,
523 #[serde(default)]
524 name: Option<String>,
525 #[serde(default)]
526 labels: Vec<ListLabelDto>,
527 #[serde(default)]
528 comments: Vec<ListLabelDto>,
529 position: i32,
530 #[serde(default)]
531 children: Vec<ListNodeDto>,
532}
533
534fn into_localized_texts(dtos: Vec<ListLabelDto>) -> Vec<LocalizedText> {
538 dtos.into_iter()
539 .map(|d| LocalizedText { value: d.value, language: d.language })
540 .collect()
541}
542
543fn build_vocabulary_tree(list: ListRootDto, requested_node: Option<String>) -> VocabularyTree {
549 VocabularyTree {
550 root: VocabularyHeader {
551 iri: list.listinfo.id,
552 name: list.listinfo.name,
553 labels: into_localized_texts(list.listinfo.labels),
554 comments: into_localized_texts(list.listinfo.comments),
555 },
556 children: convert_list_nodes(list.children),
557 project_iri: list.listinfo.project_iri,
558 requested_node,
559 }
560}
561
562struct ListNodeConversionFrame {
565 header: VocabularyHeader,
566 position: i32,
567 remaining_children: std::collections::VecDeque<ListNodeDto>,
569 converted_children: Vec<VocabularyNode>,
571}
572
573fn convert_list_nodes(dtos: Vec<ListNodeDto>) -> Vec<VocabularyNode> {
584 fn dto_to_frame(dto: ListNodeDto) -> ListNodeConversionFrame {
585 let mut children = dto.children;
586 children.sort_by_key(|c| c.position);
587 ListNodeConversionFrame {
588 header: VocabularyHeader {
589 iri: dto.id,
590 name: dto.name,
591 labels: into_localized_texts(dto.labels),
592 comments: into_localized_texts(dto.comments),
593 },
594 position: dto.position,
595 remaining_children: children.into(),
596 converted_children: Vec::new(),
597 }
598 }
599
600 let mut top_level = dtos;
601 top_level.sort_by_key(|d| d.position);
602 let mut top_level: std::collections::VecDeque<ListNodeDto> = top_level.into();
603
604 let mut result: Vec<VocabularyNode> = Vec::new();
605 let mut stack: Vec<ListNodeConversionFrame> = Vec::new();
606
607 loop {
608 let next_dto = match stack.last_mut() {
611 Some(frame) => frame.remaining_children.pop_front(),
612 None => top_level.pop_front(),
613 };
614
615 match next_dto {
616 Some(dto) => stack.push(dto_to_frame(dto)),
617 None => {
618 match stack.pop() {
622 Some(frame) => {
623 let node = VocabularyNode {
624 header: frame.header,
625 position: frame.position,
626 children: frame.converted_children,
627 };
628 match stack.last_mut() {
629 Some(parent) => parent.converted_children.push(node),
630 None => result.push(node),
631 }
632 }
633 None => break,
635 }
636 }
637 }
638 }
639
640 result
641}
642
643fn identifier_key(user: &str) -> &'static str {
651 if user.starts_with("http://") || user.starts_with("https://") {
652 "iri"
653 } else if user.contains('@') {
654 "email"
655 } else {
656 "username"
657 }
658}
659
660enum ProjectIdent<'a> {
671 Iri(&'a str),
672 Shortcode(&'a str),
673 Shortname(&'a str),
674}
675
676fn classify(project: &str) -> ProjectIdent<'_> {
677 if project.starts_with("http://") || project.starts_with("https://") {
678 ProjectIdent::Iri(project)
679 } else if project.len() == 4 && project.chars().all(|c| c.is_ascii_hexdigit()) {
680 ProjectIdent::Shortcode(project)
681 } else {
682 ProjectIdent::Shortname(project)
683 }
684}
685
686fn enc(iri: &str) -> String {
692 utf8_percent_encode(iri, NON_ALPHANUMERIC).to_string()
693}
694
695fn map_unexpected_status(status: reqwest::StatusCode, url: &str) -> Diagnostic {
707 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
708 Diagnostic::AuthRequired(
712 "your token may be missing, expired, or lack permission — run \
713 `dsp auth login` to (re)authenticate"
714 .into(),
715 )
716 } else if status.is_server_error() {
717 Diagnostic::ServerError(format!("server returned {status} for {url}"))
718 } else {
719 Diagnostic::ServerError(format!("unexpected status {status} for {url}"))
720 }
721}
722
723fn validate_dump_id(id: &str) -> Result<(), Diagnostic> {
731 if id.is_empty()
732 || id.len() > 256 || !id
734 .chars()
735 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
736 {
737 let preview: String = id.chars().take(40).collect();
739 let suffix = if id.chars().count() > 40 { "…" } else { "" };
740 return Err(Diagnostic::ServerError(format!(
741 "server returned an invalid dump id: '{preview}{suffix}'"
742 )));
743 }
744 Ok(())
745}
746
747fn project_lookup_url(base: &str, project: &str) -> String {
753 match classify(project) {
754 ProjectIdent::Shortcode(code) => {
755 format!("{base}/admin/projects/shortcode/{code}")
756 }
757 ProjectIdent::Shortname(name) => {
758 format!("{base}/admin/projects/shortname/{name}")
759 }
760 ProjectIdent::Iri(iri) => {
761 format!("{base}/admin/projects/iri/{}", enc(iri))
762 }
763 }
764}
765
766fn is_safe_shortcode(s: &str) -> bool {
776 !s.is_empty() && s.len() <= 32 && s.chars().all(|c| c.is_ascii_alphanumeric())
777}
778
779fn local_name(id: &str) -> &str {
784 id.rsplit(['#', '/', ':']).next().unwrap_or(id)
785}
786
787fn expand_class_id(id: &str, prefixes: &HashMap<String, String>) -> (String, String) {
798 let name = local_name(id).to_string();
799 let iri = match id.split_once(':') {
800 Some((prefix, local)) if !local.starts_with("//") => prefixes
801 .get(prefix)
802 .map(|ns| format!("{ns}{local}"))
803 .unwrap_or_else(|| id.to_string()),
804 _ => id.to_string(), };
806 (name, iri)
807}
808
809pub(crate) fn data_model_name_from_iri(iri: &str) -> String {
821 let t = iri.trim_end_matches('/');
822 let t = t.strip_suffix("/v2").unwrap_or(t);
823 t.rsplit('/').next().unwrap_or(t).to_string()
824}
825
826const SYSTEM_PREFIXES: &[&str] = &[
835 "knora-api",
836 "knora-base",
837 "rdf",
838 "rdfs",
839 "owl",
840 "salsah-gui",
841 "standoff",
842 "xsd",
843];
844
845const FILE_VALUE_PROPS: &[(&str, Representation)] = &[
850 ("hasStillImageFileValue", Representation::StillImage),
851 ("hasMovingImageFileValue", Representation::MovingImage),
852 ("hasAudioFileValue", Representation::Audio),
853 ("hasDocumentFileValue", Representation::Document),
854 ("hasArchiveFileValue", Representation::Archive),
855 ("hasTextFileValue", Representation::Text),
856];
857
858const MAX_SIBLING_FETCHES: usize = 16;
861
862fn is_system_prefix(prefix: &str) -> bool {
868 SYSTEM_PREFIXES.contains(&prefix)
869}
870
871fn map_object_type_to_value_type(local: &str) -> ValueType {
882 match local {
883 "TextValue" => ValueType::Text,
884 "IntValue" => ValueType::Integer,
885 "DecimalValue" => ValueType::Decimal,
886 "BooleanValue" => ValueType::Boolean,
887 "DateValue" => ValueType::Date,
888 "TimeValue" => ValueType::Time,
889 "UriValue" => ValueType::Uri,
890 "ColorValue" => ValueType::Color,
891 "GeonameValue" => ValueType::Geoname,
892 "ListValue" => ValueType::VocabularyItem,
893 "StillImageFileValue" => ValueType::StillImage,
894 "MovingImageFileValue" => ValueType::MovingImage,
895 "AudioFileValue" => ValueType::Audio,
896 "DocumentFileValue" => ValueType::Document,
897 "ArchiveFileValue" => ValueType::Archive,
898 other => ValueType::Other(object_type_to_kebab(other)),
899 }
900}
901
902fn object_type_to_kebab(local: &str) -> String {
909 let base = local.strip_suffix("Value").unwrap_or(local);
911
912 let mut result = String::with_capacity(base.len() + 4);
915 let chars: Vec<char> = base.chars().collect();
916 for (i, &ch) in chars.iter().enumerate() {
917 if i > 0 && ch.is_uppercase() {
918 if chars[i - 1].is_lowercase() {
920 result.push('-');
921 }
922 }
923 result.push(ch);
924 }
925 result.to_lowercase()
926}
927
928fn decode_cardinality(restriction: &serde_json::Value) -> Cardinality {
934 let as_u64 = |key: &str| -> Option<u64> { restriction.get(key).and_then(serde_json::Value::as_u64) };
936
937 if let Some(v) = as_u64("owl:cardinality") {
938 if v == 1 {
939 return Cardinality::One;
940 }
941 tracing::warn!(
942 value = v,
943 "owl:cardinality had unexpected value (expected 1); falling back to ZeroOrMore"
944 );
945 return Cardinality::ZeroOrMore;
946 }
947
948 if let Some(v) = as_u64("owl:maxCardinality") {
949 if v == 1 {
950 return Cardinality::ZeroOrOne;
951 }
952 tracing::warn!(
953 value = v,
954 "owl:maxCardinality had unexpected value (expected 1); falling back to ZeroOrMore"
955 );
956 return Cardinality::ZeroOrMore;
957 }
958
959 if let Some(v) = as_u64("owl:minCardinality") {
960 return match v {
961 0 => Cardinality::ZeroOrMore,
962 1 => Cardinality::OneOrMore,
963 other => {
964 tracing::warn!(
965 value = other,
966 "owl:minCardinality had unexpected value (expected 0 or 1); falling back to ZeroOrMore"
967 );
968 Cardinality::ZeroOrMore
969 }
970 };
971 }
972
973 tracing::warn!("owl:Restriction has no recognized cardinality key; falling back to ZeroOrMore");
974 Cardinality::ZeroOrMore
975}
976
977fn detect_representation(restriction_prop_locals: &[&str]) -> Option<Representation> {
983 for local in restriction_prop_locals {
984 for (file_val_local, repr) in FILE_VALUE_PROPS {
985 if local == file_val_local {
986 return Some(*repr);
987 }
988 }
989 }
990 None
991}
992
993fn curie_prefix(id: &str) -> Option<&str> {
996 id.split_once(':')
997 .filter(|(_, local)| !local.starts_with("//"))
998 .map(|(prefix, _)| prefix)
999}
1000
1001#[derive(serde::Deserialize)]
1018struct ResourceListDto {
1019 #[serde(rename = "@graph", default)]
1021 graph: Option<Vec<ResourceNodeDto>>,
1022
1023 #[serde(rename = "@id", default)]
1025 id: Option<String>,
1026
1027 #[serde(rename = "@type", default)]
1030 type_field: Option<serde_json::Value>,
1031
1032 #[serde(rename = "rdfs:label", default)]
1034 label: Option<serde_json::Value>,
1035
1036 #[serde(rename = "knora-api:arkUrl", default)]
1038 ark_url: Option<serde_json::Value>,
1039
1040 #[serde(rename = "knora-api:creationDate", default)]
1042 creation_date: Option<serde_json::Value>,
1043
1044 #[serde(rename = "knora-api:lastModificationDate", default)]
1046 last_modification_date: Option<serde_json::Value>,
1047
1048 #[serde(rename = "knora-api:mayHaveMoreResults", default)]
1050 may_have_more_results: bool,
1051}
1052
1053#[derive(serde::Deserialize)]
1060struct ResourceNodeDto {
1061 #[serde(rename = "@id")]
1062 id: String,
1063
1064 #[serde(rename = "@type", default)]
1066 type_field: Option<serde_json::Value>,
1067
1068 #[serde(rename = "rdfs:label", default)]
1070 label: Option<serde_json::Value>,
1071
1072 #[serde(rename = "knora-api:arkUrl", default)]
1074 ark_url: Option<serde_json::Value>,
1075
1076 #[serde(rename = "knora-api:creationDate", default)]
1078 creation_date: Option<serde_json::Value>,
1079
1080 #[serde(rename = "knora-api:lastModificationDate", default)]
1082 last_modification_date: Option<serde_json::Value>,
1083}
1084
1085fn extract_string_value(v: &serde_json::Value) -> Option<String> {
1090 match v {
1091 serde_json::Value::String(s) => Some(s.clone()),
1092 serde_json::Value::Object(map) => map
1093 .get("@value")
1094 .or_else(|| map.get("@id"))
1095 .and_then(|inner| inner.as_str())
1096 .map(str::to_owned),
1097 _ => None,
1098 }
1099}
1100
1101fn extract_resource_type(type_val: Option<&serde_json::Value>) -> String {
1108 match type_val {
1109 None => "unknown".to_string(),
1110 Some(serde_json::Value::String(s)) => local_name(s).to_string(),
1111 Some(serde_json::Value::Array(arr)) => arr
1112 .first()
1113 .and_then(|v| v.as_str())
1114 .map(|s| local_name(s).to_string())
1115 .unwrap_or_else(|| "unknown".to_string()),
1116 _ => "unknown".to_string(),
1117 }
1118}
1119
1120fn node_dto_to_summary(
1122 id: String,
1123 type_val: Option<&serde_json::Value>,
1124 label_val: Option<&serde_json::Value>,
1125 ark_val: Option<&serde_json::Value>,
1126 creation_val: Option<&serde_json::Value>,
1127 last_modification_val: Option<&serde_json::Value>,
1128) -> ResourceSummary {
1129 let label = label_val.and_then(extract_string_value).unwrap_or_default();
1130 let resource_type = extract_resource_type(type_val);
1131 let ark_url = ark_val.and_then(extract_string_value);
1132 let creation_date = creation_val.and_then(extract_string_value);
1140 let last_modified = last_modification_val.and_then(extract_string_value);
1141 ResourceSummary {
1142 label,
1143 iri: id,
1144 ark_url,
1145 creation_date,
1146 last_modified,
1147 resource_type,
1148 }
1149}
1150
1151#[derive(serde::Deserialize)]
1171struct ResourceDetailDto {
1172 #[serde(rename = "@id")]
1173 id: String,
1174
1175 #[serde(rename = "@type", default)]
1177 type_field: Option<serde_json::Value>,
1178
1179 #[serde(rename = "rdfs:label", default)]
1181 label: Option<serde_json::Value>,
1182
1183 #[serde(rename = "knora-api:arkUrl", default)]
1185 ark_url: Option<serde_json::Value>,
1186
1187 #[serde(rename = "knora-api:creationDate", default)]
1189 creation_date: Option<serde_json::Value>,
1190
1191 #[serde(rename = "knora-api:lastModificationDate", default)]
1193 last_modification_date: Option<serde_json::Value>,
1194
1195 #[serde(rename = "knora-api:attachedToProject", default)]
1197 attached_to_project: Option<serde_json::Value>,
1198
1199 #[serde(rename = "knora-api:attachedToUser", default)]
1201 attached_to_user: Option<serde_json::Value>,
1202
1203 #[serde(rename = "knora-api:hasPermissions", default)]
1206 has_permissions: Option<String>,
1207
1208 #[serde(rename = "knora-api:userHasPermission", default)]
1211 user_has_permission: Option<String>,
1212
1213 #[serde(rename = "@context", default)]
1220 context: Option<serde_json::Value>,
1221
1222 #[serde(flatten)]
1230 extra: serde_json::Map<String, serde_json::Value>,
1231}
1232
1233fn permission_rank(code: &str) -> u8 {
1239 match code {
1240 "RV" => 1,
1241 "V" => 2,
1242 "M" => 6,
1243 "D" => 7,
1244 "CR" => 8,
1245 _ => 0,
1246 }
1247}
1248
1249fn derive_access(user_has_permission: &str) -> Option<ResourceAccess> {
1259 match user_has_permission {
1260 "RV" => Some(ResourceAccess::RestrictedView),
1261 "V" => Some(ResourceAccess::View),
1262 "M" => Some(ResourceAccess::Edit),
1263 "D" => Some(ResourceAccess::Delete),
1264 "CR" => Some(ResourceAccess::Manage),
1265 _ => None,
1266 }
1267}
1268
1269fn derive_visibility(has_permissions: &str) -> Option<ResourceVisibility> {
1279 if has_permissions.trim().is_empty() {
1280 return None;
1281 }
1282
1283 let mut unknown_rank: u8 = 0;
1284 let mut known_rank: u8 = 0;
1285 let mut parsed_any = false;
1286
1287 for entry in has_permissions.split('|') {
1288 let entry = entry.trim();
1289 if entry.is_empty() {
1290 continue;
1291 }
1292 let Some((code, group_list)) = entry.split_once(' ') else {
1294 continue;
1296 };
1297 parsed_any = true;
1298 let rank = permission_rank(code);
1299 for group in group_list.split(',') {
1300 let group_local = local_name(group.trim());
1301 if group_local == "UnknownUser" {
1302 unknown_rank = unknown_rank.max(rank);
1303 } else if group_local == "KnownUser" {
1304 known_rank = known_rank.max(rank);
1305 }
1306 }
1307 }
1308
1309 if !parsed_any {
1310 return None;
1311 }
1312
1313 let v_rank = permission_rank("V");
1317 let rv_rank = permission_rank("RV");
1318
1319 if unknown_rank >= v_rank {
1320 Some(ResourceVisibility::Public)
1321 } else if unknown_rank >= rv_rank {
1322 Some(ResourceVisibility::PublicRestricted)
1324 } else if known_rank >= rv_rank {
1325 Some(ResourceVisibility::LoggedInUsers)
1326 } else {
1327 Some(ResourceVisibility::ProjectMembers)
1328 }
1329}
1330
1331fn dsp_api_default_headers() -> reqwest::header::HeaderMap {
1339 let mut headers = reqwest::header::HeaderMap::new();
1340 headers.insert(
1341 reqwest::header::HeaderName::from_static("dsp-client"),
1342 reqwest::header::HeaderValue::from_static(crate::util::DSP_CLIENT_HEADER),
1343 );
1344 headers
1345}
1346
1347pub struct HttpDspClient {
1353 client: reqwest::blocking::Client,
1356 download_client: reqwest::blocking::Client,
1361}
1362
1363impl HttpDspClient {
1364 pub fn new() -> Result<Self, Diagnostic> {
1373 let client = reqwest::blocking::Client::builder()
1374 .connect_timeout(Duration::from_secs(10))
1375 .timeout(Duration::from_secs(30))
1376 .user_agent(crate::util::USER_AGENT)
1377 .default_headers(dsp_api_default_headers())
1378 .build()
1379 .map_err(|e| Diagnostic::Internal(format!("failed to build HTTP client: {e}")))?;
1380 let download_client = reqwest::blocking::Client::builder()
1381 .connect_timeout(Some(Duration::from_secs(30)))
1382 .timeout(None)
1383 .user_agent(crate::util::USER_AGENT)
1384 .default_headers(dsp_api_default_headers())
1385 .build()
1386 .map_err(|e| Diagnostic::Internal(format!("failed to build download HTTP client: {e}")))?;
1387 Ok(Self { client, download_client })
1390 }
1391
1392 fn fetch_allentities(
1402 &self,
1403 server: &str,
1404 ontology_iri: &str,
1405 token: Option<&str>,
1406 ) -> Result<OntologyAllEntitiesResponse, Diagnostic> {
1407 let url = format!(
1408 "{}/v2/ontologies/allentities/{}",
1409 server.trim_end_matches('/'),
1410 enc(ontology_iri)
1411 );
1412
1413 let req = self.client.get(&url);
1414 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
1415
1416 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
1417 let status = response.status();
1418
1419 if status.is_success() {
1420 let resp: OntologyAllEntitiesResponse = response
1421 .json()
1422 .map_err(|e| Diagnostic::ServerError(format!("data-model response could not be parsed: {e}")))?;
1423 Ok(resp)
1424 } else {
1425 Err(map_unexpected_status(status, &url))
1426 }
1427 }
1428
1429 fn fetch_list_get(&self, server: &str, iri: &str, token: Option<&str>) -> Result<ListGetResponseDto, Diagnostic> {
1436 let url = format!("{}/admin/lists/{}", server.trim_end_matches('/'), enc(iri));
1437
1438 let req = self.client.get(&url);
1439 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
1440
1441 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
1442 let status = response.status();
1443
1444 if status.is_success() {
1445 response
1446 .json::<ListGetResponseDto>()
1447 .map_err(|e| Diagnostic::ServerError(format!("vocabulary response could not be parsed: {e}")))
1448 } else {
1449 Err(map_unexpected_status(status, &url))
1450 }
1451 }
1452}
1453
1454impl HttpDspClient {
1455 fn parse_resource_values(
1467 &self,
1468 server: &str,
1469 token: Option<&str>,
1470 context_val: &Option<serde_json::Value>,
1471 extra: &serde_json::Map<String, serde_json::Value>,
1472 ) -> Vec<FieldValues> {
1473 let prefixes: HashMap<String, String> = build_prefix_map(context_val);
1475
1476 const DENYLIST: &[&str] = &[
1479 "knora-api:hasIncomingLinkValue",
1480 "knora-api:hasStandoffLinkToValue",
1481 "knora-api:hasStandoffLinkValue", ];
1483
1484 let mut field_entries: Vec<(&str, Vec<&serde_json::Value>)> = Vec::new();
1487
1488 for (key, val) in extra.iter() {
1489 if DENYLIST.contains(&key.as_str()) {
1490 continue;
1491 }
1492
1493 let objs: Vec<&serde_json::Value> = match val {
1495 serde_json::Value::Array(arr) => arr.iter().collect(),
1496 obj @ serde_json::Value::Object(_) => vec![obj],
1497 _ => continue, };
1499
1500 if objs.is_empty() {
1501 continue;
1502 }
1503
1504 let first = match objs.first() {
1507 Some(v) => v,
1508 None => continue,
1509 };
1510 if !has_value_class_type(first) {
1511 continue;
1512 }
1513
1514 field_entries.push((key.as_str(), objs));
1515 }
1516
1517 struct ParsedField<'a> {
1520 key: &'a str,
1521 is_link: bool,
1522 values: Vec<Value>,
1523 }
1524
1525 let mut parsed_fields: Vec<ParsedField> = Vec::new();
1526
1527 for (key, objs) in &field_entries {
1528 let mut contents: Vec<Value> = Vec::new();
1529 let mut any_link = false;
1530
1531 for obj in objs {
1532 if get_type_local(obj) == "DeletedValue" {
1534 continue;
1535 }
1536 let (content, is_link) = parse_value(obj);
1537 if is_link {
1538 any_link = true;
1539 }
1540 contents.push(content);
1541 }
1542
1543 if contents.is_empty() {
1544 continue;
1545 }
1546
1547 parsed_fields.push(ParsedField { key, is_link: any_link, values: contents });
1548 }
1549
1550 let mut ontology_labels: HashMap<String, HashMap<String, String>> = HashMap::new(); let mut fetched_ontologies: HashSet<String> = HashSet::new();
1555
1556 for pf in &parsed_fields {
1557 let prefix = curie_prefix(pf.key).unwrap_or("");
1558 if is_system_prefix(prefix) || prefix.is_empty() {
1559 continue; }
1561 let namespace = match prefixes.get(prefix) {
1563 Some(ns) => ns,
1564 None => continue,
1565 };
1566 let ont_iri = namespace.trim_end_matches(['#', '/']).to_string();
1567 if fetched_ontologies.insert(ont_iri.clone()) {
1568 match self.fetch_allentities(server, &ont_iri, token) {
1572 Ok(resp) => {
1573 let mut prop_map: HashMap<String, String> = HashMap::new();
1574 let ctx_prefixes: HashMap<String, String> = resp
1575 .context
1576 .iter()
1577 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1578 .collect();
1579 for entity in resp.graph {
1580 if let Some(lbl) = entity.label {
1581 let (_, iri) = expand_class_id(&entity.id, &ctx_prefixes);
1582 prop_map.insert(iri, lbl);
1583 }
1584 }
1585 ontology_labels.insert(ont_iri, prop_map);
1586 }
1587 Err(e) => {
1588 tracing::warn!(
1590 prefix = %prefix,
1591 error = %e,
1592 "field-label ontology fetch failed; using local name as fallback"
1593 );
1594 }
1595 }
1596 }
1597 }
1598
1599 let mut node_labels: HashMap<String, Option<String>> = HashMap::new();
1601
1602 for pf in &parsed_fields {
1604 for v in &pf.values {
1605 if let ValueContent::VocabularyItem { node_iri, .. } = &v.content {
1606 node_labels.entry(node_iri.clone()).or_insert(None);
1607 }
1608 }
1609 }
1610
1611 for (node_iri, label_slot) in node_labels.iter_mut() {
1613 let url = format!("{}/v2/node/{}", server.trim_end_matches('/'), enc(node_iri));
1617 let req = self.client.get(&url);
1618 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
1619 match req.send() {
1620 Ok(resp) if resp.status().is_success() => {
1621 match resp.json::<serde_json::Value>() {
1623 Ok(body) => {
1624 let lbl = body.get("rdfs:label").and_then(extract_string_value);
1626 *label_slot = lbl;
1627 }
1628 Err(_) => {
1629 tracing::debug!(
1630 node_iri = %node_iri,
1631 "list-node label response could not be parsed as JSON; using node IRI as fallback"
1632 );
1633 }
1634 }
1635 }
1636 Ok(resp) => {
1637 tracing::debug!(
1639 node_iri = %node_iri,
1640 status = %resp.status(),
1641 "list-node label fetch returned non-success; using node IRI as fallback"
1642 );
1643 }
1644 Err(e) => {
1645 tracing::debug!(
1646 node_iri = %node_iri,
1647 error = %e,
1648 "list-node label fetch failed; using node IRI as fallback"
1649 );
1650 }
1651 }
1652 }
1653
1654 let mut result: Vec<FieldValues> = Vec::new();
1656
1657 for pf in parsed_fields {
1658 let raw_name = local_name(pf.key).to_string();
1660 let name = if pf.is_link {
1661 raw_name.strip_suffix("Value").unwrap_or(&raw_name).to_string()
1662 } else {
1663 raw_name
1664 };
1665
1666 let label: Option<String> = {
1668 let prefix = curie_prefix(pf.key).unwrap_or("");
1669 if is_system_prefix(prefix) || prefix.is_empty() {
1670 None
1671 } else if let Some(ns) = prefixes.get(prefix) {
1672 let ont_iri = ns.trim_end_matches(['#', '/']).to_string();
1673 let local = local_name(pf.key);
1674 let prop_iri = format!("{}{}", ns, local);
1675 ontology_labels.get(&ont_iri).and_then(|m| m.get(&prop_iri).cloned())
1676 } else {
1677 None
1678 }
1679 };
1680
1681 let values: Vec<Value> = pf
1683 .values
1684 .into_iter()
1685 .map(|v| match v.content {
1686 ValueContent::VocabularyItem { node_iri, label: _ } => {
1687 let resolved = node_labels.get(&node_iri).cloned().flatten();
1688 Value {
1689 content: ValueContent::VocabularyItem { node_iri, label: resolved },
1690 comment: v.comment,
1691 }
1692 }
1693 other => Value { content: other, comment: v.comment },
1694 })
1695 .collect();
1696
1697 result.push(FieldValues { name, label, values });
1698 }
1699
1700 result
1701 }
1702}
1703
1704fn has_value_class_type(val: &serde_json::Value) -> bool {
1712 let type_local = get_type_local(val);
1713 type_local.ends_with("Value") && !type_local.is_empty() && {
1716 let raw_type = val
1718 .as_object()
1719 .and_then(|m| m.get("@type"))
1720 .and_then(|t| t.as_str())
1721 .unwrap_or("");
1722 raw_type.starts_with("knora-api:")
1723 }
1724}
1725
1726fn get_type_local(val: &serde_json::Value) -> &str {
1730 val.as_object()
1731 .and_then(|m| m.get("@type"))
1732 .and_then(|t| t.as_str())
1733 .map(local_name)
1734 .unwrap_or("")
1735}
1736
1737fn build_prefix_map(context_val: &Option<serde_json::Value>) -> HashMap<String, String> {
1743 match context_val {
1744 Some(serde_json::Value::Object(map)) => map
1745 .iter()
1746 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1747 .collect(),
1748 _ => HashMap::new(),
1749 }
1750}
1751
1752fn parse_value_content(obj: &serde_json::Value) -> (ValueContent, bool) {
1759 let type_local = get_type_local(obj);
1760
1761 match type_local {
1762 "TextValue" => {
1764 let content = if let Some(xml) = obj.get("knora-api:textValueAsXml").and_then(|v| v.as_str()) {
1767 crate::util::text::html_to_text(xml)
1768 } else {
1769 obj.get("knora-api:valueAsString")
1770 .and_then(|v| v.as_str())
1771 .unwrap_or("")
1772 .to_string()
1773 };
1774 (ValueContent::Text(content), false)
1775 }
1776
1777 "IntValue" => {
1779 let n = obj.get("knora-api:intValueAsInt").and_then(|v| v.as_i64()).unwrap_or(0);
1780 (ValueContent::Integer(n), false)
1781 }
1782
1783 "DecimalValue" => {
1785 let s = obj
1788 .get("knora-api:decimalValueAsDecimal")
1789 .and_then(|v| {
1790 if let Some(s) = v.as_str() {
1792 Some(s.to_string())
1793 } else {
1794 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1795 }
1796 })
1797 .unwrap_or_default();
1798 (ValueContent::Decimal(s), false)
1799 }
1800
1801 "BooleanValue" => {
1803 let b = obj
1804 .get("knora-api:booleanValueAsBoolean")
1805 .and_then(|v| v.as_bool())
1806 .unwrap_or(false);
1807 (ValueContent::Boolean(b), false)
1808 }
1809
1810 "DateValue" => {
1812 let calendar = obj
1813 .get("knora-api:dateValueHasCalendar")
1814 .and_then(|v| v.as_str())
1815 .unwrap_or("GREGORIAN")
1816 .to_string();
1817
1818 let parse_point = |prefix: &str| -> DatePoint {
1819 let year_key = format!("knora-api:{prefix}Year");
1820 let month_key = format!("knora-api:{prefix}Month");
1821 let day_key = format!("knora-api:{prefix}Day");
1822 let era_key = format!("knora-api:{prefix}Era");
1823
1824 DatePoint {
1825 year: obj.get(year_key.as_str()).and_then(|v| v.as_i64()).map(|v| v as i32),
1826 month: obj.get(month_key.as_str()).and_then(|v| v.as_u64()).map(|v| v as u32),
1827 day: obj.get(day_key.as_str()).and_then(|v| v.as_u64()).map(|v| v as u32),
1828 era: obj.get(era_key.as_str()).and_then(|v| v.as_str()).map(str::to_owned),
1829 }
1830 };
1831
1832 let start = parse_point("dateValueHasStart");
1835 let end = parse_point("dateValueHasEnd");
1836
1837 if start.year.is_none() && end.year.is_none() {
1838 let raw_text = obj
1840 .get("knora-api:valueAsString")
1841 .and_then(|v| v.as_str())
1842 .unwrap_or("")
1843 .to_string();
1844 return (ValueContent::Raw { value_type: "date".to_string(), text: raw_text }, false);
1845 }
1846
1847 (ValueContent::Date(DateValue { calendar, start, end }), false)
1848 }
1849
1850 "TimeValue" => {
1852 let s = obj
1853 .get("knora-api:timeValueAsTimeStamp")
1854 .and_then(|v| {
1855 if let Some(s) = v.as_str() {
1856 Some(s.to_string())
1857 } else {
1858 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1859 }
1860 })
1861 .unwrap_or_default();
1862 (ValueContent::Time(s), false)
1863 }
1864
1865 "UriValue" => {
1867 let s = obj
1868 .get("knora-api:uriValueAsUri")
1869 .and_then(|v| {
1870 if let Some(s) = v.as_str() {
1871 Some(s.to_string())
1872 } else {
1873 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1874 }
1875 })
1876 .unwrap_or_default();
1877 (ValueContent::Uri(s), false)
1878 }
1879
1880 "ColorValue" => {
1882 let s = obj
1883 .get("knora-api:colorValueAsColor")
1884 .and_then(|v| v.as_str())
1885 .unwrap_or("")
1886 .to_string();
1887 (ValueContent::Color(s), false)
1888 }
1889
1890 "GeonameValue" => {
1892 let s = obj
1893 .get("knora-api:geonameValueAsGeonameCode")
1894 .and_then(|v| v.as_str())
1895 .unwrap_or("")
1896 .to_string();
1897 (ValueContent::Geoname(s), false)
1898 }
1899
1900 "ListValue" => {
1902 let node_iri = obj
1904 .get("knora-api:listValueAsListNode")
1905 .and_then(|v| v.get("@id"))
1906 .and_then(|v| v.as_str())
1907 .unwrap_or("")
1908 .to_string();
1909 (
1910 ValueContent::VocabularyItem {
1911 node_iri,
1912 label: None, },
1914 false,
1915 )
1916 }
1917
1918 "LinkValue" => {
1920 let (target_iri, target_label) = if let Some(target_obj) = obj.get("knora-api:linkValueHasTarget") {
1923 let iri = target_obj.get("@id").and_then(|v| v.as_str()).unwrap_or("").to_string();
1924 let lbl = target_obj.get("rdfs:label").and_then(extract_string_value);
1925 (iri, lbl)
1926 } else {
1927 let iri = obj
1928 .get("knora-api:linkValueHasTargetIri")
1929 .and_then(|v| v.get("@id"))
1930 .and_then(|v| v.as_str())
1931 .unwrap_or("")
1932 .to_string();
1933 (iri, None)
1934 };
1935 (
1936 ValueContent::Link { target_iri, target_label },
1937 true, )
1939 }
1940
1941 t if t.ends_with("FileValue") => {
1944 let filename = obj
1945 .get("knora-api:fileValueHasFilename")
1946 .and_then(|v| v.as_str())
1947 .unwrap_or("")
1948 .to_string();
1949 let url_str = obj
1950 .get("knora-api:fileValueAsUrl")
1951 .and_then(|v| {
1952 if let Some(s) = v.as_str() {
1953 Some(s.to_string())
1954 } else {
1955 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1956 }
1957 })
1958 .unwrap_or_default();
1959
1960 let value_type_opt = if t.starts_with("StillImage") {
1962 Some(ValueType::StillImage)
1963 } else if t.starts_with("MovingImage") {
1964 Some(ValueType::MovingImage)
1965 } else if t.starts_with("Audio") {
1966 Some(ValueType::Audio)
1967 } else if t.starts_with("Document") || t.starts_with("Text") {
1968 Some(ValueType::Document)
1970 } else if t.starts_with("Archive") {
1971 Some(ValueType::Archive)
1972 } else {
1973 None };
1975
1976 match value_type_opt {
1977 Some(vt) => {
1978 let (width, height) = if vt == ValueType::StillImage {
1980 let w = obj
1981 .get("knora-api:stillImageFileValueHasDimX")
1982 .and_then(|v| v.as_u64())
1983 .map(|v| v as u32);
1984 let h = obj
1985 .get("knora-api:stillImageFileValueHasDimY")
1986 .and_then(|v| v.as_u64())
1987 .map(|v| v as u32);
1988 (w, h)
1989 } else {
1990 (None, None)
1991 };
1992 (
1993 ValueContent::File(FileValue { value_type: vt, filename, url: url_str, width, height }),
1994 false,
1995 )
1996 }
1997 None => {
1998 let raw_text = obj
2000 .get("knora-api:valueAsString")
2001 .and_then(|v| v.as_str())
2002 .unwrap_or(&filename)
2003 .to_string();
2004 (ValueContent::Raw { value_type: object_type_to_kebab(t), text: raw_text }, false)
2005 }
2006 }
2007 }
2008
2009 other => {
2011 let value_type = object_type_to_kebab(other);
2012 let raw_text = obj
2015 .get("knora-api:valueAsString")
2016 .and_then(|v| v.as_str())
2017 .map(str::to_owned)
2018 .unwrap_or_else(|| compact_value_text(obj));
2019 (ValueContent::Raw { value_type, text: raw_text }, false)
2020 }
2021 }
2022}
2023
2024fn parse_value(obj: &serde_json::Value) -> (Value, bool) {
2030 let (content, is_link) = parse_value_content(obj);
2031 let comment = obj
2032 .get("knora-api:valueHasComment")
2033 .and_then(|v| v.as_str())
2034 .filter(|s| !s.trim().is_empty())
2035 .map(str::to_owned);
2036 (Value { content, comment }, is_link)
2037}
2038
2039const VALUE_META_KEYS: &[&str] = &[
2041 "@id",
2042 "@type",
2043 "knora-api:attachedToUser",
2044 "knora-api:hasPermissions",
2045 "knora-api:userHasPermission",
2046 "knora-api:valueCreationDate",
2047 "knora-api:valueHasComment",
2048 "knora-api:isDeleted",
2049 "knora-api:arkUrl",
2050 "knora-api:versionArkUrl",
2051 "knora-api:valueHasUUID",
2052];
2053
2054fn compact_value_text(obj: &serde_json::Value) -> String {
2059 if let Some(map) = obj.as_object() {
2060 let filtered: serde_json::Map<String, serde_json::Value> = map
2061 .iter()
2062 .filter(|(k, _)| !VALUE_META_KEYS.contains(&k.as_str()))
2063 .map(|(k, v)| (k.clone(), v.clone()))
2064 .collect();
2065 if filtered.is_empty() {
2066 String::new()
2067 } else {
2068 serde_json::to_string(&serde_json::Value::Object(filtered)).unwrap_or_default()
2069 }
2070 } else {
2071 String::new()
2072 }
2073}
2074
2075impl DspClient for HttpDspClient {
2076 fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic> {
2077 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
2078
2079 let mut body = serde_json::Map::with_capacity(2);
2080 body.insert(identifier_key(user).to_owned(), serde_json::Value::from(user));
2081 body.insert("password".to_owned(), serde_json::Value::from(password));
2082
2083 let response = self
2084 .client
2085 .post(&url)
2086 .json(&body)
2087 .send()
2088 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2089
2090 let status = response.status();
2091
2092 if status.is_success() {
2093 let api: LoginApiResponse = response
2094 .json()
2095 .map_err(|e| Diagnostic::ServerError(format!("login response could not be parsed: {e}")))?;
2096 let expires_at = extract_exp(&api.token);
2097 Ok(LoginResponse { token: api.token, user: user.to_string(), expires_at })
2098 } else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
2099 let body = response.text().unwrap_or_default();
2100 let preview: String = body.chars().take(200).collect();
2101 tracing::trace!("auth failure response body (capped): {}", preview);
2102 Err(Diagnostic::AuthRequired(format!("Authentication failed on {server}")))
2104 } else if status == reqwest::StatusCode::NOT_FOUND {
2105 Err(Diagnostic::NotFound(format!(
2106 "endpoint not found at {url}; check that --server resolves to a DSP-API instance, not just any HTTPS host"
2107 )))
2108 } else if status.is_server_error() {
2109 let body = response.text().unwrap_or_default();
2110 let preview: String = body.chars().take(200).collect();
2111 tracing::trace!("server error response body (capped): {}", preview);
2112 Err(Diagnostic::ServerError(format!("server returned {status}")))
2113 } else {
2114 Err(Diagnostic::ServerError(format!("unexpected status: {status}")))
2115 }
2116 }
2117
2118 fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic> {
2119 let base = server.trim_end_matches('/');
2120
2121 let url = project_lookup_url(base, project);
2122
2123 let response = self.client.get(&url).send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2125
2126 let status = response.status();
2127
2128 if status.is_success() {
2129 let api: ProjectGetApiResponse = response
2130 .json()
2131 .map_err(|e| Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}")))?;
2132 if !is_safe_shortcode(&api.project.shortcode) {
2133 return Err(Diagnostic::ServerError(
2134 "server returned a project with an unexpected shortcode".into(),
2135 ));
2136 }
2137 Ok(ProjectRef {
2138 iri: api.project.id,
2139 shortcode: api.project.shortcode,
2140 shortname: api.project.shortname,
2141 })
2142 } else if status == reqwest::StatusCode::NOT_FOUND {
2143 let display_input = truncate_for_display(project);
2145 Err(Diagnostic::NotFound(format!("project '{display_input}' not found on {server}")))
2146 } else {
2147 Err(map_unexpected_status(status, &url))
2148 }
2149 }
2150
2151 fn create_project_dump(
2152 &self,
2153 server: &str,
2154 project_iri: &str,
2155 skip_assets: bool,
2156 token: &str,
2157 ) -> Result<CreateDumpOutcome, Diagnostic> {
2158 let base = server.trim_end_matches('/');
2159 let url = format!("{base}/v3/projects/{}/exports?skipAssets={skip_assets}", enc(project_iri));
2164
2165 let response = self
2166 .client
2167 .post(&url)
2168 .bearer_auth(token)
2169 .send()
2170 .map_err(|e: reqwest::Error| Diagnostic::Network(e.to_string()))?;
2171
2172 let status = response.status();
2173
2174 match status.as_u16() {
2175 202 => {
2176 let api: DataTaskStatusApiResponse = response
2177 .json()
2178 .map_err(|e| Diagnostic::ServerError(format!("dump trigger response could not be parsed: {e}")))?;
2179 api.into_dump_task().map(CreateDumpOutcome::Created)
2180 }
2181 409 => {
2182 let body_text = response.text().unwrap_or_default();
2192 let error_body: Option<V3ErrorBody> = if body_text.len() <= 65536 {
2193 serde_json::from_str(&body_text).ok()
2194 } else {
2195 None
2196 };
2197 match error_body.as_ref().and_then(|b| b.export_exists()) {
2198 Some(ex) => {
2199 let id = ex.id.ok_or_else(|| {
2202 Diagnostic::ServerError(
2203 "the server's dump-conflict response was missing the dump id".into(),
2204 )
2205 })?;
2206 validate_dump_id(id)?;
2207 match ex.project_iri {
2213 Some(owner) if owner == project_iri => Ok(CreateDumpOutcome::Exists { id: id.to_string() }),
2214 Some(owner) => Ok(CreateDumpOutcome::ExistsForOtherProject {
2215 id: id.to_string(),
2216 project_iri: owner.to_string(),
2217 }),
2218 None => Err(Diagnostic::ServerError(
2221 "the server's dump-conflict response did not identify which \
2222project owns the existing dump; cannot safely proceed"
2223 .into(),
2224 )),
2225 }
2226 }
2227 None => Err(Diagnostic::ServerError(
2229 "server reported a 409 conflict whose detail could not be parsed".into(),
2231 )),
2232 }
2233 }
2234 401 | 403 => Err(Diagnostic::AuthRequired(
2235 "triggering a project dump requires a system-administrator token".into(),
2236 )),
2237 404 => Err(Diagnostic::NotFound(format!("project not found at {url}"))),
2238 _ => Err(map_unexpected_status(status, &url)),
2239 }
2240 }
2241
2242 fn get_project_dump_status(
2243 &self,
2244 server: &str,
2245 project_iri: &str,
2246 dump_id: &str,
2247 token: &str,
2248 ) -> Result<DumpTask, Diagnostic> {
2249 validate_dump_id(dump_id)?;
2250 let base = server.trim_end_matches('/');
2251 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2253
2254 let response = self
2255 .client
2256 .get(&url)
2257 .bearer_auth(token)
2258 .send()
2259 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2260
2261 let status = response.status();
2262
2263 match status.as_u16() {
2264 200 => {
2265 let api: DataTaskStatusApiResponse = response
2266 .json()
2267 .map_err(|e| Diagnostic::ServerError(format!("dump status response could not be parsed: {e}")))?;
2268 api.into_dump_task()
2269 }
2270 404 => Err(Diagnostic::NotFound(format!("dump '{dump_id}' not found for project at {url}"))),
2271 401 | 403 => Err(Diagnostic::AuthRequired(
2272 "fetching dump status requires a system-administrator token".into(),
2273 )),
2274 _ => Err(map_unexpected_status(status, &url)),
2275 }
2276 }
2277
2278 fn download_project_dump(
2279 &self,
2280 server: &str,
2281 project_iri: &str,
2282 dump_id: &str,
2283 token: &str,
2284 dest: &mut dyn Write,
2285 ) -> Result<u64, Diagnostic> {
2286 validate_dump_id(dump_id)?;
2287 let base = server.trim_end_matches('/');
2288 let url = format!("{base}/v3/projects/{}/exports/{dump_id}/download", enc(project_iri));
2290
2291 let mut response = self
2293 .download_client
2294 .get(&url)
2295 .bearer_auth(token)
2296 .send()
2297 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2298
2299 let status = response.status();
2300
2301 match status.as_u16() {
2304 200 => {
2305 let mut buf = [0u8; 64 * 1024];
2309 let mut total: u64 = 0;
2310 loop {
2311 let n = response
2312 .read(&mut buf)
2313 .map_err(|e| Diagnostic::Network(format!("download interrupted: {e}")))?;
2314 if n == 0 {
2315 break;
2316 }
2317 dest.write_all(&buf[..n])
2318 .map_err(|e| Diagnostic::Io(format!("failed to write dump to disk: {e}")))?;
2319 total += n as u64;
2320 }
2321 Ok(total)
2322 }
2323 409 => Err(Diagnostic::Conflict("dump not ready — still in progress or failed".into())),
2324 404 => Err(Diagnostic::NotFound(format!("dump '{dump_id}' not found at {url}"))),
2325 401 | 403 => Err(Diagnostic::AuthRequired(
2326 "downloading a project dump requires a system-administrator token".into(),
2327 )),
2328 _ => Err(map_unexpected_status(status, &url)),
2329 }
2330 }
2331
2332 fn delete_project_dump(
2333 &self,
2334 server: &str,
2335 project_iri: &str,
2336 dump_id: &str,
2337 token: &str,
2338 ) -> Result<(), Diagnostic> {
2339 validate_dump_id(dump_id)?;
2340 let base = server.trim_end_matches('/');
2341 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2343
2344 let response = self
2345 .client
2346 .delete(&url)
2347 .bearer_auth(token)
2348 .send()
2349 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2350
2351 let status = response.status();
2352
2353 match status.as_u16() {
2354 204 => Ok(()),
2355 409 => Err(Diagnostic::Conflict(
2356 "dump is still in progress and cannot be deleted yet".into(),
2357 )),
2358 404 => Err(Diagnostic::NotFound(format!("dump '{dump_id}' not found at {url}"))),
2359 401 | 403 => Err(Diagnostic::AuthRequired(
2360 "deleting a project dump requires a system-administrator token".into(),
2361 )),
2362 _ => Err(map_unexpected_status(status, &url)),
2363 }
2364 }
2365
2366 fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic> {
2367 let base = server.trim_end_matches('/');
2368 let url = format!("{base}/admin/projects");
2369
2370 let req = self.client.get(&url);
2376 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
2377
2378 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2379
2380 let status = response.status();
2381
2382 if status.is_success() {
2383 let api: ProjectsListApiResponse = response
2384 .json()
2385 .map_err(|e| Diagnostic::ServerError(format!("projects list response could not be parsed: {e}")))?;
2386 let projects = api
2387 .projects
2388 .into_iter()
2389 .map(|dto| Project {
2390 iri: dto.id,
2391 shortcode: dto.shortcode,
2392 shortname: dto.shortname,
2393 longname: dto.longname,
2394 data_models: dto.ontologies.len(),
2397 })
2398 .collect();
2399 Ok(projects)
2400 } else {
2401 Err(map_unexpected_status(status, &url))
2402 }
2403 }
2404
2405 fn describe_project(&self, server: &str, project: &str, token: Option<&str>) -> Result<ProjectDetail, Diagnostic> {
2406 let base = server.trim_end_matches('/');
2407 let url = project_lookup_url(base, project);
2408
2409 let req = self.client.get(&url);
2413 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
2414
2415 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2416
2417 let status = response.status();
2418
2419 if status.is_success() {
2420 let api: ProjectDetailApiResponse = response
2421 .json()
2422 .map_err(|e| Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}")))?;
2423 let dto = api.project;
2424
2425 let description = dto
2427 .description
2428 .into_iter()
2429 .map(|d| ProjectDescription { value: d.value, language: d.language })
2430 .collect();
2431
2432 let mut data_models: Vec<DataModelSummary> = dto
2434 .ontologies
2435 .into_iter()
2436 .map(|iri| {
2437 let name = data_model_name_from_iri(&iri);
2438 DataModelSummary { name, iri }
2439 })
2440 .collect();
2441 data_models.sort_by(|a, b| a.name.cmp(&b.name));
2442
2443 Ok(ProjectDetail {
2444 iri: dto.id,
2445 shortcode: dto.shortcode,
2446 shortname: dto.shortname,
2447 longname: dto.longname,
2448 description,
2449 keywords: dto.keywords,
2450 data_models,
2451 })
2452 } else if status == reqwest::StatusCode::NOT_FOUND {
2453 let display_input = truncate_for_display(project);
2455 Err(Diagnostic::NotFound(format!(
2456 "project '{display_input}' not found on {server}. Run `dsp vre project list --server {server}` to see available projects."
2457 )))
2458 } else {
2459 Err(map_unexpected_status(status, &url))
2460 }
2461 }
2462
2463 fn describe_data_model(
2464 &self,
2465 server: &str,
2466 data_model_iri: &str,
2467 token: Option<&str>,
2468 ) -> Result<DataModelDetail, Diagnostic> {
2469 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2470
2471 let prefixes: HashMap<String, String> = resp
2475 .context
2476 .iter()
2477 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2478 .collect();
2479
2480 let mut resource_types: Vec<ResourceTypeSummary> = resp
2481 .graph
2482 .into_iter()
2483 .filter(|dto| dto.is_resource_class)
2484 .map(|dto| {
2485 let (name, iri) = expand_class_id(&dto.id, &prefixes);
2486 ResourceTypeSummary { name, iri, label: dto.label }
2487 })
2488 .collect();
2489
2490 resource_types.sort_by(|a, b| a.name.cmp(&b.name));
2491
2492 Ok(DataModelDetail {
2493 name: data_model_name_from_iri(&resp.id),
2494 iri: resp.id,
2495 label: resp.label,
2496 last_modified: resp.last_modification_date.map(|d| d.value),
2497 resource_types,
2498 })
2499 }
2500
2501 fn data_model_structure(
2502 &self,
2503 server: &str,
2504 data_model_iri: &str,
2505 token: Option<&str>,
2506 ) -> Result<DataModelStructure, Diagnostic> {
2507 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2509
2510 let graph_entities: Vec<OntologyEntityDto> = resp.graph;
2511
2512 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2518 let mut class_nodes: Vec<OntologyEntityDto> = Vec::new();
2519 for entity in graph_entities {
2520 if entity.is_resource_class {
2521 class_nodes.push(entity);
2522 } else if entity.object_type.is_some() || entity.is_link_property || entity.is_resource_property {
2523 prop_lookup.insert(entity.id.clone(), entity);
2524 }
2525 }
2526
2527 let mut relations: Vec<Relation> = Vec::new();
2529
2530 for class in &class_nodes {
2531 let source = local_name(&class.id).to_string();
2532
2533 for element in &class.sub_class_of {
2534 if let Some(type_val) = element.get("@type")
2535 && type_val.as_str() == Some("owl:Restriction")
2536 {
2537 let on_prop_id = match element
2539 .get("owl:onProperty")
2540 .and_then(|v| v.get("@id"))
2541 .and_then(serde_json::Value::as_str)
2542 {
2543 Some(s) => s,
2544 None => continue,
2545 };
2546
2547 let node = match prop_lookup.get(on_prop_id) {
2549 Some(n) => n,
2550 None => continue, };
2552
2553 if node.is_link_value_property {
2555 continue;
2556 }
2557
2558 if !node.is_link_property {
2560 continue;
2561 }
2562
2563 let target_id = match node.object_type.as_ref() {
2565 Some(ot) => &ot.id,
2566 None => continue, };
2568 let target = local_name(target_id).to_string();
2569
2570 let t_prefix = curie_prefix(target_id).unwrap_or("");
2571 let target_data_model = if is_system_prefix(t_prefix) || t_prefix.is_empty() {
2572 None
2573 } else {
2574 Some(t_prefix.to_string())
2575 };
2576
2577 let field_prefix = curie_prefix(on_prop_id).unwrap_or("");
2579 let is_builtin = is_system_prefix(field_prefix);
2580
2581 let field = local_name(on_prop_id).to_string();
2582
2583 relations.push(Relation {
2584 source: source.clone(),
2585 target,
2586 kind: RelationKind::Link,
2587 field: Some(field),
2588 target_data_model,
2589 is_builtin,
2590 });
2591 } else if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
2592 let target = local_name(id_val).to_string();
2597
2598 let sup_prefix = curie_prefix(id_val).unwrap_or("");
2599 let is_builtin = is_system_prefix(sup_prefix);
2600 let target_data_model = if is_system_prefix(sup_prefix) || sup_prefix.is_empty() {
2601 None
2602 } else {
2603 Some(sup_prefix.to_string())
2604 };
2605
2606 relations.push(Relation {
2607 source: source.clone(),
2608 target,
2609 kind: RelationKind::Inherits,
2610 field: None,
2611 target_data_model,
2612 is_builtin,
2613 });
2614 }
2615 }
2616 }
2617
2618 relations.sort_by(|a, b| {
2622 a.source
2623 .cmp(&b.source)
2624 .then_with(|| a.kind.cmp(&b.kind))
2625 .then_with(|| a.field.cmp(&b.field))
2626 .then_with(|| a.target.cmp(&b.target))
2627 });
2628
2629 Ok(DataModelStructure {
2631 data_model: data_model_name_from_iri(data_model_iri),
2632 relations,
2633 })
2634 }
2635
2636 fn list_resources(
2637 &self,
2638 server: &str,
2639 project_iri: &str,
2640 resource_type_iri: &str,
2641 order_by: Option<&str>,
2642 page: u32,
2643 token: Option<&str>,
2644 ) -> Result<ResourcePage, Diagnostic> {
2645 let base = server.trim_end_matches('/');
2646 let url = format!("{base}/v2/resources");
2647
2648 let mut req = self.client.get(&url).query(&[
2653 ("resourceClass", resource_type_iri),
2654 ("page", &page.to_string()),
2655 ("schema", "complex"),
2656 ]);
2657 if let Some(prop_iri) = order_by {
2660 req = req.query(&[("orderByProperty", prop_iri)]);
2661 }
2662
2663 let header_value = reqwest::header::HeaderValue::from_str(project_iri)
2667 .map_err(|e| Diagnostic::Usage(format!("project IRI is not a valid HTTP header value: {e}")))?;
2668 let req = req.header("x-knora-accept-project", header_value);
2669
2670 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
2672
2673 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2674 let status = response.status();
2675
2676 if !status.is_success() {
2677 return Err(map_unexpected_status(status, &url));
2678 }
2679
2680 let dto: ResourceListDto = response
2681 .json()
2682 .map_err(|e| Diagnostic::ServerError(format!("resource list response could not be parsed: {e}")))?;
2683
2684 let may_have_more_results = dto.may_have_more_results;
2685
2686 let resources: Vec<ResourceSummary> = if let Some(graph) = dto.graph {
2691 graph
2692 .into_iter()
2693 .map(|node| {
2694 node_dto_to_summary(
2695 node.id,
2696 node.type_field.as_ref(),
2697 node.label.as_ref(),
2698 node.ark_url.as_ref(),
2699 node.creation_date.as_ref(),
2700 node.last_modification_date.as_ref(),
2701 )
2702 })
2703 .collect()
2704 } else if let Some(id) = dto.id {
2705 vec![node_dto_to_summary(
2707 id,
2708 dto.type_field.as_ref(),
2709 dto.label.as_ref(),
2710 dto.ark_url.as_ref(),
2711 dto.creation_date.as_ref(),
2712 dto.last_modification_date.as_ref(),
2713 )]
2714 } else {
2715 vec![]
2717 };
2718
2719 Ok(ResourcePage { resources, may_have_more_results })
2720 }
2721
2722 fn describe_resource(
2723 &self,
2724 server: &str,
2725 resource_iri: &str,
2726 token: Option<&str>,
2727 with_values: bool,
2728 ) -> Result<ResourceDetail, Diagnostic> {
2729 let base = server.trim_end_matches('/');
2730 let url = format!("{base}/v2/resources/{}", enc(resource_iri));
2732
2733 let req = self.client.get(&url).query(&[("schema", "complex")]);
2735 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
2736
2737 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2738 let status = response.status();
2739
2740 if status.is_success() {
2741 let dto: ResourceDetailDto = response
2742 .json()
2743 .map_err(|e| Diagnostic::ServerError(format!("resource describe response could not be parsed: {e}")))?;
2744
2745 let label = dto.label.as_ref().and_then(extract_string_value).unwrap_or_default();
2747 let resource_type = extract_resource_type(dto.type_field.as_ref());
2748 let ark_url = dto.ark_url.as_ref().and_then(extract_string_value);
2749 let creation_date = dto.creation_date.as_ref().and_then(extract_string_value);
2750 let last_modified = dto.last_modification_date.as_ref().and_then(extract_string_value);
2751 let attached_project = dto.attached_to_project.as_ref().and_then(extract_string_value);
2752 let owner = dto.attached_to_user.as_ref().and_then(extract_string_value);
2753 let visibility = dto.has_permissions.as_deref().and_then(derive_visibility);
2754 let your_access = dto.user_has_permission.as_deref().and_then(derive_access);
2755
2756 let values = if with_values {
2758 Some(self.parse_resource_values(server, token, &dto.context, &dto.extra))
2759 } else {
2760 None
2761 };
2762
2763 Ok(ResourceDetail {
2764 label,
2765 iri: dto.id,
2766 resource_type,
2767 ark_url,
2768 creation_date,
2769 last_modified,
2770 attached_project,
2771 owner,
2772 visibility,
2773 your_access,
2774 values,
2775 })
2776 } else if status == reqwest::StatusCode::NOT_FOUND {
2777 let display_iri = truncate_for_display(resource_iri);
2779 Err(Diagnostic::NotFound(format!("resource '{display_iri}' not found")))
2780 } else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
2781 let display_iri = truncate_for_display(resource_iri);
2785 Err(Diagnostic::AuthRequired(format!(
2786 "access denied for resource '{display_iri}' — log in to view this resource"
2787 )))
2788 } else {
2789 Err(map_unexpected_status(status, &url))
2790 }
2791 }
2792
2793 fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic> {
2794 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
2795
2796 let response = self
2797 .client
2798 .get(&url)
2799 .bearer_auth(token)
2800 .send()
2801 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2802
2803 let status = response.status();
2804
2805 if status.is_success() {
2806 let body = response.text().unwrap_or_default();
2809 let preview: String = body.chars().take(200).collect();
2810 tracing::trace!("verify_token success response body (capped): {}", preview);
2811 Ok(())
2812 } else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
2813 let body = response.text().unwrap_or_default();
2815 let preview: String = body.chars().take(200).collect();
2816 tracing::trace!("verify_token rejection response body (capped): {}", preview);
2817 Err(Diagnostic::AuthRequired(format!(
2819 "token rejected by {server} — it may be expired, revoked, or for a different environment"
2820 )))
2821 } else {
2822 Err(map_unexpected_status(status, &url))
2823 }
2824 }
2825
2826 fn list_data_models(
2827 &self,
2828 server: &str,
2829 project_iri: &str,
2830 token: Option<&str>,
2831 ) -> Result<Vec<DataModel>, Diagnostic> {
2832 let url = format!("{}/v2/ontologies/metadata/{}", server.trim_end_matches('/'), enc(project_iri));
2833
2834 let req = self.client.get(&url);
2839 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
2840
2841 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2842
2843 let status = response.status();
2844
2845 if status.is_success() {
2846 let resp: OntologyMetadataResponse = response
2847 .json()
2848 .map_err(|e| Diagnostic::ServerError(format!("data-models response could not be parsed: {e}")))?;
2849
2850 let dtos: Vec<OntologyMetadataDto> = match resp.graph {
2854 Some(g) => g,
2855 None => match resp.id {
2856 Some(id) => vec![OntologyMetadataDto {
2857 id,
2858 label: resp.label,
2859 last_modification_date: resp.last_modification_date,
2860 }],
2861 None => vec![],
2862 },
2863 };
2864
2865 let data_models = dtos
2866 .into_iter()
2867 .map(|dto| DataModel {
2868 name: data_model_name_from_iri(&dto.id),
2869 iri: dto.id,
2870 label: dto.label,
2871 last_modified: dto.last_modification_date.map(|d| d.value),
2872 is_builtin: false,
2873 })
2874 .collect();
2875
2876 Ok(data_models)
2877 } else {
2878 Err(map_unexpected_status(status, &url))
2879 }
2880 }
2881
2882 fn describe_resource_type(
2883 &self,
2884 server: &str,
2885 data_model_iri: &str,
2886 resource_type: &str,
2887 token: Option<&str>,
2888 ) -> Result<ResourceTypeDetail, Diagnostic> {
2889 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2891
2892 let prefixes: HashMap<String, String> = resp
2894 .context
2895 .iter()
2896 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2897 .collect();
2898
2899 let queried_id = resp.id;
2903 let mut graph_entities: Vec<OntologyEntityDto> = resp.graph;
2904
2905 let target_idx = graph_entities.iter().position(|e| {
2906 if !e.is_resource_class {
2907 return false;
2908 }
2909 let (type_local, expanded_iri) = expand_class_id(&e.id, &prefixes);
2910 type_local.eq_ignore_ascii_case(resource_type) || expanded_iri == resource_type
2912 });
2913
2914 let target_idx = match target_idx {
2915 Some(i) => i,
2916 None => {
2917 let display = truncate_for_display(resource_type);
2918 return Err(Diagnostic::NotFound(format!(
2919 "resource-type '{display}' not found in data-model '{}' on {server}",
2920 data_model_name_from_iri(data_model_iri)
2921 )));
2922 }
2923 };
2924
2925 let target = graph_entities.swap_remove(target_idx);
2928
2929 struct Restriction {
2931 on_property_id: String,
2932 cardinality: Cardinality,
2933 gui_order: u32,
2934 }
2935
2936 let mut restrictions: Vec<Restriction> = Vec::new();
2937 let mut super_type_ids: Vec<String> = Vec::new();
2938 let mut restriction_prop_locals: Vec<String> = Vec::new();
2939
2940 for element in &target.sub_class_of {
2941 if let Some(type_val) = element.get("@type")
2942 && type_val.as_str() == Some("owl:Restriction")
2943 {
2944 let on_prop_id = element
2946 .get("owl:onProperty")
2947 .and_then(|v| v.get("@id"))
2948 .and_then(serde_json::Value::as_str)
2949 .unwrap_or("")
2950 .to_string();
2951
2952 if on_prop_id.is_empty() {
2953 tracing::warn!("owl:Restriction missing owl:onProperty @id; skipping");
2954 continue;
2955 }
2956
2957 let cardinality = decode_cardinality(element);
2958 let gui_order = element
2959 .get("salsah-gui:guiOrder")
2960 .and_then(serde_json::Value::as_u64)
2961 .map(|v| v as u32)
2962 .unwrap_or(u32::MAX);
2963
2964 restriction_prop_locals.push(local_name(&on_prop_id).to_string());
2965
2966 restrictions.push(Restriction { on_property_id: on_prop_id, cardinality, gui_order });
2967 continue;
2968 }
2969 if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
2971 super_type_ids.push(id_val.to_string());
2972 }
2973 }
2974
2975 let representation =
2977 detect_representation(&restriction_prop_locals.iter().map(String::as_str).collect::<Vec<_>>());
2978
2979 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2981 for entity in graph_entities {
2982 if entity.object_type.is_some() || entity.is_link_property || entity.is_resource_property {
2985 prop_lookup.insert(entity.id.clone(), entity);
2986 }
2987 }
2988
2989 let mut missing_prefixes: Vec<String> = Vec::new();
2999 let mut seen_prefixes: HashSet<String> = HashSet::new();
3000 for restriction in &restrictions {
3001 if prop_lookup.contains_key(&restriction.on_property_id) {
3002 continue;
3003 }
3004 let prefix = match curie_prefix(&restriction.on_property_id) {
3005 Some(p) => p,
3006 None => continue,
3007 };
3008 if is_system_prefix(prefix) {
3009 continue;
3010 }
3011 if seen_prefixes.insert(prefix.to_string()) {
3012 missing_prefixes.push(prefix.to_string());
3013 }
3014 }
3015
3016 let mut fetched_sibling_iris: HashSet<String> = HashSet::new();
3018 let queried_iri_trimmed = data_model_iri.trim_end_matches(['#', '/']);
3019
3020 let mut siblings_to_fetch: Vec<String> = Vec::new();
3021 for prefix in &missing_prefixes {
3022 let namespace = match prefixes.get(prefix.as_str()) {
3023 Some(ns) => ns,
3024 None => {
3025 tracing::warn!(
3026 prefix = %prefix,
3027 "missing @context entry for prefix of cross-DM field; leaving best-effort"
3028 );
3029 continue;
3030 }
3031 };
3032 let sibling_iri = namespace.trim_end_matches(['#', '/']).to_string();
3033 if sibling_iri == queried_iri_trimmed {
3034 continue;
3036 }
3037 if fetched_sibling_iris.insert(sibling_iri.clone()) {
3038 siblings_to_fetch.push(sibling_iri);
3039 }
3040 }
3041
3042 if siblings_to_fetch.len() > MAX_SIBLING_FETCHES {
3043 tracing::warn!(
3044 count = siblings_to_fetch.len(),
3045 max = MAX_SIBLING_FETCHES,
3046 "too many sibling ontologies to fetch; capping at MAX_SIBLING_FETCHES"
3047 );
3048 siblings_to_fetch.truncate(MAX_SIBLING_FETCHES);
3049 }
3050
3051 for sibling_iri in &siblings_to_fetch {
3052 match self.fetch_allentities(server, sibling_iri, token) {
3054 Ok(sibling_resp) => {
3055 for entity in sibling_resp.graph {
3056 if entity.object_type.is_some() || entity.is_link_property || entity.is_resource_property {
3057 prop_lookup.entry(entity.id.clone()).or_insert(entity);
3058 }
3059 }
3060 }
3061 Err(e) => {
3062 tracing::warn!(
3065 iri = %sibling_iri,
3066 error = %e,
3067 "sibling ontology fetch failed; affected fields left best-effort"
3068 );
3069 }
3070 }
3071 }
3072
3073 let mut fields: Vec<(u32, Field)> = Vec::new();
3075
3076 for restriction in &restrictions {
3077 let prop_id = &restriction.on_property_id;
3078
3079 let node = prop_lookup.get(prop_id.as_str());
3081
3082 if let Some(n) = node {
3084 if n.is_link_value_property {
3085 continue;
3087 }
3088 } else {
3089 let prop_local = local_name(prop_id);
3093 if let Some(base) = prop_local.strip_suffix("Value") {
3094 let base_present = restrictions.iter().any(|r| local_name(&r.on_property_id) == base);
3096 if base_present {
3099 continue;
3100 }
3101 }
3102 }
3103
3104 let prop_prefix = curie_prefix(prop_id).unwrap_or("");
3106 let is_builtin = is_system_prefix(prop_prefix);
3107 let (prop_local, prop_iri) = expand_class_id(prop_id, &prefixes);
3108
3109 let field_data_model = if is_builtin {
3111 None
3112 } else {
3113 if prop_prefix.is_empty() {
3116 None
3117 } else {
3118 Some(prop_prefix.to_string())
3119 }
3120 };
3121
3122 let (value_type, link_target) = if let Some(n) = node {
3124 if n.is_link_property {
3125 let target_name = n
3127 .object_type
3128 .as_ref()
3129 .map(|ot| local_name(&ot.id).to_string())
3130 .unwrap_or_else(|| "unknown".to_string());
3131 (ValueType::Link, Some(target_name))
3132 } else {
3133 let obj_local = n.object_type.as_ref().map(|ot| local_name(&ot.id)).unwrap_or("");
3134 (map_object_type_to_value_type(obj_local), None)
3135 }
3136 } else {
3137 if is_builtin {
3139 if let Some(vt) = builtin_field_value_type(&prop_local) {
3140 (vt, None)
3141 } else {
3142 (ValueType::Other("—".to_string()), None)
3143 }
3144 } else {
3145 (ValueType::Other("—".to_string()), None)
3146 }
3147 };
3148
3149 let label = node.and_then(|n| n.label.clone());
3150
3151 debug_assert!(
3153 (value_type == ValueType::Link) == link_target.is_some(),
3154 "link_target must be Some iff value_type is Link"
3155 );
3156
3157 fields.push((
3158 restriction.gui_order,
3159 Field {
3160 name: prop_local,
3161 iri: prop_iri,
3162 label,
3163 value_type,
3164 link_target,
3165 cardinality: restriction.cardinality,
3166 is_builtin,
3167 data_model: field_data_model,
3168 },
3169 ));
3170 }
3171
3172 fields.sort_by(|(order_a, field_a), (order_b, field_b)| {
3174 order_a.cmp(order_b).then_with(|| field_a.name.cmp(&field_b.name))
3175 });
3176 let sorted_fields: Vec<Field> = fields.into_iter().map(|(_, f)| f).collect();
3177
3178 let super_types: Vec<String> = super_type_ids
3180 .iter()
3181 .filter(|id| {
3182 let prefix = curie_prefix(id).unwrap_or("");
3183 !is_system_prefix(prefix)
3184 })
3185 .map(|id| local_name(id).to_string())
3186 .collect();
3187
3188 let (class_name, class_iri) = expand_class_id(&target.id, &prefixes);
3190 let class_label = target.label;
3191 let dm_name = data_model_name_from_iri(&queried_id);
3192
3193 Ok(ResourceTypeDetail {
3194 name: class_name,
3195 iri: class_iri,
3196 label: class_label,
3197 data_model: dm_name,
3198 representation,
3199 super_types,
3200 fields: sorted_fields,
3201 count: None,
3202 })
3203 }
3204
3205 fn resource_counts(
3206 &self,
3207 server: &str,
3208 project_iri: &str,
3209 token: Option<&str>,
3210 ) -> Result<HashMap<String, u64>, Diagnostic> {
3211 let url = format!(
3212 "{}/v3/projects/{}/resourcesPerOntology",
3213 server.trim_end_matches('/'),
3214 enc(project_iri)
3215 );
3216
3217 let req = self.client.get(&url);
3220 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
3221
3222 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
3223 let status = response.status();
3224
3225 if status.is_success() {
3226 let entries: Vec<OntologyAndResourceClassesDto> = response
3227 .json()
3228 .map_err(|e| Diagnostic::ServerError(format!("resource-counts response could not be parsed: {e}")))?;
3229
3230 let mut counts = HashMap::new();
3231 for entry in entries {
3232 for cc in entry.classes_and_count {
3233 counts.insert(cc.resource_class.iri, cc.item_count);
3234 }
3235 }
3236 Ok(counts)
3237 } else if status == reqwest::StatusCode::NOT_FOUND {
3238 Err(Diagnostic::NotFound(format!("project not found at {url}")))
3239 } else {
3240 Err(map_unexpected_status(status, &url))
3241 }
3242 }
3243
3244 fn list_vocabularies(
3245 &self,
3246 server: &str,
3247 project_iri: &str,
3248 token: Option<&str>,
3249 ) -> Result<Vec<Vocabulary>, Diagnostic> {
3250 let url = format!("{}/admin/lists?projectIri={}", server.trim_end_matches('/'), enc(project_iri));
3251
3252 let req = self.client.get(&url);
3255 let req = if let Some(t) = token { req.bearer_auth(t) } else { req };
3256
3257 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
3258 let status = response.status();
3259
3260 if status.is_success() {
3261 let resp: ListsListApiResponse = response
3262 .json()
3263 .map_err(|e| Diagnostic::ServerError(format!("vocabulary list response could not be parsed: {e}")))?;
3264
3265 Ok(resp
3266 .lists
3267 .into_iter()
3268 .map(|dto| Vocabulary {
3269 header: VocabularyHeader {
3270 iri: dto.id,
3271 name: dto.name,
3272 labels: into_localized_texts(dto.labels),
3273 comments: into_localized_texts(dto.comments),
3274 },
3275 node_count: None,
3278 depth: None,
3279 })
3280 .collect())
3281 } else {
3282 Err(map_unexpected_status(status, &url))
3283 }
3284 }
3285
3286 fn describe_vocabulary(&self, server: &str, iri: &str, token: Option<&str>) -> Result<VocabularyTree, Diagnostic> {
3287 match self.fetch_list_get(server, iri, token)? {
3288 ListGetResponseDto::Root(root) => Ok(build_vocabulary_tree(root.list, None)),
3289 ListGetResponseDto::Node(node) => {
3290 let root_iri = node.node.nodeinfo.has_root_node;
3294 match self.fetch_list_get(server, &root_iri, token)? {
3295 ListGetResponseDto::Root(root) => Ok(build_vocabulary_tree(root.list, Some(iri.to_string()))),
3296 ListGetResponseDto::Node(_) => Err(Diagnostic::ServerError(format!(
3299 "resolving vocabulary node {iri} to its root ({root_iri}) returned \
3300 another node, not a root"
3301 ))),
3302 }
3303 }
3304 }
3305 }
3306
3307 fn sparql_query(
3308 &self,
3309 server: &str,
3310 token: &str,
3311 query: &str,
3312 accept: &str,
3313 timeout_secs: u64,
3314 ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
3315 let url = format!("{}/admin/sparql/query", server.trim_end_matches('/'));
3316
3317 tracing::debug!(method = "POST", url = %url, "sparql_query: sending request");
3318
3319 let sparql_client = reqwest::blocking::Client::builder()
3341 .connect_timeout(Duration::from_secs(10))
3342 .timeout(Duration::from_secs(timeout_secs))
3343 .redirect(reqwest::redirect::Policy::none())
3344 .user_agent(crate::util::USER_AGENT)
3345 .default_headers(dsp_api_default_headers())
3346 .build()
3347 .map_err(|e| Diagnostic::Internal(format!("failed to build SPARQL HTTP client: {e}")))?;
3348
3349 let req = sparql_client
3350 .post(&url)
3351 .bearer_auth(token)
3352 .header(reqwest::header::CONTENT_TYPE, "application/sparql-query")
3353 .header(reqwest::header::ACCEPT, accept)
3354 .body(query.to_string());
3355
3356 let response = req.send().map_err(|e| {
3357 if e.is_timeout() {
3364 Diagnostic::Network(format!(
3365 "SPARQL request to {} timed out on the client side \
3367 after {timeout_secs}s (--timeout) — this is distinct from \
3368 the server's own passthrough timeout, which would come \
3369 back as an HTTP 504: {e}",
3370 crate::util::text::sanitise_and_cap(&url)
3371 ))
3372 } else {
3373 Diagnostic::Network(e.to_string())
3374 }
3375 })?;
3376
3377 let status = response.status();
3378 let content_type = response
3379 .headers()
3380 .get(reqwest::header::CONTENT_TYPE)
3381 .and_then(|v| v.to_str().ok())
3382 .map(|s| s.to_string());
3383 let body = response.bytes().map_err(|e| Diagnostic::Network(e.to_string()))?;
3384
3385 tracing::debug!(
3386 status = status.as_u16(),
3387 content_type = content_type.as_deref().unwrap_or(""),
3388 "sparql_query: received response"
3389 );
3390
3391 match classify_sparql_status(status.as_u16(), content_type.as_deref(), &body, &url) {
3392 SparqlOutcome::DspApiError(diag) => Err(diag),
3393 SparqlOutcome::Relay => {
3394 if !status.is_success() {
3395 tracing::trace!(
3401 "sparql_query: non-2xx relay body preview (capped): {}",
3402 crate::util::text::sanitise_bytes_for_prose(&body)
3403 );
3404 }
3405 Ok(
3406 crate::client::sparql::SparqlResponse {
3407 status: status.as_u16(),
3408 content_type,
3409 body: body.to_vec(),
3410 },
3411 )
3412 }
3413 }
3414 }
3415}
3416
3417fn truncate_for_display(s: &str) -> String {
3423 let mut truncated: String = s.chars().take(80).collect();
3424 if s.chars().count() > 80 {
3425 truncated.push('…');
3426 }
3427 truncated
3428}
3429
3430#[derive(serde::Deserialize)]
3432struct SparqlErrorBody {
3433 message: String,
3434}
3435
3436enum SparqlOutcome {
3443 DspApiError(Diagnostic),
3444 Relay,
3445}
3446
3447fn classify_sparql_status(status: u16, content_type: Option<&str>, body: &[u8], url: &str) -> SparqlOutcome {
3456 match status {
3457 401 => SparqlOutcome::DspApiError(Diagnostic::AuthRequired(
3458 "authentication is required — run `dsp auth login`".into(),
3459 )),
3460 403 => SparqlOutcome::DspApiError(Diagnostic::AuthRequired(
3461 "your token is valid but is not a system administrator; \
3462 re-running `dsp auth login` will not help — the SPARQL \
3463 passthrough endpoint requires a SystemAdmin account"
3464 .into(),
3465 )),
3466 404 => SparqlOutcome::DspApiError(Diagnostic::NotFound(format!(
3467 "the SPARQL passthrough is not available at {}. Any of these \
3471 looks identical from here: the endpoint is off on this deployment \
3472 (it is off by default — allow-sparql-passthrough), the server \
3473 predates the endpoint, the store's dataset is misconfigured, or \
3474 --server is wrong.",
3475 crate::util::text::sanitise_and_cap(url)
3476 ))),
3477 413 => SparqlOutcome::DspApiError(Diagnostic::Usage(
3478 "the SPARQL query text exceeds the server's request-body size \
3479 limit"
3480 .into(),
3481 )),
3482 415 => SparqlOutcome::DspApiError(Diagnostic::Internal(
3483 "the server rejected dsp-cli's own Content-Type \
3484 (application/sparql-query) with 415 — this is either a dsp-cli \
3485 bug or an unexpected server"
3486 .into(),
3487 )),
3488 500 | 502 | 503 | 504 => {
3489 let detail = parse_sparql_error_message(content_type, body)
3490 .unwrap_or_else(|| crate::util::text::sanitise_bytes_for_prose(body));
3491 let message = if detail.trim().is_empty() {
3497 format!("the server returned HTTP {status} with no usable message")
3498 } else {
3499 format!("the server returned HTTP {status}: {detail}")
3500 };
3501 SparqlOutcome::DspApiError(Diagnostic::ServerError(message))
3502 }
3503 _ => SparqlOutcome::Relay,
3504 }
3505}
3506
3507fn parse_sparql_error_message(content_type: Option<&str>, body: &[u8]) -> Option<String> {
3519 if !content_type.unwrap_or("").starts_with("application/json") {
3520 return None;
3521 }
3522 serde_json::from_slice::<SparqlErrorBody>(body)
3523 .ok()
3524 .map(|b| crate::util::text::sanitise_and_cap(&b.message))
3525}
3526
3527#[cfg(test)]
3532mod tests {
3533 use super::*;
3534
3535 #[test]
3540 fn truncate_for_display_at_exactly_80_chars_no_ellipsis() {
3541 let s = "a".repeat(80);
3542 let result = truncate_for_display(&s);
3543 assert_eq!(result, s);
3544 assert!(!result.ends_with('…'));
3545 }
3546
3547 #[test]
3548 fn truncate_for_display_over_80_chars_truncates_with_ellipsis() {
3549 let s = "a".repeat(90);
3550 let result = truncate_for_display(&s);
3551 assert_eq!(result.chars().count(), 81); assert!(result.ends_with('…'));
3553 assert_eq!(result.chars().filter(|&c| c == 'a').count(), 80);
3554 }
3555
3556 #[test]
3561 fn map_unexpected_status_401_403_are_auth_required() {
3562 for status in [reqwest::StatusCode::UNAUTHORIZED, reqwest::StatusCode::FORBIDDEN] {
3566 let diag = map_unexpected_status(status, "https://example.org/x");
3567 match diag {
3568 Diagnostic::AuthRequired(msg) => assert!(
3569 msg.contains("dsp auth login"),
3570 "auth message should hint at re-authentication: {msg}"
3571 ),
3572 other => panic!("expected AuthRequired for {status}, got {other:?}"),
3573 }
3574 }
3575 }
3576
3577 #[test]
3578 fn map_unexpected_status_404_and_5xx_stay_server_error() {
3579 assert!(matches!(
3582 map_unexpected_status(reqwest::StatusCode::NOT_FOUND, "u"),
3583 Diagnostic::ServerError(_)
3584 ));
3585 assert!(matches!(
3586 map_unexpected_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, "u"),
3587 Diagnostic::ServerError(_)
3588 ));
3589 }
3590
3591 #[test]
3596 fn identifier_key_email_contains_at() {
3597 assert_eq!(identifier_key("a@b.ch"), "email");
3598 }
3599
3600 #[test]
3601 fn identifier_key_bare_username() {
3602 assert_eq!(identifier_key("jdoe"), "username");
3603 }
3604
3605 #[test]
3606 fn identifier_key_http_iri() {
3607 assert_eq!(identifier_key("http://rdfh.ch/users/x"), "iri");
3608 }
3609
3610 #[test]
3611 fn identifier_key_https_iri() {
3612 assert_eq!(identifier_key("https://rdfh.ch/users/x"), "iri");
3613 }
3614
3615 #[test]
3616 fn identifier_key_iri_with_at_uses_iri_not_email() {
3617 assert_eq!(identifier_key("http://example.org/users/a@b"), "iri");
3619 }
3620
3621 #[test]
3622 fn classify_http_iri() {
3623 let ident = classify("http://rdfh.ch/projects/0001");
3624 assert!(matches!(ident, ProjectIdent::Iri(_)), "http:// prefix should classify as Iri");
3625 }
3626
3627 #[test]
3628 fn classify_https_iri() {
3629 let ident = classify("https://rdfh.ch/projects/0001");
3630 assert!(matches!(ident, ProjectIdent::Iri(_)), "https:// prefix should classify as Iri");
3631 }
3632
3633 #[test]
3634 fn classify_four_digit_hex_shortcode() {
3635 let ident = classify("0001");
3636 assert!(
3637 matches!(ident, ProjectIdent::Shortcode(_)),
3638 "four hex digits should classify as Shortcode"
3639 );
3640 }
3641
3642 #[test]
3643 fn classify_four_hex_letter_shortcode() {
3644 let ident = classify("beef");
3648 assert!(
3649 matches!(ident, ProjectIdent::Shortcode(_)),
3650 "4-hex-letter input 'beef' should classify as Shortcode (documented overlap)"
3651 );
3652 }
3653
3654 #[test]
3655 fn classify_mixed_case_hex_shortcode() {
3656 let ident = classify("ABCD");
3657 assert!(
3658 matches!(ident, ProjectIdent::Shortcode(_)),
3659 "upper-case hex digits should classify as Shortcode"
3660 );
3661 }
3662
3663 #[test]
3664 fn classify_shortname() {
3665 let ident = classify("incunabula");
3666 assert!(
3667 matches!(ident, ProjectIdent::Shortname(_)),
3668 "alphabetic string longer than 4 chars should classify as Shortname"
3669 );
3670 }
3671
3672 #[test]
3673 fn classify_five_digit_hex_is_shortname() {
3674 let ident = classify("00001");
3676 assert!(
3677 matches!(ident, ProjectIdent::Shortname(_)),
3678 "5-hex-digit string should classify as Shortname, not Shortcode"
3679 );
3680 }
3681
3682 #[test]
3683 fn classify_three_digit_hex_is_shortname() {
3684 let ident = classify("001");
3685 assert!(
3686 matches!(ident, ProjectIdent::Shortname(_)),
3687 "3-hex-digit string should classify as Shortname, not Shortcode"
3688 );
3689 }
3690
3691 #[test]
3692 fn classify_non_hex_four_chars_is_shortname() {
3693 let ident = classify("zzzz");
3695 assert!(
3696 matches!(ident, ProjectIdent::Shortname(_)),
3697 "4-char non-hex string should classify as Shortname"
3698 );
3699 }
3700
3701 #[test]
3706 fn validate_dump_id_valid_accepts() {
3707 assert!(super::validate_dump_id("abc123").is_ok());
3708 assert!(super::validate_dump_id("abc-123_XYZ").is_ok());
3709 let max_id = "a".repeat(256);
3711 assert!(super::validate_dump_id(&max_id).is_ok(), "256-char id must be accepted");
3712 }
3713
3714 #[test]
3715 fn validate_dump_id_empty_is_rejected() {
3716 let result = super::validate_dump_id("");
3717 assert!(matches!(result, Err(Diagnostic::ServerError(_))), "empty id must be rejected");
3718 }
3719
3720 #[test]
3721 fn validate_dump_id_too_long_is_rejected() {
3722 let long_id = "a".repeat(257);
3723 let result = super::validate_dump_id(&long_id);
3724 assert!(
3725 matches!(result, Err(Diagnostic::ServerError(_))),
3726 "257-char id must be rejected"
3727 );
3728 }
3729
3730 #[test]
3731 fn validate_dump_id_invalid_chars_rejected() {
3732 let result = super::validate_dump_id("abc/def");
3733 assert!(
3734 matches!(result, Err(Diagnostic::ServerError(_))),
3735 "id with '/' must be rejected"
3736 );
3737 }
3738
3739 #[test]
3744 fn into_dump_task_in_progress() {
3745 let api = DataTaskStatusApiResponse {
3746 id: "abc123".into(),
3747 status: "in_progress".into(),
3748 error_message: None,
3749 created_at: None,
3750 };
3751 let task = api.into_dump_task().expect("should parse in_progress");
3752 assert_eq!(task.id, "abc123");
3753 assert_eq!(task.status, DumpStatus::InProgress);
3754 assert!(task.error_message.is_none());
3755 assert!(task.created_at.is_none());
3756 }
3757
3758 #[test]
3759 fn into_dump_task_completed() {
3760 let api = DataTaskStatusApiResponse {
3761 id: "done42".into(),
3762 status: "completed".into(),
3763 error_message: None,
3764 created_at: None,
3765 };
3766 let task = api.into_dump_task().expect("should parse completed");
3767 assert_eq!(task.status, DumpStatus::Completed);
3768 }
3769
3770 #[test]
3771 fn into_dump_task_failed_with_message() {
3772 let api = DataTaskStatusApiResponse {
3773 id: "fail7".into(),
3774 status: "failed".into(),
3775 error_message: Some("disk full".into()),
3776 created_at: None,
3777 };
3778 let task = api.into_dump_task().expect("should parse failed");
3779 assert_eq!(task.status, DumpStatus::Failed);
3780 assert_eq!(task.error_message.as_deref(), Some("disk full"));
3781 }
3782
3783 #[test]
3784 fn into_dump_task_unknown_status_is_server_error() {
3785 let api = DataTaskStatusApiResponse {
3786 id: "x".into(),
3787 status: "pending".into(), error_message: None,
3789 created_at: None,
3790 };
3791 let result = api.into_dump_task();
3792 assert!(result.is_err(), "unknown status should yield an error");
3793 assert!(
3794 matches!(result.unwrap_err(), Diagnostic::ServerError(_)),
3795 "unknown status should yield ServerError"
3796 );
3797 }
3798
3799 #[test]
3800 fn into_dump_task_long_error_message_is_truncated() {
3801 let long_msg = "x".repeat(501);
3803 let api = DataTaskStatusApiResponse {
3804 id: "trunc".into(),
3805 status: "failed".into(),
3806 error_message: Some(long_msg),
3807 created_at: None,
3808 };
3809 let task = api.into_dump_task().expect("should parse even with long message");
3810 let stored = task.error_message.unwrap();
3811 assert_eq!(
3812 stored.len(),
3813 500,
3814 "error_message must be truncated to ≤500 chars at the client boundary"
3815 );
3816 }
3817
3818 #[test]
3819 fn into_dump_task_exact_500_chars_not_truncated() {
3820 let exact_msg = "y".repeat(500);
3822 let api = DataTaskStatusApiResponse {
3823 id: "exact".into(),
3824 status: "failed".into(),
3825 error_message: Some(exact_msg.clone()),
3826 created_at: None,
3827 };
3828 let task = api.into_dump_task().expect("should parse");
3829 assert_eq!(task.error_message.unwrap(), exact_msg);
3830 }
3831
3832 #[test]
3837 fn into_dump_task_valid_created_at_is_parsed() {
3838 let api = DataTaskStatusApiResponse {
3839 id: "ts-test".into(),
3840 status: "completed".into(),
3841 error_message: None,
3842 created_at: Some("2026-05-20T14:03:00Z".into()),
3843 };
3844 let task = api.into_dump_task().expect("should parse with created_at");
3845 use chrono::Datelike;
3846 let ts = task.created_at.expect("created_at should be Some");
3847 assert_eq!(ts.year(), 2026);
3848 assert_eq!(ts.month(), 5);
3849 assert_eq!(ts.day(), 20);
3850 }
3851
3852 #[test]
3853 fn into_dump_task_garbage_created_at_yields_none() {
3854 let api = DataTaskStatusApiResponse {
3855 id: "ts-bad".into(),
3856 status: "in_progress".into(),
3857 error_message: None,
3858 created_at: Some("not-a-date!!".into()),
3859 };
3860 let task = api.into_dump_task().expect("garbage created_at must not fail parse");
3862 assert!(task.created_at.is_none(), "garbage created_at must map to None");
3863 }
3864
3865 #[test]
3870 fn export_exists_present_with_both_fields() {
3871 let body = V3ErrorBody {
3872 errors: vec![V3ErrorItem {
3873 code: "export_exists".into(),
3874 details: [
3875 ("id".to_string(), "dGVzdC1pZA".to_string()),
3876 ("projectIri".to_string(), "http://rdfh.ch/projects/0001".to_string()),
3877 ]
3878 .into(),
3879 }],
3880 };
3881 let ex = body.export_exists().expect("export_exists must be Some");
3882 assert_eq!(ex.id, Some("dGVzdC1pZA"));
3883 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3884 }
3885
3886 #[test]
3887 fn export_exists_wrong_code_returns_none() {
3888 let body = V3ErrorBody {
3889 errors: vec![V3ErrorItem {
3890 code: "some_other_error".into(),
3891 details: [("id".to_string(), "abc".to_string())].into(),
3892 }],
3893 };
3894 assert!(body.export_exists().is_none(), "wrong code must not match");
3895 }
3896
3897 #[test]
3898 fn export_exists_missing_details_id_returns_some_with_none_id() {
3899 let body = V3ErrorBody {
3900 errors: vec![V3ErrorItem {
3901 code: "export_exists".into(),
3902 details: [("projectIri".to_string(), "http://rdfh.ch/projects/0001".to_string())].into(),
3903 }],
3904 };
3905 let ex = body.export_exists().expect("export_exists must be Some when code matches");
3907 assert!(ex.id.is_none(), "id must be None when 'id' key is absent");
3908 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3909 }
3910
3911 #[test]
3912 fn export_exists_empty_errors_returns_none() {
3913 let body = V3ErrorBody { errors: vec![] };
3914 assert!(body.export_exists().is_none());
3915 }
3916
3917 #[test]
3918 fn export_exists_missing_project_iri_returns_some_with_none_iri() {
3919 let body = V3ErrorBody {
3920 errors: vec![V3ErrorItem {
3921 code: "export_exists".into(),
3922 details: [("id".to_string(), "abc123".to_string())].into(),
3923 }],
3924 };
3925 let ex = body.export_exists().expect("export_exists must be Some when code matches");
3926 assert_eq!(ex.id, Some("abc123"));
3927 assert!(
3928 ex.project_iri.is_none(),
3929 "project_iri must be None when 'projectIri' key is absent"
3930 );
3931 }
3932
3933 #[test]
3938 fn is_safe_shortcode_valid_hex_shortcode() {
3939 assert!(super::is_safe_shortcode("0001"), "4-hex-digit shortcode must be accepted");
3940 assert!(super::is_safe_shortcode("ABCD"), "upper-case hex shortcode must be accepted");
3941 assert!(super::is_safe_shortcode("beef"), "lower-case hex shortcode must be accepted");
3942 }
3943
3944 #[test]
3945 fn is_safe_shortcode_alphanumeric_within_32_chars_accepted() {
3946 let long_code = "a".repeat(32);
3947 assert!(super::is_safe_shortcode(&long_code), "32-char alphanumeric must be accepted");
3948 }
3949
3950 #[test]
3951 fn is_safe_shortcode_empty_is_rejected() {
3952 assert!(!super::is_safe_shortcode(""), "empty shortcode must be rejected");
3953 }
3954
3955 #[test]
3956 fn is_safe_shortcode_too_long_is_rejected() {
3957 let long_code = "a".repeat(33);
3958 assert!(!super::is_safe_shortcode(&long_code), "33-char shortcode must be rejected");
3959 }
3960
3961 #[test]
3962 fn is_safe_shortcode_slash_is_rejected() {
3963 assert!(!super::is_safe_shortcode("ab/cd"), "shortcode with '/' must be rejected");
3964 assert!(!super::is_safe_shortcode("/evil"), "absolute path shortcode must be rejected");
3965 }
3966
3967 #[test]
3968 fn is_safe_shortcode_dot_dot_is_rejected() {
3969 assert!(
3970 !super::is_safe_shortcode("../evil"),
3971 "path traversal shortcode must be rejected"
3972 );
3973 assert!(!super::is_safe_shortcode(".."), "'..' shortcode must be rejected");
3974 }
3975
3976 #[test]
3977 fn is_safe_shortcode_backslash_is_rejected() {
3978 assert!(!super::is_safe_shortcode("ab\\cd"), "shortcode with '\\' must be rejected");
3979 }
3980
3981 #[test]
3982 fn is_safe_shortcode_dot_is_rejected() {
3983 assert!(!super::is_safe_shortcode("ab.cd"), "shortcode with '.' must be rejected");
3985 }
3986
3987 #[test]
3988 fn resolve_project_rejects_unsafe_shortcode() {
3989 let unsafe_examples = ["../evil", "/abs", "ab/cd", "a\\b", ""];
3993 for s in &unsafe_examples {
3994 assert!(
3995 !super::is_safe_shortcode(s),
3996 "is_safe_shortcode must reject '{s}' — resolve_project would have returned ServerError for this input"
3997 );
3998 }
3999 }
4000
4001 #[test]
4006 fn data_model_name_from_iri_standard_form() {
4007 assert_eq!(
4009 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2"),
4010 "beol"
4011 );
4012 }
4013
4014 #[test]
4015 fn data_model_name_from_iri_no_v2_suffix() {
4016 assert_eq!(
4018 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol"),
4019 "beol"
4020 );
4021 }
4022
4023 #[test]
4024 fn data_model_name_from_iri_trailing_slash() {
4025 assert_eq!(
4027 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2/"),
4028 "beol"
4029 );
4030 }
4031
4032 #[test]
4033 fn data_model_name_from_iri_bare_name() {
4034 assert_eq!(super::data_model_name_from_iri("beol"), "beol");
4036 }
4037
4038 #[test]
4039 fn data_model_name_from_iri_empty_string() {
4040 assert_eq!(super::data_model_name_from_iri(""), "");
4042 }
4043
4044 fn beol_prefixes() -> HashMap<String, String> {
4049 let mut m = HashMap::new();
4050 m.insert("beol".to_string(), "http://api.dasch.swiss/ontology/0801/beol/v2#".to_string());
4051 m
4052 }
4053
4054 #[test]
4055 fn expand_class_id_curie_expands_with_known_prefix() {
4056 let (name, iri) = super::expand_class_id("beol:Archive", &beol_prefixes());
4058 assert_eq!(name, "Archive");
4059 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
4060 }
4061
4062 #[test]
4063 fn expand_class_id_unknown_prefix_falls_back_to_raw_id() {
4064 let (name, iri) = super::expand_class_id("urn:uuid:x", &HashMap::new());
4066 assert_eq!(name, "x");
4067 assert_eq!(iri, "urn:uuid:x");
4068 }
4069
4070 #[test]
4071 fn expand_class_id_full_iri_passes_through() {
4072 let (name, iri) =
4075 super::expand_class_id("http://api.dasch.swiss/ontology/0801/beol/v2#Letter", &beol_prefixes());
4076 assert_eq!(name, "Letter");
4077 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Letter");
4078 }
4079
4080 #[test]
4081 fn expand_class_id_no_colon_degenerate() {
4082 let (name, iri) = super::expand_class_id("bare", &HashMap::new());
4084 assert_eq!(name, "bare");
4085 assert_eq!(iri, "bare");
4086 }
4087
4088 #[test]
4093 fn local_name_hash_iri() {
4094 assert_eq!(super::local_name("http://example.org/onto#Thing"), "Thing");
4095 }
4096
4097 #[test]
4098 fn local_name_slash_iri() {
4099 assert_eq!(super::local_name("http://example.org/onto/Thing"), "Thing");
4100 }
4101
4102 #[test]
4103 fn local_name_curie_colon() {
4104 assert_eq!(super::local_name("incunabula:Page"), "Page");
4105 }
4106
4107 #[test]
4108 fn local_name_bare_name_fallback() {
4109 assert_eq!(super::local_name("Page"), "Page");
4110 }
4111
4112 #[test]
4113 fn local_name_empty_string() {
4114 assert_eq!(super::local_name(""), "");
4115 }
4116
4117 #[test]
4118 fn local_name_trailing_separator() {
4119 assert_eq!(super::local_name("foo#"), "");
4122 }
4123
4124 #[test]
4129 fn object_type_to_kebab_text_value() {
4130 assert_eq!(super::object_type_to_kebab("TextValue"), "text");
4131 }
4132
4133 #[test]
4134 fn object_type_to_kebab_geom_value() {
4135 assert_eq!(super::object_type_to_kebab("GeomValue"), "geom");
4137 }
4138
4139 #[test]
4140 fn object_type_to_kebab_geo_name_value() {
4141 assert_eq!(super::object_type_to_kebab("GeoNameValue"), "geo-name");
4143 }
4144
4145 #[test]
4146 fn object_type_to_kebab_uri_value() {
4147 assert_eq!(super::object_type_to_kebab("URIValue"), "uri");
4150 }
4151
4152 #[test]
4153 fn object_type_to_kebab_interval_value() {
4154 assert_eq!(super::object_type_to_kebab("IntervalValue"), "interval");
4157 }
4158
4159 #[test]
4160 fn object_type_to_kebab_no_value_suffix() {
4161 assert_eq!(super::object_type_to_kebab("Geom"), "geom");
4163 }
4164
4165 #[test]
4166 fn map_object_type_known_text_value() {
4167 use crate::model::ValueType;
4168 assert_eq!(super::map_object_type_to_value_type("TextValue"), ValueType::Text);
4169 }
4170
4171 #[test]
4172 fn map_object_type_known_list_value() {
4173 use crate::model::ValueType;
4174 assert_eq!(super::map_object_type_to_value_type("ListValue"), ValueType::VocabularyItem);
4175 }
4176
4177 #[test]
4178 fn map_object_type_other_geom() {
4179 use crate::model::ValueType;
4180 assert_eq!(
4182 super::map_object_type_to_value_type("GeomValue"),
4183 ValueType::Other("geom".to_string())
4184 );
4185 }
4186
4187 #[test]
4188 fn map_object_type_other_uri_value() {
4189 use crate::model::ValueType;
4190 assert_eq!(
4192 super::map_object_type_to_value_type("URIValue"),
4193 ValueType::Other("uri".to_string())
4194 );
4195 }
4196
4197 #[test]
4198 fn map_object_type_other_geo_name_value() {
4199 use crate::model::ValueType;
4200 assert_eq!(
4201 super::map_object_type_to_value_type("GeoNameValue"),
4202 ValueType::Other("geo-name".to_string())
4203 );
4204 }
4205
4206 #[test]
4211 fn decode_cardinality_owl_cardinality_1() {
4212 use crate::model::Cardinality;
4213 let v = serde_json::json!({"owl:cardinality": 1});
4214 assert_eq!(super::decode_cardinality(&v), Cardinality::One);
4215 }
4216
4217 #[test]
4218 fn decode_cardinality_owl_max_cardinality_1() {
4219 use crate::model::Cardinality;
4220 let v = serde_json::json!({"owl:maxCardinality": 1});
4221 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrOne);
4222 }
4223
4224 #[test]
4225 fn decode_cardinality_owl_min_cardinality_0() {
4226 use crate::model::Cardinality;
4227 let v = serde_json::json!({"owl:minCardinality": 0});
4228 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4229 }
4230
4231 #[test]
4232 fn decode_cardinality_owl_min_cardinality_1() {
4233 use crate::model::Cardinality;
4234 let v = serde_json::json!({"owl:minCardinality": 1});
4235 assert_eq!(super::decode_cardinality(&v), Cardinality::OneOrMore);
4236 }
4237
4238 #[test]
4239 fn decode_cardinality_fallback_no_key() {
4240 use crate::model::Cardinality;
4241 let v = serde_json::json!({});
4243 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4244 }
4245
4246 #[test]
4247 fn decode_cardinality_fallback_owl_cardinality_unexpected_value() {
4248 use crate::model::Cardinality;
4249 let v = serde_json::json!({"owl:cardinality": 5});
4251 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4252 }
4253
4254 #[test]
4255 fn decode_cardinality_fallback_owl_max_cardinality_gt1() {
4256 use crate::model::Cardinality;
4257 let v = serde_json::json!({"owl:maxCardinality": 2});
4260 assert_eq!(
4261 super::decode_cardinality(&v),
4262 Cardinality::ZeroOrMore,
4263 "owl:maxCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4264 );
4265 }
4266
4267 #[test]
4268 fn decode_cardinality_fallback_owl_min_cardinality_gt1() {
4269 use crate::model::Cardinality;
4270 let v = serde_json::json!({"owl:minCardinality": 2});
4273 assert_eq!(
4274 super::decode_cardinality(&v),
4275 Cardinality::ZeroOrMore,
4276 "owl:minCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4277 );
4278 }
4279
4280 #[test]
4285 fn detect_representation_still_image() {
4286 use crate::model::Representation;
4287 let locals = vec!["hasStillImageFileValue"];
4288 assert_eq!(super::detect_representation(&locals), Some(Representation::StillImage));
4289 }
4290
4291 #[test]
4292 fn detect_representation_moving_image() {
4293 use crate::model::Representation;
4294 let locals = vec!["hasMovingImageFileValue"];
4295 assert_eq!(super::detect_representation(&locals), Some(Representation::MovingImage));
4296 }
4297
4298 #[test]
4299 fn detect_representation_audio() {
4300 use crate::model::Representation;
4301 let locals = vec!["hasAudioFileValue"];
4302 assert_eq!(super::detect_representation(&locals), Some(Representation::Audio));
4303 }
4304
4305 #[test]
4306 fn detect_representation_none_when_absent() {
4307 let locals = vec!["hasTitle", "hasAuthor"];
4309 assert_eq!(super::detect_representation(&locals), None);
4310 }
4311
4312 #[test]
4313 fn detect_representation_takes_first() {
4314 use crate::model::Representation;
4315 let locals = vec!["hasDocumentFileValue", "hasStillImageFileValue"];
4317 assert_eq!(super::detect_representation(&locals), Some(Representation::Document));
4318 }
4319
4320 #[test]
4325 fn is_system_prefix_knora_api() {
4326 assert!(super::is_system_prefix("knora-api"));
4327 }
4328
4329 #[test]
4330 fn is_system_prefix_rdf() {
4331 assert!(super::is_system_prefix("rdf"));
4332 }
4333
4334 #[test]
4335 fn is_system_prefix_project_prefix_is_not_system() {
4336 assert!(!super::is_system_prefix("incunabula"));
4337 assert!(!super::is_system_prefix("beol"));
4338 assert!(!super::is_system_prefix("biblio"));
4339 }
4340
4341 #[test]
4346 fn curie_prefix_returns_prefix_for_curie() {
4347 assert_eq!(super::curie_prefix("knora-api:arkUrl"), Some("knora-api"));
4348 assert_eq!(super::curie_prefix("beol:hasTitle"), Some("beol"));
4349 }
4350
4351 #[test]
4352 fn curie_prefix_returns_none_for_full_iri() {
4353 assert_eq!(
4355 super::curie_prefix("http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle"),
4356 None
4357 );
4358 }
4359
4360 #[test]
4361 fn curie_prefix_returns_none_for_no_colon() {
4362 assert_eq!(super::curie_prefix("hasTitle"), None);
4363 }
4364
4365 #[test]
4370 fn sibling_iri_trim_hash_delimiter() {
4371 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4373 let trimmed = namespace.trim_end_matches(['#', '/']);
4374 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4375 }
4376
4377 #[test]
4378 fn sibling_iri_trim_slash_delimiter() {
4379 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2/";
4381 let trimmed = namespace.trim_end_matches(['#', '/']);
4382 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4383 }
4384
4385 #[test]
4386 fn sibling_iri_self_loop_detected() {
4387 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4389 let namespace = "http://api.dasch.swiss/ontology/0801/beol/v2#";
4390 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4391 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4392 assert_eq!(sibling_iri, queried_trimmed); }
4394
4395 #[test]
4396 fn sibling_iri_different_ontology_is_not_self_loop() {
4397 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4398 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4399 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4400 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4401 assert_ne!(sibling_iri, queried_trimmed); }
4403
4404 #[test]
4405 fn missing_prefix_in_context_is_skipped() {
4406 let prefixes: HashMap<String, String> = HashMap::new();
4408 let result = prefixes.get("biblio");
4409 assert!(result.is_none()); }
4411
4412 #[test]
4417 fn derive_access_rv() {
4418 assert_eq!(super::derive_access("RV"), Some(super::ResourceAccess::RestrictedView));
4419 }
4420
4421 #[test]
4422 fn derive_access_v() {
4423 assert_eq!(super::derive_access("V"), Some(super::ResourceAccess::View));
4424 }
4425
4426 #[test]
4427 fn derive_access_m() {
4428 assert_eq!(super::derive_access("M"), Some(super::ResourceAccess::Edit));
4429 }
4430
4431 #[test]
4432 fn derive_access_d() {
4433 assert_eq!(super::derive_access("D"), Some(super::ResourceAccess::Delete));
4434 }
4435
4436 #[test]
4437 fn derive_access_cr() {
4438 assert_eq!(super::derive_access("CR"), Some(super::ResourceAccess::Manage));
4439 }
4440
4441 #[test]
4442 fn derive_access_unknown_is_none() {
4443 assert_eq!(super::derive_access("XYZ"), None);
4444 }
4445
4446 #[test]
4447 fn derive_access_empty_is_none() {
4448 assert_eq!(super::derive_access(""), None);
4449 }
4450
4451 #[test]
4456 fn derive_visibility_public_when_unknown_user_has_view() {
4457 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|V knora-admin:KnownUser,knora-admin:UnknownUser";
4459 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::Public));
4460 }
4461
4462 #[test]
4463 fn derive_visibility_public_when_unknown_user_has_cr() {
4464 let acl = "CR knora-admin:UnknownUser";
4466 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::Public));
4467 }
4468
4469 #[test]
4470 fn derive_visibility_public_restricted_when_unknown_user_has_rv() {
4471 let acl = "RV knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4473 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::PublicRestricted));
4474 }
4475
4476 #[test]
4477 fn derive_visibility_logged_in_when_known_user_has_rv_unknown_absent() {
4478 let acl = "RV knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4480 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::LoggedInUsers));
4481 }
4482
4483 #[test]
4484 fn derive_visibility_logged_in_when_known_user_has_v() {
4485 let acl = "V knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4487 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::LoggedInUsers));
4488 }
4489
4490 #[test]
4491 fn derive_visibility_project_members_when_neither_world_group_granted() {
4492 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|M knora-admin:ProjectMember";
4494 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::ProjectMembers));
4495 }
4496
4497 #[test]
4498 fn derive_visibility_empty_string_is_none() {
4499 assert_eq!(super::derive_visibility(""), None);
4500 }
4501
4502 #[test]
4503 fn derive_visibility_whitespace_only_is_none() {
4504 assert_eq!(super::derive_visibility(" "), None);
4505 }
4506
4507 #[test]
4508 fn derive_visibility_malformed_entry_without_space_is_skipped() {
4509 let acl = "CRMALFORMED|CR knora-admin:ProjectAdmin";
4511 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::ProjectMembers));
4513 }
4514
4515 #[test]
4516 fn derive_visibility_unknown_code_ranks_zero_no_implicit_grant() {
4517 let acl = "BOGUS knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4519 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::ProjectMembers));
4521 }
4522
4523 #[test]
4524 fn derive_visibility_same_group_two_entries_max_wins() {
4525 let acl = "RV knora-admin:UnknownUser|V knora-admin:UnknownUser";
4527 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::Public));
4528 }
4529
4530 #[test]
4531 fn derive_visibility_both_world_groups_unknown_user_decides() {
4532 let acl = "V knora-admin:UnknownUser|CR knora-admin:KnownUser";
4535 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::Public));
4536 }
4537
4538 #[test]
4539 fn derive_visibility_super_unknown_user_does_not_match() {
4540 let acl = "CR knora-admin:SuperUnknownUser|CR knora-admin:ProjectAdmin";
4543 assert_eq!(super::derive_visibility(acl), Some(super::ResourceVisibility::ProjectMembers));
4545 }
4546
4547 #[test]
4548 fn derive_visibility_all_malformed_entries_no_space_returns_none() {
4549 let acl = "NOSPACE|ALSONOSPACE|STILLNOSPACE";
4553 assert_eq!(
4554 super::derive_visibility(acl),
4555 None,
4556 "all-malformed ACL (no space in any entry) must return None"
4557 );
4558 }
4559
4560 use crate::model::ValueType;
4565 use crate::model::resource::{DatePoint, DateValue, FileValue, ValueContent};
4566
4567 #[test]
4570 fn parse_value_text_plain() {
4571 let obj = serde_json::json!({
4572 "@type": "knora-api:TextValue",
4573 "knora-api:valueAsString": "Hello world"
4574 });
4575 let (content, is_link) = super::parse_value_content(&obj);
4576 assert_eq!(content, ValueContent::Text("Hello world".into()));
4577 assert!(!is_link);
4578 }
4579
4580 #[test]
4581 fn parse_value_text_standoff_xml_stripped() {
4582 let obj = serde_json::json!({
4584 "@type": "knora-api:TextValue",
4585 "knora-api:textValueAsXml": "<p>Hello <b>world</b></p>",
4586 "knora-api:valueAsString": "This is ignored when xml present"
4587 });
4588 let (content, is_link) = super::parse_value_content(&obj);
4589 assert!(matches!(content, ValueContent::Text(_)));
4591 assert!(!is_link);
4592 if let ValueContent::Text(s) = content {
4593 assert!(!s.contains('<'), "no raw tags: {s:?}");
4595 assert!(s.contains("Hello"), "text retained: {s:?}");
4596 }
4597 }
4598
4599 #[test]
4602 fn parse_value_integer() {
4603 let obj = serde_json::json!({
4604 "@type": "knora-api:IntValue",
4605 "knora-api:intValueAsInt": 42
4606 });
4607 let (content, is_link) = super::parse_value_content(&obj);
4608 assert_eq!(content, ValueContent::Integer(42));
4609 assert!(!is_link);
4610 }
4611
4612 #[test]
4613 fn parse_value_integer_negative() {
4614 let obj = serde_json::json!({
4615 "@type": "knora-api:IntValue",
4616 "knora-api:intValueAsInt": -7
4617 });
4618 let (content, _) = super::parse_value_content(&obj);
4619 assert_eq!(content, ValueContent::Integer(-7));
4620 }
4621
4622 #[test]
4625 fn parse_value_decimal_object_form() {
4626 let obj = serde_json::json!({
4628 "@type": "knora-api:DecimalValue",
4629 "knora-api:decimalValueAsDecimal": {"@value": "3.14159", "@type": "xsd:decimal"}
4630 });
4631 let (content, is_link) = super::parse_value_content(&obj);
4632 assert_eq!(content, ValueContent::Decimal("3.14159".into()));
4633 assert!(!is_link);
4634 }
4635
4636 #[test]
4637 fn parse_value_decimal_bare_string_form() {
4638 let obj = serde_json::json!({
4639 "@type": "knora-api:DecimalValue",
4640 "knora-api:decimalValueAsDecimal": "2.71828"
4641 });
4642 let (content, _) = super::parse_value_content(&obj);
4643 assert_eq!(content, ValueContent::Decimal("2.71828".into()));
4644 }
4645
4646 #[test]
4649 fn parse_value_boolean_true() {
4650 let obj = serde_json::json!({
4651 "@type": "knora-api:BooleanValue",
4652 "knora-api:booleanValueAsBoolean": true
4653 });
4654 let (content, is_link) = super::parse_value_content(&obj);
4655 assert_eq!(content, ValueContent::Boolean(true));
4656 assert!(!is_link);
4657 }
4658
4659 #[test]
4660 fn parse_value_boolean_false() {
4661 let obj = serde_json::json!({
4662 "@type": "knora-api:BooleanValue",
4663 "knora-api:booleanValueAsBoolean": false
4664 });
4665 let (content, _) = super::parse_value_content(&obj);
4666 assert_eq!(content, ValueContent::Boolean(false));
4667 }
4668
4669 #[test]
4672 fn parse_value_date_single_point() {
4673 let obj = serde_json::json!({
4675 "@type": "knora-api:DateValue",
4676 "knora-api:dateValueHasCalendar": "GREGORIAN",
4677 "knora-api:dateValueHasStartYear": 1489,
4678 "knora-api:dateValueHasStartEra": "CE",
4679 "knora-api:dateValueHasEndYear": 1489,
4680 "knora-api:dateValueHasEndEra": "CE"
4681 });
4682 let (content, is_link) = super::parse_value_content(&obj);
4683 assert!(!is_link);
4684 let expected = ValueContent::Date(DateValue {
4685 calendar: "GREGORIAN".into(),
4686 start: DatePoint {
4687 year: Some(1489),
4688 month: None,
4689 day: None,
4690 era: Some("CE".into()),
4691 },
4692 end: DatePoint {
4693 year: Some(1489),
4694 month: None,
4695 day: None,
4696 era: Some("CE".into()),
4697 },
4698 });
4699 assert_eq!(content, expected);
4700 }
4701
4702 #[test]
4703 fn parse_value_date_range() {
4704 let obj = serde_json::json!({
4706 "@type": "knora-api:DateValue",
4707 "knora-api:dateValueHasCalendar": "GREGORIAN",
4708 "knora-api:dateValueHasStartYear": 1489,
4709 "knora-api:dateValueHasStartEra": "CE",
4710 "knora-api:dateValueHasEndYear": 1490,
4711 "knora-api:dateValueHasEndEra": "CE"
4712 });
4713 let (content, _) = super::parse_value_content(&obj);
4714 if let ValueContent::Date(dv) = content {
4715 assert_eq!(dv.start.year, Some(1489));
4716 assert_eq!(dv.end.year, Some(1490));
4717 assert_ne!(dv.start, dv.end, "range: start != end");
4718 } else {
4719 panic!("expected DateValue, got {content:?}");
4720 }
4721 }
4722
4723 #[test]
4724 fn parse_value_date_full_day_precision() {
4725 let obj = serde_json::json!({
4727 "@type": "knora-api:DateValue",
4728 "knora-api:dateValueHasCalendar": "JULIAN",
4729 "knora-api:dateValueHasStartYear": 1456,
4730 "knora-api:dateValueHasStartMonth": 3,
4731 "knora-api:dateValueHasStartDay": 14,
4732 "knora-api:dateValueHasStartEra": "CE",
4733 "knora-api:dateValueHasEndYear": 1456,
4734 "knora-api:dateValueHasEndMonth": 3,
4735 "knora-api:dateValueHasEndDay": 14,
4736 "knora-api:dateValueHasEndEra": "CE"
4737 });
4738 let (content, _) = super::parse_value_content(&obj);
4739 if let ValueContent::Date(dv) = content {
4740 assert_eq!(dv.calendar, "JULIAN");
4741 assert_eq!(dv.start.month, Some(3));
4742 assert_eq!(dv.start.day, Some(14));
4743 } else {
4744 panic!("expected DateValue, got {content:?}");
4745 }
4746 }
4747
4748 #[test]
4749 fn parse_value_date_no_year_falls_back_to_raw() {
4750 let obj = serde_json::json!({
4752 "@type": "knora-api:DateValue",
4753 "knora-api:dateValueHasCalendar": "GREGORIAN",
4754 "knora-api:valueAsString": "some date"
4755 });
4756 let (content, _) = super::parse_value_content(&obj);
4757 assert!(
4758 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "date"),
4759 "missing years must degrade to Raw date"
4760 );
4761 }
4762
4763 #[test]
4766 fn parse_value_time() {
4767 let obj = serde_json::json!({
4768 "@type": "knora-api:TimeValue",
4769 "knora-api:timeValueAsTimeStamp": {"@value": "2021-01-01T12:00:00Z", "@type": "xsd:dateTimeStamp"}
4770 });
4771 let (content, is_link) = super::parse_value_content(&obj);
4772 assert_eq!(content, ValueContent::Time("2021-01-01T12:00:00Z".into()));
4773 assert!(!is_link);
4774 }
4775
4776 #[test]
4777 fn parse_value_time_bare_string() {
4778 let obj = serde_json::json!({
4779 "@type": "knora-api:TimeValue",
4780 "knora-api:timeValueAsTimeStamp": "2022-06-01T00:00:00Z"
4781 });
4782 let (content, _) = super::parse_value_content(&obj);
4783 assert_eq!(content, ValueContent::Time("2022-06-01T00:00:00Z".into()));
4784 }
4785
4786 #[test]
4789 fn parse_value_uri() {
4790 let obj = serde_json::json!({
4791 "@type": "knora-api:UriValue",
4792 "knora-api:uriValueAsUri": {"@value": "https://example.com", "@type": "xsd:anyURI"}
4793 });
4794 let (content, is_link) = super::parse_value_content(&obj);
4795 assert_eq!(content, ValueContent::Uri("https://example.com".into()));
4796 assert!(!is_link);
4797 }
4798
4799 #[test]
4802 fn parse_value_color() {
4803 let obj = serde_json::json!({
4804 "@type": "knora-api:ColorValue",
4805 "knora-api:colorValueAsColor": "#ff0000"
4806 });
4807 let (content, is_link) = super::parse_value_content(&obj);
4808 assert_eq!(content, ValueContent::Color("#ff0000".into()));
4809 assert!(!is_link);
4810 }
4811
4812 #[test]
4815 fn parse_value_geoname() {
4816 let obj = serde_json::json!({
4817 "@type": "knora-api:GeonameValue",
4818 "knora-api:geonameValueAsGeonameCode": "2661552"
4819 });
4820 let (content, is_link) = super::parse_value_content(&obj);
4821 assert_eq!(content, ValueContent::Geoname("2661552".into()));
4822 assert!(!is_link);
4823 }
4824
4825 #[test]
4828 fn parse_value_vocabulary_item() {
4829 let obj = serde_json::json!({
4830 "@type": "knora-api:ListValue",
4831 "knora-api:listValueAsListNode": {"@id": "http://rdfh.ch/lists/0001/node1"}
4832 });
4833 let (content, is_link) = super::parse_value_content(&obj);
4834 assert_eq!(
4835 content,
4836 ValueContent::VocabularyItem {
4837 node_iri: "http://rdfh.ch/lists/0001/node1".into(),
4838 label: None, }
4840 );
4841 assert!(!is_link);
4842 }
4843
4844 #[test]
4847 fn parse_value_link_with_embedded_target() {
4848 let obj = serde_json::json!({
4849 "@type": "knora-api:LinkValue",
4850 "knora-api:linkValueHasTarget": {
4851 "@id": "http://rdfh.ch/0803/res1",
4852 "@type": "incunabula:Book",
4853 "rdfs:label": "Incunabula Book 1"
4854 }
4855 });
4856 let (content, is_link) = super::parse_value_content(&obj);
4857 assert!(is_link, "LinkValue must set is_link=true");
4858 assert_eq!(
4859 content,
4860 ValueContent::Link {
4861 target_iri: "http://rdfh.ch/0803/res1".into(),
4862 target_label: Some("Incunabula Book 1".into()),
4863 }
4864 );
4865 }
4866
4867 #[test]
4868 fn parse_value_link_with_target_iri_only() {
4869 let obj = serde_json::json!({
4871 "@type": "knora-api:LinkValue",
4872 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res2"}
4873 });
4874 let (content, is_link) = super::parse_value_content(&obj);
4875 assert!(is_link);
4876 assert_eq!(
4877 content,
4878 ValueContent::Link {
4879 target_iri: "http://rdfh.ch/0803/res2".into(),
4880 target_label: None,
4881 }
4882 );
4883 }
4884
4885 #[test]
4888 fn parse_value_still_image_file() {
4889 let obj = serde_json::json!({
4890 "@type": "knora-api:StillImageFileValue",
4891 "knora-api:fileValueHasFilename": "image.jp2",
4892 "knora-api:fileValueAsUrl": {"@value": "https://iiif.example.com/image.jp2/full/max/0/default.jpg"},
4893 "knora-api:stillImageFileValueHasDimX": 1200,
4894 "knora-api:stillImageFileValueHasDimY": 800
4895 });
4896 let (content, is_link) = super::parse_value_content(&obj);
4897 assert!(!is_link);
4898 assert_eq!(
4899 content,
4900 ValueContent::File(FileValue {
4901 value_type: ValueType::StillImage,
4902 filename: "image.jp2".into(),
4903 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
4904 width: Some(1200),
4905 height: Some(800),
4906 })
4907 );
4908 }
4909
4910 #[test]
4911 fn parse_value_still_image_external_file_value() {
4912 let obj = serde_json::json!({
4914 "@type": "knora-api:StillImageExternalFileValue",
4915 "knora-api:fileValueHasFilename": "external.jpg",
4916 "knora-api:fileValueAsUrl": {"@value": "https://iiif.external.com/image.jpg"}
4917 });
4918 let (content, _) = super::parse_value_content(&obj);
4919 if let ValueContent::File(fv) = content {
4920 assert_eq!(fv.value_type, ValueType::StillImage, "StillImageExternal* → StillImage");
4921 } else {
4922 panic!("expected File, got {content:?}");
4923 }
4924 }
4925
4926 #[test]
4929 fn parse_value_moving_image_file() {
4930 let obj = serde_json::json!({
4931 "@type": "knora-api:MovingImageFileValue",
4932 "knora-api:fileValueHasFilename": "video.mp4",
4933 "knora-api:fileValueAsUrl": {"@value": "https://example.com/video.mp4"}
4934 });
4935 let (content, is_link) = super::parse_value_content(&obj);
4936 assert!(!is_link);
4937 assert_eq!(
4938 content,
4939 ValueContent::File(FileValue {
4940 value_type: ValueType::MovingImage,
4941 filename: "video.mp4".into(),
4942 url: "https://example.com/video.mp4".into(),
4943 width: None,
4944 height: None,
4945 })
4946 );
4947 }
4948
4949 #[test]
4952 fn parse_value_audio_file() {
4953 let obj = serde_json::json!({
4954 "@type": "knora-api:AudioFileValue",
4955 "knora-api:fileValueHasFilename": "sound.wav",
4956 "knora-api:fileValueAsUrl": {"@value": "https://example.com/sound.wav"}
4957 });
4958 let (content, _) = super::parse_value_content(&obj);
4959 assert_eq!(
4960 content,
4961 ValueContent::File(FileValue {
4962 value_type: ValueType::Audio,
4963 filename: "sound.wav".into(),
4964 url: "https://example.com/sound.wav".into(),
4965 width: None,
4966 height: None,
4967 })
4968 );
4969 }
4970
4971 #[test]
4974 fn parse_value_document_file() {
4975 let obj = serde_json::json!({
4976 "@type": "knora-api:DocumentFileValue",
4977 "knora-api:fileValueHasFilename": "doc.pdf",
4978 "knora-api:fileValueAsUrl": {"@value": "https://example.com/doc.pdf"}
4979 });
4980 let (content, _) = super::parse_value_content(&obj);
4981 assert_eq!(
4982 content,
4983 ValueContent::File(FileValue {
4984 value_type: ValueType::Document,
4985 filename: "doc.pdf".into(),
4986 url: "https://example.com/doc.pdf".into(),
4987 width: None,
4988 height: None,
4989 })
4990 );
4991 }
4992
4993 #[test]
4996 fn parse_value_archive_file() {
4997 let obj = serde_json::json!({
4998 "@type": "knora-api:ArchiveFileValue",
4999 "knora-api:fileValueHasFilename": "data.zip",
5000 "knora-api:fileValueAsUrl": {"@value": "https://example.com/data.zip"}
5001 });
5002 let (content, _) = super::parse_value_content(&obj);
5003 assert_eq!(
5004 content,
5005 ValueContent::File(FileValue {
5006 value_type: ValueType::Archive,
5007 filename: "data.zip".into(),
5008 url: "https://example.com/data.zip".into(),
5009 width: None,
5010 height: None,
5011 })
5012 );
5013 }
5014
5015 #[test]
5018 fn parse_value_text_file_value_maps_to_document() {
5019 let obj = serde_json::json!({
5020 "@type": "knora-api:TextFileValue",
5021 "knora-api:fileValueHasFilename": "text.txt",
5022 "knora-api:fileValueAsUrl": {"@value": "https://example.com/text.txt"}
5023 });
5024 let (content, _) = super::parse_value_content(&obj);
5025 if let ValueContent::File(fv) = content {
5026 assert_eq!(fv.value_type, ValueType::Document, "TextFileValue → Document");
5027 } else {
5028 panic!("expected File, got {content:?}");
5029 }
5030 }
5031
5032 #[test]
5035 fn parse_value_interval_raw_fallback() {
5036 let obj = serde_json::json!({
5037 "@type": "knora-api:IntervalValue",
5038 "knora-api:intervalValueHasStart": {"@value": "0.0", "@type": "xsd:decimal"},
5039 "knora-api:intervalValueHasEnd": {"@value": "10.5", "@type": "xsd:decimal"},
5040 "knora-api:valueAsString": "0.0 - 10.5"
5041 });
5042 let (content, is_link) = super::parse_value_content(&obj);
5043 assert!(!is_link);
5044 assert!(
5045 matches!(content, ValueContent::Raw { ref value_type, .. } if value_type == "interval"),
5046 "IntervalValue must degrade to Raw with token 'interval'"
5047 );
5048 if let ValueContent::Raw { text, .. } = content {
5049 assert_eq!(text, "0.0 - 10.5");
5050 }
5051 }
5052
5053 #[test]
5054 fn parse_value_geom_raw_fallback() {
5055 let obj = serde_json::json!({
5056 "@type": "knora-api:GeomValue",
5057 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
5058 });
5059 let (content, _) = super::parse_value_content(&obj);
5060 assert!(
5061 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "geom"),
5062 "GeomValue must degrade to Raw with token 'geom'"
5063 );
5064 }
5065
5066 #[test]
5069 fn parse_value_with_comment() {
5070 let obj = serde_json::json!({
5071 "@type": "knora-api:TextValue",
5072 "knora-api:valueAsString": "Hello world",
5073 "knora-api:valueHasComment": "reading uncertain"
5074 });
5075 let (value, is_link) = super::parse_value(&obj);
5076 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
5077 assert_eq!(value.comment.as_deref(), Some("reading uncertain"));
5078 assert!(!is_link);
5079 }
5080
5081 #[test]
5082 fn parse_value_without_comment() {
5083 let obj = serde_json::json!({
5084 "@type": "knora-api:TextValue",
5085 "knora-api:valueAsString": "Hello world"
5086 });
5087 let (value, is_link) = super::parse_value(&obj);
5088 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
5089 assert_eq!(value.comment, None);
5090 assert!(!is_link);
5091 }
5092
5093 #[test]
5094 fn parse_value_with_empty_comment() {
5095 let obj = serde_json::json!({
5096 "@type": "knora-api:TextValue",
5097 "knora-api:valueAsString": "Hello world",
5098 "knora-api:valueHasComment": ""
5099 });
5100 let (value, is_link) = super::parse_value(&obj);
5101 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
5102 assert_eq!(value.comment, None);
5103 assert!(!is_link);
5104 }
5105
5106 #[test]
5109 fn parse_value_link_is_link_true() {
5110 let obj = serde_json::json!({
5112 "@type": "knora-api:LinkValue",
5113 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
5114 });
5115 let (_, is_link) = super::parse_value_content(&obj);
5116 assert!(is_link, "LinkValue must report is_link=true for name derivation");
5117 }
5118
5119 #[test]
5120 fn field_name_link_strips_value_suffix() {
5121 let key = "incunabula:isPartOfBookValue";
5125 let link_obj = serde_json::json!({
5126 "@type": "knora-api:LinkValue",
5127 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
5128 });
5129 let (_, is_link) = super::parse_value_content(&link_obj);
5130 assert!(is_link, "LinkValue must report is_link=true for name derivation");
5131
5132 let raw_name = super::local_name(key).to_string();
5133 let name = if is_link {
5135 raw_name.strip_suffix("Value").unwrap_or(&raw_name).to_string()
5136 } else {
5137 raw_name
5138 };
5139 assert_eq!(name, "isPartOfBook");
5140 }
5141
5142 #[test]
5143 fn field_name_non_link_does_not_strip_value_suffix() {
5144 let key = "incunabula:hasAValue";
5149 let text_obj = serde_json::json!({
5150 "@type": "knora-api:TextValue",
5151 "knora-api:valueAsString": "some text"
5152 });
5153 let (_, is_link) = super::parse_value_content(&text_obj);
5154 assert!(!is_link, "TextValue must report is_link=false");
5155
5156 let raw_name = super::local_name(key).to_string();
5157 let name = if is_link {
5159 raw_name.strip_suffix("Value").unwrap_or(&raw_name).to_string()
5160 } else {
5161 raw_name
5162 };
5163 assert_eq!(
5164 name, "hasAValue",
5165 "non-link ending in Value must NOT be stripped; is_link={is_link}"
5166 );
5167 }
5168
5169 #[test]
5172 fn has_value_class_type_rejects_xsd_any_uri() {
5173 let obj = serde_json::json!({
5175 "@value": "http://ark.dasch.swiss/ark:/…",
5176 "@type": "xsd:anyURI"
5177 });
5178 assert!(
5179 !super::has_value_class_type(&obj),
5180 "xsd:anyURI must not pass the value-class test"
5181 );
5182 }
5183
5184 #[test]
5185 fn has_value_class_type_rejects_scalar() {
5186 let obj = serde_json::json!("just a string");
5188 assert!(!super::has_value_class_type(&obj));
5189 }
5190
5191 #[test]
5192 fn has_value_class_type_accepts_text_value() {
5193 let obj = serde_json::json!({
5194 "@type": "knora-api:TextValue",
5195 "knora-api:valueAsString": "hello"
5196 });
5197 assert!(super::has_value_class_type(&obj));
5198 }
5199
5200 #[test]
5201 fn has_value_class_type_accepts_still_image_file_value() {
5202 let obj = serde_json::json!({
5203 "@type": "knora-api:StillImageFileValue",
5204 "knora-api:fileValueHasFilename": "img.jp2"
5205 });
5206 assert!(super::has_value_class_type(&obj));
5207 }
5208
5209 #[test]
5212 fn build_prefix_map_string_entries_only() {
5213 let ctx = Some(serde_json::json!({
5214 "incunabula": "http://api.dasch.swiss/ontology/0803/incunabula/v2#",
5215 "knora-api": "http://api.knora.org/ontology/knora-api/v2#",
5216 "someterm": {"@id": "http://example.com/term", "@type": "@id"}
5218 }));
5219 let map = super::build_prefix_map(&ctx);
5220 assert_eq!(
5221 map.get("incunabula").map(String::as_str),
5222 Some("http://api.dasch.swiss/ontology/0803/incunabula/v2#")
5223 );
5224 assert_eq!(
5225 map.get("knora-api").map(String::as_str),
5226 Some("http://api.knora.org/ontology/knora-api/v2#")
5227 );
5228 assert!(!map.contains_key("someterm"), "object-valued entry must be skipped");
5229 }
5230
5231 #[test]
5232 fn build_prefix_map_empty_when_no_context() {
5233 let map = super::build_prefix_map(&None);
5234 assert!(map.is_empty());
5235 }
5236
5237 #[test]
5240 fn compact_value_text_excludes_meta_keys() {
5241 let obj = serde_json::json!({
5242 "@id": "http://rdfh.ch/0803/val1",
5243 "@type": "knora-api:GeomValue",
5244 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
5245 });
5246 let text = super::compact_value_text(&obj);
5247 assert!(text.contains("geometryValueAsGeometry"), "geometry key present: {text}");
5249 assert!(!text.contains("@id"), "@id must be excluded: {text}");
5250 assert!(!text.contains("@type"), "@type must be excluded: {text}");
5251 }
5252
5253 #[test]
5254 fn compact_value_text_all_meta_yields_empty() {
5255 let obj = serde_json::json!({
5256 "@id": "http://rdfh.ch/0803/val1",
5257 "@type": "knora-api:IntervalValue"
5258 });
5259 let text = super::compact_value_text(&obj);
5260 assert!(text.is_empty(), "all-meta object must yield empty string: {text:?}");
5261 }
5262
5263 #[test]
5266 fn list_get_response_root_shape_parses_as_root_variant() {
5267 let json = serde_json::json!({
5271 "type": "ListGetResponseADM",
5272 "list": {
5273 "listinfo": {
5274 "id": "http://rdfh.ch/lists/0001/root",
5275 "projectIri": "http://rdfh.ch/projects/0001",
5276 "name": "root-name",
5277 "labels": [
5278 {"value": "Root EN", "language": "en"},
5279 {"value": "Root DE", "language": "de"}
5280 ],
5281 "comments": []
5282 },
5283 "children": [
5284 {"id": "n2", "name": "n2", "labels": [], "comments": [], "position": 1, "children": []},
5285 {"id": "n1", "name": "n1", "labels": [], "comments": [], "position": 0, "children": [
5286 {"id": "n1a", "name": "n1a", "labels": [], "comments": [], "position": 0, "children": []}
5287 ]}
5288 ]
5289 }
5290 });
5291
5292 let parsed: ListGetResponseDto = serde_json::from_value(json).expect("root shape must parse");
5293 let root = match parsed {
5294 ListGetResponseDto::Root(root) => root,
5295 ListGetResponseDto::Node(_) => panic!("expected Root variant, got Node"),
5296 };
5297
5298 let tree = build_vocabulary_tree(root.list, None);
5299 assert_eq!(tree.root.iri, "http://rdfh.ch/lists/0001/root");
5300 assert_eq!(tree.root.name.as_deref(), Some("root-name"));
5301 assert_eq!(tree.root.labels.len(), 2, "both languages kept (D4)");
5302 assert_eq!(tree.project_iri, "http://rdfh.ch/projects/0001");
5303 assert_eq!(tree.requested_node, None);
5304
5305 assert_eq!(tree.children.len(), 2);
5308 assert_eq!(tree.children[0].header.iri, "n1");
5309 assert_eq!(tree.children[1].header.iri, "n2");
5310 assert_eq!(tree.children[0].children.len(), 1);
5311 assert_eq!(tree.children[0].children[0].header.iri, "n1a");
5312 }
5313
5314 #[test]
5315 fn list_get_response_node_shape_parses_as_node_variant_and_extracts_has_root_node() {
5316 let json = serde_json::json!({
5319 "type": "ListNodeGetResponseADM",
5320 "node": {
5321 "nodeinfo": {
5322 "id": "http://rdfh.ch/lists/0001/n1",
5323 "name": "n1",
5324 "labels": [{"value": "N1", "language": "en"}],
5325 "comments": [],
5326 "position": 0,
5327 "hasRootNode": "http://rdfh.ch/lists/0001/root"
5328 },
5329 "children": []
5330 }
5331 });
5332
5333 let parsed: ListGetResponseDto = serde_json::from_value(json).expect("node shape must parse");
5334 match parsed {
5335 ListGetResponseDto::Node(node) => {
5336 assert_eq!(node.node.nodeinfo.has_root_node, "http://rdfh.ch/lists/0001/root");
5337 }
5338 ListGetResponseDto::Root(_) => panic!("expected Node variant, got Root"),
5339 }
5340 }
5341
5342 #[test]
5343 fn list_get_response_neither_key_fails_parse() {
5344 let json = serde_json::json!({"type": "SomethingUnexpected", "foo": "bar"});
5348 let parsed = serde_json::from_value::<ListGetResponseDto>(json);
5349 assert!(parsed.is_err(), "a response with neither `list` nor `node` must fail to parse");
5350 }
5351
5352 #[test]
5353 fn into_localized_texts_keeps_all_languages_no_filtering() {
5354 let dtos = vec![
5356 ListLabelDto { value: "a".into(), language: Some("en".into()) },
5357 ListLabelDto { value: "b".into(), language: None },
5358 ];
5359 let texts = into_localized_texts(dtos);
5360 assert_eq!(texts.len(), 2);
5361 assert_eq!(texts[0].value, "a");
5362 assert_eq!(texts[0].language.as_deref(), Some("en"));
5363 assert_eq!(texts[1].value, "b");
5364 assert_eq!(texts[1].language, None);
5365 }
5366
5367 #[test]
5368 fn convert_list_nodes_sorts_and_nests_out_of_order_input() {
5369 let leaf_2b1 = ListNodeDto {
5372 id: "2b1".into(),
5373 name: None,
5374 labels: vec![],
5375 comments: vec![],
5376 position: 0,
5377 children: vec![],
5378 };
5379 let node_2b = ListNodeDto {
5380 id: "2b".into(),
5381 name: None,
5382 labels: vec![],
5383 comments: vec![],
5384 position: 1,
5385 children: vec![leaf_2b1],
5386 };
5387 let node_2a = ListNodeDto {
5388 id: "2a".into(),
5389 name: None,
5390 labels: vec![],
5391 comments: vec![],
5392 position: 0,
5393 children: vec![],
5394 };
5395 let node_2 = ListNodeDto {
5397 id: "2".into(),
5398 name: None,
5399 labels: vec![],
5400 comments: vec![],
5401 position: 1,
5402 children: vec![node_2b, node_2a],
5403 };
5404 let node_1 = ListNodeDto {
5405 id: "1".into(),
5406 name: None,
5407 labels: vec![],
5408 comments: vec![],
5409 position: 0,
5410 children: vec![],
5411 };
5412 let converted = convert_list_nodes(vec![node_2, node_1]);
5414
5415 assert_eq!(converted.len(), 2);
5416 assert_eq!(converted[0].header.iri, "1");
5417 assert_eq!(converted[0].position, 0);
5418 assert_eq!(converted[1].header.iri, "2");
5419 assert_eq!(converted[1].position, 1);
5420
5421 let node2_children = &converted[1].children;
5422 assert_eq!(node2_children.len(), 2);
5423 assert_eq!(node2_children[0].header.iri, "2a");
5424 assert_eq!(node2_children[1].header.iri, "2b");
5425 assert_eq!(node2_children[1].children.len(), 1);
5426 assert_eq!(node2_children[1].children[0].header.iri, "2b1");
5427 }
5428
5429 #[test]
5430 fn build_vocabulary_tree_sets_requested_node_when_provided() {
5431 let list = ListRootDto {
5432 listinfo: ListInfoDto {
5433 id: "root".into(),
5434 project_iri: "proj".into(),
5435 name: Some("Root".into()),
5436 labels: vec![],
5437 comments: vec![],
5438 },
5439 children: vec![],
5440 };
5441 let tree = build_vocabulary_tree(list, Some("node-iri".into()));
5442 assert_eq!(tree.requested_node.as_deref(), Some("node-iri"));
5443 assert_eq!(tree.root.iri, "root");
5444 assert_eq!(tree.project_iri, "proj");
5445 assert!(tree.children.is_empty());
5446 }
5447}