diff --git a/.sqlx/query-02d6faf0ffd8fb96031f7a8e824de4e4552ea72176c5e885cea3e517316e7774.json b/.sqlx/query-02d6faf0ffd8fb96031f7a8e824de4e4552ea72176c5e885cea3e517316e7774.json new file mode 100644 index 00000000..2c63b3c7 --- /dev/null +++ b/.sqlx/query-02d6faf0ffd8fb96031f7a8e824de4e4552ea72176c5e885cea3e517316e7774.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM beamline", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "scan_number", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "visit", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "scan", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "detector", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "tracker_file_extension", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "02d6faf0ffd8fb96031f7a8e824de4e4552ea72176c5e885cea3e517316e7774" +} diff --git a/Cargo.lock b/Cargo.lock index bc338643..8f63e14a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2770,6 +2770,7 @@ version = "1.0.138" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" dependencies = [ + "indexmap 2.7.0", "itoa", "memchr", "ryu", diff --git a/Cargo.toml b/Cargo.toml index 9fc3ad1e..d21a29a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ assert_matches = "1.5.0" async-std = { version = "1.13.0", features = ["attributes"], default-features = false } httpmock = { version = "0.7.0", default-features = false } rstest = "0.24.0" -serde_json = "1.0.138" +serde_json = { version = "1.0.138", features = ["preserve_order"] } tempfile = "3.16.0" [build-dependencies] diff --git a/README.md b/README.md index 774088ba..f650bd9b 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,9 @@ echo '{ ### Queries (read-only) -There are two read only queries, one to get the visit directory for a given -visit and beamline and one to get the current configuration for a given -beamline. +There are three read only queries, one to get the visit directory for a given +visit and beamline, one to get the current configuration for a given +beamline and one to get the current configuration(s) for one or more beamline. #### paths Get the visit directory for a beamline and visit @@ -119,7 +119,9 @@ Get the current configuration values for the given beamline visitTemplate scanTemplate detectorTemplate - latestScanNumber + dbScanNumber + fileScanNumber + trackerFileExtension } } ``` @@ -131,11 +133,91 @@ Get the current configuration values for the given beamline "visitTemplate": "/data/{instrument}/data/{year}/{visit}", "scanTemplate": "{subdirectory}/{instrument}-{scan_number}", "detectorTemplate": "{subdirectory}/{instrument}-{scan_number}-{detector}", - "latestScanNumber": 20839 + "dbScanNumber": 0, + "fileScanNumber": null, + "trackerFileExtension": null } } ``` +#### configurations +Get the current configuration values for one or more beamlines specified as a list. +Providing no beamlines returns all current configurations. + +##### Query +```graphql +{ + configurations(beamlineFilters: ["i22", "i11"]) { + beamline + visitTemplate + scanTemplate + detectorTemplate + dbScanNumber + fileScanNumber + trackerFileExtension + } +} +``` + +##### Response +```json +{ + "configurations": [ + { + "beamline": "i11", + "visitTemplate": "/tmp/{instrument}/data/{year}/{visit}", + "scanTemplate": "{subdirectory}/{instrument}-{scan_number}", + "detectorTemplate": "{subdirectory}/{instrument}-{scan_number}-{detector}", + "dbScanNumber": 0, + "fileScanNumber": null, + "trackerFileExtension": null + }, + { + "beamline": "i22", + "visitTemplate": "/tmp/{instrument}/data/{year}/{visit}", + "scanTemplate": "{subdirectory}/{instrument}-{scan_number}", + "detectorTemplate": "{subdirectory}/{instrument}-{scan_number}-{detector}", + "dbScanNumber": 0, + "fileScanNumber": null, + "trackerFileExtension": null + } + ] +} +``` + +##### Query +```graphql +{ + configurations { + beamline + visitTemplate + scanTemplate + detectorTemplate + dbScanNumber + fileScanNumber + trackerFileExtension + } +} +``` + +##### Response +```json +{ + "configurations": [ + { + "beamline": "i11", + "visitTemplate": "/tmp/{instrument}/data/{year}/{visit}", + "scanTemplate": "{subdirectory}/{instrument}-{scan_number}", + "detectorTemplate": "{subdirectory}/{instrument}-{scan_number}-{detector}", + "dbScanNumber": 0, + "fileScanNumber": null, + "trackerFileExtension": null + }, + ... + ] +} +``` + ## Mutations (read-write) #### scan diff --git a/src/db_service.rs b/src/db_service.rs index 63503557..ceef73ba 100644 --- a/src/db_service.rs +++ b/src/db_service.rs @@ -297,6 +297,32 @@ impl SqliteScanPathService { .ok_or(ConfigurationError::MissingBeamline(beamline.into())) } + pub async fn configurations( + &self, + filters: Vec, + ) -> Result, ConfigurationError> { + let mut q = QueryBuilder::new("SELECT * FROM beamline WHERE name in ("); + let mut beamlines = q.separated(", "); + for filter in filters { + beamlines.push_bind(filter); + } + q.push(")"); + + let query = q.build_query_as(); + Ok(query.fetch_all(&self.pool).await?) + } + + pub async fn all_configurations( + &self, + ) -> Result, ConfigurationError> { + Ok(query_as!(DbBeamlineConfig, "SELECT * FROM beamline") + .fetch_all(&self.pool) + .await? + .into_iter() + .map(BeamlineConfiguration::from) + .collect()) + } + pub async fn next_scan_configuration( &self, beamline: &str, @@ -575,6 +601,137 @@ mod db_tests { assert_eq!(ext, "ext"); } + #[rstest] + #[test] + async fn configurations() { + let db = SqliteScanPathService::memory().await; + ok!(update("i22") + .with_scan_number(122) + .with_extension("ext") + .insert_new(&db)); + ok!(update("i11") + .with_scan_number(111) + .with_extension("ext") + .insert_new(&db)); + + let confs = ok!(db.configurations(vec![ + "i22".to_string(), + "i11".to_string(), + "i03".to_string() + ])); + // i03 has not been configured so it will not fetch it. + assert_eq!(confs.len(), 2); + + for conf in confs.iter() { + match conf.name() { + "i22" => { + assert_eq!(conf.name(), "i22"); + assert_eq!(conf.scan_number(), 122); + assert_eq!( + conf.visit().unwrap().to_string(), + "/tmp/{instrument}/data/{year}/{visit}" + ); + assert_eq!( + conf.scan().unwrap().to_string(), + "{subdirectory}/{instrument}-{scan_number}" + ); + assert_eq!( + conf.detector().unwrap().to_string(), + "{subdirectory}/{instrument}-{scan_number}-{detector}" + ); + let Some(ext) = &conf.tracker_file_extension else { + panic!("Missing extension"); + }; + assert_eq!(ext, "ext"); + } + "i11" => { + assert_eq!(conf.name(), "i11"); + assert_eq!(conf.scan_number(), 111); + assert_eq!( + conf.visit().unwrap().to_string(), + "/tmp/{instrument}/data/{year}/{visit}" + ); + assert_eq!( + conf.scan().unwrap().to_string(), + "{subdirectory}/{instrument}-{scan_number}" + ); + assert_eq!( + conf.detector().unwrap().to_string(), + "{subdirectory}/{instrument}-{scan_number}-{detector}" + ); + let Some(ext) = &conf.tracker_file_extension else { + panic!("Missing extension"); + }; + assert_eq!(ext, "ext"); + } + other => panic!("Unexpected beamline name: {other}"), + } + } + } + + #[rstest] + #[test] + async fn all_configurations() { + let db = SqliteScanPathService::memory().await; + ok!(update("i22") + .with_scan_number(122) + .with_extension("ext") + .insert_new(&db)); + ok!(update("i11") + .with_scan_number(111) + .with_extension("ext") + .insert_new(&db)); + + let confs = ok!(db.all_configurations()); + assert_eq!(confs.len(), 2); + + for conf in confs.iter() { + match conf.name() { + "i22" => { + assert_eq!(conf.name(), "i22"); + assert_eq!(conf.scan_number(), 122); + assert_eq!( + conf.visit().unwrap().to_string(), + "/tmp/{instrument}/data/{year}/{visit}" + ); + assert_eq!( + conf.scan().unwrap().to_string(), + "{subdirectory}/{instrument}-{scan_number}" + ); + assert_eq!( + conf.detector().unwrap().to_string(), + "{subdirectory}/{instrument}-{scan_number}-{detector}" + ); + let Some(ext) = &conf.tracker_file_extension else { + panic!("Missing extension"); + }; + assert_eq!(ext, "ext"); + } + "i11" => { + assert_eq!(conf.name(), "i11"); + assert_eq!(conf.scan_number(), 111); + assert_eq!( + conf.visit().unwrap().to_string(), + "/tmp/{instrument}/data/{year}/{visit}" + ); + assert_eq!( + conf.scan().unwrap().to_string(), + "{subdirectory}/{instrument}-{scan_number}" + ); + assert_eq!( + conf.detector().unwrap().to_string(), + "{subdirectory}/{instrument}-{scan_number}-{detector}" + ); + let Some(ext) = &conf.tracker_file_extension else { + panic!("Missing extension"); + }; + assert_eq!(ext, "ext"); + } + other => panic!("Unexpected beamline name: {other}"), + } + } + } + type Update = BeamlineConfigurationUpdate; #[rstest] diff --git a/src/graphql.rs b/src/graphql.rs index 33f09ba1..826c6f4a 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -258,6 +258,10 @@ impl ScanPaths { #[Object] /// The current configuration for a beamline impl CurrentConfiguration { + /// The name of the beamline + pub async fn beamline(&self) -> async_graphql::Result<&str> { + Ok(self.db_config.name()) + } /// The template used to build the path to the visit directory for a beamline pub async fn visit_template(&self) -> async_graphql::Result { Ok(self.db_config.visit()?.to_string()) @@ -334,7 +338,10 @@ impl Query { ctx: &Context<'_>, beamline: String, ) -> async_graphql::Result { - check_auth(ctx, |policy, token| policy.check_admin(token, &beamline)).await?; + check_auth(ctx, |policy, token| { + policy.check_beamline_admin(token, &beamline) + }) + .await?; let db = ctx.data::()?; let nt = ctx.data::()?; trace!("Getting config for {beamline:?}"); @@ -348,6 +355,37 @@ impl Query { high_file, }) } + + /// Get the configurations for all available beamlines + /// Can be filtered to provide one or more specific beamlines + #[instrument(skip(self, ctx))] + async fn configurations( + &self, + ctx: &Context<'_>, + beamline_filters: Option>, + ) -> async_graphql::Result> { + check_auth(ctx, |policy, token| policy.check_admin(token)).await?; + let db = ctx.data::()?; + let nt = ctx.data::()?; + let configurations = match beamline_filters { + Some(filters) => db.configurations(filters).await?, + None => db.all_configurations().await?, + }; + + futures::future::join_all(configurations.into_iter().map(|cnf| async { + let dir = nt + .for_beamline(cnf.name(), cnf.tracker_file_extension()) + .await?; + let high_file = dir.prev().await?; + Ok(CurrentConfiguration { + db_config: cnf, + high_file, + }) + })) + .await + .into_iter() + .collect() + } } #[Object] @@ -401,7 +439,7 @@ impl Mutation { beamline: String, config: ConfigurationUpdates, ) -> async_graphql::Result { - check_auth(ctx, |pc, token| pc.check_admin(token, &beamline)).await?; + check_auth(ctx, |pc, token| pc.check_beamline_admin(token, &beamline)).await?; let db = ctx.data::()?; let nt = ctx.data::()?; trace!("Configuring: {beamline}: {config:?}"); @@ -634,7 +672,7 @@ mod tests { use super::auth::PolicyCheck; use super::{ConfigurationUpdates, InputTemplate, Mutation, Query}; use crate::cli::PolicyOptions; - use crate::db_service::SqliteScanPathService; + use crate::db_service::{ConfigurationError, SqliteScanPathService}; use crate::graphql::graphql_schema; use crate::numtracker::TempTracker; @@ -808,17 +846,78 @@ mod tests { async fn configuration(#[future(awt)] env: TestEnv) { let query = r#"{ configuration(beamline: "i22") { - visitTemplate scanTemplate detectorTemplate dbScanNumber trackerFileExtension + beamline visitTemplate scanTemplate detectorTemplate dbScanNumber trackerFileExtension }}"#; let result = env.schema.execute(query).await; let exp = value!({ - "configuration": { - "visitTemplate": "/tmp/{instrument}/data/{visit}", - "scanTemplate": "{subdirectory}/{instrument}-{scan_number}", - "detectorTemplate": "{subdirectory}/{instrument}-{scan_number}-{detector}", - "dbScanNumber": 122, - "trackerFileExtension": Value::Null - }}); + "configuration": { + "beamline":"i22", + "visitTemplate": "/tmp/{instrument}/data/{visit}", + "scanTemplate": "{subdirectory}/{instrument}-{scan_number}", + "detectorTemplate": "{subdirectory}/{instrument}-{scan_number}-{detector}", + "dbScanNumber": 122, + "trackerFileExtension": Value::Null + } + }); + assert!(result.errors.is_empty()); + assert_eq!(result.data, exp); + } + + #[rstest] + #[tokio::test] + async fn configurations(#[future(awt)] env: TestEnv) { + let query = r#"{ + configurations(beamlineFilters: ["i22"]) { + beamline visitTemplate scanTemplate detectorTemplate dbScanNumber fileScanNumber trackerFileExtension + }}"#; + let result = env.schema.execute(query).await; + let exp = value!({ + "configurations": [ + { + "beamline": "i22", + "visitTemplate": "/tmp/{instrument}/data/{visit}", + "scanTemplate": "{subdirectory}/{instrument}-{scan_number}", + "detectorTemplate": "{subdirectory}/{instrument}-{scan_number}-{detector}", + "dbScanNumber": 122, + "fileScanNumber": 122, + "trackerFileExtension": Value::Null, + } + ] + }); + assert!(result.errors.is_empty()); + assert_eq!(result.data, exp); + } + + #[rstest] + #[tokio::test] + async fn configurations_all(#[future(awt)] env: TestEnv) { + let query = r#"{ + configurations { + beamline visitTemplate scanTemplate detectorTemplate dbScanNumber fileScanNumber trackerFileExtension + }}"#; + let result = env.schema.execute(query).await; + let exp = value!({ + "configurations": [ + { + "beamline": "i22", + "visitTemplate": "/tmp/{instrument}/data/{visit}", + "scanTemplate": "{subdirectory}/{instrument}-{scan_number}", + "detectorTemplate": "{subdirectory}/{instrument}-{scan_number}-{detector}", + "dbScanNumber": 122, + "fileScanNumber": 122, + "trackerFileExtension": Value::Null, + }, + { + "beamline": "b21", + "visitTemplate": "/tmp/{instrument}/data/{visit}", + "scanTemplate": "{subdirectory}/{instrument}-{scan_number}", + "detectorTemplate": "{subdirectory}/{scan_number}/{instrument}-{scan_number}-{detector}", + "dbScanNumber": 621, + "fileScanNumber": 211, + "trackerFileExtension": "b21_ext", + }, + ] + }); assert!(result.errors.is_empty()); assert_eq!(result.data, exp); } @@ -921,7 +1020,7 @@ mod tests { async fn configure_new_beamline(#[future(awt)] env: TestEnv) { assert_matches::assert_matches!( env.db.current_configuration("i16").await, - Err(crate::db_service::ConfigurationError::MissingBeamline(bl)) if bl == "i16" + Err(ConfigurationError::MissingBeamline(bl)) if bl == "i16" ); let result = env diff --git a/src/graphql/auth.rs b/src/graphql/auth.rs index 86569e0d..86da23a6 100644 --- a/src/graphql/auth.rs +++ b/src/graphql/auth.rs @@ -27,7 +27,6 @@ const AUDIENCE: &str = "account"; type Token = Authorization; #[derive(Debug, Serialize)] -#[cfg_attr(test, derive(Deserialize))] struct Request { input: T, } @@ -39,7 +38,6 @@ struct Response { } #[derive(Debug, Serialize)] -#[cfg_attr(test, derive(Deserialize))] pub struct AccessRequest<'a> { token: &'a str, audience: &'a str, @@ -61,15 +59,15 @@ impl<'a> AccessRequest<'a> { } #[derive(Debug, Serialize)] -#[cfg_attr(test, derive(Deserialize))] pub struct AdminRequest<'a> { token: &'a str, audience: &'a str, - beamline: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + beamline: Option<&'a str>, } impl<'r> AdminRequest<'r> { - fn new(token: Option<&'r Token>, beamline: &'r str) -> Result { + fn new(token: Option<&'r Token>, beamline: Option<&'r str>) -> Result { Ok(Self { token: token.ok_or(AuthError::Missing)?.token(), audience: AUDIENCE, @@ -135,9 +133,17 @@ impl PolicyCheck { pub async fn check_admin( &self, token: Option<&Authorization>, + ) -> Result<(), AuthError> { + self.authorise(&self.admin, AdminRequest::new(token, None)?) + .await + } + + pub async fn check_beamline_admin( + &self, + token: Option<&Authorization>, beamline: &str, ) -> Result<(), AuthError> { - self.authorise(&self.admin, AdminRequest::new(token, beamline)?) + self.authorise(&self.admin, AdminRequest::new(token, Some(beamline))?) .await } @@ -258,7 +264,7 @@ mod tests { } #[tokio::test] - async fn successful_admin_check() { + async fn successful_check_beamline_admin() { let server = MockServer::start(); let mock = server .mock_async(|when, then| { @@ -280,12 +286,37 @@ mod tests { admin_query: "demo/admin".into(), }); check - .check_admin(token("token").as_ref(), "i22") + .check_beamline_admin(token("token").as_ref(), "i22") .await .unwrap(); mock.assert(); } + #[tokio::test] + async fn successful_check_admin() { + let server = MockServer::start(); + let mock = server + .mock_async(|when, then| { + when.method("POST") + .path("/demo/admin") + .json_body_obj(&json!({ + "input": { + "token": "token", + "audience": "account" + } + })); + then.status(200).json_body_obj(&json!({"result": true})); + }) + .await; + let check = PolicyCheck::new(PolicyOptions { + policy_host: server.url(""), + access_query: "demo/access".into(), + admin_query: "demo/admin".into(), + }); + check.check_admin(token("token").as_ref()).await.unwrap(); + mock.assert(); + } + #[tokio::test] async fn denied_access_check() { let server = MockServer::start(); @@ -321,7 +352,7 @@ mod tests { } #[tokio::test] - async fn denied_admin_check() { + async fn denied_check_beamline_admin() { let server = MockServer::start(); let mock = server .mock_async(|when, then| { @@ -342,7 +373,37 @@ mod tests { access_query: "demo/access".into(), admin_query: "demo/admin".into(), }); - let result = check.check_admin(token("token").as_ref(), "i22").await; + let result = check + .check_beamline_admin(token("token").as_ref(), "i22") + .await; + let Err(AuthError::Failed) = result else { + panic!("Unexpected result from unauthorised check: {result:?}"); + }; + mock.assert(); + } + + #[tokio::test] + async fn denied_check_admin() { + let server = MockServer::start(); + let mock = server + .mock_async(|when, then| { + when.method("POST") + .path("/demo/admin") + .json_body_obj(&json!({ + "input": { + "token": "token", + "audience": "account" + } + })); + then.status(200).json_body_obj(&json!({"result": false})); + }) + .await; + let check = PolicyCheck::new(PolicyOptions { + policy_host: server.url(""), + access_query: "demo/access".into(), + admin_query: "demo/admin".into(), + }); + let result = check.check_admin(token("token").as_ref()).await; let Err(AuthError::Failed) = result else { panic!("Unexpected result from unauthorised check: {result:?}"); }; @@ -370,7 +431,7 @@ mod tests { } #[tokio::test] - async fn unauthorised_admin_check() { + async fn unauthorised_check_beamline_admin() { let server = MockServer::start(); let mock = server .mock_async(|_, _| { @@ -382,7 +443,27 @@ mod tests { access_query: "demo/access".into(), admin_query: "demo/admin".into(), }); - let result = check.check_admin(None, "i22").await; + let result = check.check_beamline_admin(None, "i22").await; + let Err(AuthError::Missing) = result else { + panic!("Unexpected result from unauthorised check: {result:?}"); + }; + mock.assert_hits(0); + } + + #[tokio::test] + async fn unauthorised_check_admin() { + let server = MockServer::start(); + let mock = server + .mock_async(|_, _| { + // mock that rejects every request + }) + .await; + let check = PolicyCheck::new(PolicyOptions { + policy_host: server.url(""), + access_query: "demo/access".into(), + admin_query: "demo/admin".into(), + }); + let result = check.check_admin(None).await; let Err(AuthError::Missing) = result else { panic!("Unexpected result from unauthorised check: {result:?}"); }; @@ -403,7 +484,9 @@ mod tests { access_query: "demo/access".into(), admin_query: "demo/admin".into(), }); - let result = check.check_admin(token("token").as_ref(), "i22").await; + let result = check + .check_beamline_admin(token("token").as_ref(), "i22") + .await; let Err(AuthError::ServerError(_)) = result else { panic!("Unexpected result from unauthorised check: {result:?}"); }; diff --git a/static/service_schema b/static/service_schema index f4647ab4..e2b0d891 100644 --- a/static/service_schema +++ b/static/service_schema @@ -30,6 +30,10 @@ input ConfigurationUpdates { The current configuration for a beamline """ type CurrentConfiguration { + """ + The name of the beamline + """ + beamline: String! """ The template used to build the path to the visit directory for a beamline """ @@ -116,6 +120,11 @@ type Query { Get the current configuration for the given beamline """ configuration(beamline: String!): CurrentConfiguration! + """ + Get the configurations for all available beamlines + Can be filtered to provide one or more specific beamlines + """ + configurations(beamlineFilters: [String!]): [CurrentConfiguration!]! } """