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 .user_agent(crate::util::USER_AGENT)
1159 .build()
1160 .map_err(|e| Diagnostic::Internal(format!("failed to build HTTP client: {e}")))?;
1161 let download_client = reqwest::blocking::Client::builder()
1162 .connect_timeout(Some(Duration::from_secs(30)))
1163 .timeout(None)
1164 .user_agent(crate::util::USER_AGENT)
1165 .build()
1166 .map_err(|e| {
1167 Diagnostic::Internal(format!("failed to build download HTTP client: {e}"))
1168 })?;
1169 Ok(Self {
1170 client,
1171 download_client,
1172 })
1173 }
1174
1175 fn fetch_allentities(
1185 &self,
1186 server: &str,
1187 ontology_iri: &str,
1188 token: Option<&str>,
1189 ) -> Result<OntologyAllEntitiesResponse, Diagnostic> {
1190 let url = format!(
1191 "{}/v2/ontologies/allentities/{}",
1192 server.trim_end_matches('/'),
1193 enc(ontology_iri)
1194 );
1195
1196 let req = self.client.get(&url);
1197 let req = if let Some(t) = token {
1198 req.bearer_auth(t)
1199 } else {
1200 req
1201 };
1202
1203 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
1204 let status = response.status();
1205
1206 if status.is_success() {
1207 let resp: OntologyAllEntitiesResponse = response.json().map_err(|e| {
1208 Diagnostic::ServerError(format!("data-model response could not be parsed: {e}"))
1209 })?;
1210 Ok(resp)
1211 } else {
1212 Err(map_unexpected_status(status, &url))
1213 }
1214 }
1215}
1216
1217impl HttpDspClient {
1218 fn parse_resource_values(
1230 &self,
1231 server: &str,
1232 token: Option<&str>,
1233 context_val: &Option<serde_json::Value>,
1234 extra: &serde_json::Map<String, serde_json::Value>,
1235 ) -> Vec<FieldValues> {
1236 let prefixes: HashMap<String, String> = build_prefix_map(context_val);
1238
1239 const DENYLIST: &[&str] = &[
1242 "knora-api:hasIncomingLinkValue",
1243 "knora-api:hasStandoffLinkToValue",
1244 "knora-api:hasStandoffLinkValue", ];
1246
1247 let mut field_entries: Vec<(&str, Vec<&serde_json::Value>)> = Vec::new();
1250
1251 for (key, val) in extra.iter() {
1252 if DENYLIST.contains(&key.as_str()) {
1253 continue;
1254 }
1255
1256 let objs: Vec<&serde_json::Value> = match val {
1258 serde_json::Value::Array(arr) => arr.iter().collect(),
1259 obj @ serde_json::Value::Object(_) => vec![obj],
1260 _ => continue, };
1262
1263 if objs.is_empty() {
1264 continue;
1265 }
1266
1267 let first = match objs.first() {
1270 Some(v) => v,
1271 None => continue,
1272 };
1273 if !has_value_class_type(first) {
1274 continue;
1275 }
1276
1277 field_entries.push((key.as_str(), objs));
1278 }
1279
1280 struct ParsedField<'a> {
1283 key: &'a str,
1284 is_link: bool,
1285 values: Vec<Value>,
1286 }
1287
1288 let mut parsed_fields: Vec<ParsedField> = Vec::new();
1289
1290 for (key, objs) in &field_entries {
1291 let mut contents: Vec<Value> = Vec::new();
1292 let mut any_link = false;
1293
1294 for obj in objs {
1295 if get_type_local(obj) == "DeletedValue" {
1297 continue;
1298 }
1299 let (content, is_link) = parse_value(obj);
1300 if is_link {
1301 any_link = true;
1302 }
1303 contents.push(content);
1304 }
1305
1306 if contents.is_empty() {
1307 continue;
1308 }
1309
1310 parsed_fields.push(ParsedField {
1311 key,
1312 is_link: any_link,
1313 values: contents,
1314 });
1315 }
1316
1317 let mut ontology_labels: HashMap<String, HashMap<String, String>> = HashMap::new(); let mut fetched_ontologies: HashSet<String> = HashSet::new();
1322
1323 for pf in &parsed_fields {
1324 let prefix = curie_prefix(pf.key).unwrap_or("");
1325 if is_system_prefix(prefix) || prefix.is_empty() {
1326 continue; }
1328 let namespace = match prefixes.get(prefix) {
1330 Some(ns) => ns,
1331 None => continue,
1332 };
1333 let ont_iri = namespace.trim_end_matches(['#', '/']).to_string();
1334 if fetched_ontologies.insert(ont_iri.clone()) {
1335 match self.fetch_allentities(server, &ont_iri, token) {
1339 Ok(resp) => {
1340 let mut prop_map: HashMap<String, String> = HashMap::new();
1341 let ctx_prefixes: HashMap<String, String> = resp
1342 .context
1343 .iter()
1344 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1345 .collect();
1346 for entity in resp.graph {
1347 if let Some(lbl) = entity.label {
1348 let (_, iri) = expand_class_id(&entity.id, &ctx_prefixes);
1349 prop_map.insert(iri, lbl);
1350 }
1351 }
1352 ontology_labels.insert(ont_iri, prop_map);
1353 }
1354 Err(e) => {
1355 tracing::warn!(
1357 prefix = %prefix,
1358 error = %e,
1359 "field-label ontology fetch failed; using local name as fallback"
1360 );
1361 }
1362 }
1363 }
1364 }
1365
1366 let mut node_labels: HashMap<String, Option<String>> = HashMap::new();
1368
1369 for pf in &parsed_fields {
1371 for v in &pf.values {
1372 if let ValueContent::ListItem { node_iri, .. } = &v.content {
1373 node_labels.entry(node_iri.clone()).or_insert(None);
1374 }
1375 }
1376 }
1377
1378 for (node_iri, label_slot) in node_labels.iter_mut() {
1380 let url = format!("{}/v2/node/{}", server.trim_end_matches('/'), enc(node_iri));
1384 let req = self.client.get(&url);
1385 let req = if let Some(t) = token {
1386 req.bearer_auth(t)
1387 } else {
1388 req
1389 };
1390 match req.send() {
1391 Ok(resp) if resp.status().is_success() => {
1392 match resp.json::<serde_json::Value>() {
1394 Ok(body) => {
1395 let lbl = body.get("rdfs:label").and_then(extract_string_value);
1397 *label_slot = lbl;
1398 }
1399 Err(_) => {
1400 tracing::debug!(
1401 node_iri = %node_iri,
1402 "list-node label response could not be parsed as JSON; using node IRI as fallback"
1403 );
1404 }
1405 }
1406 }
1407 Ok(resp) => {
1408 tracing::debug!(
1410 node_iri = %node_iri,
1411 status = %resp.status(),
1412 "list-node label fetch returned non-success; using node IRI as fallback"
1413 );
1414 }
1415 Err(e) => {
1416 tracing::debug!(
1417 node_iri = %node_iri,
1418 error = %e,
1419 "list-node label fetch failed; using node IRI as fallback"
1420 );
1421 }
1422 }
1423 }
1424
1425 let mut result: Vec<FieldValues> = Vec::new();
1427
1428 for pf in parsed_fields {
1429 let raw_name = local_name(pf.key).to_string();
1431 let name = if pf.is_link {
1432 raw_name
1433 .strip_suffix("Value")
1434 .unwrap_or(&raw_name)
1435 .to_string()
1436 } else {
1437 raw_name
1438 };
1439
1440 let label: Option<String> = {
1442 let prefix = curie_prefix(pf.key).unwrap_or("");
1443 if is_system_prefix(prefix) || prefix.is_empty() {
1444 None
1445 } else if let Some(ns) = prefixes.get(prefix) {
1446 let ont_iri = ns.trim_end_matches(['#', '/']).to_string();
1447 let local = local_name(pf.key);
1448 let prop_iri = format!("{}{}", ns, local);
1449 ontology_labels
1450 .get(&ont_iri)
1451 .and_then(|m| m.get(&prop_iri).cloned())
1452 } else {
1453 None
1454 }
1455 };
1456
1457 let values: Vec<Value> = pf
1459 .values
1460 .into_iter()
1461 .map(|v| match v.content {
1462 ValueContent::ListItem { node_iri, label: _ } => {
1463 let resolved = node_labels.get(&node_iri).cloned().flatten();
1464 Value {
1465 content: ValueContent::ListItem {
1466 node_iri,
1467 label: resolved,
1468 },
1469 comment: v.comment,
1470 }
1471 }
1472 other => Value {
1473 content: other,
1474 comment: v.comment,
1475 },
1476 })
1477 .collect();
1478
1479 result.push(FieldValues {
1480 name,
1481 label,
1482 values,
1483 });
1484 }
1485
1486 result
1487 }
1488}
1489
1490fn has_value_class_type(val: &serde_json::Value) -> bool {
1498 let type_local = get_type_local(val);
1499 type_local.ends_with("Value") && !type_local.is_empty() && {
1502 let raw_type = val
1504 .as_object()
1505 .and_then(|m| m.get("@type"))
1506 .and_then(|t| t.as_str())
1507 .unwrap_or("");
1508 raw_type.starts_with("knora-api:")
1509 }
1510}
1511
1512fn get_type_local(val: &serde_json::Value) -> &str {
1516 val.as_object()
1517 .and_then(|m| m.get("@type"))
1518 .and_then(|t| t.as_str())
1519 .map(local_name)
1520 .unwrap_or("")
1521}
1522
1523fn build_prefix_map(context_val: &Option<serde_json::Value>) -> HashMap<String, String> {
1529 match context_val {
1530 Some(serde_json::Value::Object(map)) => map
1531 .iter()
1532 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1533 .collect(),
1534 _ => HashMap::new(),
1535 }
1536}
1537
1538fn parse_value_content(obj: &serde_json::Value) -> (ValueContent, bool) {
1545 let type_local = get_type_local(obj);
1546
1547 match type_local {
1548 "TextValue" => {
1550 let content =
1553 if let Some(xml) = obj.get("knora-api:textValueAsXml").and_then(|v| v.as_str()) {
1554 crate::util::text::html_to_text(xml)
1555 } else {
1556 obj.get("knora-api:valueAsString")
1557 .and_then(|v| v.as_str())
1558 .unwrap_or("")
1559 .to_string()
1560 };
1561 (ValueContent::Text(content), false)
1562 }
1563
1564 "IntValue" => {
1566 let n = obj
1567 .get("knora-api:intValueAsInt")
1568 .and_then(|v| v.as_i64())
1569 .unwrap_or(0);
1570 (ValueContent::Integer(n), false)
1571 }
1572
1573 "DecimalValue" => {
1575 let s = obj
1577 .get("knora-api:decimalValueAsDecimal")
1578 .and_then(|v| {
1579 if let Some(s) = v.as_str() {
1581 Some(s.to_string())
1582 } else {
1583 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1584 }
1585 })
1586 .unwrap_or_default();
1587 (ValueContent::Decimal(s), false)
1588 }
1589
1590 "BooleanValue" => {
1592 let b = obj
1593 .get("knora-api:booleanValueAsBoolean")
1594 .and_then(|v| v.as_bool())
1595 .unwrap_or(false);
1596 (ValueContent::Boolean(b), false)
1597 }
1598
1599 "DateValue" => {
1601 let calendar = obj
1602 .get("knora-api:dateValueHasCalendar")
1603 .and_then(|v| v.as_str())
1604 .unwrap_or("GREGORIAN")
1605 .to_string();
1606
1607 let parse_point = |prefix: &str| -> DatePoint {
1608 let year_key = format!("knora-api:{prefix}Year");
1609 let month_key = format!("knora-api:{prefix}Month");
1610 let day_key = format!("knora-api:{prefix}Day");
1611 let era_key = format!("knora-api:{prefix}Era");
1612
1613 DatePoint {
1614 year: obj
1615 .get(year_key.as_str())
1616 .and_then(|v| v.as_i64())
1617 .map(|v| v as i32),
1618 month: obj
1619 .get(month_key.as_str())
1620 .and_then(|v| v.as_u64())
1621 .map(|v| v as u32),
1622 day: obj
1623 .get(day_key.as_str())
1624 .and_then(|v| v.as_u64())
1625 .map(|v| v as u32),
1626 era: obj
1627 .get(era_key.as_str())
1628 .and_then(|v| v.as_str())
1629 .map(str::to_owned),
1630 }
1631 };
1632
1633 let start = parse_point("dateValueHasStart");
1636 let end = parse_point("dateValueHasEnd");
1637
1638 if start.year.is_none() && end.year.is_none() {
1639 let raw_text = obj
1641 .get("knora-api:valueAsString")
1642 .and_then(|v| v.as_str())
1643 .unwrap_or("")
1644 .to_string();
1645 return (
1646 ValueContent::Raw {
1647 value_type: "date".to_string(),
1648 text: raw_text,
1649 },
1650 false,
1651 );
1652 }
1653
1654 (
1655 ValueContent::Date(DateValue {
1656 calendar,
1657 start,
1658 end,
1659 }),
1660 false,
1661 )
1662 }
1663
1664 "TimeValue" => {
1666 let s = obj
1667 .get("knora-api:timeValueAsTimeStamp")
1668 .and_then(|v| {
1669 if let Some(s) = v.as_str() {
1670 Some(s.to_string())
1671 } else {
1672 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1673 }
1674 })
1675 .unwrap_or_default();
1676 (ValueContent::Time(s), false)
1677 }
1678
1679 "UriValue" => {
1681 let s = obj
1682 .get("knora-api:uriValueAsUri")
1683 .and_then(|v| {
1684 if let Some(s) = v.as_str() {
1685 Some(s.to_string())
1686 } else {
1687 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1688 }
1689 })
1690 .unwrap_or_default();
1691 (ValueContent::Uri(s), false)
1692 }
1693
1694 "ColorValue" => {
1696 let s = obj
1697 .get("knora-api:colorValueAsColor")
1698 .and_then(|v| v.as_str())
1699 .unwrap_or("")
1700 .to_string();
1701 (ValueContent::Color(s), false)
1702 }
1703
1704 "GeonameValue" => {
1706 let s = obj
1707 .get("knora-api:geonameValueAsGeonameCode")
1708 .and_then(|v| v.as_str())
1709 .unwrap_or("")
1710 .to_string();
1711 (ValueContent::Geoname(s), false)
1712 }
1713
1714 "ListValue" => {
1716 let node_iri = obj
1718 .get("knora-api:listValueAsListNode")
1719 .and_then(|v| v.get("@id"))
1720 .and_then(|v| v.as_str())
1721 .unwrap_or("")
1722 .to_string();
1723 (
1724 ValueContent::ListItem {
1725 node_iri,
1726 label: None, },
1728 false,
1729 )
1730 }
1731
1732 "LinkValue" => {
1734 let (target_iri, target_label) =
1737 if let Some(target_obj) = obj.get("knora-api:linkValueHasTarget") {
1738 let iri = target_obj
1739 .get("@id")
1740 .and_then(|v| v.as_str())
1741 .unwrap_or("")
1742 .to_string();
1743 let lbl = target_obj.get("rdfs:label").and_then(extract_string_value);
1744 (iri, lbl)
1745 } else {
1746 let iri = obj
1747 .get("knora-api:linkValueHasTargetIri")
1748 .and_then(|v| v.get("@id"))
1749 .and_then(|v| v.as_str())
1750 .unwrap_or("")
1751 .to_string();
1752 (iri, None)
1753 };
1754 (
1755 ValueContent::Link {
1756 target_iri,
1757 target_label,
1758 },
1759 true, )
1761 }
1762
1763 t if t.ends_with("FileValue") => {
1766 let filename = obj
1767 .get("knora-api:fileValueHasFilename")
1768 .and_then(|v| v.as_str())
1769 .unwrap_or("")
1770 .to_string();
1771 let url_str = obj
1772 .get("knora-api:fileValueAsUrl")
1773 .and_then(|v| {
1774 if let Some(s) = v.as_str() {
1775 Some(s.to_string())
1776 } else {
1777 v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1778 }
1779 })
1780 .unwrap_or_default();
1781
1782 let value_type_opt = if t.starts_with("StillImage") {
1784 Some(ValueType::StillImage)
1785 } else if t.starts_with("MovingImage") {
1786 Some(ValueType::MovingImage)
1787 } else if t.starts_with("Audio") {
1788 Some(ValueType::Audio)
1789 } else if t.starts_with("Document") || t.starts_with("Text") {
1790 Some(ValueType::Document)
1792 } else if t.starts_with("Archive") {
1793 Some(ValueType::Archive)
1794 } else {
1795 None };
1797
1798 match value_type_opt {
1799 Some(vt) => {
1800 let (width, height) = if vt == ValueType::StillImage {
1802 let w = obj
1803 .get("knora-api:stillImageFileValueHasDimX")
1804 .and_then(|v| v.as_u64())
1805 .map(|v| v as u32);
1806 let h = obj
1807 .get("knora-api:stillImageFileValueHasDimY")
1808 .and_then(|v| v.as_u64())
1809 .map(|v| v as u32);
1810 (w, h)
1811 } else {
1812 (None, None)
1813 };
1814 (
1815 ValueContent::File(FileValue {
1816 value_type: vt,
1817 filename,
1818 url: url_str,
1819 width,
1820 height,
1821 }),
1822 false,
1823 )
1824 }
1825 None => {
1826 let raw_text = obj
1828 .get("knora-api:valueAsString")
1829 .and_then(|v| v.as_str())
1830 .unwrap_or(&filename)
1831 .to_string();
1832 (
1833 ValueContent::Raw {
1834 value_type: object_type_to_kebab(t),
1835 text: raw_text,
1836 },
1837 false,
1838 )
1839 }
1840 }
1841 }
1842
1843 other => {
1845 let value_type = object_type_to_kebab(other);
1846 let raw_text = obj
1849 .get("knora-api:valueAsString")
1850 .and_then(|v| v.as_str())
1851 .map(str::to_owned)
1852 .unwrap_or_else(|| compact_value_text(obj));
1853 (
1854 ValueContent::Raw {
1855 value_type,
1856 text: raw_text,
1857 },
1858 false,
1859 )
1860 }
1861 }
1862}
1863
1864fn parse_value(obj: &serde_json::Value) -> (Value, bool) {
1870 let (content, is_link) = parse_value_content(obj);
1871 let comment = obj
1872 .get("knora-api:valueHasComment")
1873 .and_then(|v| v.as_str())
1874 .filter(|s| !s.trim().is_empty())
1875 .map(str::to_owned);
1876 (Value { content, comment }, is_link)
1877}
1878
1879const VALUE_META_KEYS: &[&str] = &[
1881 "@id",
1882 "@type",
1883 "knora-api:attachedToUser",
1884 "knora-api:hasPermissions",
1885 "knora-api:userHasPermission",
1886 "knora-api:valueCreationDate",
1887 "knora-api:valueHasComment",
1888 "knora-api:isDeleted",
1889 "knora-api:arkUrl",
1890 "knora-api:versionArkUrl",
1891 "knora-api:valueHasUUID",
1892];
1893
1894fn compact_value_text(obj: &serde_json::Value) -> String {
1899 if let Some(map) = obj.as_object() {
1900 let filtered: serde_json::Map<String, serde_json::Value> = map
1901 .iter()
1902 .filter(|(k, _)| !VALUE_META_KEYS.contains(&k.as_str()))
1903 .map(|(k, v)| (k.clone(), v.clone()))
1904 .collect();
1905 if filtered.is_empty() {
1906 String::new()
1907 } else {
1908 serde_json::to_string(&serde_json::Value::Object(filtered)).unwrap_or_default()
1909 }
1910 } else {
1911 String::new()
1912 }
1913}
1914
1915impl DspClient for HttpDspClient {
1916 fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic> {
1917 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
1918
1919 let mut body = serde_json::Map::with_capacity(2);
1920 body.insert(
1921 identifier_key(user).to_owned(),
1922 serde_json::Value::from(user),
1923 );
1924 body.insert("password".to_owned(), serde_json::Value::from(password));
1925
1926 let response = self
1927 .client
1928 .post(&url)
1929 .json(&body)
1930 .send()
1931 .map_err(|e| Diagnostic::Network(e.to_string()))?;
1932
1933 let status = response.status();
1934
1935 if status.is_success() {
1936 let api: LoginApiResponse = response.json().map_err(|e| {
1937 Diagnostic::ServerError(format!("login response could not be parsed: {e}"))
1938 })?;
1939 let expires_at = extract_exp(&api.token);
1940 Ok(LoginResponse {
1941 token: api.token,
1942 user: user.to_string(),
1943 expires_at,
1944 })
1945 } else if status == reqwest::StatusCode::UNAUTHORIZED
1946 || status == reqwest::StatusCode::FORBIDDEN
1947 {
1948 let body = response.text().unwrap_or_default();
1949 let preview: String = body.chars().take(200).collect();
1950 tracing::trace!("auth failure response body (capped): {}", preview);
1951 Err(Diagnostic::AuthRequired(format!(
1953 "Authentication failed on {server}"
1954 )))
1955 } else if status == reqwest::StatusCode::NOT_FOUND {
1956 Err(Diagnostic::NotFound(format!(
1957 "endpoint not found at {url}; check that --server resolves to a DSP-API instance, not just any HTTPS host"
1958 )))
1959 } else if status.is_server_error() {
1960 let body = response.text().unwrap_or_default();
1961 let preview: String = body.chars().take(200).collect();
1962 tracing::trace!("server error response body (capped): {}", preview);
1963 Err(Diagnostic::ServerError(format!("server returned {status}")))
1964 } else {
1965 Err(Diagnostic::ServerError(format!(
1966 "unexpected status: {status}"
1967 )))
1968 }
1969 }
1970
1971 fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic> {
1972 let base = server.trim_end_matches('/');
1973
1974 let url = project_lookup_url(base, project);
1975
1976 let response = self
1978 .client
1979 .get(&url)
1980 .send()
1981 .map_err(|e| Diagnostic::Network(e.to_string()))?;
1982
1983 let status = response.status();
1984
1985 if status.is_success() {
1986 let api: ProjectGetApiResponse = response.json().map_err(|e| {
1987 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
1988 })?;
1989 if !is_safe_shortcode(&api.project.shortcode) {
1990 return Err(Diagnostic::ServerError(
1991 "server returned a project with an unexpected shortcode".into(),
1992 ));
1993 }
1994 Ok(ProjectRef {
1995 iri: api.project.id,
1996 shortcode: api.project.shortcode,
1997 shortname: api.project.shortname,
1998 })
1999 } else if status == reqwest::StatusCode::NOT_FOUND {
2000 let display_input: String = project.chars().take(80).collect();
2002 let suffix = if project.chars().count() > 80 {
2003 "…"
2004 } else {
2005 ""
2006 };
2007 Err(Diagnostic::NotFound(format!(
2008 "project '{display_input}{suffix}' not found on {server}"
2009 )))
2010 } else {
2011 Err(map_unexpected_status(status, &url))
2012 }
2013 }
2014
2015 fn create_project_dump(
2016 &self,
2017 server: &str,
2018 project_iri: &str,
2019 skip_assets: bool,
2020 token: &str,
2021 ) -> Result<CreateDumpOutcome, Diagnostic> {
2022 let base = server.trim_end_matches('/');
2023 let url = format!(
2028 "{base}/v3/projects/{}/exports?skipAssets={skip_assets}",
2029 enc(project_iri)
2030 );
2031
2032 let response = self
2033 .client
2034 .post(&url)
2035 .bearer_auth(token)
2036 .send()
2037 .map_err(|e: reqwest::Error| Diagnostic::Network(e.to_string()))?;
2038
2039 let status = response.status();
2040
2041 match status.as_u16() {
2042 202 => {
2043 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2044 Diagnostic::ServerError(format!(
2045 "dump trigger response could not be parsed: {e}"
2046 ))
2047 })?;
2048 api.into_dump_task().map(CreateDumpOutcome::Created)
2049 }
2050 409 => {
2051 let body_text = response.text().unwrap_or_default();
2061 let error_body: Option<V3ErrorBody> = if body_text.len() <= 65536 {
2062 serde_json::from_str(&body_text).ok()
2063 } else {
2064 None
2065 };
2066 match error_body.as_ref().and_then(|b| b.export_exists()) {
2067 Some(ex) => {
2068 let id = ex.id.ok_or_else(|| {
2071 Diagnostic::ServerError(
2072 "the server's dump-conflict response was missing the dump id"
2073 .into(),
2074 )
2075 })?;
2076 validate_dump_id(id)?;
2077 match ex.project_iri {
2083 Some(owner) if owner == project_iri => {
2084 Ok(CreateDumpOutcome::Exists { id: id.to_string() })
2085 }
2086 Some(owner) => Ok(CreateDumpOutcome::ExistsForOtherProject {
2087 id: id.to_string(),
2088 project_iri: owner.to_string(),
2089 }),
2090 None => Err(Diagnostic::ServerError(
2093 "the server's dump-conflict response did not identify which \
2094project owns the existing dump; cannot safely proceed"
2095 .into(),
2096 )),
2097 }
2098 }
2099 None => Err(Diagnostic::ServerError(
2101 "server reported a 409 conflict whose detail could not be parsed".into(),
2103 )),
2104 }
2105 }
2106 401 | 403 => Err(Diagnostic::AuthRequired(
2107 "triggering a project dump requires a system-administrator token".into(),
2108 )),
2109 404 => Err(Diagnostic::NotFound(format!("project not found at {url}"))),
2110 _ => Err(map_unexpected_status(status, &url)),
2111 }
2112 }
2113
2114 fn get_project_dump_status(
2115 &self,
2116 server: &str,
2117 project_iri: &str,
2118 dump_id: &str,
2119 token: &str,
2120 ) -> Result<DumpTask, Diagnostic> {
2121 validate_dump_id(dump_id)?;
2122 let base = server.trim_end_matches('/');
2123 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2125
2126 let response = self
2127 .client
2128 .get(&url)
2129 .bearer_auth(token)
2130 .send()
2131 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2132
2133 let status = response.status();
2134
2135 match status.as_u16() {
2136 200 => {
2137 let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2138 Diagnostic::ServerError(format!(
2139 "dump status response could not be parsed: {e}"
2140 ))
2141 })?;
2142 api.into_dump_task()
2143 }
2144 404 => Err(Diagnostic::NotFound(format!(
2145 "dump '{dump_id}' not found for project at {url}"
2146 ))),
2147 401 | 403 => Err(Diagnostic::AuthRequired(
2148 "fetching dump status requires a system-administrator token".into(),
2149 )),
2150 _ => Err(map_unexpected_status(status, &url)),
2151 }
2152 }
2153
2154 fn download_project_dump(
2155 &self,
2156 server: &str,
2157 project_iri: &str,
2158 dump_id: &str,
2159 token: &str,
2160 dest: &mut dyn Write,
2161 ) -> Result<u64, Diagnostic> {
2162 validate_dump_id(dump_id)?;
2163 let base = server.trim_end_matches('/');
2164 let url = format!(
2166 "{base}/v3/projects/{}/exports/{dump_id}/download",
2167 enc(project_iri)
2168 );
2169
2170 let mut response = self
2172 .download_client
2173 .get(&url)
2174 .bearer_auth(token)
2175 .send()
2176 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2177
2178 let status = response.status();
2179
2180 match status.as_u16() {
2183 200 => {
2184 let mut buf = [0u8; 64 * 1024];
2188 let mut total: u64 = 0;
2189 loop {
2190 let n = response
2191 .read(&mut buf)
2192 .map_err(|e| Diagnostic::Network(format!("download interrupted: {e}")))?;
2193 if n == 0 {
2194 break;
2195 }
2196 dest.write_all(&buf[..n]).map_err(|e| {
2197 Diagnostic::Io(format!("failed to write dump to disk: {e}"))
2198 })?;
2199 total += n as u64;
2200 }
2201 Ok(total)
2202 }
2203 409 => Err(Diagnostic::Conflict(
2204 "dump not ready — still in progress or failed".into(),
2205 )),
2206 404 => Err(Diagnostic::NotFound(format!(
2207 "dump '{dump_id}' not found at {url}"
2208 ))),
2209 401 | 403 => Err(Diagnostic::AuthRequired(
2210 "downloading a project dump requires a system-administrator token".into(),
2211 )),
2212 _ => Err(map_unexpected_status(status, &url)),
2213 }
2214 }
2215
2216 fn delete_project_dump(
2217 &self,
2218 server: &str,
2219 project_iri: &str,
2220 dump_id: &str,
2221 token: &str,
2222 ) -> Result<(), Diagnostic> {
2223 validate_dump_id(dump_id)?;
2224 let base = server.trim_end_matches('/');
2225 let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2227
2228 let response = self
2229 .client
2230 .delete(&url)
2231 .bearer_auth(token)
2232 .send()
2233 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2234
2235 let status = response.status();
2236
2237 match status.as_u16() {
2238 204 => Ok(()),
2239 409 => Err(Diagnostic::Conflict(
2240 "dump is still in progress and cannot be deleted yet".into(),
2241 )),
2242 404 => Err(Diagnostic::NotFound(format!(
2243 "dump '{dump_id}' not found at {url}"
2244 ))),
2245 401 | 403 => Err(Diagnostic::AuthRequired(
2246 "deleting a project dump requires a system-administrator token".into(),
2247 )),
2248 _ => Err(map_unexpected_status(status, &url)),
2249 }
2250 }
2251
2252 fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic> {
2253 let base = server.trim_end_matches('/');
2254 let url = format!("{base}/admin/projects");
2255
2256 let req = self.client.get(&url);
2262 let req = if let Some(t) = token {
2263 req.bearer_auth(t)
2264 } else {
2265 req
2266 };
2267
2268 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2269
2270 let status = response.status();
2271
2272 if status.is_success() {
2273 let api: ProjectsListApiResponse = response.json().map_err(|e| {
2274 Diagnostic::ServerError(format!("projects list response could not be parsed: {e}"))
2275 })?;
2276 let projects = api
2277 .projects
2278 .into_iter()
2279 .map(|dto| Project {
2280 iri: dto.id,
2281 shortcode: dto.shortcode,
2282 shortname: dto.shortname,
2283 longname: dto.longname,
2284 status: if dto.status {
2288 ProjectStatus::Active
2289 } else {
2290 ProjectStatus::Inactive
2291 },
2292 data_models: dto.ontologies.len(),
2295 })
2296 .collect();
2297 Ok(projects)
2298 } else {
2299 Err(map_unexpected_status(status, &url))
2300 }
2301 }
2302
2303 fn describe_project(
2304 &self,
2305 server: &str,
2306 project: &str,
2307 token: Option<&str>,
2308 ) -> Result<ProjectDetail, Diagnostic> {
2309 let base = server.trim_end_matches('/');
2310 let url = project_lookup_url(base, project);
2311
2312 let req = self.client.get(&url);
2316 let req = if let Some(t) = token {
2317 req.bearer_auth(t)
2318 } else {
2319 req
2320 };
2321
2322 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2323
2324 let status = response.status();
2325
2326 if status.is_success() {
2327 let api: ProjectDetailApiResponse = response.json().map_err(|e| {
2328 Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
2329 })?;
2330 let dto = api.project;
2331
2332 let project_status = if dto.status {
2334 ProjectStatus::Active
2335 } else {
2336 ProjectStatus::Inactive
2337 };
2338
2339 let description = dto
2341 .description
2342 .into_iter()
2343 .map(|d| ProjectDescription {
2344 value: d.value,
2345 language: d.language,
2346 })
2347 .collect();
2348
2349 let mut data_models: Vec<DataModelSummary> = dto
2351 .ontologies
2352 .into_iter()
2353 .map(|iri| {
2354 let name = data_model_name_from_iri(&iri);
2355 DataModelSummary { name, iri }
2356 })
2357 .collect();
2358 data_models.sort_by(|a, b| a.name.cmp(&b.name));
2359
2360 Ok(ProjectDetail {
2361 iri: dto.id,
2362 shortcode: dto.shortcode,
2363 shortname: dto.shortname,
2364 longname: dto.longname,
2365 status: project_status,
2366 description,
2367 keywords: dto.keywords,
2368 data_models,
2369 })
2370 } else if status == reqwest::StatusCode::NOT_FOUND {
2371 let display_input: String = project.chars().take(80).collect();
2373 let suffix = if project.chars().count() > 80 {
2374 "…"
2375 } else {
2376 ""
2377 };
2378 Err(Diagnostic::NotFound(format!(
2379 "project '{display_input}{suffix}' not found on {server}. Run `dsp vre project list --server {server}` to see available projects."
2380 )))
2381 } else {
2382 Err(map_unexpected_status(status, &url))
2383 }
2384 }
2385
2386 fn describe_data_model(
2387 &self,
2388 server: &str,
2389 data_model_iri: &str,
2390 token: Option<&str>,
2391 ) -> Result<DataModelDetail, Diagnostic> {
2392 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2393
2394 let prefixes: HashMap<String, String> = resp
2398 .context
2399 .iter()
2400 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2401 .collect();
2402
2403 let mut resource_types: Vec<ResourceTypeSummary> = resp
2404 .graph
2405 .into_iter()
2406 .filter(|dto| dto.is_resource_class)
2407 .map(|dto| {
2408 let (name, iri) = expand_class_id(&dto.id, &prefixes);
2409 ResourceTypeSummary {
2410 name,
2411 iri,
2412 label: dto.label,
2413 }
2414 })
2415 .collect();
2416
2417 resource_types.sort_by(|a, b| a.name.cmp(&b.name));
2418
2419 Ok(DataModelDetail {
2420 name: data_model_name_from_iri(&resp.id),
2421 iri: resp.id,
2422 label: resp.label,
2423 last_modified: resp.last_modification_date.map(|d| d.value),
2424 resource_types,
2425 })
2426 }
2427
2428 fn data_model_structure(
2429 &self,
2430 server: &str,
2431 data_model_iri: &str,
2432 token: Option<&str>,
2433 ) -> Result<DataModelStructure, Diagnostic> {
2434 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2436
2437 let graph_entities: Vec<OntologyEntityDto> = resp.graph;
2438
2439 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2445 let mut class_nodes: Vec<OntologyEntityDto> = Vec::new();
2446 for entity in graph_entities {
2447 if entity.is_resource_class {
2448 class_nodes.push(entity);
2449 } else if entity.object_type.is_some()
2450 || entity.is_link_property
2451 || entity.is_resource_property
2452 {
2453 prop_lookup.insert(entity.id.clone(), entity);
2454 }
2455 }
2456
2457 let mut relations: Vec<Relation> = Vec::new();
2459
2460 for class in &class_nodes {
2461 let source = local_name(&class.id).to_string();
2462
2463 for element in &class.sub_class_of {
2464 if let Some(type_val) = element.get("@type")
2465 && type_val.as_str() == Some("owl:Restriction")
2466 {
2467 let on_prop_id = match element
2469 .get("owl:onProperty")
2470 .and_then(|v| v.get("@id"))
2471 .and_then(serde_json::Value::as_str)
2472 {
2473 Some(s) => s,
2474 None => continue,
2475 };
2476
2477 let node = match prop_lookup.get(on_prop_id) {
2479 Some(n) => n,
2480 None => continue, };
2482
2483 if node.is_link_value_property {
2485 continue;
2486 }
2487
2488 if !node.is_link_property {
2490 continue;
2491 }
2492
2493 let target_id = match node.object_type.as_ref() {
2495 Some(ot) => &ot.id,
2496 None => continue, };
2498 let target = local_name(target_id).to_string();
2499
2500 let t_prefix = curie_prefix(target_id).unwrap_or("");
2501 let target_data_model = if is_system_prefix(t_prefix) || t_prefix.is_empty() {
2502 None
2503 } else {
2504 Some(t_prefix.to_string())
2505 };
2506
2507 let field_prefix = curie_prefix(on_prop_id).unwrap_or("");
2509 let is_builtin = is_system_prefix(field_prefix);
2510
2511 let field = local_name(on_prop_id).to_string();
2512
2513 relations.push(Relation {
2514 source: source.clone(),
2515 target,
2516 kind: RelationKind::Link,
2517 field: Some(field),
2518 target_data_model,
2519 is_builtin,
2520 });
2521 } else if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str)
2522 {
2523 let target = local_name(id_val).to_string();
2528
2529 let sup_prefix = curie_prefix(id_val).unwrap_or("");
2530 let is_builtin = is_system_prefix(sup_prefix);
2531 let target_data_model = if is_system_prefix(sup_prefix) || sup_prefix.is_empty()
2532 {
2533 None
2534 } else {
2535 Some(sup_prefix.to_string())
2536 };
2537
2538 relations.push(Relation {
2539 source: source.clone(),
2540 target,
2541 kind: RelationKind::Inherits,
2542 field: None,
2543 target_data_model,
2544 is_builtin,
2545 });
2546 }
2547 }
2548 }
2549
2550 relations.sort_by(|a, b| {
2554 a.source
2555 .cmp(&b.source)
2556 .then_with(|| a.kind.cmp(&b.kind))
2557 .then_with(|| a.field.cmp(&b.field))
2558 .then_with(|| a.target.cmp(&b.target))
2559 });
2560
2561 Ok(DataModelStructure {
2563 data_model: data_model_name_from_iri(data_model_iri),
2564 relations,
2565 })
2566 }
2567
2568 fn list_resources(
2569 &self,
2570 server: &str,
2571 project_iri: &str,
2572 resource_type_iri: &str,
2573 order_by: Option<&str>,
2574 page: u32,
2575 token: Option<&str>,
2576 ) -> Result<ResourcePage, Diagnostic> {
2577 let base = server.trim_end_matches('/');
2578 let url = format!("{base}/v2/resources");
2579
2580 let mut req = self.client.get(&url).query(&[
2585 ("resourceClass", resource_type_iri),
2586 ("page", &page.to_string()),
2587 ("schema", "complex"),
2588 ]);
2589 if let Some(prop_iri) = order_by {
2592 req = req.query(&[("orderByProperty", prop_iri)]);
2593 }
2594
2595 let header_value = reqwest::header::HeaderValue::from_str(project_iri).map_err(|e| {
2599 Diagnostic::Usage(format!("project IRI is not a valid HTTP header value: {e}"))
2600 })?;
2601 let req = req.header("x-knora-accept-project", header_value);
2602
2603 let req = if let Some(t) = token {
2605 req.bearer_auth(t)
2606 } else {
2607 req
2608 };
2609
2610 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2611 let status = response.status();
2612
2613 if !status.is_success() {
2614 return Err(map_unexpected_status(status, &url));
2615 }
2616
2617 let dto: ResourceListDto = response.json().map_err(|e| {
2618 Diagnostic::ServerError(format!("resource list response could not be parsed: {e}"))
2619 })?;
2620
2621 let may_have_more_results = dto.may_have_more_results;
2622
2623 let resources: Vec<ResourceSummary> = if let Some(graph) = dto.graph {
2628 graph
2629 .into_iter()
2630 .map(|node| {
2631 node_dto_to_summary(
2632 node.id,
2633 node.type_field.as_ref(),
2634 node.label.as_ref(),
2635 node.ark_url.as_ref(),
2636 node.creation_date.as_ref(),
2637 node.last_modification_date.as_ref(),
2638 )
2639 })
2640 .collect()
2641 } else if let Some(id) = dto.id {
2642 vec![node_dto_to_summary(
2644 id,
2645 dto.type_field.as_ref(),
2646 dto.label.as_ref(),
2647 dto.ark_url.as_ref(),
2648 dto.creation_date.as_ref(),
2649 dto.last_modification_date.as_ref(),
2650 )]
2651 } else {
2652 vec![]
2654 };
2655
2656 Ok(ResourcePage {
2657 resources,
2658 may_have_more_results,
2659 })
2660 }
2661
2662 fn describe_resource(
2663 &self,
2664 server: &str,
2665 resource_iri: &str,
2666 token: Option<&str>,
2667 with_values: bool,
2668 ) -> Result<ResourceDetail, Diagnostic> {
2669 let base = server.trim_end_matches('/');
2670 let url = format!("{base}/v2/resources/{}", enc(resource_iri));
2672
2673 let req = self.client.get(&url).query(&[("schema", "complex")]);
2675 let req = if let Some(t) = token {
2676 req.bearer_auth(t)
2677 } else {
2678 req
2679 };
2680
2681 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2682 let status = response.status();
2683
2684 if status.is_success() {
2685 let dto: ResourceDetailDto = response.json().map_err(|e| {
2686 Diagnostic::ServerError(format!(
2687 "resource describe response could not be parsed: {e}"
2688 ))
2689 })?;
2690
2691 let label = dto
2693 .label
2694 .as_ref()
2695 .and_then(extract_string_value)
2696 .unwrap_or_default();
2697 let resource_type = extract_resource_type(dto.type_field.as_ref());
2698 let ark_url = dto.ark_url.as_ref().and_then(extract_string_value);
2699 let creation_date = dto.creation_date.as_ref().and_then(extract_string_value);
2700 let last_modified = dto
2701 .last_modification_date
2702 .as_ref()
2703 .and_then(extract_string_value);
2704 let attached_project = dto
2705 .attached_to_project
2706 .as_ref()
2707 .and_then(extract_string_value);
2708 let owner = dto.attached_to_user.as_ref().and_then(extract_string_value);
2709 let visibility = dto.has_permissions.as_deref().and_then(derive_visibility);
2710 let your_access = dto.user_has_permission.as_deref().and_then(derive_access);
2711
2712 let values = if with_values {
2714 Some(self.parse_resource_values(server, token, &dto.context, &dto.extra))
2715 } else {
2716 None
2717 };
2718
2719 Ok(ResourceDetail {
2720 label,
2721 iri: dto.id,
2722 resource_type,
2723 ark_url,
2724 creation_date,
2725 last_modified,
2726 attached_project,
2727 owner,
2728 visibility,
2729 your_access,
2730 values,
2731 })
2732 } else if status == reqwest::StatusCode::NOT_FOUND {
2733 let display_iri: String = resource_iri.chars().take(80).collect();
2735 let iri_suffix = if resource_iri.chars().count() > 80 {
2736 "…"
2737 } else {
2738 ""
2739 };
2740 Err(Diagnostic::NotFound(format!(
2741 "resource '{display_iri}{iri_suffix}' not found"
2742 )))
2743 } else if status == reqwest::StatusCode::UNAUTHORIZED
2744 || status == reqwest::StatusCode::FORBIDDEN
2745 {
2746 let display_iri: String = resource_iri.chars().take(80).collect();
2750 let iri_suffix = if resource_iri.chars().count() > 80 {
2751 "…"
2752 } else {
2753 ""
2754 };
2755 Err(Diagnostic::AuthRequired(format!(
2756 "access denied for resource '{display_iri}{iri_suffix}' — log in to view this resource"
2757 )))
2758 } else {
2759 Err(map_unexpected_status(status, &url))
2760 }
2761 }
2762
2763 fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic> {
2764 let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
2765
2766 let response = self
2767 .client
2768 .get(&url)
2769 .bearer_auth(token)
2770 .send()
2771 .map_err(|e| Diagnostic::Network(e.to_string()))?;
2772
2773 let status = response.status();
2774
2775 if status.is_success() {
2776 let body = response.text().unwrap_or_default();
2779 let preview: String = body.chars().take(200).collect();
2780 tracing::trace!("verify_token success response body (capped): {}", preview);
2781 Ok(())
2782 } else if status == reqwest::StatusCode::UNAUTHORIZED
2783 || status == reqwest::StatusCode::FORBIDDEN
2784 {
2785 let body = response.text().unwrap_or_default();
2787 let preview: String = body.chars().take(200).collect();
2788 tracing::trace!("verify_token rejection response body (capped): {}", preview);
2789 Err(Diagnostic::AuthRequired(format!(
2791 "token rejected by {server} — it may be expired, revoked, or for a different environment"
2792 )))
2793 } else {
2794 Err(map_unexpected_status(status, &url))
2795 }
2796 }
2797
2798 fn list_data_models(
2799 &self,
2800 server: &str,
2801 project_iri: &str,
2802 token: Option<&str>,
2803 ) -> Result<Vec<DataModel>, Diagnostic> {
2804 let url = format!(
2805 "{}/v2/ontologies/metadata/{}",
2806 server.trim_end_matches('/'),
2807 enc(project_iri)
2808 );
2809
2810 let req = self.client.get(&url);
2815 let req = if let Some(t) = token {
2816 req.bearer_auth(t)
2817 } else {
2818 req
2819 };
2820
2821 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2822
2823 let status = response.status();
2824
2825 if status.is_success() {
2826 let resp: OntologyMetadataResponse = response.json().map_err(|e| {
2827 Diagnostic::ServerError(format!("data-models response could not be parsed: {e}"))
2828 })?;
2829
2830 let dtos: Vec<OntologyMetadataDto> = match resp.graph {
2834 Some(g) => g,
2835 None => match resp.id {
2836 Some(id) => vec![OntologyMetadataDto {
2837 id,
2838 label: resp.label,
2839 last_modification_date: resp.last_modification_date,
2840 }],
2841 None => vec![],
2842 },
2843 };
2844
2845 let data_models = dtos
2846 .into_iter()
2847 .map(|dto| DataModel {
2848 name: data_model_name_from_iri(&dto.id),
2849 iri: dto.id,
2850 label: dto.label,
2851 last_modified: dto.last_modification_date.map(|d| d.value),
2852 is_builtin: false,
2853 })
2854 .collect();
2855
2856 Ok(data_models)
2857 } else {
2858 Err(map_unexpected_status(status, &url))
2859 }
2860 }
2861
2862 fn describe_resource_type(
2863 &self,
2864 server: &str,
2865 data_model_iri: &str,
2866 resource_type: &str,
2867 token: Option<&str>,
2868 ) -> Result<ResourceTypeDetail, Diagnostic> {
2869 let resp = self.fetch_allentities(server, data_model_iri, token)?;
2871
2872 let prefixes: HashMap<String, String> = resp
2874 .context
2875 .iter()
2876 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2877 .collect();
2878
2879 let queried_id = resp.id;
2883 let mut graph_entities: Vec<OntologyEntityDto> = resp.graph;
2884
2885 let target_idx = graph_entities.iter().position(|e| {
2886 if !e.is_resource_class {
2887 return false;
2888 }
2889 let (type_local, expanded_iri) = expand_class_id(&e.id, &prefixes);
2890 type_local.eq_ignore_ascii_case(resource_type) || expanded_iri == resource_type
2892 });
2893
2894 let target_idx = match target_idx {
2895 Some(i) => i,
2896 None => {
2897 let display: String = resource_type.chars().take(80).collect();
2898 let suffix = if resource_type.chars().count() > 80 {
2899 "…"
2900 } else {
2901 ""
2902 };
2903 return Err(Diagnostic::NotFound(format!(
2904 "resource-type '{display}{suffix}' not found in data-model '{}' on {server}",
2905 data_model_name_from_iri(data_model_iri)
2906 )));
2907 }
2908 };
2909
2910 let target = graph_entities.swap_remove(target_idx);
2913
2914 struct Restriction {
2916 on_property_id: String,
2917 cardinality: Cardinality,
2918 gui_order: u32,
2919 }
2920
2921 let mut restrictions: Vec<Restriction> = Vec::new();
2922 let mut super_type_ids: Vec<String> = Vec::new();
2923 let mut restriction_prop_locals: Vec<String> = Vec::new();
2924
2925 for element in &target.sub_class_of {
2926 if let Some(type_val) = element.get("@type")
2927 && type_val.as_str() == Some("owl:Restriction")
2928 {
2929 let on_prop_id = element
2931 .get("owl:onProperty")
2932 .and_then(|v| v.get("@id"))
2933 .and_then(serde_json::Value::as_str)
2934 .unwrap_or("")
2935 .to_string();
2936
2937 if on_prop_id.is_empty() {
2938 tracing::warn!("owl:Restriction missing owl:onProperty @id; skipping");
2939 continue;
2940 }
2941
2942 let cardinality = decode_cardinality(element);
2943 let gui_order = element
2944 .get("salsah-gui:guiOrder")
2945 .and_then(serde_json::Value::as_u64)
2946 .map(|v| v as u32)
2947 .unwrap_or(u32::MAX);
2948
2949 restriction_prop_locals.push(local_name(&on_prop_id).to_string());
2950
2951 restrictions.push(Restriction {
2952 on_property_id: on_prop_id,
2953 cardinality,
2954 gui_order,
2955 });
2956 continue;
2957 }
2958 if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
2960 super_type_ids.push(id_val.to_string());
2961 }
2962 }
2963
2964 let representation = detect_representation(
2966 &restriction_prop_locals
2967 .iter()
2968 .map(String::as_str)
2969 .collect::<Vec<_>>(),
2970 );
2971
2972 let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2974 for entity in graph_entities {
2975 if entity.object_type.is_some()
2978 || entity.is_link_property
2979 || entity.is_resource_property
2980 {
2981 prop_lookup.insert(entity.id.clone(), entity);
2982 }
2983 }
2984
2985 let mut missing_prefixes: Vec<String> = Vec::new();
2995 let mut seen_prefixes: HashSet<String> = HashSet::new();
2996 for restriction in &restrictions {
2997 if prop_lookup.contains_key(&restriction.on_property_id) {
2998 continue;
2999 }
3000 let prefix = match curie_prefix(&restriction.on_property_id) {
3001 Some(p) => p,
3002 None => continue,
3003 };
3004 if is_system_prefix(prefix) {
3005 continue;
3006 }
3007 if seen_prefixes.insert(prefix.to_string()) {
3008 missing_prefixes.push(prefix.to_string());
3009 }
3010 }
3011
3012 let mut fetched_sibling_iris: HashSet<String> = HashSet::new();
3014 let queried_iri_trimmed = data_model_iri.trim_end_matches(['#', '/']);
3015
3016 let mut siblings_to_fetch: Vec<String> = Vec::new();
3017 for prefix in &missing_prefixes {
3018 let namespace = match prefixes.get(prefix.as_str()) {
3019 Some(ns) => ns,
3020 None => {
3021 tracing::warn!(
3022 prefix = %prefix,
3023 "missing @context entry for prefix of cross-DM field; leaving best-effort"
3024 );
3025 continue;
3026 }
3027 };
3028 let sibling_iri = namespace.trim_end_matches(['#', '/']).to_string();
3029 if sibling_iri == queried_iri_trimmed {
3030 continue;
3032 }
3033 if fetched_sibling_iris.insert(sibling_iri.clone()) {
3034 siblings_to_fetch.push(sibling_iri);
3035 }
3036 }
3037
3038 if siblings_to_fetch.len() > MAX_SIBLING_FETCHES {
3039 tracing::warn!(
3040 count = siblings_to_fetch.len(),
3041 max = MAX_SIBLING_FETCHES,
3042 "too many sibling ontologies to fetch; capping at MAX_SIBLING_FETCHES"
3043 );
3044 siblings_to_fetch.truncate(MAX_SIBLING_FETCHES);
3045 }
3046
3047 for sibling_iri in &siblings_to_fetch {
3048 match self.fetch_allentities(server, sibling_iri, token) {
3050 Ok(sibling_resp) => {
3051 for entity in sibling_resp.graph {
3052 if entity.object_type.is_some()
3053 || entity.is_link_property
3054 || entity.is_resource_property
3055 {
3056 prop_lookup.entry(entity.id.clone()).or_insert(entity);
3057 }
3058 }
3059 }
3060 Err(e) => {
3061 tracing::warn!(
3064 iri = %sibling_iri,
3065 error = %e,
3066 "sibling ontology fetch failed; affected fields left best-effort"
3067 );
3068 }
3069 }
3070 }
3071
3072 let mut fields: Vec<(u32, Field)> = Vec::new();
3074
3075 for restriction in &restrictions {
3076 let prop_id = &restriction.on_property_id;
3077
3078 let node = prop_lookup.get(prop_id.as_str());
3080
3081 if let Some(n) = node {
3083 if n.is_link_value_property {
3084 continue;
3086 }
3087 } else {
3088 let prop_local = local_name(prop_id);
3092 if let Some(base) = prop_local.strip_suffix("Value") {
3093 let base_present = restrictions
3095 .iter()
3096 .any(|r| local_name(&r.on_property_id) == base);
3097 if base_present {
3100 continue;
3101 }
3102 }
3103 }
3104
3105 let prop_prefix = curie_prefix(prop_id).unwrap_or("");
3107 let is_builtin = is_system_prefix(prop_prefix);
3108 let (prop_local, prop_iri) = expand_class_id(prop_id, &prefixes);
3109
3110 let field_data_model = if is_builtin {
3112 None
3113 } else {
3114 if prop_prefix.is_empty() {
3117 None
3118 } else {
3119 Some(prop_prefix.to_string())
3120 }
3121 };
3122
3123 let (value_type, link_target) = if let Some(n) = node {
3125 if n.is_link_property {
3126 let target_name = n
3128 .object_type
3129 .as_ref()
3130 .map(|ot| local_name(&ot.id).to_string())
3131 .unwrap_or_else(|| "unknown".to_string());
3132 (ValueType::Link, Some(target_name))
3133 } else {
3134 let obj_local = n
3135 .object_type
3136 .as_ref()
3137 .map(|ot| local_name(&ot.id))
3138 .unwrap_or("");
3139 (map_object_type_to_value_type(obj_local), None)
3140 }
3141 } else {
3142 if is_builtin {
3144 if let Some(vt) = builtin_field_value_type(&prop_local) {
3145 (vt, None)
3146 } else {
3147 (ValueType::Other("—".to_string()), None)
3148 }
3149 } else {
3150 (ValueType::Other("—".to_string()), None)
3151 }
3152 };
3153
3154 let label = node.and_then(|n| n.label.clone());
3155
3156 debug_assert!(
3158 (value_type == ValueType::Link) == link_target.is_some(),
3159 "link_target must be Some iff value_type is Link"
3160 );
3161
3162 fields.push((
3163 restriction.gui_order,
3164 Field {
3165 name: prop_local,
3166 iri: prop_iri,
3167 label,
3168 value_type,
3169 link_target,
3170 cardinality: restriction.cardinality,
3171 is_builtin,
3172 data_model: field_data_model,
3173 },
3174 ));
3175 }
3176
3177 fields.sort_by(|(order_a, field_a), (order_b, field_b)| {
3179 order_a
3180 .cmp(order_b)
3181 .then_with(|| field_a.name.cmp(&field_b.name))
3182 });
3183 let sorted_fields: Vec<Field> = fields.into_iter().map(|(_, f)| f).collect();
3184
3185 let super_types: Vec<String> = super_type_ids
3187 .iter()
3188 .filter(|id| {
3189 let prefix = curie_prefix(id).unwrap_or("");
3190 !is_system_prefix(prefix)
3191 })
3192 .map(|id| local_name(id).to_string())
3193 .collect();
3194
3195 let (class_name, class_iri) = expand_class_id(&target.id, &prefixes);
3197 let class_label = target.label;
3198 let dm_name = data_model_name_from_iri(&queried_id);
3199
3200 Ok(ResourceTypeDetail {
3201 name: class_name,
3202 iri: class_iri,
3203 label: class_label,
3204 data_model: dm_name,
3205 representation,
3206 super_types,
3207 fields: sorted_fields,
3208 count: None,
3209 })
3210 }
3211
3212 fn resource_counts(
3213 &self,
3214 server: &str,
3215 project_iri: &str,
3216 token: Option<&str>,
3217 ) -> Result<HashMap<String, u64>, Diagnostic> {
3218 let url = format!(
3219 "{}/v3/projects/{}/resourcesPerOntology",
3220 server.trim_end_matches('/'),
3221 enc(project_iri)
3222 );
3223
3224 let req = self.client.get(&url);
3227 let req = if let Some(t) = token {
3228 req.bearer_auth(t)
3229 } else {
3230 req
3231 };
3232
3233 let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
3234 let status = response.status();
3235
3236 if status.is_success() {
3237 let entries: Vec<OntologyAndResourceClassesDto> = response.json().map_err(|e| {
3238 Diagnostic::ServerError(format!(
3239 "resource-counts response could not be parsed: {e}"
3240 ))
3241 })?;
3242
3243 let mut counts = HashMap::new();
3244 for entry in entries {
3245 for cc in entry.classes_and_count {
3246 counts.insert(cc.resource_class.iri, cc.item_count);
3247 }
3248 }
3249 Ok(counts)
3250 } else if status == reqwest::StatusCode::NOT_FOUND {
3251 Err(Diagnostic::NotFound(format!("project not found at {url}")))
3252 } else {
3253 Err(map_unexpected_status(status, &url))
3254 }
3255 }
3256}
3257
3258#[cfg(test)]
3263mod tests {
3264 use super::*;
3265
3266 #[test]
3271 fn map_unexpected_status_401_403_are_auth_required() {
3272 for status in [
3276 reqwest::StatusCode::UNAUTHORIZED,
3277 reqwest::StatusCode::FORBIDDEN,
3278 ] {
3279 let diag = map_unexpected_status(status, "https://example.org/x");
3280 match diag {
3281 Diagnostic::AuthRequired(msg) => assert!(
3282 msg.contains("dsp auth login"),
3283 "auth message should hint at re-authentication: {msg}"
3284 ),
3285 other => panic!("expected AuthRequired for {status}, got {other:?}"),
3286 }
3287 }
3288 }
3289
3290 #[test]
3291 fn map_unexpected_status_404_and_5xx_stay_server_error() {
3292 assert!(matches!(
3295 map_unexpected_status(reqwest::StatusCode::NOT_FOUND, "u"),
3296 Diagnostic::ServerError(_)
3297 ));
3298 assert!(matches!(
3299 map_unexpected_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, "u"),
3300 Diagnostic::ServerError(_)
3301 ));
3302 }
3303
3304 #[test]
3309 fn identifier_key_email_contains_at() {
3310 assert_eq!(identifier_key("a@b.ch"), "email");
3311 }
3312
3313 #[test]
3314 fn identifier_key_bare_username() {
3315 assert_eq!(identifier_key("jdoe"), "username");
3316 }
3317
3318 #[test]
3319 fn identifier_key_http_iri() {
3320 assert_eq!(identifier_key("http://rdfh.ch/users/x"), "iri");
3321 }
3322
3323 #[test]
3324 fn identifier_key_https_iri() {
3325 assert_eq!(identifier_key("https://rdfh.ch/users/x"), "iri");
3326 }
3327
3328 #[test]
3329 fn identifier_key_iri_with_at_uses_iri_not_email() {
3330 assert_eq!(identifier_key("http://example.org/users/a@b"), "iri");
3332 }
3333
3334 #[test]
3335 fn classify_http_iri() {
3336 let ident = classify("http://rdfh.ch/projects/0001");
3337 assert!(
3338 matches!(ident, ProjectIdent::Iri(_)),
3339 "http:// prefix should classify as Iri"
3340 );
3341 }
3342
3343 #[test]
3344 fn classify_https_iri() {
3345 let ident = classify("https://rdfh.ch/projects/0001");
3346 assert!(
3347 matches!(ident, ProjectIdent::Iri(_)),
3348 "https:// prefix should classify as Iri"
3349 );
3350 }
3351
3352 #[test]
3353 fn classify_four_digit_hex_shortcode() {
3354 let ident = classify("0001");
3355 assert!(
3356 matches!(ident, ProjectIdent::Shortcode(_)),
3357 "four hex digits should classify as Shortcode"
3358 );
3359 }
3360
3361 #[test]
3362 fn classify_four_hex_letter_shortcode() {
3363 let ident = classify("beef");
3367 assert!(
3368 matches!(ident, ProjectIdent::Shortcode(_)),
3369 "4-hex-letter input 'beef' should classify as Shortcode (documented overlap)"
3370 );
3371 }
3372
3373 #[test]
3374 fn classify_mixed_case_hex_shortcode() {
3375 let ident = classify("ABCD");
3376 assert!(
3377 matches!(ident, ProjectIdent::Shortcode(_)),
3378 "upper-case hex digits should classify as Shortcode"
3379 );
3380 }
3381
3382 #[test]
3383 fn classify_shortname() {
3384 let ident = classify("incunabula");
3385 assert!(
3386 matches!(ident, ProjectIdent::Shortname(_)),
3387 "alphabetic string longer than 4 chars should classify as Shortname"
3388 );
3389 }
3390
3391 #[test]
3392 fn classify_five_digit_hex_is_shortname() {
3393 let ident = classify("00001");
3395 assert!(
3396 matches!(ident, ProjectIdent::Shortname(_)),
3397 "5-hex-digit string should classify as Shortname, not Shortcode"
3398 );
3399 }
3400
3401 #[test]
3402 fn classify_three_digit_hex_is_shortname() {
3403 let ident = classify("001");
3404 assert!(
3405 matches!(ident, ProjectIdent::Shortname(_)),
3406 "3-hex-digit string should classify as Shortname, not Shortcode"
3407 );
3408 }
3409
3410 #[test]
3411 fn classify_non_hex_four_chars_is_shortname() {
3412 let ident = classify("zzzz");
3414 assert!(
3415 matches!(ident, ProjectIdent::Shortname(_)),
3416 "4-char non-hex string should classify as Shortname"
3417 );
3418 }
3419
3420 #[test]
3425 fn validate_dump_id_valid_accepts() {
3426 assert!(super::validate_dump_id("abc123").is_ok());
3427 assert!(super::validate_dump_id("abc-123_XYZ").is_ok());
3428 let max_id = "a".repeat(256);
3430 assert!(
3431 super::validate_dump_id(&max_id).is_ok(),
3432 "256-char id must be accepted"
3433 );
3434 }
3435
3436 #[test]
3437 fn validate_dump_id_empty_is_rejected() {
3438 let result = super::validate_dump_id("");
3439 assert!(
3440 matches!(result, Err(Diagnostic::ServerError(_))),
3441 "empty id must be rejected"
3442 );
3443 }
3444
3445 #[test]
3446 fn validate_dump_id_too_long_is_rejected() {
3447 let long_id = "a".repeat(257);
3448 let result = super::validate_dump_id(&long_id);
3449 assert!(
3450 matches!(result, Err(Diagnostic::ServerError(_))),
3451 "257-char id must be rejected"
3452 );
3453 }
3454
3455 #[test]
3456 fn validate_dump_id_invalid_chars_rejected() {
3457 let result = super::validate_dump_id("abc/def");
3458 assert!(
3459 matches!(result, Err(Diagnostic::ServerError(_))),
3460 "id with '/' must be rejected"
3461 );
3462 }
3463
3464 #[test]
3469 fn into_dump_task_in_progress() {
3470 let api = DataTaskStatusApiResponse {
3471 id: "abc123".into(),
3472 status: "in_progress".into(),
3473 error_message: None,
3474 created_at: None,
3475 };
3476 let task = api.into_dump_task().expect("should parse in_progress");
3477 assert_eq!(task.id, "abc123");
3478 assert_eq!(task.status, DumpStatus::InProgress);
3479 assert!(task.error_message.is_none());
3480 assert!(task.created_at.is_none());
3481 }
3482
3483 #[test]
3484 fn into_dump_task_completed() {
3485 let api = DataTaskStatusApiResponse {
3486 id: "done42".into(),
3487 status: "completed".into(),
3488 error_message: None,
3489 created_at: None,
3490 };
3491 let task = api.into_dump_task().expect("should parse completed");
3492 assert_eq!(task.status, DumpStatus::Completed);
3493 }
3494
3495 #[test]
3496 fn into_dump_task_failed_with_message() {
3497 let api = DataTaskStatusApiResponse {
3498 id: "fail7".into(),
3499 status: "failed".into(),
3500 error_message: Some("disk full".into()),
3501 created_at: None,
3502 };
3503 let task = api.into_dump_task().expect("should parse failed");
3504 assert_eq!(task.status, DumpStatus::Failed);
3505 assert_eq!(task.error_message.as_deref(), Some("disk full"));
3506 }
3507
3508 #[test]
3509 fn into_dump_task_unknown_status_is_server_error() {
3510 let api = DataTaskStatusApiResponse {
3511 id: "x".into(),
3512 status: "pending".into(), error_message: None,
3514 created_at: None,
3515 };
3516 let result = api.into_dump_task();
3517 assert!(result.is_err(), "unknown status should yield an error");
3518 assert!(
3519 matches!(result.unwrap_err(), Diagnostic::ServerError(_)),
3520 "unknown status should yield ServerError"
3521 );
3522 }
3523
3524 #[test]
3525 fn into_dump_task_long_error_message_is_truncated() {
3526 let long_msg = "x".repeat(501);
3528 let api = DataTaskStatusApiResponse {
3529 id: "trunc".into(),
3530 status: "failed".into(),
3531 error_message: Some(long_msg),
3532 created_at: None,
3533 };
3534 let task = api
3535 .into_dump_task()
3536 .expect("should parse even with long message");
3537 let stored = task.error_message.unwrap();
3538 assert_eq!(
3539 stored.len(),
3540 500,
3541 "error_message must be truncated to ≤500 chars at the client boundary"
3542 );
3543 }
3544
3545 #[test]
3546 fn into_dump_task_exact_500_chars_not_truncated() {
3547 let exact_msg = "y".repeat(500);
3549 let api = DataTaskStatusApiResponse {
3550 id: "exact".into(),
3551 status: "failed".into(),
3552 error_message: Some(exact_msg.clone()),
3553 created_at: None,
3554 };
3555 let task = api.into_dump_task().expect("should parse");
3556 assert_eq!(task.error_message.unwrap(), exact_msg);
3557 }
3558
3559 #[test]
3564 fn into_dump_task_valid_created_at_is_parsed() {
3565 let api = DataTaskStatusApiResponse {
3566 id: "ts-test".into(),
3567 status: "completed".into(),
3568 error_message: None,
3569 created_at: Some("2026-05-20T14:03:00Z".into()),
3570 };
3571 let task = api.into_dump_task().expect("should parse with created_at");
3572 use chrono::Datelike;
3573 let ts = task.created_at.expect("created_at should be Some");
3574 assert_eq!(ts.year(), 2026);
3575 assert_eq!(ts.month(), 5);
3576 assert_eq!(ts.day(), 20);
3577 }
3578
3579 #[test]
3580 fn into_dump_task_garbage_created_at_yields_none() {
3581 let api = DataTaskStatusApiResponse {
3582 id: "ts-bad".into(),
3583 status: "in_progress".into(),
3584 error_message: None,
3585 created_at: Some("not-a-date!!".into()),
3586 };
3587 let task = api
3589 .into_dump_task()
3590 .expect("garbage created_at must not fail parse");
3591 assert!(
3592 task.created_at.is_none(),
3593 "garbage created_at must map to None"
3594 );
3595 }
3596
3597 #[test]
3602 fn export_exists_present_with_both_fields() {
3603 let body = V3ErrorBody {
3604 errors: vec![V3ErrorItem {
3605 code: "export_exists".into(),
3606 details: [
3607 ("id".to_string(), "dGVzdC1pZA".to_string()),
3608 (
3609 "projectIri".to_string(),
3610 "http://rdfh.ch/projects/0001".to_string(),
3611 ),
3612 ]
3613 .into(),
3614 }],
3615 };
3616 let ex = body.export_exists().expect("export_exists must be Some");
3617 assert_eq!(ex.id, Some("dGVzdC1pZA"));
3618 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3619 }
3620
3621 #[test]
3622 fn export_exists_wrong_code_returns_none() {
3623 let body = V3ErrorBody {
3624 errors: vec![V3ErrorItem {
3625 code: "some_other_error".into(),
3626 details: [("id".to_string(), "abc".to_string())].into(),
3627 }],
3628 };
3629 assert!(body.export_exists().is_none(), "wrong code must not match");
3630 }
3631
3632 #[test]
3633 fn export_exists_missing_details_id_returns_some_with_none_id() {
3634 let body = V3ErrorBody {
3635 errors: vec![V3ErrorItem {
3636 code: "export_exists".into(),
3637 details: [(
3638 "projectIri".to_string(),
3639 "http://rdfh.ch/projects/0001".to_string(),
3640 )]
3641 .into(),
3642 }],
3643 };
3644 let ex = body
3646 .export_exists()
3647 .expect("export_exists must be Some when code matches");
3648 assert!(ex.id.is_none(), "id must be None when 'id' key is absent");
3649 assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3650 }
3651
3652 #[test]
3653 fn export_exists_empty_errors_returns_none() {
3654 let body = V3ErrorBody { errors: vec![] };
3655 assert!(body.export_exists().is_none());
3656 }
3657
3658 #[test]
3659 fn export_exists_missing_project_iri_returns_some_with_none_iri() {
3660 let body = V3ErrorBody {
3661 errors: vec![V3ErrorItem {
3662 code: "export_exists".into(),
3663 details: [("id".to_string(), "abc123".to_string())].into(),
3664 }],
3665 };
3666 let ex = body
3667 .export_exists()
3668 .expect("export_exists must be Some when code matches");
3669 assert_eq!(ex.id, Some("abc123"));
3670 assert!(
3671 ex.project_iri.is_none(),
3672 "project_iri must be None when 'projectIri' key is absent"
3673 );
3674 }
3675
3676 #[test]
3681 fn is_safe_shortcode_valid_hex_shortcode() {
3682 assert!(
3683 super::is_safe_shortcode("0001"),
3684 "4-hex-digit shortcode must be accepted"
3685 );
3686 assert!(
3687 super::is_safe_shortcode("ABCD"),
3688 "upper-case hex shortcode must be accepted"
3689 );
3690 assert!(
3691 super::is_safe_shortcode("beef"),
3692 "lower-case hex shortcode must be accepted"
3693 );
3694 }
3695
3696 #[test]
3697 fn is_safe_shortcode_alphanumeric_within_32_chars_accepted() {
3698 let long_code = "a".repeat(32);
3699 assert!(
3700 super::is_safe_shortcode(&long_code),
3701 "32-char alphanumeric must be accepted"
3702 );
3703 }
3704
3705 #[test]
3706 fn is_safe_shortcode_empty_is_rejected() {
3707 assert!(
3708 !super::is_safe_shortcode(""),
3709 "empty shortcode must be rejected"
3710 );
3711 }
3712
3713 #[test]
3714 fn is_safe_shortcode_too_long_is_rejected() {
3715 let long_code = "a".repeat(33);
3716 assert!(
3717 !super::is_safe_shortcode(&long_code),
3718 "33-char shortcode must be rejected"
3719 );
3720 }
3721
3722 #[test]
3723 fn is_safe_shortcode_slash_is_rejected() {
3724 assert!(
3725 !super::is_safe_shortcode("ab/cd"),
3726 "shortcode with '/' must be rejected"
3727 );
3728 assert!(
3729 !super::is_safe_shortcode("/evil"),
3730 "absolute path shortcode must be rejected"
3731 );
3732 }
3733
3734 #[test]
3735 fn is_safe_shortcode_dot_dot_is_rejected() {
3736 assert!(
3737 !super::is_safe_shortcode("../evil"),
3738 "path traversal shortcode must be rejected"
3739 );
3740 assert!(
3741 !super::is_safe_shortcode(".."),
3742 "'..' shortcode must be rejected"
3743 );
3744 }
3745
3746 #[test]
3747 fn is_safe_shortcode_backslash_is_rejected() {
3748 assert!(
3749 !super::is_safe_shortcode("ab\\cd"),
3750 "shortcode with '\\' must be rejected"
3751 );
3752 }
3753
3754 #[test]
3755 fn is_safe_shortcode_dot_is_rejected() {
3756 assert!(
3758 !super::is_safe_shortcode("ab.cd"),
3759 "shortcode with '.' must be rejected"
3760 );
3761 }
3762
3763 #[test]
3764 fn resolve_project_rejects_unsafe_shortcode() {
3765 let unsafe_examples = ["../evil", "/abs", "ab/cd", "a\\b", ""];
3769 for s in &unsafe_examples {
3770 assert!(
3771 !super::is_safe_shortcode(s),
3772 "is_safe_shortcode must reject '{s}' — resolve_project would have returned ServerError for this input"
3773 );
3774 }
3775 }
3776
3777 #[test]
3782 fn data_model_name_from_iri_standard_form() {
3783 assert_eq!(
3785 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2"),
3786 "beol"
3787 );
3788 }
3789
3790 #[test]
3791 fn data_model_name_from_iri_no_v2_suffix() {
3792 assert_eq!(
3794 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol"),
3795 "beol"
3796 );
3797 }
3798
3799 #[test]
3800 fn data_model_name_from_iri_trailing_slash() {
3801 assert_eq!(
3803 super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2/"),
3804 "beol"
3805 );
3806 }
3807
3808 #[test]
3809 fn data_model_name_from_iri_bare_name() {
3810 assert_eq!(super::data_model_name_from_iri("beol"), "beol");
3812 }
3813
3814 #[test]
3815 fn data_model_name_from_iri_empty_string() {
3816 assert_eq!(super::data_model_name_from_iri(""), "");
3818 }
3819
3820 fn beol_prefixes() -> HashMap<String, String> {
3825 let mut m = HashMap::new();
3826 m.insert(
3827 "beol".to_string(),
3828 "http://api.dasch.swiss/ontology/0801/beol/v2#".to_string(),
3829 );
3830 m
3831 }
3832
3833 #[test]
3834 fn expand_class_id_curie_expands_with_known_prefix() {
3835 let (name, iri) = super::expand_class_id("beol:Archive", &beol_prefixes());
3837 assert_eq!(name, "Archive");
3838 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
3839 }
3840
3841 #[test]
3842 fn expand_class_id_unknown_prefix_falls_back_to_raw_id() {
3843 let (name, iri) = super::expand_class_id("urn:uuid:x", &HashMap::new());
3845 assert_eq!(name, "x");
3846 assert_eq!(iri, "urn:uuid:x");
3847 }
3848
3849 #[test]
3850 fn expand_class_id_full_iri_passes_through() {
3851 let (name, iri) = super::expand_class_id(
3854 "http://api.dasch.swiss/ontology/0801/beol/v2#Letter",
3855 &beol_prefixes(),
3856 );
3857 assert_eq!(name, "Letter");
3858 assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Letter");
3859 }
3860
3861 #[test]
3862 fn expand_class_id_no_colon_degenerate() {
3863 let (name, iri) = super::expand_class_id("bare", &HashMap::new());
3865 assert_eq!(name, "bare");
3866 assert_eq!(iri, "bare");
3867 }
3868
3869 #[test]
3874 fn local_name_hash_iri() {
3875 assert_eq!(super::local_name("http://example.org/onto#Thing"), "Thing");
3876 }
3877
3878 #[test]
3879 fn local_name_slash_iri() {
3880 assert_eq!(super::local_name("http://example.org/onto/Thing"), "Thing");
3881 }
3882
3883 #[test]
3884 fn local_name_curie_colon() {
3885 assert_eq!(super::local_name("incunabula:Page"), "Page");
3886 }
3887
3888 #[test]
3889 fn local_name_bare_name_fallback() {
3890 assert_eq!(super::local_name("Page"), "Page");
3891 }
3892
3893 #[test]
3894 fn local_name_empty_string() {
3895 assert_eq!(super::local_name(""), "");
3896 }
3897
3898 #[test]
3899 fn local_name_trailing_separator() {
3900 assert_eq!(super::local_name("foo#"), "");
3903 }
3904
3905 #[test]
3910 fn object_type_to_kebab_text_value() {
3911 assert_eq!(super::object_type_to_kebab("TextValue"), "text");
3912 }
3913
3914 #[test]
3915 fn object_type_to_kebab_geom_value() {
3916 assert_eq!(super::object_type_to_kebab("GeomValue"), "geom");
3918 }
3919
3920 #[test]
3921 fn object_type_to_kebab_geo_name_value() {
3922 assert_eq!(super::object_type_to_kebab("GeoNameValue"), "geo-name");
3924 }
3925
3926 #[test]
3927 fn object_type_to_kebab_uri_value() {
3928 assert_eq!(super::object_type_to_kebab("URIValue"), "uri");
3931 }
3932
3933 #[test]
3934 fn object_type_to_kebab_interval_value() {
3935 assert_eq!(super::object_type_to_kebab("IntervalValue"), "interval");
3938 }
3939
3940 #[test]
3941 fn object_type_to_kebab_no_value_suffix() {
3942 assert_eq!(super::object_type_to_kebab("Geom"), "geom");
3944 }
3945
3946 #[test]
3947 fn map_object_type_known_text_value() {
3948 use crate::model::ValueType;
3949 assert_eq!(
3950 super::map_object_type_to_value_type("TextValue"),
3951 ValueType::Text
3952 );
3953 }
3954
3955 #[test]
3956 fn map_object_type_known_list_value() {
3957 use crate::model::ValueType;
3958 assert_eq!(
3959 super::map_object_type_to_value_type("ListValue"),
3960 ValueType::ListItem
3961 );
3962 }
3963
3964 #[test]
3965 fn map_object_type_other_geom() {
3966 use crate::model::ValueType;
3967 assert_eq!(
3969 super::map_object_type_to_value_type("GeomValue"),
3970 ValueType::Other("geom".to_string())
3971 );
3972 }
3973
3974 #[test]
3975 fn map_object_type_other_uri_value() {
3976 use crate::model::ValueType;
3977 assert_eq!(
3979 super::map_object_type_to_value_type("URIValue"),
3980 ValueType::Other("uri".to_string())
3981 );
3982 }
3983
3984 #[test]
3985 fn map_object_type_other_geo_name_value() {
3986 use crate::model::ValueType;
3987 assert_eq!(
3988 super::map_object_type_to_value_type("GeoNameValue"),
3989 ValueType::Other("geo-name".to_string())
3990 );
3991 }
3992
3993 #[test]
3998 fn decode_cardinality_owl_cardinality_1() {
3999 use crate::model::Cardinality;
4000 let v = serde_json::json!({"owl:cardinality": 1});
4001 assert_eq!(super::decode_cardinality(&v), Cardinality::One);
4002 }
4003
4004 #[test]
4005 fn decode_cardinality_owl_max_cardinality_1() {
4006 use crate::model::Cardinality;
4007 let v = serde_json::json!({"owl:maxCardinality": 1});
4008 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrOne);
4009 }
4010
4011 #[test]
4012 fn decode_cardinality_owl_min_cardinality_0() {
4013 use crate::model::Cardinality;
4014 let v = serde_json::json!({"owl:minCardinality": 0});
4015 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4016 }
4017
4018 #[test]
4019 fn decode_cardinality_owl_min_cardinality_1() {
4020 use crate::model::Cardinality;
4021 let v = serde_json::json!({"owl:minCardinality": 1});
4022 assert_eq!(super::decode_cardinality(&v), Cardinality::OneOrMore);
4023 }
4024
4025 #[test]
4026 fn decode_cardinality_fallback_no_key() {
4027 use crate::model::Cardinality;
4028 let v = serde_json::json!({});
4030 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4031 }
4032
4033 #[test]
4034 fn decode_cardinality_fallback_owl_cardinality_unexpected_value() {
4035 use crate::model::Cardinality;
4036 let v = serde_json::json!({"owl:cardinality": 5});
4038 assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4039 }
4040
4041 #[test]
4042 fn decode_cardinality_fallback_owl_max_cardinality_gt1() {
4043 use crate::model::Cardinality;
4044 let v = serde_json::json!({"owl:maxCardinality": 2});
4047 assert_eq!(
4048 super::decode_cardinality(&v),
4049 Cardinality::ZeroOrMore,
4050 "owl:maxCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4051 );
4052 }
4053
4054 #[test]
4055 fn decode_cardinality_fallback_owl_min_cardinality_gt1() {
4056 use crate::model::Cardinality;
4057 let v = serde_json::json!({"owl:minCardinality": 2});
4060 assert_eq!(
4061 super::decode_cardinality(&v),
4062 Cardinality::ZeroOrMore,
4063 "owl:minCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4064 );
4065 }
4066
4067 #[test]
4072 fn detect_representation_still_image() {
4073 use crate::model::Representation;
4074 let locals = vec!["hasStillImageFileValue"];
4075 assert_eq!(
4076 super::detect_representation(&locals),
4077 Some(Representation::StillImage)
4078 );
4079 }
4080
4081 #[test]
4082 fn detect_representation_moving_image() {
4083 use crate::model::Representation;
4084 let locals = vec!["hasMovingImageFileValue"];
4085 assert_eq!(
4086 super::detect_representation(&locals),
4087 Some(Representation::MovingImage)
4088 );
4089 }
4090
4091 #[test]
4092 fn detect_representation_audio() {
4093 use crate::model::Representation;
4094 let locals = vec!["hasAudioFileValue"];
4095 assert_eq!(
4096 super::detect_representation(&locals),
4097 Some(Representation::Audio)
4098 );
4099 }
4100
4101 #[test]
4102 fn detect_representation_none_when_absent() {
4103 let locals = vec!["hasTitle", "hasAuthor"];
4105 assert_eq!(super::detect_representation(&locals), None);
4106 }
4107
4108 #[test]
4109 fn detect_representation_takes_first() {
4110 use crate::model::Representation;
4111 let locals = vec!["hasDocumentFileValue", "hasStillImageFileValue"];
4113 assert_eq!(
4114 super::detect_representation(&locals),
4115 Some(Representation::Document)
4116 );
4117 }
4118
4119 #[test]
4124 fn is_system_prefix_knora_api() {
4125 assert!(super::is_system_prefix("knora-api"));
4126 }
4127
4128 #[test]
4129 fn is_system_prefix_rdf() {
4130 assert!(super::is_system_prefix("rdf"));
4131 }
4132
4133 #[test]
4134 fn is_system_prefix_project_prefix_is_not_system() {
4135 assert!(!super::is_system_prefix("incunabula"));
4136 assert!(!super::is_system_prefix("beol"));
4137 assert!(!super::is_system_prefix("biblio"));
4138 }
4139
4140 #[test]
4145 fn curie_prefix_returns_prefix_for_curie() {
4146 assert_eq!(super::curie_prefix("knora-api:arkUrl"), Some("knora-api"));
4147 assert_eq!(super::curie_prefix("beol:hasTitle"), Some("beol"));
4148 }
4149
4150 #[test]
4151 fn curie_prefix_returns_none_for_full_iri() {
4152 assert_eq!(
4154 super::curie_prefix("http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle"),
4155 None
4156 );
4157 }
4158
4159 #[test]
4160 fn curie_prefix_returns_none_for_no_colon() {
4161 assert_eq!(super::curie_prefix("hasTitle"), None);
4162 }
4163
4164 #[test]
4169 fn sibling_iri_trim_hash_delimiter() {
4170 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4172 let trimmed = namespace.trim_end_matches(['#', '/']);
4173 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4174 }
4175
4176 #[test]
4177 fn sibling_iri_trim_slash_delimiter() {
4178 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2/";
4180 let trimmed = namespace.trim_end_matches(['#', '/']);
4181 assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4182 }
4183
4184 #[test]
4185 fn sibling_iri_self_loop_detected() {
4186 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4188 let namespace = "http://api.dasch.swiss/ontology/0801/beol/v2#";
4189 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4190 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4191 assert_eq!(sibling_iri, queried_trimmed); }
4193
4194 #[test]
4195 fn sibling_iri_different_ontology_is_not_self_loop() {
4196 let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4197 let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4198 let sibling_iri = namespace.trim_end_matches(['#', '/']);
4199 let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4200 assert_ne!(sibling_iri, queried_trimmed); }
4202
4203 #[test]
4204 fn missing_prefix_in_context_is_skipped() {
4205 let prefixes: HashMap<String, String> = HashMap::new();
4207 let result = prefixes.get("biblio");
4208 assert!(result.is_none()); }
4210
4211 #[test]
4216 fn derive_access_rv() {
4217 assert_eq!(
4218 super::derive_access("RV"),
4219 Some(super::ResourceAccess::RestrictedView)
4220 );
4221 }
4222
4223 #[test]
4224 fn derive_access_v() {
4225 assert_eq!(super::derive_access("V"), Some(super::ResourceAccess::View));
4226 }
4227
4228 #[test]
4229 fn derive_access_m() {
4230 assert_eq!(super::derive_access("M"), Some(super::ResourceAccess::Edit));
4231 }
4232
4233 #[test]
4234 fn derive_access_d() {
4235 assert_eq!(
4236 super::derive_access("D"),
4237 Some(super::ResourceAccess::Delete)
4238 );
4239 }
4240
4241 #[test]
4242 fn derive_access_cr() {
4243 assert_eq!(
4244 super::derive_access("CR"),
4245 Some(super::ResourceAccess::Manage)
4246 );
4247 }
4248
4249 #[test]
4250 fn derive_access_unknown_is_none() {
4251 assert_eq!(super::derive_access("XYZ"), None);
4252 }
4253
4254 #[test]
4255 fn derive_access_empty_is_none() {
4256 assert_eq!(super::derive_access(""), None);
4257 }
4258
4259 #[test]
4264 fn derive_visibility_public_when_unknown_user_has_view() {
4265 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|V knora-admin:KnownUser,knora-admin:UnknownUser";
4267 assert_eq!(
4268 super::derive_visibility(acl),
4269 Some(super::ResourceVisibility::Public)
4270 );
4271 }
4272
4273 #[test]
4274 fn derive_visibility_public_when_unknown_user_has_cr() {
4275 let acl = "CR knora-admin:UnknownUser";
4277 assert_eq!(
4278 super::derive_visibility(acl),
4279 Some(super::ResourceVisibility::Public)
4280 );
4281 }
4282
4283 #[test]
4284 fn derive_visibility_public_restricted_when_unknown_user_has_rv() {
4285 let acl = "RV knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4287 assert_eq!(
4288 super::derive_visibility(acl),
4289 Some(super::ResourceVisibility::PublicRestricted)
4290 );
4291 }
4292
4293 #[test]
4294 fn derive_visibility_logged_in_when_known_user_has_rv_unknown_absent() {
4295 let acl = "RV knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4297 assert_eq!(
4298 super::derive_visibility(acl),
4299 Some(super::ResourceVisibility::LoggedInUsers)
4300 );
4301 }
4302
4303 #[test]
4304 fn derive_visibility_logged_in_when_known_user_has_v() {
4305 let acl = "V knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4307 assert_eq!(
4308 super::derive_visibility(acl),
4309 Some(super::ResourceVisibility::LoggedInUsers)
4310 );
4311 }
4312
4313 #[test]
4314 fn derive_visibility_project_members_when_neither_world_group_granted() {
4315 let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|M knora-admin:ProjectMember";
4317 assert_eq!(
4318 super::derive_visibility(acl),
4319 Some(super::ResourceVisibility::ProjectMembers)
4320 );
4321 }
4322
4323 #[test]
4324 fn derive_visibility_empty_string_is_none() {
4325 assert_eq!(super::derive_visibility(""), None);
4326 }
4327
4328 #[test]
4329 fn derive_visibility_whitespace_only_is_none() {
4330 assert_eq!(super::derive_visibility(" "), None);
4331 }
4332
4333 #[test]
4334 fn derive_visibility_malformed_entry_without_space_is_skipped() {
4335 let acl = "CRMALFORMED|CR knora-admin:ProjectAdmin";
4337 assert_eq!(
4339 super::derive_visibility(acl),
4340 Some(super::ResourceVisibility::ProjectMembers)
4341 );
4342 }
4343
4344 #[test]
4345 fn derive_visibility_unknown_code_ranks_zero_no_implicit_grant() {
4346 let acl = "BOGUS knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4348 assert_eq!(
4350 super::derive_visibility(acl),
4351 Some(super::ResourceVisibility::ProjectMembers)
4352 );
4353 }
4354
4355 #[test]
4356 fn derive_visibility_same_group_two_entries_max_wins() {
4357 let acl = "RV knora-admin:UnknownUser|V knora-admin:UnknownUser";
4359 assert_eq!(
4360 super::derive_visibility(acl),
4361 Some(super::ResourceVisibility::Public)
4362 );
4363 }
4364
4365 #[test]
4366 fn derive_visibility_both_world_groups_unknown_user_decides() {
4367 let acl = "V knora-admin:UnknownUser|CR knora-admin:KnownUser";
4370 assert_eq!(
4371 super::derive_visibility(acl),
4372 Some(super::ResourceVisibility::Public)
4373 );
4374 }
4375
4376 #[test]
4377 fn derive_visibility_super_unknown_user_does_not_match() {
4378 let acl = "CR knora-admin:SuperUnknownUser|CR knora-admin:ProjectAdmin";
4381 assert_eq!(
4383 super::derive_visibility(acl),
4384 Some(super::ResourceVisibility::ProjectMembers)
4385 );
4386 }
4387
4388 #[test]
4389 fn derive_visibility_all_malformed_entries_no_space_returns_none() {
4390 let acl = "NOSPACE|ALSONOSPACE|STILLNOSPACE";
4394 assert_eq!(
4395 super::derive_visibility(acl),
4396 None,
4397 "all-malformed ACL (no space in any entry) must return None"
4398 );
4399 }
4400
4401 use crate::model::ValueType;
4406 use crate::model::resource::{DatePoint, DateValue, FileValue, ValueContent};
4407
4408 #[test]
4411 fn parse_value_text_plain() {
4412 let obj = serde_json::json!({
4413 "@type": "knora-api:TextValue",
4414 "knora-api:valueAsString": "Hello world"
4415 });
4416 let (content, is_link) = super::parse_value_content(&obj);
4417 assert_eq!(content, ValueContent::Text("Hello world".into()));
4418 assert!(!is_link);
4419 }
4420
4421 #[test]
4422 fn parse_value_text_standoff_xml_stripped() {
4423 let obj = serde_json::json!({
4425 "@type": "knora-api:TextValue",
4426 "knora-api:textValueAsXml": "<p>Hello <b>world</b></p>",
4427 "knora-api:valueAsString": "This is ignored when xml present"
4428 });
4429 let (content, is_link) = super::parse_value_content(&obj);
4430 assert!(matches!(content, ValueContent::Text(_)));
4432 assert!(!is_link);
4433 if let ValueContent::Text(s) = content {
4434 assert!(!s.contains('<'), "no raw tags: {s:?}");
4436 assert!(s.contains("Hello"), "text retained: {s:?}");
4437 }
4438 }
4439
4440 #[test]
4443 fn parse_value_integer() {
4444 let obj = serde_json::json!({
4445 "@type": "knora-api:IntValue",
4446 "knora-api:intValueAsInt": 42
4447 });
4448 let (content, is_link) = super::parse_value_content(&obj);
4449 assert_eq!(content, ValueContent::Integer(42));
4450 assert!(!is_link);
4451 }
4452
4453 #[test]
4454 fn parse_value_integer_negative() {
4455 let obj = serde_json::json!({
4456 "@type": "knora-api:IntValue",
4457 "knora-api:intValueAsInt": -7
4458 });
4459 let (content, _) = super::parse_value_content(&obj);
4460 assert_eq!(content, ValueContent::Integer(-7));
4461 }
4462
4463 #[test]
4466 fn parse_value_decimal_object_form() {
4467 let obj = serde_json::json!({
4469 "@type": "knora-api:DecimalValue",
4470 "knora-api:decimalValueAsDecimal": {"@value": "3.14159", "@type": "xsd:decimal"}
4471 });
4472 let (content, is_link) = super::parse_value_content(&obj);
4473 assert_eq!(content, ValueContent::Decimal("3.14159".into()));
4474 assert!(!is_link);
4475 }
4476
4477 #[test]
4478 fn parse_value_decimal_bare_string_form() {
4479 let obj = serde_json::json!({
4480 "@type": "knora-api:DecimalValue",
4481 "knora-api:decimalValueAsDecimal": "2.71828"
4482 });
4483 let (content, _) = super::parse_value_content(&obj);
4484 assert_eq!(content, ValueContent::Decimal("2.71828".into()));
4485 }
4486
4487 #[test]
4490 fn parse_value_boolean_true() {
4491 let obj = serde_json::json!({
4492 "@type": "knora-api:BooleanValue",
4493 "knora-api:booleanValueAsBoolean": true
4494 });
4495 let (content, is_link) = super::parse_value_content(&obj);
4496 assert_eq!(content, ValueContent::Boolean(true));
4497 assert!(!is_link);
4498 }
4499
4500 #[test]
4501 fn parse_value_boolean_false() {
4502 let obj = serde_json::json!({
4503 "@type": "knora-api:BooleanValue",
4504 "knora-api:booleanValueAsBoolean": false
4505 });
4506 let (content, _) = super::parse_value_content(&obj);
4507 assert_eq!(content, ValueContent::Boolean(false));
4508 }
4509
4510 #[test]
4513 fn parse_value_date_single_point() {
4514 let obj = serde_json::json!({
4516 "@type": "knora-api:DateValue",
4517 "knora-api:dateValueHasCalendar": "GREGORIAN",
4518 "knora-api:dateValueHasStartYear": 1489,
4519 "knora-api:dateValueHasStartEra": "CE",
4520 "knora-api:dateValueHasEndYear": 1489,
4521 "knora-api:dateValueHasEndEra": "CE"
4522 });
4523 let (content, is_link) = super::parse_value_content(&obj);
4524 assert!(!is_link);
4525 let expected = ValueContent::Date(DateValue {
4526 calendar: "GREGORIAN".into(),
4527 start: DatePoint {
4528 year: Some(1489),
4529 month: None,
4530 day: None,
4531 era: Some("CE".into()),
4532 },
4533 end: DatePoint {
4534 year: Some(1489),
4535 month: None,
4536 day: None,
4537 era: Some("CE".into()),
4538 },
4539 });
4540 assert_eq!(content, expected);
4541 }
4542
4543 #[test]
4544 fn parse_value_date_range() {
4545 let obj = serde_json::json!({
4547 "@type": "knora-api:DateValue",
4548 "knora-api:dateValueHasCalendar": "GREGORIAN",
4549 "knora-api:dateValueHasStartYear": 1489,
4550 "knora-api:dateValueHasStartEra": "CE",
4551 "knora-api:dateValueHasEndYear": 1490,
4552 "knora-api:dateValueHasEndEra": "CE"
4553 });
4554 let (content, _) = super::parse_value_content(&obj);
4555 if let ValueContent::Date(dv) = content {
4556 assert_eq!(dv.start.year, Some(1489));
4557 assert_eq!(dv.end.year, Some(1490));
4558 assert_ne!(dv.start, dv.end, "range: start != end");
4559 } else {
4560 panic!("expected DateValue, got {content:?}");
4561 }
4562 }
4563
4564 #[test]
4565 fn parse_value_date_full_day_precision() {
4566 let obj = serde_json::json!({
4568 "@type": "knora-api:DateValue",
4569 "knora-api:dateValueHasCalendar": "JULIAN",
4570 "knora-api:dateValueHasStartYear": 1456,
4571 "knora-api:dateValueHasStartMonth": 3,
4572 "knora-api:dateValueHasStartDay": 14,
4573 "knora-api:dateValueHasStartEra": "CE",
4574 "knora-api:dateValueHasEndYear": 1456,
4575 "knora-api:dateValueHasEndMonth": 3,
4576 "knora-api:dateValueHasEndDay": 14,
4577 "knora-api:dateValueHasEndEra": "CE"
4578 });
4579 let (content, _) = super::parse_value_content(&obj);
4580 if let ValueContent::Date(dv) = content {
4581 assert_eq!(dv.calendar, "JULIAN");
4582 assert_eq!(dv.start.month, Some(3));
4583 assert_eq!(dv.start.day, Some(14));
4584 } else {
4585 panic!("expected DateValue, got {content:?}");
4586 }
4587 }
4588
4589 #[test]
4590 fn parse_value_date_no_year_falls_back_to_raw() {
4591 let obj = serde_json::json!({
4593 "@type": "knora-api:DateValue",
4594 "knora-api:dateValueHasCalendar": "GREGORIAN",
4595 "knora-api:valueAsString": "some date"
4596 });
4597 let (content, _) = super::parse_value_content(&obj);
4598 assert!(
4599 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "date"),
4600 "missing years must degrade to Raw date"
4601 );
4602 }
4603
4604 #[test]
4607 fn parse_value_time() {
4608 let obj = serde_json::json!({
4609 "@type": "knora-api:TimeValue",
4610 "knora-api:timeValueAsTimeStamp": {"@value": "2021-01-01T12:00:00Z", "@type": "xsd:dateTimeStamp"}
4611 });
4612 let (content, is_link) = super::parse_value_content(&obj);
4613 assert_eq!(content, ValueContent::Time("2021-01-01T12:00:00Z".into()));
4614 assert!(!is_link);
4615 }
4616
4617 #[test]
4618 fn parse_value_time_bare_string() {
4619 let obj = serde_json::json!({
4620 "@type": "knora-api:TimeValue",
4621 "knora-api:timeValueAsTimeStamp": "2022-06-01T00:00:00Z"
4622 });
4623 let (content, _) = super::parse_value_content(&obj);
4624 assert_eq!(content, ValueContent::Time("2022-06-01T00:00:00Z".into()));
4625 }
4626
4627 #[test]
4630 fn parse_value_uri() {
4631 let obj = serde_json::json!({
4632 "@type": "knora-api:UriValue",
4633 "knora-api:uriValueAsUri": {"@value": "https://example.com", "@type": "xsd:anyURI"}
4634 });
4635 let (content, is_link) = super::parse_value_content(&obj);
4636 assert_eq!(content, ValueContent::Uri("https://example.com".into()));
4637 assert!(!is_link);
4638 }
4639
4640 #[test]
4643 fn parse_value_color() {
4644 let obj = serde_json::json!({
4645 "@type": "knora-api:ColorValue",
4646 "knora-api:colorValueAsColor": "#ff0000"
4647 });
4648 let (content, is_link) = super::parse_value_content(&obj);
4649 assert_eq!(content, ValueContent::Color("#ff0000".into()));
4650 assert!(!is_link);
4651 }
4652
4653 #[test]
4656 fn parse_value_geoname() {
4657 let obj = serde_json::json!({
4658 "@type": "knora-api:GeonameValue",
4659 "knora-api:geonameValueAsGeonameCode": "2661552"
4660 });
4661 let (content, is_link) = super::parse_value_content(&obj);
4662 assert_eq!(content, ValueContent::Geoname("2661552".into()));
4663 assert!(!is_link);
4664 }
4665
4666 #[test]
4669 fn parse_value_list_item() {
4670 let obj = serde_json::json!({
4671 "@type": "knora-api:ListValue",
4672 "knora-api:listValueAsListNode": {"@id": "http://rdfh.ch/lists/0001/node1"}
4673 });
4674 let (content, is_link) = super::parse_value_content(&obj);
4675 assert_eq!(
4676 content,
4677 ValueContent::ListItem {
4678 node_iri: "http://rdfh.ch/lists/0001/node1".into(),
4679 label: None, }
4681 );
4682 assert!(!is_link);
4683 }
4684
4685 #[test]
4688 fn parse_value_link_with_embedded_target() {
4689 let obj = serde_json::json!({
4690 "@type": "knora-api:LinkValue",
4691 "knora-api:linkValueHasTarget": {
4692 "@id": "http://rdfh.ch/0803/res1",
4693 "@type": "incunabula:Book",
4694 "rdfs:label": "Incunabula Book 1"
4695 }
4696 });
4697 let (content, is_link) = super::parse_value_content(&obj);
4698 assert!(is_link, "LinkValue must set is_link=true");
4699 assert_eq!(
4700 content,
4701 ValueContent::Link {
4702 target_iri: "http://rdfh.ch/0803/res1".into(),
4703 target_label: Some("Incunabula Book 1".into()),
4704 }
4705 );
4706 }
4707
4708 #[test]
4709 fn parse_value_link_with_target_iri_only() {
4710 let obj = serde_json::json!({
4712 "@type": "knora-api:LinkValue",
4713 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res2"}
4714 });
4715 let (content, is_link) = super::parse_value_content(&obj);
4716 assert!(is_link);
4717 assert_eq!(
4718 content,
4719 ValueContent::Link {
4720 target_iri: "http://rdfh.ch/0803/res2".into(),
4721 target_label: None,
4722 }
4723 );
4724 }
4725
4726 #[test]
4729 fn parse_value_still_image_file() {
4730 let obj = serde_json::json!({
4731 "@type": "knora-api:StillImageFileValue",
4732 "knora-api:fileValueHasFilename": "image.jp2",
4733 "knora-api:fileValueAsUrl": {"@value": "https://iiif.example.com/image.jp2/full/max/0/default.jpg"},
4734 "knora-api:stillImageFileValueHasDimX": 1200,
4735 "knora-api:stillImageFileValueHasDimY": 800
4736 });
4737 let (content, is_link) = super::parse_value_content(&obj);
4738 assert!(!is_link);
4739 assert_eq!(
4740 content,
4741 ValueContent::File(FileValue {
4742 value_type: ValueType::StillImage,
4743 filename: "image.jp2".into(),
4744 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
4745 width: Some(1200),
4746 height: Some(800),
4747 })
4748 );
4749 }
4750
4751 #[test]
4752 fn parse_value_still_image_external_file_value() {
4753 let obj = serde_json::json!({
4755 "@type": "knora-api:StillImageExternalFileValue",
4756 "knora-api:fileValueHasFilename": "external.jpg",
4757 "knora-api:fileValueAsUrl": {"@value": "https://iiif.external.com/image.jpg"}
4758 });
4759 let (content, _) = super::parse_value_content(&obj);
4760 if let ValueContent::File(fv) = content {
4761 assert_eq!(
4762 fv.value_type,
4763 ValueType::StillImage,
4764 "StillImageExternal* → StillImage"
4765 );
4766 } else {
4767 panic!("expected File, got {content:?}");
4768 }
4769 }
4770
4771 #[test]
4774 fn parse_value_moving_image_file() {
4775 let obj = serde_json::json!({
4776 "@type": "knora-api:MovingImageFileValue",
4777 "knora-api:fileValueHasFilename": "video.mp4",
4778 "knora-api:fileValueAsUrl": {"@value": "https://example.com/video.mp4"}
4779 });
4780 let (content, is_link) = super::parse_value_content(&obj);
4781 assert!(!is_link);
4782 assert_eq!(
4783 content,
4784 ValueContent::File(FileValue {
4785 value_type: ValueType::MovingImage,
4786 filename: "video.mp4".into(),
4787 url: "https://example.com/video.mp4".into(),
4788 width: None,
4789 height: None,
4790 })
4791 );
4792 }
4793
4794 #[test]
4797 fn parse_value_audio_file() {
4798 let obj = serde_json::json!({
4799 "@type": "knora-api:AudioFileValue",
4800 "knora-api:fileValueHasFilename": "sound.wav",
4801 "knora-api:fileValueAsUrl": {"@value": "https://example.com/sound.wav"}
4802 });
4803 let (content, _) = super::parse_value_content(&obj);
4804 assert_eq!(
4805 content,
4806 ValueContent::File(FileValue {
4807 value_type: ValueType::Audio,
4808 filename: "sound.wav".into(),
4809 url: "https://example.com/sound.wav".into(),
4810 width: None,
4811 height: None,
4812 })
4813 );
4814 }
4815
4816 #[test]
4819 fn parse_value_document_file() {
4820 let obj = serde_json::json!({
4821 "@type": "knora-api:DocumentFileValue",
4822 "knora-api:fileValueHasFilename": "doc.pdf",
4823 "knora-api:fileValueAsUrl": {"@value": "https://example.com/doc.pdf"}
4824 });
4825 let (content, _) = super::parse_value_content(&obj);
4826 assert_eq!(
4827 content,
4828 ValueContent::File(FileValue {
4829 value_type: ValueType::Document,
4830 filename: "doc.pdf".into(),
4831 url: "https://example.com/doc.pdf".into(),
4832 width: None,
4833 height: None,
4834 })
4835 );
4836 }
4837
4838 #[test]
4841 fn parse_value_archive_file() {
4842 let obj = serde_json::json!({
4843 "@type": "knora-api:ArchiveFileValue",
4844 "knora-api:fileValueHasFilename": "data.zip",
4845 "knora-api:fileValueAsUrl": {"@value": "https://example.com/data.zip"}
4846 });
4847 let (content, _) = super::parse_value_content(&obj);
4848 assert_eq!(
4849 content,
4850 ValueContent::File(FileValue {
4851 value_type: ValueType::Archive,
4852 filename: "data.zip".into(),
4853 url: "https://example.com/data.zip".into(),
4854 width: None,
4855 height: None,
4856 })
4857 );
4858 }
4859
4860 #[test]
4863 fn parse_value_text_file_value_maps_to_document() {
4864 let obj = serde_json::json!({
4865 "@type": "knora-api:TextFileValue",
4866 "knora-api:fileValueHasFilename": "text.txt",
4867 "knora-api:fileValueAsUrl": {"@value": "https://example.com/text.txt"}
4868 });
4869 let (content, _) = super::parse_value_content(&obj);
4870 if let ValueContent::File(fv) = content {
4871 assert_eq!(
4872 fv.value_type,
4873 ValueType::Document,
4874 "TextFileValue → Document"
4875 );
4876 } else {
4877 panic!("expected File, got {content:?}");
4878 }
4879 }
4880
4881 #[test]
4884 fn parse_value_interval_raw_fallback() {
4885 let obj = serde_json::json!({
4886 "@type": "knora-api:IntervalValue",
4887 "knora-api:intervalValueHasStart": {"@value": "0.0", "@type": "xsd:decimal"},
4888 "knora-api:intervalValueHasEnd": {"@value": "10.5", "@type": "xsd:decimal"},
4889 "knora-api:valueAsString": "0.0 - 10.5"
4890 });
4891 let (content, is_link) = super::parse_value_content(&obj);
4892 assert!(!is_link);
4893 assert!(
4894 matches!(content, ValueContent::Raw { ref value_type, .. } if value_type == "interval"),
4895 "IntervalValue must degrade to Raw with token 'interval'"
4896 );
4897 if let ValueContent::Raw { text, .. } = content {
4898 assert_eq!(text, "0.0 - 10.5");
4899 }
4900 }
4901
4902 #[test]
4903 fn parse_value_geom_raw_fallback() {
4904 let obj = serde_json::json!({
4905 "@type": "knora-api:GeomValue",
4906 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
4907 });
4908 let (content, _) = super::parse_value_content(&obj);
4909 assert!(
4910 matches!(content, ValueContent::Raw { value_type, .. } if value_type == "geom"),
4911 "GeomValue must degrade to Raw with token 'geom'"
4912 );
4913 }
4914
4915 #[test]
4918 fn parse_value_with_comment() {
4919 let obj = serde_json::json!({
4920 "@type": "knora-api:TextValue",
4921 "knora-api:valueAsString": "Hello world",
4922 "knora-api:valueHasComment": "reading uncertain"
4923 });
4924 let (value, is_link) = super::parse_value(&obj);
4925 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4926 assert_eq!(value.comment.as_deref(), Some("reading uncertain"));
4927 assert!(!is_link);
4928 }
4929
4930 #[test]
4931 fn parse_value_without_comment() {
4932 let obj = serde_json::json!({
4933 "@type": "knora-api:TextValue",
4934 "knora-api:valueAsString": "Hello world"
4935 });
4936 let (value, is_link) = super::parse_value(&obj);
4937 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4938 assert_eq!(value.comment, None);
4939 assert!(!is_link);
4940 }
4941
4942 #[test]
4943 fn parse_value_with_empty_comment() {
4944 let obj = serde_json::json!({
4945 "@type": "knora-api:TextValue",
4946 "knora-api:valueAsString": "Hello world",
4947 "knora-api:valueHasComment": ""
4948 });
4949 let (value, is_link) = super::parse_value(&obj);
4950 assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4951 assert_eq!(value.comment, None);
4952 assert!(!is_link);
4953 }
4954
4955 #[test]
4958 fn parse_value_link_is_link_true() {
4959 let obj = serde_json::json!({
4961 "@type": "knora-api:LinkValue",
4962 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4963 });
4964 let (_, is_link) = super::parse_value_content(&obj);
4965 assert!(
4966 is_link,
4967 "LinkValue must report is_link=true for name derivation"
4968 );
4969 }
4970
4971 #[test]
4972 fn field_name_link_strips_value_suffix() {
4973 let key = "incunabula:isPartOfBookValue";
4977 let link_obj = serde_json::json!({
4978 "@type": "knora-api:LinkValue",
4979 "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4980 });
4981 let (_, is_link) = super::parse_value_content(&link_obj);
4982 assert!(
4983 is_link,
4984 "LinkValue must report is_link=true for name derivation"
4985 );
4986
4987 let raw_name = super::local_name(key).to_string();
4988 let name = if is_link {
4990 raw_name
4991 .strip_suffix("Value")
4992 .unwrap_or(&raw_name)
4993 .to_string()
4994 } else {
4995 raw_name
4996 };
4997 assert_eq!(name, "isPartOfBook");
4998 }
4999
5000 #[test]
5001 fn field_name_non_link_does_not_strip_value_suffix() {
5002 let key = "incunabula:hasAValue";
5007 let text_obj = serde_json::json!({
5008 "@type": "knora-api:TextValue",
5009 "knora-api:valueAsString": "some text"
5010 });
5011 let (_, is_link) = super::parse_value_content(&text_obj);
5012 assert!(!is_link, "TextValue must report is_link=false");
5013
5014 let raw_name = super::local_name(key).to_string();
5015 let name = if is_link {
5017 raw_name
5018 .strip_suffix("Value")
5019 .unwrap_or(&raw_name)
5020 .to_string()
5021 } else {
5022 raw_name
5023 };
5024 assert_eq!(
5025 name, "hasAValue",
5026 "non-link ending in Value must NOT be stripped; is_link={is_link}"
5027 );
5028 }
5029
5030 #[test]
5033 fn has_value_class_type_rejects_xsd_any_uri() {
5034 let obj = serde_json::json!({
5036 "@value": "http://ark.dasch.swiss/ark:/…",
5037 "@type": "xsd:anyURI"
5038 });
5039 assert!(
5040 !super::has_value_class_type(&obj),
5041 "xsd:anyURI must not pass the value-class test"
5042 );
5043 }
5044
5045 #[test]
5046 fn has_value_class_type_rejects_scalar() {
5047 let obj = serde_json::json!("just a string");
5049 assert!(!super::has_value_class_type(&obj));
5050 }
5051
5052 #[test]
5053 fn has_value_class_type_accepts_text_value() {
5054 let obj = serde_json::json!({
5055 "@type": "knora-api:TextValue",
5056 "knora-api:valueAsString": "hello"
5057 });
5058 assert!(super::has_value_class_type(&obj));
5059 }
5060
5061 #[test]
5062 fn has_value_class_type_accepts_still_image_file_value() {
5063 let obj = serde_json::json!({
5064 "@type": "knora-api:StillImageFileValue",
5065 "knora-api:fileValueHasFilename": "img.jp2"
5066 });
5067 assert!(super::has_value_class_type(&obj));
5068 }
5069
5070 #[test]
5073 fn build_prefix_map_string_entries_only() {
5074 let ctx = Some(serde_json::json!({
5075 "incunabula": "http://api.dasch.swiss/ontology/0803/incunabula/v2#",
5076 "knora-api": "http://api.knora.org/ontology/knora-api/v2#",
5077 "someterm": {"@id": "http://example.com/term", "@type": "@id"}
5079 }));
5080 let map = super::build_prefix_map(&ctx);
5081 assert_eq!(
5082 map.get("incunabula").map(String::as_str),
5083 Some("http://api.dasch.swiss/ontology/0803/incunabula/v2#")
5084 );
5085 assert_eq!(
5086 map.get("knora-api").map(String::as_str),
5087 Some("http://api.knora.org/ontology/knora-api/v2#")
5088 );
5089 assert!(
5090 !map.contains_key("someterm"),
5091 "object-valued entry must be skipped"
5092 );
5093 }
5094
5095 #[test]
5096 fn build_prefix_map_empty_when_no_context() {
5097 let map = super::build_prefix_map(&None);
5098 assert!(map.is_empty());
5099 }
5100
5101 #[test]
5104 fn compact_value_text_excludes_meta_keys() {
5105 let obj = serde_json::json!({
5106 "@id": "http://rdfh.ch/0803/val1",
5107 "@type": "knora-api:GeomValue",
5108 "knora-api:geometryValueAsGeometry": "POINT(1 2)"
5109 });
5110 let text = super::compact_value_text(&obj);
5111 assert!(
5113 text.contains("geometryValueAsGeometry"),
5114 "geometry key present: {text}"
5115 );
5116 assert!(!text.contains("@id"), "@id must be excluded: {text}");
5117 assert!(!text.contains("@type"), "@type must be excluded: {text}");
5118 }
5119
5120 #[test]
5121 fn compact_value_text_all_meta_yields_empty() {
5122 let obj = serde_json::json!({
5123 "@id": "http://rdfh.ch/0803/val1",
5124 "@type": "knora-api:IntervalValue"
5125 });
5126 let text = super::compact_value_text(&obj);
5127 assert!(
5128 text.is_empty(),
5129 "all-meta object must yield empty string: {text:?}"
5130 );
5131 }
5132}