use std::io::{self, Write};
use crate::diagnostic::Diagnostic;
use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail};
use crate::render::auth::{
AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
};
use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
use crate::render::table::render_table_disclosure;
use crate::render::value::{build_metadata_row, build_value_rows};
use crate::render::{
AUTH_LOGIN_COLUMNS, AUTH_LOGOUT_COLUMNS, DATA_MODEL_DESCRIBE_COLUMNS,
DATA_MODEL_STRUCTURE_COLUMNS, DATA_MODELS_COLUMNS, DataModelListView, MetaContext,
PROJECT_DUMP_COLUMNS, PROJECT_DUMP_DELETED_COLUMNS, PROJECTS_COLUMNS, ProjectListView,
QuoteMode, RESOURCE_DESCRIBE_COLUMNS, RESOURCE_DESCRIBE_VALUES_COLUMNS,
RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS, RESOURCE_LIST_COLUMNS,
RESOURCE_TYPE_DESCRIBE_COLUMNS, RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS, RESOURCE_TYPES_COLUMNS,
Renderer, ResourceListView, ResourceTypeListView, TableOptions, TableSpec, render_table,
};
pub struct CsvRenderer {
out: Box<dyn Write>,
err: Box<dyn Write>,
options: TableOptions,
}
impl CsvRenderer {
pub fn new() -> Self {
Self {
out: Box::new(io::stdout()),
err: Box::new(io::stderr()),
options: TableOptions::default(),
}
}
pub fn with_writer(w: impl Write + 'static) -> Self {
Self {
out: Box::new(w),
err: Box::new(io::sink()),
options: TableOptions::default(),
}
}
pub fn with_writers(out: impl Write + 'static, err: impl Write + 'static) -> Self {
Self {
out: Box::new(out),
err: Box::new(err),
options: TableOptions::default(),
}
}
pub fn with_options(mut self, opts: TableOptions) -> Self {
self.options = opts;
self
}
}
impl Default for CsvRenderer {
fn default() -> Self {
Self::new()
}
}
impl Renderer for CsvRenderer {
fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
Ok(())
}
fn auth_login(
&mut self,
outcome: &AuthLoginOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
let expires = outcome
.expires_at
.map(|dt| dt.to_rfc3339())
.unwrap_or_default();
let rows = vec![vec![
outcome.server.clone(),
outcome.user.clone(),
expires,
"login_success".to_string(),
]];
let projected = self.options.projected();
let spec = TableSpec {
all_columns: AUTH_LOGIN_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)
}
fn auth_status(
&mut self,
outcome: &AuthStatusOutcome,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
let row = match outcome {
AuthStatusOutcome::LoggedIn {
server,
user,
expires_at,
expired,
} => {
let user_str = user.as_deref().unwrap_or("").to_string();
let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
let state = if *expired { "expired" } else { "logged_in" };
vec![server.clone(), user_str, expires, state.to_string()]
}
AuthStatusOutcome::AuthenticatedViaEnv {
server,
expires_at,
expired,
} => {
let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
let state = if *expired { "expired" } else { "logged_in" };
vec![server.clone(), String::new(), expires, state.to_string()]
}
AuthStatusOutcome::NotLoggedIn { server } => {
vec![
server.clone(),
String::new(),
String::new(),
"not_logged_in".to_string(),
]
}
};
let rows = vec![row];
let projected = self.options.projected();
let spec = TableSpec {
all_columns: AUTH_LOGIN_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
fn auth_logout(
&mut self,
outcome: &AuthLogoutOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
let rows = vec![vec![outcome.server.clone(), outcome.was_cached.to_string()]];
let projected = self.options.projected();
let spec = TableSpec {
all_columns: AUTH_LOGOUT_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)
}
fn auth_set_token(
&mut self,
outcome: &AuthSetTokenOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
let user_str = outcome.user.as_deref().unwrap_or("").to_string();
let expires = outcome
.expires_at
.map(|dt| dt.to_rfc3339())
.unwrap_or_default();
let rows = vec![vec![
outcome.server.clone(),
user_str,
expires,
"token_cached".to_string(),
]];
let projected = self.options.projected();
let spec = TableSpec {
all_columns: AUTH_LOGIN_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)
}
fn project_dump(
&mut self,
outcome: &DumpOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
let rows = vec![vec![outcome.path.display().to_string()]];
let projected = self.options.projected();
let spec = TableSpec {
all_columns: PROJECT_DUMP_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)
}
fn project_dump_deleted(
&mut self,
outcome: &DumpDeleteOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
let rows = vec![vec![outcome.deleted.to_string()]];
let projected = self.options.projected();
let spec = TableSpec {
all_columns: PROJECT_DUMP_DELETED_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)
}
fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
let rows: Vec<Vec<String>> = view
.items
.iter()
.map(|item| {
let longname = item.longname.as_deref().unwrap_or("").to_string();
let data_models_str = item.data_models.to_string();
vec![
item.shortcode.clone(),
item.shortname.clone(),
longname,
item.status.as_str().to_string(),
data_models_str,
item.iri.clone(),
]
})
.collect();
let projected = self.options.projected();
let spec = TableSpec {
all_columns: PROJECTS_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
fn project_describe(
&mut self,
project: &ProjectDetail,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
let longname = project.longname.as_deref().unwrap_or("").to_string();
let data_models_str = project.data_models.len().to_string();
let rows = vec![vec![
project.shortcode.clone(),
project.shortname.clone(),
longname,
project.status.as_str().to_string(),
data_models_str,
project.iri.clone(),
]];
let projected = self.options.projected();
let spec = TableSpec {
all_columns: PROJECTS_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
fn data_model_describe(
&mut self,
detail: &DataModelDetail,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
let label = detail.label.as_deref().unwrap_or("").to_string();
let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
let resource_types_str = detail.resource_types.len().to_string();
let rows = vec![vec![
detail.name.clone(),
detail.iri.clone(),
label,
last_modified,
resource_types_str,
]];
let projected = self.options.projected();
let spec = TableSpec {
all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
fn data_models(
&mut self,
view: &DataModelListView,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
let rows: Vec<Vec<String>> = view
.items
.iter()
.map(|item| {
let label = item.label.as_deref().unwrap_or("").to_string();
let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
let is_builtin_str = if item.is_builtin { "true" } else { "false" };
vec![
item.name.clone(),
item.iri.clone(),
label,
last_modified,
is_builtin_str.to_string(),
]
})
.collect();
let projected = self.options.projected();
let spec = TableSpec {
all_columns: DATA_MODELS_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
fn resource_types(
&mut self,
view: &ResourceTypeListView,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
let rows: Vec<Vec<String>> = view
.items
.iter()
.map(|item| {
let label = item.label.as_deref().unwrap_or("").to_string();
let is_builtin_str = if item.is_builtin { "true" } else { "false" };
vec![
item.name.clone(),
item.iri.clone(),
label,
is_builtin_str.to_string(),
]
})
.collect();
let projected = self.options.projected();
let spec = TableSpec {
all_columns: RESOURCE_TYPES_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
fn resource_type_describe(
&mut self,
detail: &crate::model::ResourceTypeDetail,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
let rows: Vec<Vec<String>> = detail
.fields
.iter()
.map(|field| {
let value_type_str = field.value_type.to_string();
let link_target = field.link_target.as_deref().unwrap_or("").to_string();
let card_str = field.cardinality.to_string();
let label = field.label.as_deref().unwrap_or("").to_string();
let is_builtin_str = if field.is_builtin { "true" } else { "false" };
let data_model = field.data_model.as_deref().unwrap_or("").to_string();
vec![
field.name.clone(),
field.iri.clone(),
value_type_str,
link_target,
card_str,
label,
is_builtin_str.to_string(),
data_model,
]
})
.collect();
let projected = self.options.projected();
let spec = TableSpec {
all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
rows: &rows,
default_columns: Some(RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS),
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
fn data_model_structure(
&mut self,
structure: &DataModelStructure,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
let rows: Vec<Vec<String>> = structure
.relations
.iter()
.map(|rel| {
let kind_str = rel.kind.to_string();
let field = rel.field.as_deref().unwrap_or("").to_string();
let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
vec![
rel.source.clone(),
rel.target.clone(),
kind_str,
field,
target_dm,
]
})
.collect();
let projected = self.options.projected();
let spec = TableSpec {
all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
let rows: Vec<Vec<String>> = view
.items
.iter()
.map(|item| {
vec![
item.label.clone(),
item.iri.clone(),
item.ark_url.as_deref().unwrap_or("").to_string(),
item.creation_date.as_deref().unwrap_or("").to_string(),
item.last_modified.as_deref().unwrap_or("").to_string(),
item.resource_type.clone(),
]
})
.collect();
let projected = self.options.projected();
let spec = TableSpec {
all_columns: RESOURCE_LIST_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
fn resource_describe(
&mut self,
detail: &crate::model::ResourceDetail,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
match &detail.values {
None => {
let rows = vec![build_metadata_row(detail)];
let projected = self.options.projected();
let spec = TableSpec {
all_columns: RESOURCE_DESCRIBE_COLUMNS,
rows: &rows,
default_columns: None,
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
}
Some(fields) => {
let rows = build_value_rows(&detail.label, &detail.iri, fields);
let projected = self.options.projected();
let spec = TableSpec {
all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
rows: &rows,
default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
projected,
quote: QuoteMode::Csv,
header: self.options.header,
};
render_table(&mut *self.out, &spec)?;
}
}
render_table_disclosure(&mut *self.err, meta)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
DataModel, DataModelDetail, DataModelSummary, Project, ProjectDetail, ProjectStatus,
ResourceType, ResourceTypeSummary,
};
use crate::render::test_support::{SharedBuf, make_meta};
use crate::render::{DataModelListView, ResourceTypeListView};
fn make_fixture() -> Vec<Project> {
vec![
Project {
iri: "http://rdfh.ch/projects/0001".into(),
shortcode: "0001".into(),
shortname: "anything".into(),
longname: Some("Anything Project".into()),
status: ProjectStatus::Active,
data_models: 2,
},
Project {
iri: "http://rdfh.ch/projects/0002".into(),
shortcode: "0002".into(),
shortname: "images".into(),
longname: None,
status: ProjectStatus::Inactive,
data_models: 0,
},
Project {
iri: "http://rdfh.ch/projects/0803".into(),
shortcode: "0803".into(),
shortname: "daschland".into(),
longname: Some("=Formula,Project".into()),
status: ProjectStatus::Active,
data_models: 1,
},
]
}
#[test]
fn projects_csv_output() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let view = ProjectListView {
items: make_fixture(),
total: 3,
filter: None,
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.projects(&view, &meta).unwrap();
let stdout = out.string();
assert!(stdout.starts_with("shortcode,shortname,longname,status,data_models,iri\n"));
assert!(
stdout.contains("0001,anything,Anything Project,active,2,http://rdfh.ch/projects/0001")
);
assert!(stdout.contains("0002,images,,inactive,0,http://rdfh.ch/projects/0002"));
assert!(stdout.contains(
r#"0803,daschland,"=Formula,Project",active,1,http://rdfh.ch/projects/0803"#
));
}
#[test]
fn projects_csv_stderr_disclosure() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let view = ProjectListView {
items: make_fixture(),
total: 3,
filter: None,
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.projects(&view, &meta).unwrap();
let err_str = err.string();
assert_eq!(
err_str.trim(),
"[anonymous on https://api.test.dasch.swiss]"
);
assert!(!out.string().contains("[anonymous"));
}
#[test]
fn auth_status_csv_stderr_disclosure() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let meta = make_meta(
"authenticated via DSP_TOKEN",
"https://api.prod.dasch.swiss",
);
renderer
.auth_status(
&AuthStatusOutcome::AuthenticatedViaEnv {
server: "https://api.prod.dasch.swiss".to_string(),
expires_at: None,
expired: false,
},
&meta,
)
.unwrap();
let err_str = err.string();
assert_eq!(
err_str.trim(),
"[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
);
assert!(!out.string().contains('['));
let out2 = SharedBuf::new();
let err2 = SharedBuf::new();
let mut renderer2 = CsvRenderer::with_writers(out2.clone(), err2.clone());
let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
renderer2
.auth_status(
&AuthStatusOutcome::NotLoggedIn {
server: "https://api.prod.dasch.swiss".to_string(),
},
&meta2,
)
.unwrap();
let err2_str = err2.string();
assert_eq!(
err2_str.trim(),
"[anonymous on https://api.prod.dasch.swiss]"
);
assert!(!out2.string().contains('['));
}
#[test]
fn projects_csv_with_writer_no_stderr_leak() {
let out = SharedBuf::new();
let mut renderer = CsvRenderer::with_writer(out.clone());
let view = ProjectListView {
items: vec![],
total: 0,
filter: None,
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.projects(&view, &meta).unwrap();
assert_eq!(
out.string(),
"shortcode,shortname,longname,status,data_models,iri\n"
);
}
#[test]
fn project_describe_csv_output() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let detail = ProjectDetail {
iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
shortcode: "0801".into(),
shortname: "beol".into(),
longname: Some("Bernoulli-Euler Online".into()),
status: ProjectStatus::Active,
description: vec![],
keywords: vec![],
data_models: vec![
DataModelSummary {
name: "beol".into(),
iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
},
DataModelSummary {
name: "biblio".into(),
iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
},
],
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.project_describe(&detail, &meta).unwrap();
let stdout = out.string();
assert!(stdout.starts_with("shortcode,shortname,longname,status,data_models,iri\n"));
assert!(stdout.contains(
"0801,beol,Bernoulli-Euler Online,active,2,http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"
));
}
#[test]
fn project_describe_csv_no_longname() {
let out = SharedBuf::new();
let mut renderer = CsvRenderer::with_writer(out.clone());
let detail = ProjectDetail {
iri: "http://rdfh.ch/projects/0000".into(),
shortcode: "0000".into(),
shortname: "minimal".into(),
longname: None,
status: ProjectStatus::Inactive,
description: vec![],
keywords: vec![],
data_models: vec![],
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.project_describe(&detail, &meta).unwrap();
let stdout = out.string();
assert!(stdout.contains("0000,minimal,,inactive,0,http://rdfh.ch/projects/0000"));
}
#[test]
fn project_describe_csv_formula_injection_longname() {
let out = SharedBuf::new();
let mut renderer = CsvRenderer::with_writer(out.clone());
let detail = ProjectDetail {
iri: "http://rdfh.ch/projects/0803".into(),
shortcode: "0803".into(),
shortname: "daschland".into(),
longname: Some("=Formula,Project".into()),
status: ProjectStatus::Active,
description: vec![],
keywords: vec![],
data_models: vec![],
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.project_describe(&detail, &meta).unwrap();
let stdout = out.string();
assert!(stdout.contains(r#"0803,daschland,"=Formula,Project",active,0"#));
}
#[test]
fn project_describe_csv_stderr_disclosure() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let detail = ProjectDetail {
iri: "http://rdfh.ch/projects/0001".into(),
shortcode: "0001".into(),
shortname: "test".into(),
longname: None,
status: ProjectStatus::Active,
description: vec![],
keywords: vec![],
data_models: vec![],
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.project_describe(&detail, &meta).unwrap();
let err_str = err.string();
assert_eq!(
err_str.trim(),
"[anonymous on https://api.test.dasch.swiss]"
);
assert!(!out.string().contains("[anonymous"));
}
fn make_data_model_fixture() -> Vec<DataModel> {
vec![
DataModel {
name: "beol".into(),
iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
label: Some("The BEOL data-model".into()),
last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
is_builtin: false,
},
DataModel {
name: "biblio".into(),
iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
label: None,
last_modified: None,
is_builtin: false,
},
DataModel {
name: "knora-api".into(),
iri: "http://api.knora.org/ontology/knora-api/v2".into(),
label: None,
last_modified: None,
is_builtin: true,
},
]
}
#[test]
fn data_models_csv_output() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let view = DataModelListView {
items: make_data_model_fixture(),
total: 3,
filter: None,
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.data_models(&view, &meta).unwrap();
let stdout = out.string();
assert!(stdout.starts_with("name,iri,label,last_modified,is_builtin\n"));
assert!(stdout.contains(
"beol,http://api.dasch.swiss/ontology/0801/beol/v2,The BEOL data-model,2024-05-27T13:43:26.233048Z,false"
));
assert!(stdout.contains("biblio,http://api.dasch.swiss/ontology/0801/biblio/v2,,,false"));
assert!(stdout.contains("knora-api,http://api.knora.org/ontology/knora-api/v2,,,true"));
}
#[test]
fn data_models_csv_formula_injection_label() {
let out = SharedBuf::new();
let mut renderer = CsvRenderer::with_writer(out.clone());
let view = DataModelListView {
items: vec![DataModel {
name: "test".into(),
iri: "http://api.dasch.swiss/ontology/0001/test/v2".into(),
label: Some("=Formula,Label".into()),
last_modified: None,
is_builtin: false,
}],
total: 1,
filter: None,
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.data_models(&view, &meta).unwrap();
let stdout = out.string();
assert!(
stdout.contains(
r#"test,http://api.dasch.swiss/ontology/0001/test/v2,"=Formula,Label",,false"#
),
"formula-injected label must be quoted; got:\n{stdout}"
);
}
#[test]
fn data_models_csv_stderr_disclosure() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let view = DataModelListView {
items: make_data_model_fixture(),
total: 3,
filter: None,
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.data_models(&view, &meta).unwrap();
let err_str = err.string();
assert_eq!(
err_str.trim(),
"[anonymous on https://api.test.dasch.swiss]"
);
assert!(!out.string().contains("[anonymous"));
}
fn make_beol_dm_detail() -> DataModelDetail {
DataModelDetail {
name: "beol".into(),
iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
label: Some("The BEOL data-model".into()),
last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
resource_types: vec![
ResourceTypeSummary {
name: "Archive".into(),
iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
label: Some("Archive".into()),
},
ResourceTypeSummary {
name: "basicLetter".into(),
iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
label: None,
},
ResourceTypeSummary {
name: "letter".into(),
iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
label: Some("Letter".into()),
},
],
}
}
#[test]
fn data_model_describe_csv_output() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer
.data_model_describe(&make_beol_dm_detail(), &meta)
.unwrap();
let stdout = out.string();
assert!(
stdout.starts_with("name,iri,label,last_modified,resource_types\n"),
"header row missing; got:\n{stdout}"
);
assert!(
stdout.contains(
"beol,http://api.dasch.swiss/ontology/0801/beol/v2,The BEOL data-model,2024-05-27T13:43:26.233048Z,3"
),
"data row missing; got:\n{stdout}"
);
}
#[test]
fn data_model_describe_csv_no_label_no_last_modified() {
let out = SharedBuf::new();
let mut renderer = CsvRenderer::with_writer(out.clone());
let detail = DataModelDetail {
name: "minimal".into(),
iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
label: None,
last_modified: None,
resource_types: vec![],
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.data_model_describe(&detail, &meta).unwrap();
let stdout = out.string();
assert!(
stdout.contains("minimal,http://api.dasch.swiss/ontology/0000/minimal/v2,,,0"),
"None → empty fields must be handled; got:\n{stdout}"
);
}
#[test]
fn data_model_describe_csv_formula_injection_label() {
let out = SharedBuf::new();
let mut renderer = CsvRenderer::with_writer(out.clone());
let detail = DataModelDetail {
name: "test".into(),
iri: "http://api.dasch.swiss/ontology/0001/test/v2".into(),
label: Some("=Formula,Label".into()),
last_modified: None,
resource_types: vec![],
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.data_model_describe(&detail, &meta).unwrap();
let stdout = out.string();
assert!(
stdout.contains(
r#"test,http://api.dasch.swiss/ontology/0001/test/v2,"=Formula,Label",,0"#
),
"formula-injected label must be quoted; got:\n{stdout}"
);
}
#[test]
fn data_model_describe_csv_stderr_disclosure() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer
.data_model_describe(&make_beol_dm_detail(), &meta)
.unwrap();
let err_str = err.string();
assert_eq!(
err_str.trim(),
"[anonymous on https://api.test.dasch.swiss]"
);
assert!(!out.string().contains("[anonymous"));
}
fn make_resource_type_fixture() -> Vec<ResourceType> {
vec![
ResourceType {
name: "Archive".into(),
iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
label: Some("Archive".into()),
is_builtin: false,
},
ResourceType {
name: "letter".into(),
iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
label: None,
is_builtin: false,
},
]
}
#[test]
fn resource_types_csv_output() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let view = ResourceTypeListView {
items: make_resource_type_fixture(),
total: 2,
filter: None,
data_model: "beol".into(),
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.resource_types(&view, &meta).unwrap();
let stdout = out.string();
assert!(
stdout.starts_with("name,iri,label,is_builtin\n"),
"header row missing; got:\n{stdout}"
);
assert!(
stdout.contains(
"Archive,http://api.dasch.swiss/ontology/0801/beol/v2#Archive,Archive,false"
),
"Archive row missing; got:\n{stdout}"
);
assert!(
stdout.contains("letter,http://api.dasch.swiss/ontology/0801/beol/v2#letter,,false"),
"letter row missing; got:\n{stdout}"
);
}
#[test]
fn resource_types_csv_with_builtins() {
let out = SharedBuf::new();
let mut renderer = CsvRenderer::with_writer(out.clone());
let view = ResourceTypeListView {
items: vec![ResourceType {
name: "Region".into(),
iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
label: Some("Region".into()),
is_builtin: true,
}],
total: 1,
filter: None,
data_model: "beol".into(),
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.resource_types(&view, &meta).unwrap();
let stdout = out.string();
assert!(
stdout.contains("Region,http://api.knora.org/ontology/knora-api/v2#Region,Region,true"),
"built-in row missing; got:\n{stdout}"
);
}
#[test]
fn resource_types_csv_with_filter() {
let out = SharedBuf::new();
let mut renderer = CsvRenderer::with_writer(out.clone());
let view = ResourceTypeListView {
items: vec![make_resource_type_fixture().remove(0)], total: 2,
filter: Some("arch".to_string()),
data_model: "beol".into(),
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.resource_types(&view, &meta).unwrap();
let stdout = out.string();
assert!(stdout.starts_with("name,iri,label,is_builtin\n"));
assert!(stdout.contains("Archive"));
}
#[test]
fn resource_types_csv_empty() {
let out = SharedBuf::new();
let mut renderer = CsvRenderer::with_writer(out.clone());
let view = ResourceTypeListView {
items: vec![],
total: 0,
filter: None,
data_model: "beol".into(),
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.resource_types(&view, &meta).unwrap();
assert_eq!(out.string(), "name,iri,label,is_builtin\n");
}
#[test]
fn resource_types_csv_stderr_disclosure() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let view = ResourceTypeListView {
items: make_resource_type_fixture(),
total: 2,
filter: None,
data_model: "beol".into(),
};
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer.resource_types(&view, &meta).unwrap();
let err_str = err.string();
assert_eq!(
err_str.trim(),
"[anonymous on https://api.test.dasch.swiss]"
);
assert!(!out.string().contains("[anonymous"));
}
use crate::model::{
FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
};
fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
ResourceDetail {
label: "Test Resource".into(),
iri: "http://rdfh.ch/0803/abc123".into(),
resource_type: "Page".into(),
ark_url: None,
creation_date: None,
last_modified: None,
attached_project: None,
owner: None,
visibility: Some(ResourceVisibility::Public),
your_access: Some(ResourceAccess::View),
values,
}
}
#[test]
fn resource_describe_csv_some_values_no_note_renders_value_rows() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer
.resource_describe(
&make_resource_detail(Some(vec![FieldValues {
name: "hasTitle".into(),
label: None,
values: vec![ValueContent::Text("Hello".into()).into()],
}])),
&meta,
)
.unwrap();
let err_str = err.string();
assert!(
!err_str.contains("values not shown"),
"no values note must be emitted when values is Some; got:\n{err_str}"
);
let stdout = out.string();
assert!(
stdout.starts_with("field,field_label,value_type,value\n"),
"csv values header must be the compact default set; got:\n{stdout}"
);
assert!(
stdout.contains("hasTitle,,text,Hello\n"),
"expected value row for hasTitle; got:\n{stdout}"
);
assert!(
!stdout.contains("Test Resource"),
"metadata row must not appear in values mode; got:\n{stdout}"
);
}
#[test]
fn resource_describe_csv_no_values_no_note() {
let out = SharedBuf::new();
let err = SharedBuf::new();
let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
renderer
.resource_describe(&make_resource_detail(None), &meta)
.unwrap();
assert!(
!err.string().contains("values not shown"),
"no note expected when values is None; got:\n{}",
err.string()
);
}
}