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