1use std::io::{Read, Write};
29use std::time::Duration;
30
31use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
32
33use std::collections::{HashMap, HashSet};
34
35use crate::client::DspClient;
36use crate::client::builtins::builtin_field_value_type;
37use crate::client::jwt::extract_exp;
38use crate::diagnostic::Diagnostic;
39use crate::model::auth::LoginResponse;
40use crate::model::resource::{DatePoint, DateValue, FieldValues, FileValue, Value, ValueContent};
41use crate::model::{
42 Cardinality, CreateDumpOutcome, DataModel, DataModelDetail, DataModelStructure,
43 DataModelSummary, DumpStatus, DumpTask, Field, Project, ProjectDescription, ProjectDetail,
44 ProjectRef, ProjectStatus, Relation, RelationKind, Representation, ResourceAccess,
45 ResourceDetail, ResourcePage, ResourceSummary, ResourceTypeDetail, ResourceTypeSummary,
46 ResourceVisibility, ValueType,
47};
48
49#[derive(serde::Deserialize)]
58struct LoginApiResponse {
59 token: String,
60}
61
62#[derive(serde::Deserialize)]
68struct ProjectGetApiResponse {
69 project: ProjectApiDto,
70}
71
72#[derive(serde::Deserialize)]
73struct ProjectApiDto {
74 id: String,
75 shortcode: String,
76 shortname: String,
77}
78
79#[derive(serde::Deserialize)]
86struct DataTaskStatusApiResponse {
87 id: String,
88 status: String,
89 #[serde(default, rename = "errorMessage")]
90 error_message: Option<String>,
91 #[serde(default, rename = "createdAt")]
96 created_at: Option<String>,
97}
98
99#[derive(serde::Deserialize)]
107struct V3ErrorBody {
108 #[serde(default)]
109 errors: Vec<V3ErrorItem>,
110}
111
112#[derive(serde::Deserialize)]
113struct V3ErrorItem {
114 code: String,
115 #[serde(default)]
116 details: std::collections::HashMap<String, String>,
117}
118
119#[derive(serde::Deserialize)]
128struct OntologyAndResourceClassesDto {
129 #[serde(rename = "classesAndCount", default)]
130 classes_and_count: Vec<ClassAndCountDto>,
131}
132
133#[derive(serde::Deserialize)]
140struct ClassAndCountDto {
141 #[serde(rename = "resourceClass")]
142 resource_class: ResourceClassRefDto,
143 #[serde(rename = "itemCount")]
144 item_count: u64,
145}
146
147#[derive(serde::Deserialize)]
150struct ResourceClassRefDto {
151 iri: String,
152}
153
154#[derive(serde::Deserialize)]
159struct ProjectsListApiResponse {
160 projects: Vec<ProjectListItemDto>,
161}
162
163#[derive(serde::Deserialize)]
168struct ProjectListItemDto {
169 id: String,
170 shortname: String,
171 shortcode: String,
172 #[serde(default)]
173 longname: Option<String>,
174 status: bool,
180 #[serde(default)]
181 ontologies: Vec<String>,
182}
183
184#[derive(serde::Deserialize)]
193struct ProjectDetailApiResponse {
194 project: ProjectDetailApiDto,
195}
196
197#[derive(serde::Deserialize)]
198struct ProjectDetailApiDto {
199 id: String,
200 shortcode: String,
201 shortname: String,
202 #[serde(default)]
203 longname: Option<String>,
204 status: bool,
207 #[serde(default)]
208 description: Vec<ProjectDescriptionDto>,
209 #[serde(default)]
210 keywords: Vec<String>,
211 #[serde(default)]
212 ontologies: Vec<String>,
213}
214
215#[derive(serde::Deserialize)]
216struct ProjectDescriptionDto {
217 value: String,
218 #[serde(default)]
219 language: Option<String>,
220}
221
222#[derive(serde::Deserialize)]
234struct OntologyMetadataResponse {
235 #[serde(rename = "@graph")]
236 graph: Option<Vec<OntologyMetadataDto>>,
237 #[serde(rename = "@id")]
239 id: Option<String>,
240 #[serde(rename = "rdfs:label")]
241 label: Option<String>,
242 #[serde(rename = "knora-api:lastModificationDate", default)]
243 last_modification_date: Option<LastModDto>,
244}
245
246#[derive(serde::Deserialize)]
247struct OntologyMetadataDto {
248 #[serde(rename = "@id")]
249 id: String,
250 #[serde(rename = "rdfs:label")]
251 label: Option<String>,
252 #[serde(rename = "knora-api:lastModificationDate", default)]
253 last_modification_date: Option<LastModDto>,
254}
255
256#[derive(serde::Deserialize)]
264struct LastModDto {
265 #[serde(rename = "@value")]
266 value: String,
267}
268
269#[derive(serde::Deserialize)]
273struct OntologyAllEntitiesResponse {
274 #[serde(rename = "@id")]
275 id: String,
276 #[serde(rename = "rdfs:label")]
277 label: Option<String>,
278 #[serde(rename = "knora-api:lastModificationDate", default)]
279 last_modification_date: Option<LastModDto>,
280 #[serde(rename = "@graph", default)]
281 graph: Vec<OntologyEntityDto>,
282 #[serde(rename = "@context", default)]
290 context: HashMap<String, serde_json::Value>,
291}
292
293#[derive(serde::Deserialize)]
313struct OntologyEntityDto {
314 #[serde(rename = "@id")]
315 id: String,
316 #[serde(rename = "rdfs:label")]
317 label: Option<String>,
318 #[serde(rename = "knora-api:isResourceClass", default)]
319 is_resource_class: bool,
320 #[serde(rename = "rdfs:subClassOf", default)]
325 sub_class_of: Vec<serde_json::Value>,
326 #[serde(rename = "knora-api:objectType")]
329 object_type: Option<ObjectTypeDto>,
330 #[serde(rename = "knora-api:isLinkProperty", default)]
332 is_link_property: bool,
333 #[serde(rename = "knora-api:isLinkValueProperty", default)]
336 is_link_value_property: bool,
337 #[serde(rename = "knora-api:isResourceProperty", default)]
339 is_resource_property: bool,
340}
341
342#[derive(serde::Deserialize, Clone)]
344struct ObjectTypeDto {
345 #[serde(rename = "@id")]
346 id: String,
347}
348
349struct ExportExists<'a> {
354 id: Option<&'a str>,
356 project_iri: Option<&'a str>,
358}
359
360impl V3ErrorBody {
361 fn export_exists(&self) -> Option<ExportExists<'_>> {
367 self.errors
368 .iter()
369 .find(|e| e.code == "export_exists")
370 .map(|e| ExportExists {
371 id: e.details.get("id").map(String::as_str),
372 project_iri: e.details.get("projectIri").map(String::as_str),
373 })
374 }
375}
376
377impl DataTaskStatusApiResponse {
378 fn into_dump_task(self) -> Result<DumpTask, Diagnostic> {
391 validate_dump_id(&self.id)?;
395
396 let status = match self.status.as_str() {
397 "in_progress" => DumpStatus::InProgress,
398 "completed" => DumpStatus::Completed,
399 "failed" => DumpStatus::Failed,
400 other => {
401 return Err(Diagnostic::ServerError(format!(
402 "server returned unknown dump status: '{other}'"
403 )));
404 }
405 };
406
407 let error_message = self.error_message.map(|raw| {
411 let truncated = if raw.chars().count() > 500 {
412 raw.chars().take(500).collect::<String>()
413 } else {
414 raw
415 };
416 tracing::trace!("dump task error_message (truncated): {}", truncated);
417 truncated
418 });
419
420 let created_at = self.created_at.and_then(|s| {
423 match chrono::DateTime::parse_from_rfc3339(&s) {
424 Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
425 Err(_) => {
426 tracing::debug!(raw = %s, "dump task createdAt could not be parsed as RFC3339; using None");
427 None
428 }
429 }
430 });
431
432 Ok(DumpTask {
433 id: self.id,
434 status,
435 error_message,
436 created_at,
437 })
438 }
439}
440
441fn identifier_key(user: &str) -> &'static str {
449 if user.starts_with("http://") || user.starts_with("https://") {
450 "iri"
451 } else if user.contains('@') {
452 "email"
453 } else {
454 "username"
455 }
456}
457
458enum ProjectIdent<'a> {
469 Iri(&'a str),
470 Shortcode(&'a str),
471 Shortname(&'a str),
472}
473
474fn classify(project: &str) -> ProjectIdent<'_> {
475 if project.starts_with("http://") || project.starts_with("https://") {
476 ProjectIdent::Iri(project)
477 } else if project.len() == 4 && project.chars().all(|c| c.is_ascii_hexdigit()) {
478 ProjectIdent::Shortcode(project)
479 } else {
480 ProjectIdent::Shortname(project)
481 }
482}
483
484fn enc(iri: &str) -> String {
490 utf8_percent_encode(iri, NON_ALPHANUMERIC).to_string()
491}
492
493fn map_unexpected_status(status: reqwest::StatusCode, url: &str) -> Diagnostic {
505 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
506 Diagnostic::AuthRequired(
510 "your token may be missing, expired, or lack permission — run \
511 `dsp auth login` to (re)authenticate"
512 .into(),
513 )
514 } else if status.is_server_error() {
515 Diagnostic::ServerError(format!("server returned {status} for {url}"))
516 } else {
517 Diagnostic::ServerError(format!("unexpected status {status} for {url}"))
518 }
519}
520
521fn validate_dump_id(id: &str) -> Result<(), Diagnostic> {
529 if id.is_empty()
530 || id.len() > 256 || !id
532 .chars()
533 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
534 {
535 let preview: String = id.chars().take(40).collect();
537 let suffix = if id.chars().count() > 40 { "…" } else { "" };
538 return Err(Diagnostic::ServerError(format!(
539 "server returned an invalid dump id: '{preview}{suffix}'"
540 )));
541 }
542 Ok(())
543}
544
545fn project_lookup_url(base: &str, project: &str) -> String {
551 match classify(project) {
552 ProjectIdent::Shortcode(code) => {
553 format!("{base}/admin/projects/shortcode/{code}")
554 }
555 ProjectIdent::Shortname(name) => {
556 format!("{base}/admin/projects/shortname/{name}")
557 }
558 ProjectIdent::Iri(iri) => {
559 format!("{base}/admin/projects/iri/{}", enc(iri))
560 }
561 }
562}
563
564fn is_safe_shortcode(s: &str) -> bool {
574 !s.is_empty() && s.len() <= 32 && s.chars().all(|c| c.is_ascii_alphanumeric())
575}
576
577fn local_name(id: &str) -> &str {
582 id.rsplit(['#', '/', ':']).next().unwrap_or(id)
583}
584
585fn expand_class_id(id: &str, prefixes: &HashMap<String, String>) -> (String, String) {
596 let name = local_name(id).to_string();
597 let iri = match id.split_once(':') {
598 Some((prefix, local)) if !local.starts_with("//") => prefixes
599 .get(prefix)
600 .map(|ns| format!("{ns}{local}"))
601 .unwrap_or_else(|| id.to_string()),
602 _ => id.to_string(), };
604 (name, iri)
605}
606
607pub(crate) fn data_model_name_from_iri(iri: &str) -> String {
619 let t = iri.trim_end_matches('/');
620 let t = t.strip_suffix("/v2").unwrap_or(t);
621 t.rsplit('/').next().unwrap_or(t).to_string()
622}
623
624const SYSTEM_PREFIXES: &[&str] = &[
633 "knora-api",
634 "knora-base",
635 "rdf",
636 "rdfs",
637 "owl",
638 "salsah-gui",
639 "standoff",
640 "xsd",
641];
642
643const FILE_VALUE_PROPS: &[(&str, Representation)] = &[
648 ("hasStillImageFileValue", Representation::StillImage),
649 ("hasMovingImageFileValue", Representation::MovingImage),
650 ("hasAudioFileValue", Representation::Audio),
651 ("hasDocumentFileValue", Representation::Document),
652 ("hasArchiveFileValue", Representation::Archive),
653 ("hasTextFileValue", Representation::Text),
654];
655
656const MAX_SIBLING_FETCHES: usize = 16;
659
660fn is_system_prefix(prefix: &str) -> bool {
666 SYSTEM_PREFIXES.contains(&prefix)
667}
668
669fn map_object_type_to_value_type(local: &str) -> ValueType {
680 match local {
681 "TextValue" => ValueType::Text,
682 "IntValue" => ValueType::Integer,
683 "DecimalValue" => ValueType::Decimal,
684 "BooleanValue" => ValueType::Boolean,
685 "DateValue" => ValueType::Date,
686 "TimeValue" => ValueType::Time,
687 "UriValue" => ValueType::Uri,
688 "ColorValue" => ValueType::Color,
689 "GeonameValue" => ValueType::Geoname,
690 "ListValue" => ValueType::ListItem,
691 "StillImageFileValue" => ValueType::StillImage,
692 "MovingImageFileValue" => ValueType::MovingImage,
693 "AudioFileValue" => ValueType::Audio,
694 "DocumentFileValue" => ValueType::Document,
695 "ArchiveFileValue" => ValueType::Archive,
696 other => ValueType::Other(object_type_to_kebab(other)),
697 }
698}
699
700fn object_type_to_kebab(local: &str) -> String {
707 let base = local.strip_suffix("Value").unwrap_or(local);
709
710 let mut result = String::with_capacity(base.len() + 4);
713 let chars: Vec<char> = base.chars().collect();
714 for (i, &ch) in chars.iter().enumerate() {
715 if i > 0 && ch.is_uppercase() {
716 if chars[i - 1].is_lowercase() {
718 result.push('-');
719 }
720 }
721 result.push(ch);
722 }
723 result.to_lowercase()
724}
725
726fn decode_cardinality(restriction: &serde_json::Value) -> Cardinality {
732 let as_u64 =
734 |key: &str| -> Option<u64> { restriction.get(key).and_then(serde_json::Value::as_u64) };
735
736 if let Some(v) = as_u64("owl:cardinality") {
737 if v == 1 {
738 return Cardinality::One;
739 }
740 tracing::warn!(
741 value = v,
742 "owl:cardinality had unexpected value (expected 1); falling back to ZeroOrMore"
743 );
744 return Cardinality::ZeroOrMore;
745 }
746
747 if let Some(v) = as_u64("owl:maxCardinality") {
748 if v == 1 {
749 return Cardinality::ZeroOrOne;
750 }
751 tracing::warn!(
752 value = v,
753 "owl:maxCardinality had unexpected value (expected 1); falling back to ZeroOrMore"
754 );
755 return Cardinality::ZeroOrMore;
756 }
757
758 if let Some(v) = as_u64("owl:minCardinality") {
759 return match v {
760 0 => Cardinality::ZeroOrMore,
761 1 => Cardinality::OneOrMore,
762 other => {
763 tracing::warn!(
764 value = other,
765 "owl:minCardinality had unexpected value (expected 0 or 1); falling back to ZeroOrMore"
766 );
767 Cardinality::ZeroOrMore
768 }
769 };
770 }
771
772 tracing::warn!("owl:Restriction has no recognized cardinality key; falling back to ZeroOrMore");
773 Cardinality::ZeroOrMore
774}
775
776fn detect_representation(restriction_prop_locals: &[&str]) -> Option<Representation> {
782 for local in restriction_prop_locals {
783 for (file_val_local, repr) in FILE_VALUE_PROPS {
784 if local == file_val_local {
785 return Some(*repr);
786 }
787 }
788 }
789 None
790}
791
792fn curie_prefix(id: &str) -> Option<&str> {
795 id.split_once(':')
796 .filter(|(_, local)| !local.starts_with("//"))
797 .map(|(prefix, _)| prefix)
798}
799
800#[derive(serde::Deserialize)]
816struct ResourceListDto {
817 #[serde(rename = "@graph", default)]
819 graph: Option<Vec<ResourceNodeDto>>,
820
821 #[serde(rename = "@id", default)]
823 id: Option<String>,
824
825 #[serde(rename = "@type", default)]
828 type_field: Option<serde_json::Value>,
829
830 #[serde(rename = "rdfs:label", default)]
832 label: Option<serde_json::Value>,
833
834 #[serde(rename = "knora-api:arkUrl", default)]
836 ark_url: Option<serde_json::Value>,
837
838 #[serde(rename = "knora-api:creationDate", default)]
840 creation_date: Option<serde_json::Value>,
841
842 #[serde(rename = "knora-api:lastModificationDate", default)]
844 last_modification_date: Option<serde_json::Value>,
845
846 #[serde(rename = "knora-api:mayHaveMoreResults", default)]
848 may_have_more_results: bool,
849}
850
851#[derive(serde::Deserialize)]
858struct ResourceNodeDto {
859 #[serde(rename = "@id")]
860 id: String,
861
862 #[serde(rename = "@type", default)]
864 type_field: Option<serde_json::Value>,
865
866 #[serde(rename = "rdfs:label", default)]
868 label: Option<serde_json::Value>,
869
870 #[serde(rename = "knora-api:arkUrl", default)]
872 ark_url: Option<serde_json::Value>,
873
874 #[serde(rename = "knora-api:creationDate", default)]
876 creation_date: Option<serde_json::Value>,
877
878 #[serde(rename = "knora-api:lastModificationDate", default)]
880 last_modification_date: Option<serde_json::Value>,
881}
882
883fn extract_string_value(v: &serde_json::Value) -> Option<String> {
888 match v {
889 serde_json::Value::String(s) => Some(s.clone()),
890 serde_json::Value::Object(map) => map
891 .get("@value")
892 .or_else(|| map.get("@id"))
893 .and_then(|inner| inner.as_str())
894 .map(str::to_owned),
895 _ => None,
896 }
897}
898
899fn extract_resource_type(type_val: Option<&serde_json::Value>) -> String {
906 match type_val {
907 None => "unknown".to_string(),
908 Some(serde_json::Value::String(s)) => local_name(s).to_string(),
909 Some(serde_json::Value::Array(arr)) => arr
910 .first()
911 .and_then(|v| v.as_str())
912 .map(|s| local_name(s).to_string())
913 .unwrap_or_else(|| "unknown".to_string()),
914 _ => "unknown".to_string(),
915 }
916}
917
918fn node_dto_to_summary(
920 id: String,
921 type_val: Option<&serde_json::Value>,
922 label_val: Option<&serde_json::Value>,
923 ark_val: Option<&serde_json::Value>,
924 creation_val: Option<&serde_json::Value>,
925 last_modification_val: Option<&serde_json::Value>,
926) -> ResourceSummary {
927 let label = label_val.and_then(extract_string_value).unwrap_or_default();
928 let resource_type = extract_resource_type(type_val);
929 let ark_url = ark_val.and_then(extract_string_value);
930 let creation_date = creation_val.and_then(extract_string_value);
938 let last_modified = last_modification_val.and_then(extract_string_value);
939 ResourceSummary {
940 label,
941 iri: id,
942 ark_url,
943 creation_date,
944 last_modified,
945 resource_type,
946 }
947}
948
949#[derive(serde::Deserialize)]
969struct ResourceDetailDto {
970 #[serde(rename = "@id")]
971 id: String,
972
973 #[serde(rename = "@type", default)]
975 type_field: Option<serde_json::Value>,
976
977 #[serde(rename = "rdfs:label", default)]
979 label: Option<serde_json::Value>,
980
981 #[serde(rename = "knora-api:arkUrl", default)]
983 ark_url: Option<serde_json::Value>,
984
985 #[serde(rename = "knora-api:creationDate", default)]
987 creation_date: Option<serde_json::Value>,
988
989 #[serde(rename = "knora-api:lastModificationDate", default)]
991 last_modification_date: Option<serde_json::Value>,
992
993 #[serde(rename = "knora-api:attachedToProject", default)]
995 attached_to_project: Option<serde_json::Value>,
996
997 #[serde(rename = "knora-api:attachedToUser", default)]
999 attached_to_user: Option<serde_json::Value>,
1000
1001 #[serde(rename = "knora-api:hasPermissions", default)]
1004 has_permissions: Option<String>,
1005
1006 #[serde(rename = "knora-api:userHasPermission", default)]
1009 user_has_permission: Option<String>,
1010
1011 #[serde(rename = "@context", default)]
1018 context: Option<serde_json::Value>,
1019
1020 #[serde(flatten)]
1028 extra: serde_json::Map<String, serde_json::Value>,
1029}
1030
1031fn permission_rank(code: &str) -> u8 {
1037 match code {
1038 "RV" => 1,
1039 "V" => 2,
1040 "M" => 6,
1041 "D" => 7,
1042 "CR" => 8,
1043 _ => 0,
1044 }
1045}
1046
1047fn derive_access(user_has_permission: &str) -> Option<ResourceAccess> {
1057 match user_has_permission {
1058 "RV" => Some(ResourceAccess::RestrictedView),
1059 "V" => Some(ResourceAccess::View),
1060 "M" => Some(ResourceAccess::Edit),
1061 "D" => Some(ResourceAccess::Delete),
1062 "CR" => Some(ResourceAccess::Manage),
1063 _ => None,
1064 }
1065}
1066
1067fn derive_visibility(has_permissions: &str) -> Option<ResourceVisibility> {
1077 if has_permissions.trim().is_empty() {
1078 return None;
1079 }
1080
1081 let mut unknown_rank: u8 = 0;
1082 let mut known_rank: u8 = 0;
1083 let mut parsed_any = false;
1084
1085 for entry in has_permissions.split('|') {
1086 let entry = entry.trim();
1087 if entry.is_empty() {
1088 continue;
1089 }
1090 let Some((code, group_list)) = entry.split_once(' ') else {
1092 continue;
1094 };
1095 parsed_any = true;
1096 let rank = permission_rank(code);
1097 for group in group_list.split(',') {
1098 let group_local = local_name(group.trim());
1099 if group_local == "UnknownUser" {
1100 unknown_rank = unknown_rank.max(rank);
1101 } else if group_local == "KnownUser" {
1102 known_rank = known_rank.max(rank);
1103 }
1104 }
1105 }
1106
1107 if !parsed_any {
1108 return None;
1109 }
1110
1111 let v_rank = permission_rank("V");
1115 let rv_rank = permission_rank("RV");
1116
1117 if unknown_rank >= v_rank {
1118 Some(ResourceVisibility::Public)
1119 } else if unknown_rank >= rv_rank {
1120 Some(ResourceVisibility::PublicRestricted)
1122 } else if known_rank >= rv_rank {
1123 Some(ResourceVisibility::LoggedInUsers)
1124 } else {
1125 Some(ResourceVisibility::ProjectMembers)
1126 }
1127}
1128
1129pub struct HttpDspClient {
1135 client: reqwest::blocking::Client,
1138 download_client: reqwest::blocking::Client,
1143}
1144
1145impl HttpDspClient {
1146 pub fn new() -> Result<Self, Diagnostic> {
1155 let client = reqwest::blocking::Client::builder()
1156 .connect_timeout(Duration::from_secs(10))
1157 .timeout(Duration::from_secs(30))
1158 .build()
1159 .map_err(|e| Diagnostic::Internal(format!("failed to build HTTP client: {e}")))?;
1160 let download_client = reqwest::blocking::Client::builder()
1161 .connect_timeout(Some(Duration::from_secs(30)))
1162 .timeout(None)
1163 .build()
1164 .map_err(|e| {
1165 Diagnostic::Internal(format!("failed to build download HTTP client: {e}"))
1166 })?;
1167 Ok(Self {
1168 client,
1169 download_client,
1170 })
1171 }
1172
1173 fn fetch_allentities(
1183 &self,
1184 server: &str,
1185 ontology_iri: &str,
1186 token: Option<&str>,
1187 ) -> Result<OntologyAllEntitiesResponse, Diagnostic> {
1188 let url = format!(
1189 "{}/v2/ontologies/allentities/{}",
1190 server.trim_end_matches('/'),
1191 enc(ontology_iri)
1192 );
1193
1194 let req = self.client.get(&url);
1195 let req = if let Some(t) = token {
1196 req.bearer_auth(t)
1197 } else {
1198 req
1199 };
1200
1201 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
1202 let status = response.status();
1203
1204 if status.is_success() {
1205 let resp: OntologyAllEntitiesResponse = response.json().map_err(|e| {
1206 Diagnostic::ServerError(format!("data-model response could not be parsed: {e}"))
1207 })?;
1208 Ok(resp)
1209 } else {
1210 Err(map_unexpected_status(status, &url))
1211 }
1212 }
1213}
1214
1215impl HttpDspClient {
1216 fn parse_resource_values(
1228 &self,
1229 server: &str,
1230 token: Option<&str>,
1231 context_val: &Option<serde_json::Value>,
1232 extra: &serde_json::Map<String, serde_json::Value>,
1233 ) -> Vec<FieldValues> {
1234 let prefixes: HashMap<String, String> = build_prefix_map(context_val);
1236
1237 const DENYLIST: &[&str] = &[
1240 "knora-api:hasIncomingLinkValue",
1241 "knora-api:hasStandoffLinkToValue",
1242 "knora-api:hasStandoffLinkValue", ];
1244
1245 let mut field_entries: Vec<(&str, Vec<&serde_json::Value>)> = Vec::new();
1248
1249 for (key, val) in extra.iter() {
1250 if DENYLIST.contains(&key.as_str()) {
1251 continue;
1252 }
1253
1254 let objs: Vec<&serde_json::Value> = match val {
1256 serde_json::Value::Array(arr) => arr.iter().collect(),
1257 obj @ serde_json::Value::Object(_) => vec![obj],
1258 _ => continue, };
1260
1261 if objs.is_empty() {
1262 continue;
1263 }
1264
1265 let first = match objs.first() {
1268 Some(v) => v,
1269 None => continue,
1270 };
1271 if !has_value_class_type(first) {
1272 continue;
1273 }
1274
1275 field_entries.push((key.as_str(), objs));
1276 }
1277
1278 struct ParsedField<'a> {
1281 key: &'a str,
1282 is_link: bool,
1283 values: Vec<Value>,
1284 }
1285
1286 let mut parsed_fields: Vec<ParsedField> = Vec::new();
1287
1288 for (key, objs) in &field_entries {
1289 let mut contents: Vec<Value> = Vec::new();
1290 let mut any_link = false;
1291
1292 for obj in objs {
1293 if get_type_local(obj) == "DeletedValue" {
1295 continue;
1296 }
1297 let (content, is_link) = parse_value(obj);
1298 if is_link {
1299 any_link = true;
1300 }
1301 contents.push(content);
1302 }
1303
1304 if contents.is_empty() {
1305 continue;
1306 }
1307
1308 parsed_fields.push(ParsedField {
1309 key,
1310 is_link: any_link,
1311 values: contents,
1312 });
1313 }
1314
1315 let mut ontology_labels: HashMap<String, HashMap<String, String>> = HashMap::new(); let mut fetched_ontologies: HashSet<String> = HashSet::new();
1320
1321 for pf in &parsed_fields {
1322 let prefix = curie_prefix(pf.key).unwrap_or("");
1323 if is_system_prefix(prefix) || prefix.is_empty() {
1324 continue; }
1326 let namespace = match prefixes.get(prefix) {
1328 Some(ns) => ns,
1329 None => continue,
1330 };
1331 let ont_iri = namespace.trim_end_matches(['#', '/']).to_string();
1332 if fetched_ontologies.insert(ont_iri.clone()) {
1333 match self.fetch_allentities(server, &ont_iri, token) {
1337 Ok(resp) => {
1338 let mut prop_map: HashMap<String, String> = HashMap::new();
1339 let ctx_prefixes: HashMap<String, String> = resp
1340 .context
1341 .iter()
1342 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1343 .collect();
1344 for entity in resp.graph {
1345 if let Some(lbl) = entity.label {
1346 let (_, iri) = expand_class_id(&entity.id, &ctx_prefixes);
1347 prop_map.insert(iri, lbl);
1348 }
1349 }
1350 ontology_labels.insert(ont_iri, prop_map);
1351 }
1352 Err(e) => {
1353 tracing::warn!(
1355 prefix = %prefix,
1356 error = %e,
1357 "field-label ontology fetch failed; using local name as fallback"
1358 );
1359 }
1360 }
1361 }
1362 }
1363
1364 let mut node_labels: HashMap<String, Option<String>> = HashMap::new();
1366
1367 for pf in &parsed_fields {
1369 for v in &pf.values {
1370 if let ValueContent::ListItem { node_iri, .. } = &v.content {
1371 node_labels.entry(node_iri.clone()).or_insert(None);
1372 }
1373 }
1374 }
1375
1376 for (node_iri, label_slot) in node_labels.iter_mut() {
1378 let url = format!("{}/v2/node/{}", server.trim_end_matches('/'), enc(node_iri));
1382 let req = self.client.get(&url);
1383 let req = if let Some(t) = token {
1384 req.bearer_auth(t)
1385 } else {
1386 req
1387 };
1388 match req.send() {
1389 Ok(resp) if resp.status().is_success() => {
1390 match resp.json::<serde_json::Value>() {
1392 Ok(body) => {
1393 let lbl = body.get("rdfs:label").and_then(extract_string_value);
1395 *label_slot = lbl;
1396 }
1397 Err(_) => {
1398 tracing::debug!(
1399 node_iri = %node_iri,
1400 "list-node label response could not be parsed as JSON; using node IRI as fallback"
1401 );
1402 }
1403 }
1404 }
1405 Ok(resp) => {
1406 tracing::debug!(
1408 node_iri = %node_iri,
1409 status = %resp.status(),
1410 "list-node label fetch returned non-success; using node IRI as fallback"
1411 );
1412 }
1413 Err(e) => {
1414 tracing::debug!(
1415 node_iri = %node_iri,
1416 error = %e,
1417 "list-node label fetch failed; using node IRI as fallback"
1418 );
1419 }
1420 }
1421 }
1422
1423 let mut result: Vec<FieldValues> = Vec::new();
1425
1426 for pf in parsed_fields {
1427 let raw_name = local_name(pf.key).to_string();
1429 let name = if pf.is_link {
1430 raw_name
1431 .strip_suffix("Value")
1432 .unwrap_or(&raw_name)
1433 .to_string()
1434 } else {
1435 raw_name
1436 };
1437
1438 let label: Option<String> = {
1440 let prefix = curie_prefix(pf.key).unwrap_or("");
1441 if is_system_prefix(prefix) || prefix.is_empty() {
1442 None
1443 } else if let Some(ns) = prefixes.get(prefix) {
1444 let ont_iri = ns.trim_end_matches(['#', '/']).to_string();
1445 let local = local_name(pf.key);
1446 let prop_iri = format!("{}{}", ns, local);
1447 ontology_labels
1448 .get(&ont_iri)
1449 .and_then(|m| m.get(&prop_iri).cloned())
1450 } else {
1451 None
1452 }
1453 };
1454
1455 let values: Vec<Value> = pf
1457 .values
1458 .into_iter()
1459 .map(|v| match v.content {
1460 ValueContent::ListItem { node_iri, label: _ } => {
1461 let resolved = node_labels.get(&node_iri).cloned().flatten();
1462 Value {
1463 content: ValueContent::ListItem {
1464 node_iri,
1465 label: resolved,
1466 },
1467 comment: v.comment,
1468 }
1469 }
1470 other => Value {
1471 content: other,
1472 comment: v.comment,
1473 },
1474 })
1475 .collect();
1476
1477 result.push(FieldValues {
1478 name,
1479 label,
1480 values,
1481 });
1482 }
1483
1484 result
1485 }
1486}
1487
1488fn has_value_class_type(val: &serde_json::Value) -> bool {
1496 let type_local = get_type_local(val);
1497 type_local.ends_with("Value") && !type_local.is_empty() && {
1500 let raw_type = val
1502 .as_object()
1503 .and_then(|m| m.get("@type"))
1504 .and_then(|t| t.as_str())
1505 .unwrap_or("");
1506 raw_type.starts_with("knora-api:")
1507 }
1508}
1509
1510fn get_type_local(val: &serde_json::Value) -> &str {
1514 val.as_object()
1515 .and_then(|m| m.get("@type"))
1516 .and_then(|t| t.as_str())
1517 .map(local_name)
1518 .unwrap_or("")
1519}
1520
1521fn build_prefix_map(context_val: &Option<serde_json::Value>) -> HashMap<String, String> {
1527 match context_val {
1528 Some(serde_json::Value::Object(map)) => map
1529 .iter()
1530 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1531 .collect(),
1532 _ => HashMap::new(),
1533 }
1534}
1535
1536fn parse_value_content(obj: &serde_json::Value) -> (ValueContent, bool) {
1543 let type_local = get_type_local(obj);
1544
1545 match type_local {
1546 "TextValue" => {
1548 let content =
1551 if let Some(xml) = obj.get("knora-api:textValueAsXml").and_then(|v| v.as_str()) {
1552 crate::util::text::html_to_text(xml)
1553 } else {
1554 obj.get("knora-api:valueAsString")
1555 .and_then(|v| v.as_str())
1556 .unwrap_or("")
1557 .to_string()
1558 };
1559 (ValueContent::Text(content), false)
1560 }
1561
1562 "IntValue" => {
1564 let n = obj
1565 .get("knora-api:intValueAsInt")
1566 .and_then(|v| v.as_i64())
1567 .unwrap_or(0);
1568 (ValueContent::Integer(n), false)
1569 }
1570
1571 "DecimalValue" => {
1573 let s = obj
1575 .get("knora-api:decimalValueAsDecimal")
1576 .and_then(|v| {
1577 if let Some(s) = v.as_str() {
1579 Some(s.to_string())
1580 } else {
1581 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1582 }
1583 })
1584 .unwrap_or_default();
1585 (ValueContent::Decimal(s), false)
1586 }
1587
1588 "BooleanValue" => {
1590 let b = obj
1591 .get("knora-api:booleanValueAsBoolean")
1592 .and_then(|v| v.as_bool())
1593 .unwrap_or(false);
1594 (ValueContent::Boolean(b), false)
1595 }
1596
1597 "DateValue" => {
1599 let calendar = obj
1600 .get("knora-api:dateValueHasCalendar")
1601 .and_then(|v| v.as_str())
1602 .unwrap_or("GREGORIAN")
1603 .to_string();
1604
1605 let parse_point = |prefix: &str| -> DatePoint {
1606 let year_key = format!("knora-api:{prefix}Year");
1607 let month_key = format!("knora-api:{prefix}Month");
1608 let day_key = format!("knora-api:{prefix}Day");
1609 let era_key = format!("knora-api:{prefix}Era");
1610
1611 DatePoint {
1612 year: obj
1613 .get(year_key.as_str())
1614 .and_then(|v| v.as_i64())
1615 .map(|v| v as i32),
1616 month: obj
1617 .get(month_key.as_str())
1618 .and_then(|v| v.as_u64())
1619 .map(|v| v as u32),
1620 day: obj
1621 .get(day_key.as_str())
1622 .and_then(|v| v.as_u64())
1623 .map(|v| v as u32),
1624 era: obj
1625 .get(era_key.as_str())
1626 .and_then(|v| v.as_str())
1627 .map(str::to_owned),
1628 }
1629 };
1630
1631 let start = parse_point("dateValueHasStart");
1634 let end = parse_point("dateValueHasEnd");
1635
1636 if start.year.is_none() && end.year.is_none() {
1637 let raw_text = obj
1639 .get("knora-api:valueAsString")
1640 .and_then(|v| v.as_str())
1641 .unwrap_or("")
1642 .to_string();
1643 return (
1644 ValueContent::Raw {
1645 value_type: "date".to_string(),
1646 text: raw_text,
1647 },
1648 false,
1649 );
1650 }
1651
1652 (
1653 ValueContent::Date(DateValue {
1654 calendar,
1655 start,
1656 end,
1657 }),
1658 false,
1659 )
1660 }
1661
1662 "TimeValue" => {
1664 let s = obj
1665 .get("knora-api:timeValueAsTimeStamp")
1666 .and_then(|v| {
1667 if let Some(s) = v.as_str() {
1668 Some(s.to_string())
1669 } else {
1670 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1671 }
1672 })
1673 .unwrap_or_default();
1674 (ValueContent::Time(s), false)
1675 }
1676
1677 "UriValue" => {
1679 let s = obj
1680 .get("knora-api:uriValueAsUri")
1681 .and_then(|v| {
1682 if let Some(s) = v.as_str() {
1683 Some(s.to_string())
1684 } else {
1685 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1686 }
1687 })
1688 .unwrap_or_default();
1689 (ValueContent::Uri(s), false)
1690 }
1691
1692 "ColorValue" => {
1694 let s = obj
1695 .get("knora-api:colorValueAsColor")
1696 .and_then(|v| v.as_str())
1697 .unwrap_or("")
1698 .to_string();
1699 (ValueContent::Color(s), false)
1700 }
1701
1702 "GeonameValue" => {
1704 let s = obj
1705 .get("knora-api:geonameValueAsGeonameCode")
1706 .and_then(|v| v.as_str())
1707 .unwrap_or("")
1708 .to_string();
1709 (ValueContent::Geoname(s), false)
1710 }
1711
1712 "ListValue" => {
1714 let node_iri = obj
1716 .get("knora-api:listValueAsListNode")
1717 .and_then(|v| v.get("@id"))
1718 .and_then(|v| v.as_str())
1719 .unwrap_or("")
1720 .to_string();
1721 (
1722 ValueContent::ListItem {
1723 node_iri,
1724 label: None, },
1726 false,
1727 )
1728 }
1729
1730 "LinkValue" => {
1732 let (target_iri, target_label) =
1735 if let Some(target_obj) = obj.get("knora-api:linkValueHasTarget") {
1736 let iri = target_obj
1737 .get("@id")
1738 .and_then(|v| v.as_str())
1739 .unwrap_or("")
1740 .to_string();
1741 let lbl = target_obj.get("rdfs:label").and_then(extract_string_value);
1742 (iri, lbl)
1743 } else {
1744 let iri = obj
1745 .get("knora-api:linkValueHasTargetIri")
1746 .and_then(|v| v.get("@id"))
1747 .and_then(|v| v.as_str())
1748 .unwrap_or("")
1749 .to_string();
1750 (iri, None)
1751 };
1752 (
1753 ValueContent::Link {
1754 target_iri,
1755 target_label,
1756 },
1757 true, )
1759 }
1760
1761 t if t.ends_with("FileValue") => {
1764 let filename = obj
1765 .get("knora-api:fileValueHasFilename")
1766 .and_then(|v| v.as_str())
1767 .unwrap_or("")
1768 .to_string();
1769 let url_str = obj
1770 .get("knora-api:fileValueAsUrl")
1771 .and_then(|v| {
1772 if let Some(s) = v.as_str() {
1773 Some(s.to_string())
1774 } else {
1775 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1776 }
1777 })
1778 .unwrap_or_default();
1779
1780 let value_type_opt = if t.starts_with("StillImage") {
1782 Some(ValueType::StillImage)
1783 } else if t.starts_with("MovingImage") {
1784 Some(ValueType::MovingImage)
1785 } else if t.starts_with("Audio") {
1786 Some(ValueType::Audio)
1787 } else if t.starts_with("Document") || t.starts_with("Text") {
1788 Some(ValueType::Document)
1790 } else if t.starts_with("Archive") {
1791 Some(ValueType::Archive)
1792 } else {
1793 None };
1795
1796 match value_type_opt {
1797 Some(vt) => {
1798 let (width, height) = if vt == ValueType::StillImage {
1800 let w = obj
1801 .get("knora-api:stillImageFileValueHasDimX")
1802 .and_then(|v| v.as_u64())
1803 .map(|v| v as u32);
1804 let h = obj
1805 .get("knora-api:stillImageFileValueHasDimY")
1806 .and_then(|v| v.as_u64())
1807 .map(|v| v as u32);
1808 (w, h)
1809 } else {
1810 (None, None)
1811 };
1812 (
1813 ValueContent::File(FileValue {
1814 value_type: vt,
1815 filename,
1816 url: url_str,
1817 width,
1818 height,
1819 }),
1820 false,
1821 )
1822 }
1823 None => {
1824 let raw_text = obj
1826 .get("knora-api:valueAsString")
1827 .and_then(|v| v.as_str())
1828 .unwrap_or(&filename)
1829 .to_string();
1830 (
1831 ValueContent::Raw {
1832 value_type: object_type_to_kebab(t),
1833 text: raw_text,
1834 },
1835 false,
1836 )
1837 }
1838 }
1839 }
1840
1841 other => {
1843 let value_type = object_type_to_kebab(other);
1844 let raw_text = obj
1847 .get("knora-api:valueAsString")
1848 .and_then(|v| v.as_str())
1849 .map(str::to_owned)
1850 .unwrap_or_else(|| compact_value_text(obj));
1851 (
1852 ValueContent::Raw {
1853 value_type,
1854 text: raw_text,
1855 },
1856 false,
1857 )
1858 }
1859 }
1860}
1861
1862fn parse_value(obj: &serde_json::Value) -> (Value, bool) {
1868 let (content, is_link) = parse_value_content(obj);
1869 let comment = obj
1870 .get("knora-api:valueHasComment")
1871 .and_then(|v| v.as_str())
1872 .filter(|s| !s.trim().is_empty())
1873 .map(str::to_owned);
1874 (Value { content, comment }, is_link)
1875}
1876
1877const VALUE_META_KEYS: &[&str] = &[
1879 "@id",
1880 "@type",
1881 "knora-api:attachedToUser",
1882 "knora-api:hasPermissions",
1883 "knora-api:userHasPermission",
1884 "knora-api:valueCreationDate",
1885 "knora-api:valueHasComment",
1886 "knora-api:isDeleted",
1887 "knora-api:arkUrl",
1888 "knora-api:versionArkUrl",
1889 "knora-api:valueHasUUID",
1890];
1891
1892fn compact_value_text(obj: &serde_json::Value) -> String {
1897 if let Some(map) = obj.as_object() {
1898 let filtered: serde_json::Map<String, serde_json::Value> = map
1899 .iter()
1900 .filter(|(k, _)| !VALUE_META_KEYS.contains(&k.as_str()))
1901 .map(|(k, v)| (k.clone(), v.clone()))
1902 .collect();
1903 if filtered.is_empty() {
1904 String::new()
1905 } else {
1906 serde_json::to_string(&serde_json::Value::Object(filtered)).unwrap_or_default()
1907 }
1908 } else {
1909 String::new()
1910 }
1911}
1912
1913impl DspClient for HttpDspClient {
1914 fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic> {
1915 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
1916
1917 let mut body = serde_json::Map::with_capacity(2);
1918 body.insert(
1919 identifier_key(user).to_owned(),
1920 serde_json::Value::from(user),
1921 );
1922 body.insert("password".to_owned(), serde_json::Value::from(password));
1923
1924 let response = self
1925 .client
1926 .post(&url)
1927 .json(&body)
1928 .send()
1929 .map_err(|e| Diagnostic::Network(e.to_string()))?;
1930
1931 let status = response.status();
1932
1933 if status.is_success() {
1934 let api: LoginApiResponse = response.json().map_err(|e| {
1935 Diagnostic::ServerError(format!("login response could not be parsed: {e}"))
1936 })?;
1937 let expires_at = extract_exp(&api.token);
1938 Ok(LoginResponse {
1939 token: api.token,
1940 user: user.to_string(),
1941 expires_at,
1942 })
1943 } else if status == reqwest::StatusCode::UNAUTHORIZED
1944 || status == reqwest::StatusCode::FORBIDDEN
1945 {
1946 let body = response.text().unwrap_or_default();
1947 let preview: String = body.chars().take(200).collect();
1948 tracing::trace!("auth failure response body (capped): {}", preview);
1949 Err(Diagnostic::AuthRequired(format!(
1951 "Authentication failed on {server}"
1952 )))
1953 } else if status == reqwest::StatusCode::NOT_FOUND {
1954 Err(Diagnostic::NotFound(format!(
1955 "endpoint not found at {url}; check that --server resolves to a DSP-API instance, not just any HTTPS host"
1956 )))
1957 } else if status.is_server_error() {
1958 let body = response.text().unwrap_or_default();
1959 let preview: String = body.chars().take(200).collect();
1960 tracing::trace!("server error response body (capped): {}", preview);
1961 Err(Diagnostic::ServerError(format!("server returned {status}")))
1962 } else {
1963 Err(Diagnostic::ServerError(format!(
1964 "unexpected status: {status}"
1965 )))
1966 }
1967 }
1968
1969 fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic> {
1970 let base = server.trim_end_matches('/');
1971
1972 let url = project_lookup_url(base, project);
1973
1974 let response = self
1976 .client
1977 .get(&url)
1978 .send()
1979 .map_err(|e| Diagnostic::Network(e.to_string()))?;
1980
1981 let status = response.status();
1982
1983 if status.is_success() {
1984 let api: ProjectGetApiResponse = response.json().map_err(|e| {
1985 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
1986 })?;
1987 if !is_safe_shortcode(&api.project.shortcode) {
1988 return Err(Diagnostic::ServerError(
1989 "server returned a project with an unexpected shortcode".into(),
1990 ));
1991 }
1992 Ok(ProjectRef {
1993 iri: api.project.id,
1994 shortcode: api.project.shortcode,
1995 shortname: api.project.shortname,
1996 })
1997 } else if status == reqwest::StatusCode::NOT_FOUND {
1998 let display_input: String = project.chars().take(80).collect();
2000 let suffix = if project.chars().count() > 80 {
2001 "…"
2002 } else {
2003 ""
2004 };
2005 Err(Diagnostic::NotFound(format!(
2006 "project '{display_input}{suffix}' not found on {server}"
2007 )))
2008 } else {
2009 Err(map_unexpected_status(status, &url))
2010 }
2011 }
2012
2013 fn create_project_dump(
2014 &self,
2015 server: &str,
2016 project_iri: &str,
2017 skip_assets: bool,
2018 token: &str,
2019 ) -> Result<CreateDumpOutcome, Diagnostic> {
2020 let base = server.trim_end_matches('/');
2021 let url = format!(
2026 "{base}/v3/projects/{}/exports?skipAssets={skip_assets}",
2027 enc(project_iri)
2028 );
2029
2030 let response = self
2031 .client
2032 .post(&url)
2033 .bearer_auth(token)
2034 .send()
2035 .map_err(|e: reqwest::Error| Diagnostic::Network(e.to_string()))?;
2036
2037 let status = response.status();
2038
2039 match status.as_u16() {
2040 202 => {
2041 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2042 Diagnostic::ServerError(format!(
2043 "dump trigger response could not be parsed: {e}"
2044 ))
2045 })?;
2046 api.into_dump_task().map(CreateDumpOutcome::Created)
2047 }
2048 409 => {
2049 let body_text = response.text().unwrap_or_default();
2059 let error_body: Option<V3ErrorBody> = if body_text.len() <= 65536 {
2060 serde_json::from_str(&body_text).ok()
2061 } else {
2062 None
2063 };
2064 match error_body.as_ref().and_then(|b| b.export_exists()) {
2065 Some(ex) => {
2066 let id = ex.id.ok_or_else(|| {
2069 Diagnostic::ServerError(
2070 "the server's dump-conflict response was missing the dump id"
2071 .into(),
2072 )
2073 })?;
2074 validate_dump_id(id)?;
2075 match ex.project_iri {
2081 Some(owner) if owner == project_iri => {
2082 Ok(CreateDumpOutcome::Exists { id: id.to_string() })
2083 }
2084 Some(owner) => Ok(CreateDumpOutcome::ExistsForOtherProject {
2085 id: id.to_string(),
2086 project_iri: owner.to_string(),
2087 }),
2088 None => Err(Diagnostic::ServerError(
2091 "the server's dump-conflict response did not identify which \
2092project owns the existing dump; cannot safely proceed"
2093 .into(),
2094 )),
2095 }
2096 }
2097 None => Err(Diagnostic::ServerError(
2099 "server reported a 409 conflict whose detail could not be parsed".into(),
2101 )),
2102 }
2103 }
2104 401 | 403 => Err(Diagnostic::AuthRequired(
2105 "triggering a project dump requires a system-administrator token".into(),
2106 )),
2107 404 => Err(Diagnostic::NotFound(format!("project not found at {url}"))),
2108 _ => Err(map_unexpected_status(status, &url)),
2109 }
2110 }
2111
2112 fn get_project_dump_status(
2113 &self,
2114 server: &str,
2115 project_iri: &str,
2116 dump_id: &str,
2117 token: &str,
2118 ) -> Result<DumpTask, Diagnostic> {
2119 validate_dump_id(dump_id)?;
2120 let base = server.trim_end_matches('/');
2121 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2123
2124 let response = self
2125 .client
2126 .get(&url)
2127 .bearer_auth(token)
2128 .send()
2129 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2130
2131 let status = response.status();
2132
2133 match status.as_u16() {
2134 200 => {
2135 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2136 Diagnostic::ServerError(format!(
2137 "dump status response could not be parsed: {e}"
2138 ))
2139 })?;
2140 api.into_dump_task()
2141 }
2142 404 => Err(Diagnostic::NotFound(format!(
2143 "dump '{dump_id}' not found for project at {url}"
2144 ))),
2145 401 | 403 => Err(Diagnostic::AuthRequired(
2146 "fetching dump status requires a system-administrator token".into(),
2147 )),
2148 _ => Err(map_unexpected_status(status, &url)),
2149 }
2150 }
2151
2152 fn download_project_dump(
2153 &self,
2154 server: &str,
2155 project_iri: &str,
2156 dump_id: &str,
2157 token: &str,
2158 dest: &mut dyn Write,
2159 ) -> Result<u64, Diagnostic> {
2160 validate_dump_id(dump_id)?;
2161 let base = server.trim_end_matches('/');
2162 let url = format!(
2164 "{base}/v3/projects/{}/exports/{dump_id}/download",
2165 enc(project_iri)
2166 );
2167
2168 let mut response = self
2170 .download_client
2171 .get(&url)
2172 .bearer_auth(token)
2173 .send()
2174 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2175
2176 let status = response.status();
2177
2178 match status.as_u16() {
2181 200 => {
2182 let mut buf = [0u8; 64 * 1024];
2186 let mut total: u64 = 0;
2187 loop {
2188 let n = response
2189 .read(&mut buf)
2190 .map_err(|e| Diagnostic::Network(format!("download interrupted: {e}")))?;
2191 if n == 0 {
2192 break;
2193 }
2194 dest.write_all(&buf[..n]).map_err(|e| {
2195 Diagnostic::Io(format!("failed to write dump to disk: {e}"))
2196 })?;
2197 total += n as u64;
2198 }
2199 Ok(total)
2200 }
2201 409 => Err(Diagnostic::Conflict(
2202 "dump not ready — still in progress or failed".into(),
2203 )),
2204 404 => Err(Diagnostic::NotFound(format!(
2205 "dump '{dump_id}' not found at {url}"
2206 ))),
2207 401 | 403 => Err(Diagnostic::AuthRequired(
2208 "downloading a project dump requires a system-administrator token".into(),
2209 )),
2210 _ => Err(map_unexpected_status(status, &url)),
2211 }
2212 }
2213
2214 fn delete_project_dump(
2215 &self,
2216 server: &str,
2217 project_iri: &str,
2218 dump_id: &str,
2219 token: &str,
2220 ) -> Result<(), Diagnostic> {
2221 validate_dump_id(dump_id)?;
2222 let base = server.trim_end_matches('/');
2223 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2225
2226 let response = self
2227 .client
2228 .delete(&url)
2229 .bearer_auth(token)
2230 .send()
2231 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2232
2233 let status = response.status();
2234
2235 match status.as_u16() {
2236 204 => Ok(()),
2237 409 => Err(Diagnostic::Conflict(
2238 "dump is still in progress and cannot be deleted yet".into(),
2239 )),
2240 404 => Err(Diagnostic::NotFound(format!(
2241 "dump '{dump_id}' not found at {url}"
2242 ))),
2243 401 | 403 => Err(Diagnostic::AuthRequired(
2244 "deleting a project dump requires a system-administrator token".into(),
2245 )),
2246 _ => Err(map_unexpected_status(status, &url)),
2247 }
2248 }
2249
2250 fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic> {
2251 let base = server.trim_end_matches('/');
2252 let url = format!("{base}/admin/projects");
2253
2254 let req = self.client.get(&url);
2260 let req = if let Some(t) = token {
2261 req.bearer_auth(t)
2262 } else {
2263 req
2264 };
2265
2266 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2267
2268 let status = response.status();
2269
2270 if status.is_success() {
2271 let api: ProjectsListApiResponse = response.json().map_err(|e| {
2272 Diagnostic::ServerError(format!("projects list response could not be parsed: {e}"))
2273 })?;
2274 let projects = api
2275 .projects
2276 .into_iter()
2277 .map(|dto| Project {
2278 iri: dto.id,
2279 shortcode: dto.shortcode,
2280 shortname: dto.shortname,
2281 longname: dto.longname,
2282 status: if dto.status {
2286 ProjectStatus::Active
2287 } else {
2288 ProjectStatus::Inactive
2289 },
2290 data_models: dto.ontologies.len(),
2293 })
2294 .collect();
2295 Ok(projects)
2296 } else {
2297 Err(map_unexpected_status(status, &url))
2298 }
2299 }
2300
2301 fn describe_project(
2302 &self,
2303 server: &str,
2304 project: &str,
2305 token: Option<&str>,
2306 ) -> Result<ProjectDetail, Diagnostic> {
2307 let base = server.trim_end_matches('/');
2308 let url = project_lookup_url(base, project);
2309
2310 let req = self.client.get(&url);
2314 let req = if let Some(t) = token {
2315 req.bearer_auth(t)
2316 } else {
2317 req
2318 };
2319
2320 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2321
2322 let status = response.status();
2323
2324 if status.is_success() {
2325 let api: ProjectDetailApiResponse = response.json().map_err(|e| {
2326 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
2327 })?;
2328 let dto = api.project;
2329
2330 let project_status = if dto.status {
2332 ProjectStatus::Active
2333 } else {
2334 ProjectStatus::Inactive
2335 };
2336
2337 let description = dto
2339 .description
2340 .into_iter()
2341 .map(|d| ProjectDescription {
2342 value: d.value,
2343 language: d.language,
2344 })
2345 .collect();
2346
2347 let mut data_models: Vec<DataModelSummary> = dto
2349 .ontologies
2350 .into_iter()
2351 .map(|iri| {
2352 let name = data_model_name_from_iri(&iri);
2353 DataModelSummary { name, iri }
2354 })
2355 .collect();
2356 data_models.sort_by(|a, b| a.name.cmp(&b.name));
2357
2358 Ok(ProjectDetail {
2359 iri: dto.id,
2360 shortcode: dto.shortcode,
2361 shortname: dto.shortname,
2362 longname: dto.longname,
2363 status: project_status,
2364 description,
2365 keywords: dto.keywords,
2366 data_models,
2367 })
2368 } else if status == reqwest::StatusCode::NOT_FOUND {
2369 let display_input: String = project.chars().take(80).collect();
2371 let suffix = if project.chars().count() > 80 {
2372 "…"
2373 } else {
2374 ""
2375 };
2376 Err(Diagnostic::NotFound(format!(
2377 "project '{display_input}{suffix}' not found on {server}. Run `dsp vre project list --server {server}` to see available projects."
2378 )))
2379 } else {
2380 Err(map_unexpected_status(status, &url))
2381 }
2382 }
2383
2384 fn describe_data_model(
2385 &self,
2386 server: &str,
2387 data_model_iri: &str,
2388 token: Option<&str>,
2389 ) -> Result<DataModelDetail, Diagnostic> {
2390 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2391
2392 let prefixes: HashMap<String, String> = resp
2396 .context
2397 .iter()
2398 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2399 .collect();
2400
2401 let mut resource_types: Vec<ResourceTypeSummary> = resp
2402 .graph
2403 .into_iter()
2404 .filter(|dto| dto.is_resource_class)
2405 .map(|dto| {
2406 let (name, iri) = expand_class_id(&dto.id, &prefixes);
2407 ResourceTypeSummary {
2408 name,
2409 iri,
2410 label: dto.label,
2411 }
2412 })
2413 .collect();
2414
2415 resource_types.sort_by(|a, b| a.name.cmp(&b.name));
2416
2417 Ok(DataModelDetail {
2418 name: data_model_name_from_iri(&resp.id),
2419 iri: resp.id,
2420 label: resp.label,
2421 last_modified: resp.last_modification_date.map(|d| d.value),
2422 resource_types,
2423 })
2424 }
2425
2426 fn data_model_structure(
2427 &self,
2428 server: &str,
2429 data_model_iri: &str,
2430 token: Option<&str>,
2431 ) -> Result<DataModelStructure, Diagnostic> {
2432 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2434
2435 let graph_entities: Vec<OntologyEntityDto> = resp.graph;
2436
2437 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2443 let mut class_nodes: Vec<OntologyEntityDto> = Vec::new();
2444 for entity in graph_entities {
2445 if entity.is_resource_class {
2446 class_nodes.push(entity);
2447 } else if entity.object_type.is_some()
2448 || entity.is_link_property
2449 || entity.is_resource_property
2450 {
2451 prop_lookup.insert(entity.id.clone(), entity);
2452 }
2453 }
2454
2455 let mut relations: Vec<Relation> = Vec::new();
2457
2458 for class in &class_nodes {
2459 let source = local_name(&class.id).to_string();
2460
2461 for element in &class.sub_class_of {
2462 if let Some(type_val) = element.get("@type")
2463 && type_val.as_str() == Some("owl:Restriction")
2464 {
2465 let on_prop_id = match element
2467 .get("owl:onProperty")
2468 .and_then(|v| v.get("@id"))
2469 .and_then(serde_json::Value::as_str)
2470 {
2471 Some(s) => s,
2472 None => continue,
2473 };
2474
2475 let node = match prop_lookup.get(on_prop_id) {
2477 Some(n) => n,
2478 None => continue, };
2480
2481 if node.is_link_value_property {
2483 continue;
2484 }
2485
2486 if !node.is_link_property {
2488 continue;
2489 }
2490
2491 let target_id = match node.object_type.as_ref() {
2493 Some(ot) => &ot.id,
2494 None => continue, };
2496 let target = local_name(target_id).to_string();
2497
2498 let t_prefix = curie_prefix(target_id).unwrap_or("");
2499 let target_data_model = if is_system_prefix(t_prefix) || t_prefix.is_empty() {
2500 None
2501 } else {
2502 Some(t_prefix.to_string())
2503 };
2504
2505 let field_prefix = curie_prefix(on_prop_id).unwrap_or("");
2507 let is_builtin = is_system_prefix(field_prefix);
2508
2509 let field = local_name(on_prop_id).to_string();
2510
2511 relations.push(Relation {
2512 source: source.clone(),
2513 target,
2514 kind: RelationKind::Link,
2515 field: Some(field),
2516 target_data_model,
2517 is_builtin,
2518 });
2519 } else if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str)
2520 {
2521 let target = local_name(id_val).to_string();
2526
2527 let sup_prefix = curie_prefix(id_val).unwrap_or("");
2528 let is_builtin = is_system_prefix(sup_prefix);
2529 let target_data_model = if is_system_prefix(sup_prefix) || sup_prefix.is_empty()
2530 {
2531 None
2532 } else {
2533 Some(sup_prefix.to_string())
2534 };
2535
2536 relations.push(Relation {
2537 source: source.clone(),
2538 target,
2539 kind: RelationKind::Inherits,
2540 field: None,
2541 target_data_model,
2542 is_builtin,
2543 });
2544 }
2545 }
2546 }
2547
2548 relations.sort_by(|a, b| {
2552 a.source
2553 .cmp(&b.source)
2554 .then_with(|| a.kind.cmp(&b.kind))
2555 .then_with(|| a.field.cmp(&b.field))
2556 .then_with(|| a.target.cmp(&b.target))
2557 });
2558
2559 Ok(DataModelStructure {
2561 data_model: data_model_name_from_iri(data_model_iri),
2562 relations,
2563 })
2564 }
2565
2566 fn list_resources(
2567 &self,
2568 server: &str,
2569 project_iri: &str,
2570 resource_type_iri: &str,
2571 order_by: Option<&str>,
2572 page: u32,
2573 token: Option<&str>,
2574 ) -> Result<ResourcePage, Diagnostic> {
2575 let base = server.trim_end_matches('/');
2576 let url = format!("{base}/v2/resources");
2577
2578 let mut req = self.client.get(&url).query(&[
2583 ("resourceClass", resource_type_iri),
2584 ("page", &page.to_string()),
2585 ("schema", "complex"),
2586 ]);
2587 if let Some(prop_iri) = order_by {
2590 req = req.query(&[("orderByProperty", prop_iri)]);
2591 }
2592
2593 let header_value = reqwest::header::HeaderValue::from_str(project_iri).map_err(|e| {
2597 Diagnostic::Usage(format!("project IRI is not a valid HTTP header value: {e}"))
2598 })?;
2599 let req = req.header("x-knora-accept-project", header_value);
2600
2601 let req = if let Some(t) = token {
2603 req.bearer_auth(t)
2604 } else {
2605 req
2606 };
2607
2608 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2609 let status = response.status();
2610
2611 if !status.is_success() {
2612 return Err(map_unexpected_status(status, &url));
2613 }
2614
2615 let dto: ResourceListDto = response.json().map_err(|e| {
2616 Diagnostic::ServerError(format!("resource list response could not be parsed: {e}"))
2617 })?;
2618
2619 let may_have_more_results = dto.may_have_more_results;
2620
2621 let resources: Vec<ResourceSummary> = if let Some(graph) = dto.graph {
2626 graph
2627 .into_iter()
2628 .map(|node| {
2629 node_dto_to_summary(
2630 node.id,
2631 node.type_field.as_ref(),
2632 node.label.as_ref(),
2633 node.ark_url.as_ref(),
2634 node.creation_date.as_ref(),
2635 node.last_modification_date.as_ref(),
2636 )
2637 })
2638 .collect()
2639 } else if let Some(id) = dto.id {
2640 vec![node_dto_to_summary(
2642 id,
2643 dto.type_field.as_ref(),
2644 dto.label.as_ref(),
2645 dto.ark_url.as_ref(),
2646 dto.creation_date.as_ref(),
2647 dto.last_modification_date.as_ref(),
2648 )]
2649 } else {
2650 vec![]
2652 };
2653
2654 Ok(ResourcePage {
2655 resources,
2656 may_have_more_results,
2657 })
2658 }
2659
2660 fn describe_resource(
2661 &self,
2662 server: &str,
2663 resource_iri: &str,
2664 token: Option<&str>,
2665 with_values: bool,
2666 ) -> Result<ResourceDetail, Diagnostic> {
2667 let base = server.trim_end_matches('/');
2668 let url = format!("{base}/v2/resources/{}", enc(resource_iri));
2670
2671 let req = self.client.get(&url).query(&[("schema", "complex")]);
2673 let req = if let Some(t) = token {
2674 req.bearer_auth(t)
2675 } else {
2676 req
2677 };
2678
2679 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2680 let status = response.status();
2681
2682 if status.is_success() {
2683 let dto: ResourceDetailDto = response.json().map_err(|e| {
2684 Diagnostic::ServerError(format!(
2685 "resource describe response could not be parsed: {e}"
2686 ))
2687 })?;
2688
2689 let label = dto
2691 .label
2692 .as_ref()
2693 .and_then(extract_string_value)
2694 .unwrap_or_default();
2695 let resource_type = extract_resource_type(dto.type_field.as_ref());
2696 let ark_url = dto.ark_url.as_ref().and_then(extract_string_value);
2697 let creation_date = dto.creation_date.as_ref().and_then(extract_string_value);
2698 let last_modified = dto
2699 .last_modification_date
2700 .as_ref()
2701 .and_then(extract_string_value);
2702 let attached_project = dto
2703 .attached_to_project
2704 .as_ref()
2705 .and_then(extract_string_value);
2706 let owner = dto.attached_to_user.as_ref().and_then(extract_string_value);
2707 let visibility = dto.has_permissions.as_deref().and_then(derive_visibility);
2708 let your_access = dto.user_has_permission.as_deref().and_then(derive_access);
2709
2710 let values = if with_values {
2712 Some(self.parse_resource_values(server, token, &dto.context, &dto.extra))
2713 } else {
2714 None
2715 };
2716
2717 Ok(ResourceDetail {
2718 label,
2719 iri: dto.id,
2720 resource_type,
2721 ark_url,
2722 creation_date,
2723 last_modified,
2724 attached_project,
2725 owner,
2726 visibility,
2727 your_access,
2728 values,
2729 })
2730 } else if status == reqwest::StatusCode::NOT_FOUND {
2731 let display_iri: String = resource_iri.chars().take(80).collect();
2733 let iri_suffix = if resource_iri.chars().count() > 80 {
2734 "…"
2735 } else {
2736 ""
2737 };
2738 Err(Diagnostic::NotFound(format!(
2739 "resource '{display_iri}{iri_suffix}' not found"
2740 )))
2741 } else if status == reqwest::StatusCode::UNAUTHORIZED
2742 || status == reqwest::StatusCode::FORBIDDEN
2743 {
2744 let display_iri: String = resource_iri.chars().take(80).collect();
2748 let iri_suffix = if resource_iri.chars().count() > 80 {
2749 "…"
2750 } else {
2751 ""
2752 };
2753 Err(Diagnostic::AuthRequired(format!(
2754 "access denied for resource '{display_iri}{iri_suffix}' — log in to view this resource"
2755 )))
2756 } else {
2757 Err(map_unexpected_status(status, &url))
2758 }
2759 }
2760
2761 fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic> {
2762 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
2763
2764 let response = self
2765 .client
2766 .get(&url)
2767 .bearer_auth(token)
2768 .send()
2769 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2770
2771 let status = response.status();
2772
2773 if status.is_success() {
2774 let body = response.text().unwrap_or_default();
2777 let preview: String = body.chars().take(200).collect();
2778 tracing::trace!("verify_token success response body (capped): {}", preview);
2779 Ok(())
2780 } else if status == reqwest::StatusCode::UNAUTHORIZED
2781 || status == reqwest::StatusCode::FORBIDDEN
2782 {
2783 let body = response.text().unwrap_or_default();
2785 let preview: String = body.chars().take(200).collect();
2786 tracing::trace!("verify_token rejection response body (capped): {}", preview);
2787 Err(Diagnostic::AuthRequired(format!(
2789 "token rejected by {server} — it may be expired, revoked, or for a different environment"
2790 )))
2791 } else {
2792 Err(map_unexpected_status(status, &url))
2793 }
2794 }
2795
2796 fn list_data_models(
2797 &self,
2798 server: &str,
2799 project_iri: &str,
2800 token: Option<&str>,
2801 ) -> Result<Vec<DataModel>, Diagnostic> {
2802 let url = format!(
2803 "{}/v2/ontologies/metadata/{}",
2804 server.trim_end_matches('/'),
2805 enc(project_iri)
2806 );
2807
2808 let req = self.client.get(&url);
2813 let req = if let Some(t) = token {
2814 req.bearer_auth(t)
2815 } else {
2816 req
2817 };
2818
2819 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2820
2821 let status = response.status();
2822
2823 if status.is_success() {
2824 let resp: OntologyMetadataResponse = response.json().map_err(|e| {
2825 Diagnostic::ServerError(format!("data-models response could not be parsed: {e}"))
2826 })?;
2827
2828 let dtos: Vec<OntologyMetadataDto> = match resp.graph {
2832 Some(g) => g,
2833 None => match resp.id {
2834 Some(id) => vec![OntologyMetadataDto {
2835 id,
2836 label: resp.label,
2837 last_modification_date: resp.last_modification_date,
2838 }],
2839 None => vec![],
2840 },
2841 };
2842
2843 let data_models = dtos
2844 .into_iter()
2845 .map(|dto| DataModel {
2846 name: data_model_name_from_iri(&dto.id),
2847 iri: dto.id,
2848 label: dto.label,
2849 last_modified: dto.last_modification_date.map(|d| d.value),
2850 is_builtin: false,
2851 })
2852 .collect();
2853
2854 Ok(data_models)
2855 } else {
2856 Err(map_unexpected_status(status, &url))
2857 }
2858 }
2859
2860 fn describe_resource_type(
2861 &self,
2862 server: &str,
2863 data_model_iri: &str,
2864 resource_type: &str,
2865 token: Option<&str>,
2866 ) -> Result<ResourceTypeDetail, Diagnostic> {
2867 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2869
2870 let prefixes: HashMap<String, String> = resp
2872 .context
2873 .iter()
2874 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2875 .collect();
2876
2877 let queried_id = resp.id;
2881 let mut graph_entities: Vec<OntologyEntityDto> = resp.graph;
2882
2883 let target_idx = graph_entities.iter().position(|e| {
2884 if !e.is_resource_class {
2885 return false;
2886 }
2887 let (type_local, expanded_iri) = expand_class_id(&e.id, &prefixes);
2888 type_local.eq_ignore_ascii_case(resource_type) || expanded_iri == resource_type
2890 });
2891
2892 let target_idx = match target_idx {
2893 Some(i) => i,
2894 None => {
2895 let display: String = resource_type.chars().take(80).collect();
2896 let suffix = if resource_type.chars().count() > 80 {
2897 "…"
2898 } else {
2899 ""
2900 };
2901 return Err(Diagnostic::NotFound(format!(
2902 "resource-type '{display}{suffix}' not found in data-model '{}' on {server}",
2903 data_model_name_from_iri(data_model_iri)
2904 )));
2905 }
2906 };
2907
2908 let target = graph_entities.swap_remove(target_idx);
2911
2912 struct Restriction {
2914 on_property_id: String,
2915 cardinality: Cardinality,
2916 gui_order: u32,
2917 }
2918
2919 let mut restrictions: Vec<Restriction> = Vec::new();
2920 let mut super_type_ids: Vec<String> = Vec::new();
2921 let mut restriction_prop_locals: Vec<String> = Vec::new();
2922
2923 for element in &target.sub_class_of {
2924 if let Some(type_val) = element.get("@type")
2925 && type_val.as_str() == Some("owl:Restriction")
2926 {
2927 let on_prop_id = element
2929 .get("owl:onProperty")
2930 .and_then(|v| v.get("@id"))
2931 .and_then(serde_json::Value::as_str)
2932 .unwrap_or("")
2933 .to_string();
2934
2935 if on_prop_id.is_empty() {
2936 tracing::warn!("owl:Restriction missing owl:onProperty @id; skipping");
2937 continue;
2938 }
2939
2940 let cardinality = decode_cardinality(element);
2941 let gui_order = element
2942 .get("salsah-gui:guiOrder")
2943 .and_then(serde_json::Value::as_u64)
2944 .map(|v| v as u32)
2945 .unwrap_or(u32::MAX);
2946
2947 restriction_prop_locals.push(local_name(&on_prop_id).to_string());
2948
2949 restrictions.push(Restriction {
2950 on_property_id: on_prop_id,
2951 cardinality,
2952 gui_order,
2953 });
2954 continue;
2955 }
2956 if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
2958 super_type_ids.push(id_val.to_string());
2959 }
2960 }
2961
2962 let representation = detect_representation(
2964 &restriction_prop_locals
2965 .iter()
2966 .map(String::as_str)
2967 .collect::<Vec<_>>(),
2968 );
2969
2970 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2972 for entity in graph_entities {
2973 if entity.object_type.is_some()
2976 || entity.is_link_property
2977 || entity.is_resource_property
2978 {
2979 prop_lookup.insert(entity.id.clone(), entity);
2980 }
2981 }
2982
2983 let mut missing_prefixes: Vec<String> = Vec::new();
2993 let mut seen_prefixes: HashSet<String> = HashSet::new();
2994 for restriction in &restrictions {
2995 if prop_lookup.contains_key(&restriction.on_property_id) {
2996 continue;
2997 }
2998 let prefix = match curie_prefix(&restriction.on_property_id) {
2999 Some(p) => p,
3000 None => continue,
3001 };
3002 if is_system_prefix(prefix) {
3003 continue;
3004 }
3005 if seen_prefixes.insert(prefix.to_string()) {
3006 missing_prefixes.push(prefix.to_string());
3007 }
3008 }
3009
3010 let mut fetched_sibling_iris: HashSet<String> = HashSet::new();
3012 let queried_iri_trimmed = data_model_iri.trim_end_matches(['#', '/']);
3013
3014 let mut siblings_to_fetch: Vec<String> = Vec::new();
3015 for prefix in &missing_prefixes {
3016 let namespace = match prefixes.get(prefix.as_str()) {
3017 Some(ns) => ns,
3018 None => {
3019 tracing::warn!(
3020 prefix = %prefix,
3021 "missing @context entry for prefix of cross-DM field; leaving best-effort"
3022 );
3023 continue;
3024 }
3025 };
3026 let sibling_iri = namespace.trim_end_matches(['#', '/']).to_string();
3027 if sibling_iri == queried_iri_trimmed {
3028 continue;
3030 }
3031 if fetched_sibling_iris.insert(sibling_iri.clone()) {
3032 siblings_to_fetch.push(sibling_iri);
3033 }
3034 }
3035
3036 if siblings_to_fetch.len() > MAX_SIBLING_FETCHES {
3037 tracing::warn!(
3038 count = siblings_to_fetch.len(),
3039 max = MAX_SIBLING_FETCHES,
3040 "too many sibling ontologies to fetch; capping at MAX_SIBLING_FETCHES"
3041 );
3042 siblings_to_fetch.truncate(MAX_SIBLING_FETCHES);
3043 }
3044
3045 for sibling_iri in &siblings_to_fetch {
3046 match self.fetch_allentities(server, sibling_iri, token) {
3048 Ok(sibling_resp) => {
3049 for entity in sibling_resp.graph {
3050 if entity.object_type.is_some()
3051 || entity.is_link_property
3052 || entity.is_resource_property
3053 {
3054 prop_lookup.entry(entity.id.clone()).or_insert(entity);
3055 }
3056 }
3057 }
3058 Err(e) => {
3059 tracing::warn!(
3062 iri = %sibling_iri,
3063 error = %e,
3064 "sibling ontology fetch failed; affected fields left best-effort"
3065 );
3066 }
3067 }
3068 }
3069
3070 let mut fields: Vec<(u32, Field)> = Vec::new();
3072
3073 for restriction in &restrictions {
3074 let prop_id = &restriction.on_property_id;
3075
3076 let node = prop_lookup.get(prop_id.as_str());
3078
3079 if let Some(n) = node {
3081 if n.is_link_value_property {
3082 continue;
3084 }
3085 } else {
3086 let prop_local = local_name(prop_id);
3090 if let Some(base) = prop_local.strip_suffix("Value") {
3091 let base_present = restrictions
3093 .iter()
3094 .any(|r| local_name(&r.on_property_id) == base);
3095 if base_present {
3098 continue;
3099 }
3100 }
3101 }
3102
3103 let prop_prefix = curie_prefix(prop_id).unwrap_or("");
3105 let is_builtin = is_system_prefix(prop_prefix);
3106 let (prop_local, prop_iri) = expand_class_id(prop_id, &prefixes);
3107
3108 let field_data_model = if is_builtin {
3110 None
3111 } else {
3112 if prop_prefix.is_empty() {
3115 None
3116 } else {
3117 Some(prop_prefix.to_string())
3118 }
3119 };
3120
3121 let (value_type, link_target) = if let Some(n) = node {
3123 if n.is_link_property {
3124 let target_name = n
3126 .object_type
3127 .as_ref()
3128 .map(|ot| local_name(&ot.id).to_string())
3129 .unwrap_or_else(|| "unknown".to_string());
3130 (ValueType::Link, Some(target_name))
3131 } else {
3132 let obj_local = n
3133 .object_type
3134 .as_ref()
3135 .map(|ot| local_name(&ot.id))
3136 .unwrap_or("");
3137 (map_object_type_to_value_type(obj_local), None)
3138 }
3139 } else {
3140 if is_builtin {
3142 if let Some(vt) = builtin_field_value_type(&prop_local) {
3143 (vt, None)
3144 } else {
3145 (ValueType::Other("—".to_string()), None)
3146 }
3147 } else {
3148 (ValueType::Other("—".to_string()), None)
3149 }
3150 };
3151
3152 let label = node.and_then(|n| n.label.clone());
3153
3154 debug_assert!(
3156 (value_type == ValueType::Link) == link_target.is_some(),
3157 "link_target must be Some iff value_type is Link"
3158 );
3159
3160 fields.push((
3161 restriction.gui_order,
3162 Field {
3163 name: prop_local,
3164 iri: prop_iri,
3165 label,
3166 value_type,
3167 link_target,
3168 cardinality: restriction.cardinality,
3169 is_builtin,
3170 data_model: field_data_model,
3171 },
3172 ));
3173 }
3174
3175 fields.sort_by(|(order_a, field_a), (order_b, field_b)| {
3177 order_a
3178 .cmp(order_b)
3179 .then_with(|| field_a.name.cmp(&field_b.name))
3180 });
3181 let sorted_fields: Vec<Field> = fields.into_iter().map(|(_, f)| f).collect();
3182
3183 let super_types: Vec<String> = super_type_ids
3185 .iter()
3186 .filter(|id| {
3187 let prefix = curie_prefix(id).unwrap_or("");
3188 !is_system_prefix(prefix)
3189 })
3190 .map(|id| local_name(id).to_string())
3191 .collect();
3192
3193 let (class_name, class_iri) = expand_class_id(&target.id, &prefixes);
3195 let class_label = target.label;
3196 let dm_name = data_model_name_from_iri(&queried_id);
3197
3198 Ok(ResourceTypeDetail {
3199 name: class_name,
3200 iri: class_iri,
3201 label: class_label,
3202 data_model: dm_name,
3203 representation,
3204 super_types,
3205 fields: sorted_fields,
3206 count: None,
3207 })
3208 }
3209
3210 fn resource_counts(
3211 &self,
3212 server: &str,
3213 project_iri: &str,
3214 token: Option<&str>,
3215 ) -> Result<HashMap<String, u64>, Diagnostic> {
3216 let url = format!(
3217 "{}/v3/projects/{}/resourcesPerOntology",
3218 server.trim_end_matches('/'),
3219 enc(project_iri)
3220 );
3221
3222 let req = self.client.get(&url);
3225 let req = if let Some(t) = token {
3226 req.bearer_auth(t)
3227 } else {
3228 req
3229 };
3230
3231 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
3232 let status = response.status();
3233
3234 if status.is_success() {
3235 let entries: Vec<OntologyAndResourceClassesDto> = response.json().map_err(|e| {
3236 Diagnostic::ServerError(format!(
3237 "resource-counts response could not be parsed: {e}"
3238 ))
3239 })?;
3240
3241 let mut counts = HashMap::new();
3242 for entry in entries {
3243 for cc in entry.classes_and_count {
3244 counts.insert(cc.resource_class.iri, cc.item_count);
3245 }
3246 }
3247 Ok(counts)
3248 } else if status == reqwest::StatusCode::NOT_FOUND {
3249 Err(Diagnostic::NotFound(format!("project not found at {url}")))
3250 } else {
3251 Err(map_unexpected_status(status, &url))
3252 }
3253 }
3254}
3255
3256#[cfg(test)]
3261mod tests {
3262 use super::*;
3263
3264 #[test]
3269 fn map_unexpected_status_401_403_are_auth_required() {
3270 for status in [
3274 reqwest::StatusCode::UNAUTHORIZED,
3275 reqwest::StatusCode::FORBIDDEN,
3276 ] {
3277 let diag = map_unexpected_status(status, "https://example.org/x");
3278 match diag {
3279 Diagnostic::AuthRequired(msg) => assert!(
3280 msg.contains("dsp auth login"),
3281 "auth message should hint at re-authentication: {msg}"
3282 ),
3283 other => panic!("expected AuthRequired for {status}, got {other:?}"),
3284 }
3285 }
3286 }
3287
3288 #[test]
3289 fn map_unexpected_status_404_and_5xx_stay_server_error() {
3290 assert!(matches!(
3293 map_unexpected_status(reqwest::StatusCode::NOT_FOUND, "u"),
3294 Diagnostic::ServerError(_)
3295 ));
3296 assert!(matches!(
3297 map_unexpected_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, "u"),
3298 Diagnostic::ServerError(_)
3299 ));
3300 }
3301
3302 #[test]
3307 fn identifier_key_email_contains_at() {
3308 assert_eq!(identifier_key("a@b.ch"), "email");
3309 }
3310
3311 #[test]
3312 fn identifier_key_bare_username() {
3313 assert_eq!(identifier_key("jdoe"), "username");
3314 }
3315
3316 #[test]
3317 fn identifier_key_http_iri() {
3318 assert_eq!(identifier_key("http://rdfh.ch/users/x"), "iri");
3319 }
3320
3321 #[test]
3322 fn identifier_key_https_iri() {
3323 assert_eq!(identifier_key("https://rdfh.ch/users/x"), "iri");
3324 }
3325
3326 #[test]
3327 fn identifier_key_iri_with_at_uses_iri_not_email() {
3328 assert_eq!(identifier_key("http://example.org/users/a@b"), "iri");
3330 }
3331
3332 #[test]
3333 fn classify_http_iri() {
3334 let ident = classify("http://rdfh.ch/projects/0001");
3335 assert!(
3336 matches!(ident, ProjectIdent::Iri(_)),
3337 "http:// prefix should classify as Iri"
3338 );
3339 }
3340
3341 #[test]
3342 fn classify_https_iri() {
3343 let ident = classify("https://rdfh.ch/projects/0001");
3344 assert!(
3345 matches!(ident, ProjectIdent::Iri(_)),
3346 "https:// prefix should classify as Iri"
3347 );
3348 }
3349
3350 #[test]
3351 fn classify_four_digit_hex_shortcode() {
3352 let ident = classify("0001");
3353 assert!(
3354 matches!(ident, ProjectIdent::Shortcode(_)),
3355 "four hex digits should classify as Shortcode"
3356 );
3357 }
3358
3359 #[test]
3360 fn classify_four_hex_letter_shortcode() {
3361 let ident = classify("beef");
3365 assert!(
3366 matches!(ident, ProjectIdent::Shortcode(_)),
3367 "4-hex-letter input 'beef' should classify as Shortcode (documented overlap)"
3368 );
3369 }
3370
3371 #[test]
3372 fn classify_mixed_case_hex_shortcode() {
3373 let ident = classify("ABCD");
3374 assert!(
3375 matches!(ident, ProjectIdent::Shortcode(_)),
3376 "upper-case hex digits should classify as Shortcode"
3377 );
3378 }
3379
3380 #[test]
3381 fn classify_shortname() {
3382 let ident = classify("incunabula");
3383 assert!(
3384 matches!(ident, ProjectIdent::Shortname(_)),
3385 "alphabetic string longer than 4 chars should classify as Shortname"
3386 );
3387 }
3388
3389 #[test]
3390 fn classify_five_digit_hex_is_shortname() {
3391 let ident = classify("00001");
3393 assert!(
3394 matches!(ident, ProjectIdent::Shortname(_)),
3395 "5-hex-digit string should classify as Shortname, not Shortcode"
3396 );
3397 }
3398
3399 #[test]
3400 fn classify_three_digit_hex_is_shortname() {
3401 let ident = classify("001");
3402 assert!(
3403 matches!(ident, ProjectIdent::Shortname(_)),
3404 "3-hex-digit string should classify as Shortname, not Shortcode"
3405 );
3406 }
3407
3408 #[test]
3409 fn classify_non_hex_four_chars_is_shortname() {
3410 let ident = classify("zzzz");
3412 assert!(
3413 matches!(ident, ProjectIdent::Shortname(_)),
3414 "4-char non-hex string should classify as Shortname"
3415 );
3416 }
3417
3418 #[test]
3423 fn validate_dump_id_valid_accepts() {
3424 assert!(super::validate_dump_id("abc123").is_ok());
3425 assert!(super::validate_dump_id("abc-123_XYZ").is_ok());
3426 let max_id = "a".repeat(256);
3428 assert!(
3429 super::validate_dump_id(&max_id).is_ok(),
3430 "256-char id must be accepted"
3431 );
3432 }
3433
3434 #[test]
3435 fn validate_dump_id_empty_is_rejected() {
3436 let result = super::validate_dump_id("");
3437 assert!(
3438 matches!(result, Err(Diagnostic::ServerError(_))),
3439 "empty id must be rejected"
3440 );
3441 }
3442
3443 #[test]
3444 fn validate_dump_id_too_long_is_rejected() {
3445 let long_id = "a".repeat(257);
3446 let result = super::validate_dump_id(&long_id);
3447 assert!(
3448 matches!(result, Err(Diagnostic::ServerError(_))),
3449 "257-char id must be rejected"
3450 );
3451 }
3452
3453 #[test]
3454 fn validate_dump_id_invalid_chars_rejected() {
3455 let result = super::validate_dump_id("abc/def");
3456 assert!(
3457 matches!(result, Err(Diagnostic::ServerError(_))),
3458 "id with '/' must be rejected"
3459 );
3460 }
3461
3462 #[test]
3467 fn into_dump_task_in_progress() {
3468 let api = DataTaskStatusApiResponse {
3469 id: "abc123".into(),
3470 status: "in_progress".into(),
3471 error_message: None,
3472 created_at: None,
3473 };
3474 let task = api.into_dump_task().expect("should parse in_progress");
3475 assert_eq!(task.id, "abc123");
3476 assert_eq!(task.status, DumpStatus::InProgress);
3477 assert!(task.error_message.is_none());
3478 assert!(task.created_at.is_none());
3479 }
3480
3481 #[test]
3482 fn into_dump_task_completed() {
3483 let api = DataTaskStatusApiResponse {
3484 id: "done42".into(),
3485 status: "completed".into(),
3486 error_message: None,
3487 created_at: None,
3488 };
3489 let task = api.into_dump_task().expect("should parse completed");
3490 assert_eq!(task.status, DumpStatus::Completed);
3491 }
3492
3493 #[test]
3494 fn into_dump_task_failed_with_message() {
3495 let api = DataTaskStatusApiResponse {
3496 id: "fail7".into(),
3497 status: "failed".into(),
3498 error_message: Some("disk full".into()),
3499 created_at: None,
3500 };
3501 let task = api.into_dump_task().expect("should parse failed");
3502 assert_eq!(task.status, DumpStatus::Failed);
3503 assert_eq!(task.error_message.as_deref(), Some("disk full"));
3504 }
3505
3506 #[test]
3507 fn into_dump_task_unknown_status_is_server_error() {
3508 let api = DataTaskStatusApiResponse {
3509 id: "x".into(),
3510 status: "pending".into(), error_message: None,
3512 created_at: None,
3513 };
3514 let result = api.into_dump_task();
3515 assert!(result.is_err(), "unknown status should yield an error");
3516 assert!(
3517 matches!(result.unwrap_err(), Diagnostic::ServerError(_)),
3518 "unknown status should yield ServerError"
3519 );
3520 }
3521
3522 #[test]
3523 fn into_dump_task_long_error_message_is_truncated() {
3524 let long_msg = "x".repeat(501);
3526 let api = DataTaskStatusApiResponse {
3527 id: "trunc".into(),
3528 status: "failed".into(),
3529 error_message: Some(long_msg),
3530 created_at: None,
3531 };
3532 let task = api
3533 .into_dump_task()
3534 .expect("should parse even with long message");
3535 let stored = task.error_message.unwrap();
3536 assert_eq!(
3537 stored.len(),
3538 500,
3539 "error_message must be truncated to ≤500 chars at the client boundary"
3540 );
3541 }
3542
3543 #[test]
3544 fn into_dump_task_exact_500_chars_not_truncated() {
3545 let exact_msg = "y".repeat(500);
3547 let api = DataTaskStatusApiResponse {
3548 id: "exact".into(),
3549 status: "failed".into(),
3550 error_message: Some(exact_msg.clone()),
3551 created_at: None,
3552 };
3553 let task = api.into_dump_task().expect("should parse");
3554 assert_eq!(task.error_message.unwrap(), exact_msg);
3555 }
3556
3557 #[test]
3562 fn into_dump_task_valid_created_at_is_parsed() {
3563 let api = DataTaskStatusApiResponse {
3564 id: "ts-test".into(),
3565 status: "completed".into(),
3566 error_message: None,
3567 created_at: Some("2026-05-20T14:03:00Z".into()),
3568 };
3569 let task = api.into_dump_task().expect("should parse with created_at");
3570 use chrono::Datelike;
3571 let ts = task.created_at.expect("created_at should be Some");
3572 assert_eq!(ts.year(), 2026);
3573 assert_eq!(ts.month(), 5);
3574 assert_eq!(ts.day(), 20);
3575 }
3576
3577 #[test]
3578 fn into_dump_task_garbage_created_at_yields_none() {
3579 let api = DataTaskStatusApiResponse {
3580 id: "ts-bad".into(),
3581 status: "in_progress".into(),
3582 error_message: None,
3583 created_at: Some("not-a-date!!".into()),
3584 };
3585 let task = api
3587 .into_dump_task()
3588 .expect("garbage created_at must not fail parse");
3589 assert!(
3590 task.created_at.is_none(),
3591 "garbage created_at must map to None"
3592 );
3593 }
3594
3595 #[test]
3600 fn export_exists_present_with_both_fields() {
3601 let body = V3ErrorBody {
3602 errors: vec![V3ErrorItem {
3603 code: "export_exists".into(),
3604 details: [
3605 ("id".to_string(), "dGVzdC1pZA".to_string()),
3606 (
3607 "projectIri".to_string(),
3608 "http://rdfh.ch/projects/0001".to_string(),
3609 ),
3610 ]
3611 .into(),
3612 }],
3613 };
3614 let ex = body.export_exists().expect("export_exists must be Some");
3615 assert_eq!(ex.id, Some("dGVzdC1pZA"));
3616 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3617 }
3618
3619 #[test]
3620 fn export_exists_wrong_code_returns_none() {
3621 let body = V3ErrorBody {
3622 errors: vec![V3ErrorItem {
3623 code: "some_other_error".into(),
3624 details: [("id".to_string(), "abc".to_string())].into(),
3625 }],
3626 };
3627 assert!(body.export_exists().is_none(), "wrong code must not match");
3628 }
3629
3630 #[test]
3631 fn export_exists_missing_details_id_returns_some_with_none_id() {
3632 let body = V3ErrorBody {
3633 errors: vec![V3ErrorItem {
3634 code: "export_exists".into(),
3635 details: [(
3636 "projectIri".to_string(),
3637 "http://rdfh.ch/projects/0001".to_string(),
3638 )]
3639 .into(),
3640 }],
3641 };
3642 let ex = body
3644 .export_exists()
3645 .expect("export_exists must be Some when code matches");
3646 assert!(ex.id.is_none(), "id must be None when 'id' key is absent");
3647 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3648 }
3649
3650 #[test]
3651 fn export_exists_empty_errors_returns_none() {
3652 let body = V3ErrorBody { errors: vec![] };
3653 assert!(body.export_exists().is_none());
3654 }
3655
3656 #[test]
3657 fn export_exists_missing_project_iri_returns_some_with_none_iri() {
3658 let body = V3ErrorBody {
3659 errors: vec![V3ErrorItem {
3660 code: "export_exists".into(),
3661 details: [("id".to_string(), "abc123".to_string())].into(),
3662 }],
3663 };
3664 let ex = body
3665 .export_exists()
3666 .expect("export_exists must be Some when code matches");
3667 assert_eq!(ex.id, Some("abc123"));
3668 assert!(
3669 ex.project_iri.is_none(),
3670 "project_iri must be None when 'projectIri' key is absent"
3671 );
3672 }
3673
3674 #[test]
3679 fn is_safe_shortcode_valid_hex_shortcode() {
3680 assert!(
3681 super::is_safe_shortcode("0001"),
3682 "4-hex-digit shortcode must be accepted"
3683 );
3684 assert!(
3685 super::is_safe_shortcode("ABCD"),
3686 "upper-case hex shortcode must be accepted"
3687 );
3688 assert!(
3689 super::is_safe_shortcode("beef"),
3690 "lower-case hex shortcode must be accepted"
3691 );
3692 }
3693
3694 #[test]
3695 fn is_safe_shortcode_alphanumeric_within_32_chars_accepted() {
3696 let long_code = "a".repeat(32);
3697 assert!(
3698 super::is_safe_shortcode(&long_code),
3699 "32-char alphanumeric must be accepted"
3700 );
3701 }
3702
3703 #[test]
3704 fn is_safe_shortcode_empty_is_rejected() {
3705 assert!(
3706 !super::is_safe_shortcode(""),
3707 "empty shortcode must be rejected"
3708 );
3709 }
3710
3711 #[test]
3712 fn is_safe_shortcode_too_long_is_rejected() {
3713 let long_code = "a".repeat(33);
3714 assert!(
3715 !super::is_safe_shortcode(&long_code),
3716 "33-char shortcode must be rejected"
3717 );
3718 }
3719
3720 #[test]
3721 fn is_safe_shortcode_slash_is_rejected() {
3722 assert!(
3723 !super::is_safe_shortcode("ab/cd"),
3724 "shortcode with '/' must be rejected"
3725 );
3726 assert!(
3727 !super::is_safe_shortcode("/evil"),
3728 "absolute path shortcode must be rejected"
3729 );
3730 }
3731
3732 #[test]
3733 fn is_safe_shortcode_dot_dot_is_rejected() {
3734 assert!(
3735 !super::is_safe_shortcode("../evil"),
3736 "path traversal shortcode must be rejected"
3737 );
3738 assert!(
3739 !super::is_safe_shortcode(".."),
3740 "'..' shortcode must be rejected"
3741 );
3742 }
3743
3744 #[test]
3745 fn is_safe_shortcode_backslash_is_rejected() {
3746 assert!(
3747 !super::is_safe_shortcode("ab\\cd"),
3748 "shortcode with '\\' must be rejected"
3749 );
3750 }
3751
3752 #[test]
3753 fn is_safe_shortcode_dot_is_rejected() {
3754 assert!(
3756 !super::is_safe_shortcode("ab.cd"),
3757 "shortcode with '.' must be rejected"
3758 );
3759 }
3760
3761 #[test]
3762 fn resolve_project_rejects_unsafe_shortcode() {
3763 let unsafe_examples = ["../evil", "/abs", "ab/cd", "a\\b", ""];
3767 for s in &unsafe_examples {
3768 assert!(
3769 !super::is_safe_shortcode(s),
3770 "is_safe_shortcode must reject '{s}' — resolve_project would have returned ServerError for this input"
3771 );
3772 }
3773 }
3774
3775 #[test]
3780 fn data_model_name_from_iri_standard_form() {
3781 assert_eq!(
3783 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2"),
3784 "beol"
3785 );
3786 }
3787
3788 #[test]
3789 fn data_model_name_from_iri_no_v2_suffix() {
3790 assert_eq!(
3792 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol"),
3793 "beol"
3794 );
3795 }
3796
3797 #[test]
3798 fn data_model_name_from_iri_trailing_slash() {
3799 assert_eq!(
3801 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2/"),
3802 "beol"
3803 );
3804 }
3805
3806 #[test]
3807 fn data_model_name_from_iri_bare_name() {
3808 assert_eq!(super::data_model_name_from_iri("beol"), "beol");
3810 }
3811
3812 #[test]
3813 fn data_model_name_from_iri_empty_string() {
3814 assert_eq!(super::data_model_name_from_iri(""), "");
3816 }
3817
3818 fn beol_prefixes() -> HashMap<String, String> {
3823 let mut m = HashMap::new();
3824 m.insert(
3825 "beol".to_string(),
3826 "http://api.dasch.swiss/ontology/0801/beol/v2#".to_string(),
3827 );
3828 m
3829 }
3830
3831 #[test]
3832 fn expand_class_id_curie_expands_with_known_prefix() {
3833 let (name, iri) = super::expand_class_id("beol:Archive", &beol_prefixes());
3835 assert_eq!(name, "Archive");
3836 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
3837 }
3838
3839 #[test]
3840 fn expand_class_id_unknown_prefix_falls_back_to_raw_id() {
3841 let (name, iri) = super::expand_class_id("urn:uuid:x", &HashMap::new());
3843 assert_eq!(name, "x");
3844 assert_eq!(iri, "urn:uuid:x");
3845 }
3846
3847 #[test]
3848 fn expand_class_id_full_iri_passes_through() {
3849 let (name, iri) = super::expand_class_id(
3852 "http://api.dasch.swiss/ontology/0801/beol/v2#Letter",
3853 &beol_prefixes(),
3854 );
3855 assert_eq!(name, "Letter");
3856 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Letter");
3857 }
3858
3859 #[test]
3860 fn expand_class_id_no_colon_degenerate() {
3861 let (name, iri) = super::expand_class_id("bare", &HashMap::new());
3863 assert_eq!(name, "bare");
3864 assert_eq!(iri, "bare");
3865 }
3866
3867 #[test]
3872 fn local_name_hash_iri() {
3873 assert_eq!(super::local_name("http://example.org/onto#Thing"), "Thing");
3874 }
3875
3876 #[test]
3877 fn local_name_slash_iri() {
3878 assert_eq!(super::local_name("http://example.org/onto/Thing"), "Thing");
3879 }
3880
3881 #[test]
3882 fn local_name_curie_colon() {
3883 assert_eq!(super::local_name("incunabula:Page"), "Page");
3884 }
3885
3886 #[test]
3887 fn local_name_bare_name_fallback() {
3888 assert_eq!(super::local_name("Page"), "Page");
3889 }
3890
3891 #[test]
3892 fn local_name_empty_string() {
3893 assert_eq!(super::local_name(""), "");
3894 }
3895
3896 #[test]
3897 fn local_name_trailing_separator() {
3898 assert_eq!(super::local_name("foo#"), "");
3901 }
3902
3903 #[test]
3908 fn object_type_to_kebab_text_value() {
3909 assert_eq!(super::object_type_to_kebab("TextValue"), "text");
3910 }
3911
3912 #[test]
3913 fn object_type_to_kebab_geom_value() {
3914 assert_eq!(super::object_type_to_kebab("GeomValue"), "geom");
3916 }
3917
3918 #[test]
3919 fn object_type_to_kebab_geo_name_value() {
3920 assert_eq!(super::object_type_to_kebab("GeoNameValue"), "geo-name");
3922 }
3923
3924 #[test]
3925 fn object_type_to_kebab_uri_value() {
3926 assert_eq!(super::object_type_to_kebab("URIValue"), "uri");
3929 }
3930
3931 #[test]
3932 fn object_type_to_kebab_interval_value() {
3933 assert_eq!(super::object_type_to_kebab("IntervalValue"), "interval");
3936 }
3937
3938 #[test]
3939 fn object_type_to_kebab_no_value_suffix() {
3940 assert_eq!(super::object_type_to_kebab("Geom"), "geom");
3942 }
3943
3944 #[test]
3945 fn map_object_type_known_text_value() {
3946 use crate::model::ValueType;
3947 assert_eq!(
3948 super::map_object_type_to_value_type("TextValue"),
3949 ValueType::Text
3950 );
3951 }
3952
3953 #[test]
3954 fn map_object_type_known_list_value() {
3955 use crate::model::ValueType;
3956 assert_eq!(
3957 super::map_object_type_to_value_type("ListValue"),
3958 ValueType::ListItem
3959 );
3960 }
3961
3962 #[test]
3963 fn map_object_type_other_geom() {
3964 use crate::model::ValueType;
3965 assert_eq!(
3967 super::map_object_type_to_value_type("GeomValue"),
3968 ValueType::Other("geom".to_string())
3969 );
3970 }
3971
3972 #[test]
3973 fn map_object_type_other_uri_value() {
3974 use crate::model::ValueType;
3975 assert_eq!(
3977 super::map_object_type_to_value_type("URIValue"),
3978 ValueType::Other("uri".to_string())
3979 );
3980 }
3981
3982 #[test]
3983 fn map_object_type_other_geo_name_value() {
3984 use crate::model::ValueType;
3985 assert_eq!(
3986 super::map_object_type_to_value_type("GeoNameValue"),
3987 ValueType::Other("geo-name".to_string())
3988 );
3989 }
3990
3991 #[test]
3996 fn decode_cardinality_owl_cardinality_1() {
3997 use crate::model::Cardinality;
3998 let v = serde_json::json!({"owl:cardinality": 1});
3999 assert_eq!(super::decode_cardinality(&v), Cardinality::One);
4000 }
4001
4002 #[test]
4003 fn decode_cardinality_owl_max_cardinality_1() {
4004 use crate::model::Cardinality;
4005 let v = serde_json::json!({"owl:maxCardinality": 1});
4006 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrOne);
4007 }
4008
4009 #[test]
4010 fn decode_cardinality_owl_min_cardinality_0() {
4011 use crate::model::Cardinality;
4012 let v = serde_json::json!({"owl:minCardinality": 0});
4013 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4014 }
4015
4016 #[test]
4017 fn decode_cardinality_owl_min_cardinality_1() {
4018 use crate::model::Cardinality;
4019 let v = serde_json::json!({"owl:minCardinality": 1});
4020 assert_eq!(super::decode_cardinality(&v), Cardinality::OneOrMore);
4021 }
4022
4023 #[test]
4024 fn decode_cardinality_fallback_no_key() {
4025 use crate::model::Cardinality;
4026 let v = serde_json::json!({});
4028 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4029 }
4030
4031 #[test]
4032 fn decode_cardinality_fallback_owl_cardinality_unexpected_value() {
4033 use crate::model::Cardinality;
4034 let v = serde_json::json!({"owl:cardinality": 5});
4036 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4037 }
4038
4039 #[test]
4040 fn decode_cardinality_fallback_owl_max_cardinality_gt1() {
4041 use crate::model::Cardinality;
4042 let v = serde_json::json!({"owl:maxCardinality": 2});
4045 assert_eq!(
4046 super::decode_cardinality(&v),
4047 Cardinality::ZeroOrMore,
4048 "owl:maxCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4049 );
4050 }
4051
4052 #[test]
4053 fn decode_cardinality_fallback_owl_min_cardinality_gt1() {
4054 use crate::model::Cardinality;
4055 let v = serde_json::json!({"owl:minCardinality": 2});
4058 assert_eq!(
4059 super::decode_cardinality(&v),
4060 Cardinality::ZeroOrMore,
4061 "owl:minCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4062 );
4063 }
4064
4065 #[test]
4070 fn detect_representation_still_image() {
4071 use crate::model::Representation;
4072 let locals = vec!["hasStillImageFileValue"];
4073 assert_eq!(
4074 super::detect_representation(&locals),
4075 Some(Representation::StillImage)
4076 );
4077 }
4078
4079 #[test]
4080 fn detect_representation_moving_image() {
4081 use crate::model::Representation;
4082 let locals = vec!["hasMovingImageFileValue"];
4083 assert_eq!(
4084 super::detect_representation(&locals),
4085 Some(Representation::MovingImage)
4086 );
4087 }
4088
4089 #[test]
4090 fn detect_representation_audio() {
4091 use crate::model::Representation;
4092 let locals = vec!["hasAudioFileValue"];
4093 assert_eq!(
4094 super::detect_representation(&locals),
4095 Some(Representation::Audio)
4096 );
4097 }
4098
4099 #[test]
4100 fn detect_representation_none_when_absent() {
4101 let locals = vec!["hasTitle", "hasAuthor"];
4103 assert_eq!(super::detect_representation(&locals), None);
4104 }
4105
4106 #[test]
4107 fn detect_representation_takes_first() {
4108 use crate::model::Representation;
4109 let locals = vec!["hasDocumentFileValue", "hasStillImageFileValue"];
4111 assert_eq!(
4112 super::detect_representation(&locals),
4113 Some(Representation::Document)
4114 );
4115 }
4116
4117 #[test]
4122 fn is_system_prefix_knora_api() {
4123 assert!(super::is_system_prefix("knora-api"));
4124 }
4125
4126 #[test]
4127 fn is_system_prefix_rdf() {
4128 assert!(super::is_system_prefix("rdf"));
4129 }
4130
4131 #[test]
4132 fn is_system_prefix_project_prefix_is_not_system() {
4133 assert!(!super::is_system_prefix("incunabula"));
4134 assert!(!super::is_system_prefix("beol"));
4135 assert!(!super::is_system_prefix("biblio"));
4136 }
4137
4138 #[test]
4143 fn curie_prefix_returns_prefix_for_curie() {
4144 assert_eq!(super::curie_prefix("knora-api:arkUrl"), Some("knora-api"));
4145 assert_eq!(super::curie_prefix("beol:hasTitle"), Some("beol"));
4146 }
4147
4148 #[test]
4149 fn curie_prefix_returns_none_for_full_iri() {
4150 assert_eq!(
4152 super::curie_prefix("http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle"),
4153 None
4154 );
4155 }
4156
4157 #[test]
4158 fn curie_prefix_returns_none_for_no_colon() {
4159 assert_eq!(super::curie_prefix("hasTitle"), None);
4160 }
4161
4162 #[test]
4167 fn sibling_iri_trim_hash_delimiter() {
4168 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4170 let trimmed = namespace.trim_end_matches(['#', '/']);
4171 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4172 }
4173
4174 #[test]
4175 fn sibling_iri_trim_slash_delimiter() {
4176 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2/";
4178 let trimmed = namespace.trim_end_matches(['#', '/']);
4179 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4180 }
4181
4182 #[test]
4183 fn sibling_iri_self_loop_detected() {
4184 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4186 let namespace = "http://api.dasch.swiss/ontology/0801/beol/v2#";
4187 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4188 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4189 assert_eq!(sibling_iri, queried_trimmed); }
4191
4192 #[test]
4193 fn sibling_iri_different_ontology_is_not_self_loop() {
4194 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4195 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4196 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4197 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4198 assert_ne!(sibling_iri, queried_trimmed); }
4200
4201 #[test]
4202 fn missing_prefix_in_context_is_skipped() {
4203 let prefixes: HashMap<String, String> = HashMap::new();
4205 let result = prefixes.get("biblio");
4206 assert!(result.is_none()); }
4208
4209 #[test]
4214 fn derive_access_rv() {
4215 assert_eq!(
4216 super::derive_access("RV"),
4217 Some(super::ResourceAccess::RestrictedView)
4218 );
4219 }
4220
4221 #[test]
4222 fn derive_access_v() {
4223 assert_eq!(super::derive_access("V"), Some(super::ResourceAccess::View));
4224 }
4225
4226 #[test]
4227 fn derive_access_m() {
4228 assert_eq!(super::derive_access("M"), Some(super::ResourceAccess::Edit));
4229 }
4230
4231 #[test]
4232 fn derive_access_d() {
4233 assert_eq!(
4234 super::derive_access("D"),
4235 Some(super::ResourceAccess::Delete)
4236 );
4237 }
4238
4239 #[test]
4240 fn derive_access_cr() {
4241 assert_eq!(
4242 super::derive_access("CR"),
4243 Some(super::ResourceAccess::Manage)
4244 );
4245 }
4246
4247 #[test]
4248 fn derive_access_unknown_is_none() {
4249 assert_eq!(super::derive_access("XYZ"), None);
4250 }
4251
4252 #[test]
4253 fn derive_access_empty_is_none() {
4254 assert_eq!(super::derive_access(""), None);
4255 }
4256
4257 #[test]
4262 fn derive_visibility_public_when_unknown_user_has_view() {
4263 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|V knora-admin:KnownUser,knora-admin:UnknownUser";
4265 assert_eq!(
4266 super::derive_visibility(acl),
4267 Some(super::ResourceVisibility::Public)
4268 );
4269 }
4270
4271 #[test]
4272 fn derive_visibility_public_when_unknown_user_has_cr() {
4273 let acl = "CR knora-admin:UnknownUser";
4275 assert_eq!(
4276 super::derive_visibility(acl),
4277 Some(super::ResourceVisibility::Public)
4278 );
4279 }
4280
4281 #[test]
4282 fn derive_visibility_public_restricted_when_unknown_user_has_rv() {
4283 let acl = "RV knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4285 assert_eq!(
4286 super::derive_visibility(acl),
4287 Some(super::ResourceVisibility::PublicRestricted)
4288 );
4289 }
4290
4291 #[test]
4292 fn derive_visibility_logged_in_when_known_user_has_rv_unknown_absent() {
4293 let acl = "RV knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4295 assert_eq!(
4296 super::derive_visibility(acl),
4297 Some(super::ResourceVisibility::LoggedInUsers)
4298 );
4299 }
4300
4301 #[test]
4302 fn derive_visibility_logged_in_when_known_user_has_v() {
4303 let acl = "V knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4305 assert_eq!(
4306 super::derive_visibility(acl),
4307 Some(super::ResourceVisibility::LoggedInUsers)
4308 );
4309 }
4310
4311 #[test]
4312 fn derive_visibility_project_members_when_neither_world_group_granted() {
4313 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|M knora-admin:ProjectMember";
4315 assert_eq!(
4316 super::derive_visibility(acl),
4317 Some(super::ResourceVisibility::ProjectMembers)
4318 );
4319 }
4320
4321 #[test]
4322 fn derive_visibility_empty_string_is_none() {
4323 assert_eq!(super::derive_visibility(""), None);
4324 }
4325
4326 #[test]
4327 fn derive_visibility_whitespace_only_is_none() {
4328 assert_eq!(super::derive_visibility(" "), None);
4329 }
4330
4331 #[test]
4332 fn derive_visibility_malformed_entry_without_space_is_skipped() {
4333 let acl = "CRMALFORMED|CR knora-admin:ProjectAdmin";
4335 assert_eq!(
4337 super::derive_visibility(acl),
4338 Some(super::ResourceVisibility::ProjectMembers)
4339 );
4340 }
4341
4342 #[test]
4343 fn derive_visibility_unknown_code_ranks_zero_no_implicit_grant() {
4344 let acl = "BOGUS knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4346 assert_eq!(
4348 super::derive_visibility(acl),
4349 Some(super::ResourceVisibility::ProjectMembers)
4350 );
4351 }
4352
4353 #[test]
4354 fn derive_visibility_same_group_two_entries_max_wins() {
4355 let acl = "RV knora-admin:UnknownUser|V knora-admin:UnknownUser";
4357 assert_eq!(
4358 super::derive_visibility(acl),
4359 Some(super::ResourceVisibility::Public)
4360 );
4361 }
4362
4363 #[test]
4364 fn derive_visibility_both_world_groups_unknown_user_decides() {
4365 let acl = "V knora-admin:UnknownUser|CR knora-admin:KnownUser";
4368 assert_eq!(
4369 super::derive_visibility(acl),
4370 Some(super::ResourceVisibility::Public)
4371 );
4372 }
4373
4374 #[test]
4375 fn derive_visibility_super_unknown_user_does_not_match() {
4376 let acl = "CR knora-admin:SuperUnknownUser|CR knora-admin:ProjectAdmin";
4379 assert_eq!(
4381 super::derive_visibility(acl),
4382 Some(super::ResourceVisibility::ProjectMembers)
4383 );
4384 }
4385
4386 #[test]
4387 fn derive_visibility_all_malformed_entries_no_space_returns_none() {
4388 let acl = "NOSPACE|ALSONOSPACE|STILLNOSPACE";
4392 assert_eq!(
4393 super::derive_visibility(acl),
4394 None,
4395 "all-malformed ACL (no space in any entry) must return None"
4396 );
4397 }
4398
4399 use crate::model::ValueType;
4404 use crate::model::resource::{DatePoint, DateValue, FileValue, ValueContent};
4405
4406 #[test]
4409 fn parse_value_text_plain() {
4410 let obj = serde_json::json!({
4411 "@type": "knora-api:TextValue",
4412 "knora-api:valueAsString": "Hello world"
4413 });
4414 let (content, is_link) = super::parse_value_content(&obj);
4415 assert_eq!(content, ValueContent::Text("Hello world".into()));
4416 assert!(!is_link);
4417 }
4418
4419 #[test]
4420 fn parse_value_text_standoff_xml_stripped() {
4421 let obj = serde_json::json!({
4423 "@type": "knora-api:TextValue",
4424 "knora-api:textValueAsXml": "<p>Hello <b>world</b></p>",
4425 "knora-api:valueAsString": "This is ignored when xml present"
4426 });
4427 let (content, is_link) = super::parse_value_content(&obj);
4428 assert!(matches!(content, ValueContent::Text(_)));
4430 assert!(!is_link);
4431 if let ValueContent::Text(s) = content {
4432 assert!(!s.contains('<'), "no raw tags: {s:?}");
4434 assert!(s.contains("Hello"), "text retained: {s:?}");
4435 }
4436 }
4437
4438 #[test]
4441 fn parse_value_integer() {
4442 let obj = serde_json::json!({
4443 "@type": "knora-api:IntValue",
4444 "knora-api:intValueAsInt": 42
4445 });
4446 let (content, is_link) = super::parse_value_content(&obj);
4447 assert_eq!(content, ValueContent::Integer(42));
4448 assert!(!is_link);
4449 }
4450
4451 #[test]
4452 fn parse_value_integer_negative() {
4453 let obj = serde_json::json!({
4454 "@type": "knora-api:IntValue",
4455 "knora-api:intValueAsInt": -7
4456 });
4457 let (content, _) = super::parse_value_content(&obj);
4458 assert_eq!(content, ValueContent::Integer(-7));
4459 }
4460
4461 #[test]
4464 fn parse_value_decimal_object_form() {
4465 let obj = serde_json::json!({
4467 "@type": "knora-api:DecimalValue",
4468 "knora-api:decimalValueAsDecimal": {"@value": "3.14159", "@type": "xsd:decimal"}
4469 });
4470 let (content, is_link) = super::parse_value_content(&obj);
4471 assert_eq!(content, ValueContent::Decimal("3.14159".into()));
4472 assert!(!is_link);
4473 }
4474
4475 #[test]
4476 fn parse_value_decimal_bare_string_form() {
4477 let obj = serde_json::json!({
4478 "@type": "knora-api:DecimalValue",
4479 "knora-api:decimalValueAsDecimal": "2.71828"
4480 });
4481 let (content, _) = super::parse_value_content(&obj);
4482 assert_eq!(content, ValueContent::Decimal("2.71828".into()));
4483 }
4484
4485 #[test]
4488 fn parse_value_boolean_true() {
4489 let obj = serde_json::json!({
4490 "@type": "knora-api:BooleanValue",
4491 "knora-api:booleanValueAsBoolean": true
4492 });
4493 let (content, is_link) = super::parse_value_content(&obj);
4494 assert_eq!(content, ValueContent::Boolean(true));
4495 assert!(!is_link);
4496 }
4497
4498 #[test]
4499 fn parse_value_boolean_false() {
4500 let obj = serde_json::json!({
4501 "@type": "knora-api:BooleanValue",
4502 "knora-api:booleanValueAsBoolean": false
4503 });
4504 let (content, _) = super::parse_value_content(&obj);
4505 assert_eq!(content, ValueContent::Boolean(false));
4506 }
4507
4508 #[test]
4511 fn parse_value_date_single_point() {
4512 let obj = serde_json::json!({
4514 "@type": "knora-api:DateValue",
4515 "knora-api:dateValueHasCalendar": "GREGORIAN",
4516 "knora-api:dateValueHasStartYear": 1489,
4517 "knora-api:dateValueHasStartEra": "CE",
4518 "knora-api:dateValueHasEndYear": 1489,
4519 "knora-api:dateValueHasEndEra": "CE"
4520 });
4521 let (content, is_link) = super::parse_value_content(&obj);
4522 assert!(!is_link);
4523 let expected = ValueContent::Date(DateValue {
4524 calendar: "GREGORIAN".into(),
4525 start: DatePoint {
4526 year: Some(1489),
4527 month: None,
4528 day: None,
4529 era: Some("CE".into()),
4530 },
4531 end: DatePoint {
4532 year: Some(1489),
4533 month: None,
4534 day: None,
4535 era: Some("CE".into()),
4536 },
4537 });
4538 assert_eq!(content, expected);
4539 }
4540
4541 #[test]
4542 fn parse_value_date_range() {
4543 let obj = serde_json::json!({
4545 "@type": "knora-api:DateValue",
4546 "knora-api:dateValueHasCalendar": "GREGORIAN",
4547 "knora-api:dateValueHasStartYear": 1489,
4548 "knora-api:dateValueHasStartEra": "CE",
4549 "knora-api:dateValueHasEndYear": 1490,
4550 "knora-api:dateValueHasEndEra": "CE"
4551 });
4552 let (content, _) = super::parse_value_content(&obj);
4553 if let ValueContent::Date(dv) = content {
4554 assert_eq!(dv.start.year, Some(1489));
4555 assert_eq!(dv.end.year, Some(1490));
4556 assert_ne!(dv.start, dv.end, "range: start != end");
4557 } else {
4558 panic!("expected DateValue, got {content:?}");
4559 }
4560 }
4561
4562 #[test]
4563 fn parse_value_date_full_day_precision() {
4564 let obj = serde_json::json!({
4566 "@type": "knora-api:DateValue",
4567 "knora-api:dateValueHasCalendar": "JULIAN",
4568 "knora-api:dateValueHasStartYear": 1456,
4569 "knora-api:dateValueHasStartMonth": 3,
4570 "knora-api:dateValueHasStartDay": 14,
4571 "knora-api:dateValueHasStartEra": "CE",
4572 "knora-api:dateValueHasEndYear": 1456,
4573 "knora-api:dateValueHasEndMonth": 3,
4574 "knora-api:dateValueHasEndDay": 14,
4575 "knora-api:dateValueHasEndEra": "CE"
4576 });
4577 let (content, _) = super::parse_value_content(&obj);
4578 if let ValueContent::Date(dv) = content {
4579 assert_eq!(dv.calendar, "JULIAN");
4580 assert_eq!(dv.start.month, Some(3));
4581 assert_eq!(dv.start.day, Some(14));
4582 } else {
4583 panic!("expected DateValue, got {content:?}");
4584 }
4585 }
4586
4587 #[test]
4588 fn parse_value_date_no_year_falls_back_to_raw() {
4589 let obj = serde_json::json!({
4591 "@type": "knora-api:DateValue",
4592 "knora-api:dateValueHasCalendar": "GREGORIAN",
4593 "knora-api:valueAsString": "some date"
4594 });
4595 let (content, _) = super::parse_value_content(&obj);
4596 assert!(
4597 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "date"),
4598 "missing years must degrade to Raw date"
4599 );
4600 }
4601
4602 #[test]
4605 fn parse_value_time() {
4606 let obj = serde_json::json!({
4607 "@type": "knora-api:TimeValue",
4608 "knora-api:timeValueAsTimeStamp": {"@value": "2021-01-01T12:00:00Z", "@type": "xsd:dateTimeStamp"}
4609 });
4610 let (content, is_link) = super::parse_value_content(&obj);
4611 assert_eq!(content, ValueContent::Time("2021-01-01T12:00:00Z".into()));
4612 assert!(!is_link);
4613 }
4614
4615 #[test]
4616 fn parse_value_time_bare_string() {
4617 let obj = serde_json::json!({
4618 "@type": "knora-api:TimeValue",
4619 "knora-api:timeValueAsTimeStamp": "2022-06-01T00:00:00Z"
4620 });
4621 let (content, _) = super::parse_value_content(&obj);
4622 assert_eq!(content, ValueContent::Time("2022-06-01T00:00:00Z".into()));
4623 }
4624
4625 #[test]
4628 fn parse_value_uri() {
4629 let obj = serde_json::json!({
4630 "@type": "knora-api:UriValue",
4631 "knora-api:uriValueAsUri": {"@value": "https://example.com", "@type": "xsd:anyURI"}
4632 });
4633 let (content, is_link) = super::parse_value_content(&obj);
4634 assert_eq!(content, ValueContent::Uri("https://example.com".into()));
4635 assert!(!is_link);
4636 }
4637
4638 #[test]
4641 fn parse_value_color() {
4642 let obj = serde_json::json!({
4643 "@type": "knora-api:ColorValue",
4644 "knora-api:colorValueAsColor": "#ff0000"
4645 });
4646 let (content, is_link) = super::parse_value_content(&obj);
4647 assert_eq!(content, ValueContent::Color("#ff0000".into()));
4648 assert!(!is_link);
4649 }
4650
4651 #[test]
4654 fn parse_value_geoname() {
4655 let obj = serde_json::json!({
4656 "@type": "knora-api:GeonameValue",
4657 "knora-api:geonameValueAsGeonameCode": "2661552"
4658 });
4659 let (content, is_link) = super::parse_value_content(&obj);
4660 assert_eq!(content, ValueContent::Geoname("2661552".into()));
4661 assert!(!is_link);
4662 }
4663
4664 #[test]
4667 fn parse_value_list_item() {
4668 let obj = serde_json::json!({
4669 "@type": "knora-api:ListValue",
4670 "knora-api:listValueAsListNode": {"@id": "http://rdfh.ch/lists/0001/node1"}
4671 });
4672 let (content, is_link) = super::parse_value_content(&obj);
4673 assert_eq!(
4674 content,
4675 ValueContent::ListItem {
4676 node_iri: "http://rdfh.ch/lists/0001/node1".into(),
4677 label: None, }
4679 );
4680 assert!(!is_link);
4681 }
4682
4683 #[test]
4686 fn parse_value_link_with_embedded_target() {
4687 let obj = serde_json::json!({
4688 "@type": "knora-api:LinkValue",
4689 "knora-api:linkValueHasTarget": {
4690 "@id": "http://rdfh.ch/0803/res1",
4691 "@type": "incunabula:Book",
4692 "rdfs:label": "Incunabula Book 1"
4693 }
4694 });
4695 let (content, is_link) = super::parse_value_content(&obj);
4696 assert!(is_link, "LinkValue must set is_link=true");
4697 assert_eq!(
4698 content,
4699 ValueContent::Link {
4700 target_iri: "http://rdfh.ch/0803/res1".into(),
4701 target_label: Some("Incunabula Book 1".into()),
4702 }
4703 );
4704 }
4705
4706 #[test]
4707 fn parse_value_link_with_target_iri_only() {
4708 let obj = serde_json::json!({
4710 "@type": "knora-api:LinkValue",
4711 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res2"}
4712 });
4713 let (content, is_link) = super::parse_value_content(&obj);
4714 assert!(is_link);
4715 assert_eq!(
4716 content,
4717 ValueContent::Link {
4718 target_iri: "http://rdfh.ch/0803/res2".into(),
4719 target_label: None,
4720 }
4721 );
4722 }
4723
4724 #[test]
4727 fn parse_value_still_image_file() {
4728 let obj = serde_json::json!({
4729 "@type": "knora-api:StillImageFileValue",
4730 "knora-api:fileValueHasFilename": "image.jp2",
4731 "knora-api:fileValueAsUrl": {"@value": "https://iiif.example.com/image.jp2/full/max/0/default.jpg"},
4732 "knora-api:stillImageFileValueHasDimX": 1200,
4733 "knora-api:stillImageFileValueHasDimY": 800
4734 });
4735 let (content, is_link) = super::parse_value_content(&obj);
4736 assert!(!is_link);
4737 assert_eq!(
4738 content,
4739 ValueContent::File(FileValue {
4740 value_type: ValueType::StillImage,
4741 filename: "image.jp2".into(),
4742 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
4743 width: Some(1200),
4744 height: Some(800),
4745 })
4746 );
4747 }
4748
4749 #[test]
4750 fn parse_value_still_image_external_file_value() {
4751 let obj = serde_json::json!({
4753 "@type": "knora-api:StillImageExternalFileValue",
4754 "knora-api:fileValueHasFilename": "external.jpg",
4755 "knora-api:fileValueAsUrl": {"@value": "https://iiif.external.com/image.jpg"}
4756 });
4757 let (content, _) = super::parse_value_content(&obj);
4758 if let ValueContent::File(fv) = content {
4759 assert_eq!(
4760 fv.value_type,
4761 ValueType::StillImage,
4762 "StillImageExternal* → StillImage"
4763 );
4764 } else {
4765 panic!("expected File, got {content:?}");
4766 }
4767 }
4768
4769 #[test]
4772 fn parse_value_moving_image_file() {
4773 let obj = serde_json::json!({
4774 "@type": "knora-api:MovingImageFileValue",
4775 "knora-api:fileValueHasFilename": "video.mp4",
4776 "knora-api:fileValueAsUrl": {"@value": "https://example.com/video.mp4"}
4777 });
4778 let (content, is_link) = super::parse_value_content(&obj);
4779 assert!(!is_link);
4780 assert_eq!(
4781 content,
4782 ValueContent::File(FileValue {
4783 value_type: ValueType::MovingImage,
4784 filename: "video.mp4".into(),
4785 url: "https://example.com/video.mp4".into(),
4786 width: None,
4787 height: None,
4788 })
4789 );
4790 }
4791
4792 #[test]
4795 fn parse_value_audio_file() {
4796 let obj = serde_json::json!({
4797 "@type": "knora-api:AudioFileValue",
4798 "knora-api:fileValueHasFilename": "sound.wav",
4799 "knora-api:fileValueAsUrl": {"@value": "https://example.com/sound.wav"}
4800 });
4801 let (content, _) = super::parse_value_content(&obj);
4802 assert_eq!(
4803 content,
4804 ValueContent::File(FileValue {
4805 value_type: ValueType::Audio,
4806 filename: "sound.wav".into(),
4807 url: "https://example.com/sound.wav".into(),
4808 width: None,
4809 height: None,
4810 })
4811 );
4812 }
4813
4814 #[test]
4817 fn parse_value_document_file() {
4818 let obj = serde_json::json!({
4819 "@type": "knora-api:DocumentFileValue",
4820 "knora-api:fileValueHasFilename": "doc.pdf",
4821 "knora-api:fileValueAsUrl": {"@value": "https://example.com/doc.pdf"}
4822 });
4823 let (content, _) = super::parse_value_content(&obj);
4824 assert_eq!(
4825 content,
4826 ValueContent::File(FileValue {
4827 value_type: ValueType::Document,
4828 filename: "doc.pdf".into(),
4829 url: "https://example.com/doc.pdf".into(),
4830 width: None,
4831 height: None,
4832 })
4833 );
4834 }
4835
4836 #[test]
4839 fn parse_value_archive_file() {
4840 let obj = serde_json::json!({
4841 "@type": "knora-api:ArchiveFileValue",
4842 "knora-api:fileValueHasFilename": "data.zip",
4843 "knora-api:fileValueAsUrl": {"@value": "https://example.com/data.zip"}
4844 });
4845 let (content, _) = super::parse_value_content(&obj);
4846 assert_eq!(
4847 content,
4848 ValueContent::File(FileValue {
4849 value_type: ValueType::Archive,
4850 filename: "data.zip".into(),
4851 url: "https://example.com/data.zip".into(),
4852 width: None,
4853 height: None,
4854 })
4855 );
4856 }
4857
4858 #[test]
4861 fn parse_value_text_file_value_maps_to_document() {
4862 let obj = serde_json::json!({
4863 "@type": "knora-api:TextFileValue",
4864 "knora-api:fileValueHasFilename": "text.txt",
4865 "knora-api:fileValueAsUrl": {"@value": "https://example.com/text.txt"}
4866 });
4867 let (content, _) = super::parse_value_content(&obj);
4868 if let ValueContent::File(fv) = content {
4869 assert_eq!(
4870 fv.value_type,
4871 ValueType::Document,
4872 "TextFileValue → Document"
4873 );
4874 } else {
4875 panic!("expected File, got {content:?}");
4876 }
4877 }
4878
4879 #[test]
4882 fn parse_value_interval_raw_fallback() {
4883 let obj = serde_json::json!({
4884 "@type": "knora-api:IntervalValue",
4885 "knora-api:intervalValueHasStart": {"@value": "0.0", "@type": "xsd:decimal"},
4886 "knora-api:intervalValueHasEnd": {"@value": "10.5", "@type": "xsd:decimal"},
4887 "knora-api:valueAsString": "0.0 - 10.5"
4888 });
4889 let (content, is_link) = super::parse_value_content(&obj);
4890 assert!(!is_link);
4891 assert!(
4892 matches!(content, ValueContent::Raw { ref value_type, .. } if value_type == "interval"),
4893 "IntervalValue must degrade to Raw with token 'interval'"
4894 );
4895 if let ValueContent::Raw { text, .. } = content {
4896 assert_eq!(text, "0.0 - 10.5");
4897 }
4898 }
4899
4900 #[test]
4901 fn parse_value_geom_raw_fallback() {
4902 let obj = serde_json::json!({
4903 "@type": "knora-api:GeomValue",
4904 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
4905 });
4906 let (content, _) = super::parse_value_content(&obj);
4907 assert!(
4908 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "geom"),
4909 "GeomValue must degrade to Raw with token 'geom'"
4910 );
4911 }
4912
4913 #[test]
4916 fn parse_value_with_comment() {
4917 let obj = serde_json::json!({
4918 "@type": "knora-api:TextValue",
4919 "knora-api:valueAsString": "Hello world",
4920 "knora-api:valueHasComment": "reading uncertain"
4921 });
4922 let (value, is_link) = super::parse_value(&obj);
4923 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4924 assert_eq!(value.comment.as_deref(), Some("reading uncertain"));
4925 assert!(!is_link);
4926 }
4927
4928 #[test]
4929 fn parse_value_without_comment() {
4930 let obj = serde_json::json!({
4931 "@type": "knora-api:TextValue",
4932 "knora-api:valueAsString": "Hello world"
4933 });
4934 let (value, is_link) = super::parse_value(&obj);
4935 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4936 assert_eq!(value.comment, None);
4937 assert!(!is_link);
4938 }
4939
4940 #[test]
4941 fn parse_value_with_empty_comment() {
4942 let obj = serde_json::json!({
4943 "@type": "knora-api:TextValue",
4944 "knora-api:valueAsString": "Hello world",
4945 "knora-api:valueHasComment": ""
4946 });
4947 let (value, is_link) = super::parse_value(&obj);
4948 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4949 assert_eq!(value.comment, None);
4950 assert!(!is_link);
4951 }
4952
4953 #[test]
4956 fn parse_value_link_is_link_true() {
4957 let obj = serde_json::json!({
4959 "@type": "knora-api:LinkValue",
4960 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4961 });
4962 let (_, is_link) = super::parse_value_content(&obj);
4963 assert!(
4964 is_link,
4965 "LinkValue must report is_link=true for name derivation"
4966 );
4967 }
4968
4969 #[test]
4970 fn field_name_link_strips_value_suffix() {
4971 let key = "incunabula:isPartOfBookValue";
4975 let link_obj = serde_json::json!({
4976 "@type": "knora-api:LinkValue",
4977 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4978 });
4979 let (_, is_link) = super::parse_value_content(&link_obj);
4980 assert!(
4981 is_link,
4982 "LinkValue must report is_link=true for name derivation"
4983 );
4984
4985 let raw_name = super::local_name(key).to_string();
4986 let name = if is_link {
4988 raw_name
4989 .strip_suffix("Value")
4990 .unwrap_or(&raw_name)
4991 .to_string()
4992 } else {
4993 raw_name
4994 };
4995 assert_eq!(name, "isPartOfBook");
4996 }
4997
4998 #[test]
4999 fn field_name_non_link_does_not_strip_value_suffix() {
5000 let key = "incunabula:hasAValue";
5005 let text_obj = serde_json::json!({
5006 "@type": "knora-api:TextValue",
5007 "knora-api:valueAsString": "some text"
5008 });
5009 let (_, is_link) = super::parse_value_content(&text_obj);
5010 assert!(!is_link, "TextValue must report is_link=false");
5011
5012 let raw_name = super::local_name(key).to_string();
5013 let name = if is_link {
5015 raw_name
5016 .strip_suffix("Value")
5017 .unwrap_or(&raw_name)
5018 .to_string()
5019 } else {
5020 raw_name
5021 };
5022 assert_eq!(
5023 name, "hasAValue",
5024 "non-link ending in Value must NOT be stripped; is_link={is_link}"
5025 );
5026 }
5027
5028 #[test]
5031 fn has_value_class_type_rejects_xsd_any_uri() {
5032 let obj = serde_json::json!({
5034 "@value": "http://ark.dasch.swiss/ark:/…",
5035 "@type": "xsd:anyURI"
5036 });
5037 assert!(
5038 !super::has_value_class_type(&obj),
5039 "xsd:anyURI must not pass the value-class test"
5040 );
5041 }
5042
5043 #[test]
5044 fn has_value_class_type_rejects_scalar() {
5045 let obj = serde_json::json!("just a string");
5047 assert!(!super::has_value_class_type(&obj));
5048 }
5049
5050 #[test]
5051 fn has_value_class_type_accepts_text_value() {
5052 let obj = serde_json::json!({
5053 "@type": "knora-api:TextValue",
5054 "knora-api:valueAsString": "hello"
5055 });
5056 assert!(super::has_value_class_type(&obj));
5057 }
5058
5059 #[test]
5060 fn has_value_class_type_accepts_still_image_file_value() {
5061 let obj = serde_json::json!({
5062 "@type": "knora-api:StillImageFileValue",
5063 "knora-api:fileValueHasFilename": "img.jp2"
5064 });
5065 assert!(super::has_value_class_type(&obj));
5066 }
5067
5068 #[test]
5071 fn build_prefix_map_string_entries_only() {
5072 let ctx = Some(serde_json::json!({
5073 "incunabula": "http://api.dasch.swiss/ontology/0803/incunabula/v2#",
5074 "knora-api": "http://api.knora.org/ontology/knora-api/v2#",
5075 "someterm": {"@id": "http://example.com/term", "@type": "@id"}
5077 }));
5078 let map = super::build_prefix_map(&ctx);
5079 assert_eq!(
5080 map.get("incunabula").map(String::as_str),
5081 Some("http://api.dasch.swiss/ontology/0803/incunabula/v2#")
5082 );
5083 assert_eq!(
5084 map.get("knora-api").map(String::as_str),
5085 Some("http://api.knora.org/ontology/knora-api/v2#")
5086 );
5087 assert!(
5088 !map.contains_key("someterm"),
5089 "object-valued entry must be skipped"
5090 );
5091 }
5092
5093 #[test]
5094 fn build_prefix_map_empty_when_no_context() {
5095 let map = super::build_prefix_map(&None);
5096 assert!(map.is_empty());
5097 }
5098
5099 #[test]
5102 fn compact_value_text_excludes_meta_keys() {
5103 let obj = serde_json::json!({
5104 "@id": "http://rdfh.ch/0803/val1",
5105 "@type": "knora-api:GeomValue",
5106 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
5107 });
5108 let text = super::compact_value_text(&obj);
5109 assert!(
5111 text.contains("geometryValueAsGeometry"),
5112 "geometry key present: {text}"
5113 );
5114 assert!(!text.contains("@id"), "@id must be excluded: {text}");
5115 assert!(!text.contains("@type"), "@type must be excluded: {text}");
5116 }
5117
5118 #[test]
5119 fn compact_value_text_all_meta_yields_empty() {
5120 let obj = serde_json::json!({
5121 "@id": "http://rdfh.ch/0803/val1",
5122 "@type": "knora-api:IntervalValue"
5123 });
5124 let text = super::compact_value_text(&obj);
5125 assert!(
5126 text.is_empty(),
5127 "all-meta object must yield empty string: {text:?}"
5128 );
5129 }
5130}