From f9ac8efc431b33a5fbf20be18ef9d65ff602d397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Fri, 22 Nov 2024 10:28:47 +0100 Subject: [PATCH 01/13] add permissions and listing wip --- services/src/machine_learning/mod.rs | 11 +++++++++-- services/src/pro/api/handlers/machine_learning.rs | 2 +- services/src/pro/api/handlers/permissions.rs | 4 ++++ services/src/pro/machine_learning/mod.rs | 9 ++++++--- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/services/src/machine_learning/mod.rs b/services/src/machine_learning/mod.rs index 43b70db83..b9ea06e6b 100644 --- a/services/src/machine_learning/mod.rs +++ b/services/src/machine_learning/mod.rs @@ -29,6 +29,13 @@ pub mod name; identifier!(MlModelId); +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, FromSql, ToSql)] +#[serde(rename_all = "camelCase")] +pub struct MlModelIdAndName { + pub id: MlModelId, + pub name: MlModelName, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, FromSql, ToSql)] #[serde(rename_all = "camelCase")] pub struct MlModel { @@ -106,7 +113,7 @@ pub trait MlModelDb { name: &MlModelName, ) -> Result; - async fn add_model(&self, model: MlModel) -> Result<(), MachineLearningError>; + async fn add_model(&self, model: MlModel) -> Result; } #[async_trait] @@ -135,7 +142,7 @@ where unimplemented!() } - async fn add_model(&self, _model: MlModel) -> Result<(), MachineLearningError> { + async fn add_model(&self, _model: MlModel) -> Result { unimplemented!() } } diff --git a/services/src/pro/api/handlers/machine_learning.rs b/services/src/pro/api/handlers/machine_learning.rs index e71a523eb..09528d6e4 100644 --- a/services/src/pro/api/handlers/machine_learning.rs +++ b/services/src/pro/api/handlers/machine_learning.rs @@ -61,7 +61,7 @@ pub(crate) async fn add_ml_model( model: web::Json, ) -> Result { let model = model.into_inner(); - app_ctx + let id_and_name = app_ctx .session_context(session) .db() .add_model(model) diff --git a/services/src/pro/api/handlers/permissions.rs b/services/src/pro/api/handlers/permissions.rs index eeab24cef..29840966e 100644 --- a/services/src/pro/api/handlers/permissions.rs +++ b/services/src/pro/api/handlers/permissions.rs @@ -2,6 +2,7 @@ use crate::api::model::datatypes::{DatasetId, LayerId}; use crate::contexts::{ApplicationContext, SessionContext}; use crate::error::Result; use crate::layers::listing::LayerCollectionId; +use crate::machine_learning::MlModelId; use crate::pro::contexts::{ProApplicationContext, ProGeoEngineDb}; use crate::pro::permissions::{Permission, PermissionListing}; use crate::pro::permissions::{PermissionDb, ResourceId, RoleId}; @@ -52,6 +53,8 @@ pub enum Resource { Project(ProjectId), #[schema(title = "DatasetResource")] Dataset(DatasetId), + #[schema(title = "MlModelResource")] + MlModel(MlModelId), } impl From for ResourceId { @@ -63,6 +66,7 @@ impl From for ResourceId { } Resource::Project(project_id) => ResourceId::Project(project_id), Resource::Dataset(dataset_id) => ResourceId::DatasetId(dataset_id.into()), + Resource::MlModel(ml_model_id) => ResourceId::MlModel(ml_model_id), } } } diff --git a/services/src/pro/machine_learning/mod.rs b/services/src/pro/machine_learning/mod.rs index e1b2b3663..d7d1363d6 100644 --- a/services/src/pro/machine_learning/mod.rs +++ b/services/src/pro/machine_learning/mod.rs @@ -13,7 +13,7 @@ use crate::{ MachineLearningError, }, name::MlModelName, - MlModel, MlModelDb, MlModelId, MlModelListOptions, MlModelMetadata, + MlModel, MlModelDb, MlModelId, MlModelIdAndName, MlModelListOptions, MlModelMetadata, }, util::postgres::PostgresErrorExt, }; @@ -147,7 +147,7 @@ where Ok(row.get(1)) } - async fn add_model(&self, model: MlModel) -> Result<(), MachineLearningError> { + async fn add_model(&self, model: MlModel) -> Result { self.check_ml_model_namespace(&model.name)?; let mut conn = self @@ -202,6 +202,9 @@ where tx.commit().await.context(PostgresMachineLearningError)?; - Ok(()) + Ok(MlModelIdAndName { + id, + name: model.name, + }) } } From 8743ef34d4c6bf8a8939457179cf9d8a3f798808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Mon, 13 Jan 2025 20:04:14 +0100 Subject: [PATCH 02/13] add ml model name response --- .../src/api/model/responses/ml_models/mod.rs | 19 +++++++++++++++++++ services/src/api/model/responses/mod.rs | 1 + .../src/pro/api/handlers/machine_learning.rs | 6 +++--- 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 services/src/api/model/responses/ml_models/mod.rs diff --git a/services/src/api/model/responses/ml_models/mod.rs b/services/src/api/model/responses/ml_models/mod.rs new file mode 100644 index 000000000..6c16f227f --- /dev/null +++ b/services/src/api/model/responses/ml_models/mod.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToResponse; + +use crate::machine_learning::name::MlModelName; + +#[derive(Debug, Serialize, Deserialize, Clone, ToResponse)] +#[serde(rename_all = "camelCase")] +#[response(description = "Name of generated resource", example = json!({ + "name": "ns:name" +}))] +pub struct MlModelNameResponse { + pub dataset_name: MlModelName, +} + +impl From for MlModelNameResponse { + fn from(dataset_name: MlModelName) -> Self { + Self { dataset_name } + } +} diff --git a/services/src/api/model/responses/mod.rs b/services/src/api/model/responses/mod.rs index 553477ec8..de558f88c 100644 --- a/services/src/api/model/responses/mod.rs +++ b/services/src/api/model/responses/mod.rs @@ -1,4 +1,5 @@ pub mod datasets; +pub mod ml_models; use actix_http::StatusCode; use actix_web::{dev::ServiceResponse, HttpResponse}; diff --git a/services/src/pro/api/handlers/machine_learning.rs b/services/src/pro/api/handlers/machine_learning.rs index 09528d6e4..7c13522f2 100644 --- a/services/src/pro/api/handlers/machine_learning.rs +++ b/services/src/pro/api/handlers/machine_learning.rs @@ -1,7 +1,7 @@ use actix_web::{web, FromRequest, HttpResponse, ResponseError}; use crate::{ - api::model::responses::ErrorResponse, + api::model::responses::{ml_models::MlModelNameResponse, ErrorResponse}, contexts::{ApplicationContext, SessionContext}, machine_learning::{ error::MachineLearningError, name::MlModelName, MlModel, MlModelDb, MlModelListOptions, @@ -59,14 +59,14 @@ pub(crate) async fn add_ml_model( session: C::Session, app_ctx: web::Data, model: web::Json, -) -> Result { +) -> Result, MachineLearningError> { let model = model.into_inner(); let id_and_name = app_ctx .session_context(session) .db() .add_model(model) .await?; - Ok(HttpResponse::Ok().finish()) + Ok(web::Json(id_and_name.name.into())) } /// List ml models. From 5c974f24a907f1c990b3739f46411dd094c3e0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Tue, 10 Dec 2024 22:43:45 +0100 Subject: [PATCH 03/13] use dataset and model names in resource --- services/src/error.rs | 7 +- services/src/machine_learning/mod.rs | 12 +++ services/src/pro/api/handlers/permissions.rs | 87 ++++++++++++-------- services/src/pro/machine_learning/mod.rs | 23 ++++++ 4 files changed, 94 insertions(+), 35 deletions(-) diff --git a/services/src/error.rs b/services/src/error.rs index 986436b85..c56f37ca4 100644 --- a/services/src/error.rs +++ b/services/src/error.rs @@ -216,7 +216,6 @@ pub enum Error { UploadFieldMissingFileName, UnknownUploadId, - UnknownModelId, PathIsNotAFile, #[snafu(display("Failed loading multipart body: {reason}"))] Multipart { @@ -511,6 +510,12 @@ pub enum Error { CannotAccessVolumePath { volume_name: String, }, + + #[snafu(display("Unknown resource name {} of kind {}", name, kind))] + UnknownResource { + kind: String, + name: String, + }, } impl actix_web::error::ResponseError for Error { diff --git a/services/src/machine_learning/mod.rs b/services/src/machine_learning/mod.rs index b9ea06e6b..40da348c2 100644 --- a/services/src/machine_learning/mod.rs +++ b/services/src/machine_learning/mod.rs @@ -114,6 +114,11 @@ pub trait MlModelDb { ) -> Result; async fn add_model(&self, model: MlModel) -> Result; + + async fn resolve_model_name_to_id( + &self, + name: &MlModelName, + ) -> Result, MachineLearningError>; } #[async_trait] @@ -145,4 +150,11 @@ where async fn add_model(&self, _model: MlModel) -> Result { unimplemented!() } + + async fn resolve_model_name_to_id( + &self, + _name: &MlModelName, + ) -> Result, MachineLearningError> { + unimplemented!() + } } diff --git a/services/src/pro/api/handlers/permissions.rs b/services/src/pro/api/handlers/permissions.rs index 29840966e..df285dd7f 100644 --- a/services/src/pro/api/handlers/permissions.rs +++ b/services/src/pro/api/handlers/permissions.rs @@ -1,14 +1,17 @@ -use crate::api::model::datatypes::{DatasetId, LayerId}; +use crate::api::model::datatypes::LayerId; use crate::contexts::{ApplicationContext, SessionContext}; -use crate::error::Result; +use crate::datasets::storage::DatasetDb; +use crate::datasets::DatasetName; +use crate::error::{self, Result}; use crate::layers::listing::LayerCollectionId; -use crate::machine_learning::MlModelId; +use crate::machine_learning::MlModelDb; use crate::pro::contexts::{ProApplicationContext, ProGeoEngineDb}; use crate::pro::permissions::{Permission, PermissionListing}; use crate::pro::permissions::{PermissionDb, ResourceId, RoleId}; use crate::projects::ProjectId; use actix_web::{web, FromRequest, HttpResponse}; use geoengine_datatypes::error::BoxedResultExt; +use geoengine_datatypes::machine_learning::MlModelName; use serde::Deserialize; use utoipa::{IntoParams, ToSchema}; @@ -46,27 +49,47 @@ pub struct PermissionRequest { #[serde(rename_all = "camelCase", tag = "type", content = "id")] pub enum Resource { #[schema(title = "LayerResource")] - Layer(LayerId), + Layer(LayerId), // TODO: check model #[schema(title = "LayerCollectionResource")] LayerCollection(LayerCollectionId), #[schema(title = "ProjectResource")] Project(ProjectId), #[schema(title = "DatasetResource")] - Dataset(DatasetId), + Dataset(DatasetName), // TODO: add a DatasetName to model! #[schema(title = "MlModelResource")] - MlModel(MlModelId), + MlModel(MlModelName), } -impl From for ResourceId { - fn from(resource: Resource) -> Self { - match resource { - Resource::Layer(layer_id) => ResourceId::Layer(layer_id.into()), - Resource::LayerCollection(layer_collection_id) => { - ResourceId::LayerCollection(layer_collection_id) +impl Resource { + pub async fn resolve_resource_id(self, db: &D) -> Result { + match self { + Resource::Layer(layer) => Ok(ResourceId::Layer(layer.into())), + Resource::LayerCollection(layer_collection) => { + Ok(ResourceId::LayerCollection(layer_collection)) + } + Resource::Project(project_id) => Ok(ResourceId::Project(project_id)), + Resource::Dataset(dataset_name) => { + let dataset_id_option = db.resolve_dataset_name_to_id(&dataset_name).await?; + dataset_id_option + .ok_or(error::Error::UnknownResource { + kind: "Dataset".to_owned(), + name: dataset_name.to_string(), + }) + .map(ResourceId::DatasetId) + } + Resource::MlModel(model_name) => { + let actual_name = model_name.into(); + let model_id_option = db + .resolve_model_name_to_id(&actual_name) + .await + .map_err(|_| error::Error::RoleNotAssigned)?; // TODO: use a matching error here! + model_id_option + .ok_or(error::Error::UnknownResource { + kind: "MlModel".to_owned(), + name: actual_name.to_string(), + }) + .map(ResourceId::MlModel) } - Resource::Project(project_id) => ResourceId::Project(project_id), - Resource::Dataset(dataset_id) => ResourceId::DatasetId(dataset_id.into()), - Resource::MlModel(ml_model_id) => ResourceId::MlModel(ml_model_id), } } } @@ -148,13 +171,11 @@ where let permission = permission.into_inner(); let db = app_ctx.session_context(session).db(); - db.add_permission::( - permission.role_id, - permission.resource.into(), - permission.permission, - ) - .await - .boxed_context(crate::error::PermissionDb)?; + let permission_id = permission.resource.resolve_resource_id(&db).await?; + + db.add_permission::(permission.role_id, permission_id, permission.permission) + .await + .boxed_context(crate::error::PermissionDb)?; Ok(HttpResponse::Ok().finish()) } @@ -192,13 +213,11 @@ where let permission = permission.into_inner(); let db = app_ctx.session_context(session).db(); - db.remove_permission::( - permission.role_id, - permission.resource.into(), - permission.permission, - ) - .await - .boxed_context(crate::error::PermissionDb)?; + let permission_id = permission.resource.resolve_resource_id(&db).await?; + + db.remove_permission::(permission.role_id, permission_id, permission.permission) + .await + .boxed_context(crate::error::PermissionDb)?; Ok(HttpResponse::Ok().finish()) } @@ -328,11 +347,11 @@ mod tests { async fn it_lists_permissions(app_ctx: ProPostgresContext) { let admin_session = admin_login(&app_ctx).await; - let (gdal_dataset_id, _) = add_ndvi_to_datasets(&app_ctx, true, true).await; + let (dataset_id, _named_data) = add_ndvi_to_datasets(&app_ctx, true, true).await; let req = actix_web::test::TestRequest::get() .uri(&format!( - "/permissions/resources/dataset/{gdal_dataset_id}?offset=0&limit=10", + "/permissions/resources/dataset/{dataset_id}?offset=0&limit=10", )) .append_header((header::CONTENT_LENGTH, 0)) .append_header(( @@ -350,7 +369,7 @@ mod tests { json!([{ "permission":"Owner", "resourceId": { - "id": gdal_dataset_id.to_string(), + "id": dataset_id.to_string(), "type": "DatasetId" }, "role": { @@ -361,7 +380,7 @@ mod tests { }, { "permission": "Read", "resourceId": { - "id": gdal_dataset_id.to_string(), + "id": dataset_id.to_string(), "type": "DatasetId" }, "role": { @@ -371,7 +390,7 @@ mod tests { }, { "permission": "Read", "resourceId": { - "id": gdal_dataset_id.to_string(), + "id": dataset_id.to_string(), "type": "DatasetId" }, "role": { diff --git a/services/src/pro/machine_learning/mod.rs b/services/src/pro/machine_learning/mod.rs index d7d1363d6..6701a1547 100644 --- a/services/src/pro/machine_learning/mod.rs +++ b/services/src/pro/machine_learning/mod.rs @@ -207,4 +207,27 @@ where name: model.name, }) } + + async fn resolve_model_name_to_id( + &self, + model_name: &MlModelName, + ) -> Result, MachineLearningError> { + let conn = self + .conn_pool + .get() + .await + .context(Bb8MachineLearningError)?; + + let stmt = conn + .prepare( + "SELECT id + FROM ml_models + WHERE name = $1::\"MlModelName\"", + ) + .await?; + + let row_option = conn.query_opt(&stmt, &[&model_name]).await?; + + Ok(row_option.map(|row| row.get(0))) + } } From 248d248a2ffce1dfb85a69ff795665c838d26deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Mon, 13 Jan 2025 20:45:26 +0100 Subject: [PATCH 04/13] add error --- services/src/error.rs | 6 ++++++ services/src/pro/api/handlers/permissions.rs | 10 ++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/services/src/error.rs b/services/src/error.rs index c56f37ca4..530249617 100644 --- a/services/src/error.rs +++ b/services/src/error.rs @@ -516,6 +516,12 @@ pub enum Error { kind: String, name: String, }, + + #[snafu(display("MachineLearning error: {}", source))] + MachineLearning { + // TODO: make `source: MachineLearningError`, once pro features is removed + source: Box, + }, } impl actix_web::error::ResponseError for Error { diff --git a/services/src/pro/api/handlers/permissions.rs b/services/src/pro/api/handlers/permissions.rs index df285dd7f..9b16f7154 100644 --- a/services/src/pro/api/handlers/permissions.rs +++ b/services/src/pro/api/handlers/permissions.rs @@ -79,10 +79,12 @@ impl Resource { } Resource::MlModel(model_name) => { let actual_name = model_name.into(); - let model_id_option = db - .resolve_model_name_to_id(&actual_name) - .await - .map_err(|_| error::Error::RoleNotAssigned)?; // TODO: use a matching error here! + let model_id_option = + db.resolve_model_name_to_id(&actual_name) + .await + .map_err(|e| error::Error::MachineLearning { + source: Box::new(e), + })?; // should prob. also map to UnknownResource oder something like that model_id_option .ok_or(error::Error::UnknownResource { kind: "MlModel".to_owned(), From 854afecaa6351ca243c17a8fce0840f5a2282d92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Wed, 22 Jan 2025 16:42:52 +0100 Subject: [PATCH 05/13] add ToSchema --- services/src/api/model/responses/ml_models/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/src/api/model/responses/ml_models/mod.rs b/services/src/api/model/responses/ml_models/mod.rs index 6c16f227f..47128cd73 100644 --- a/services/src/api/model/responses/ml_models/mod.rs +++ b/services/src/api/model/responses/ml_models/mod.rs @@ -1,9 +1,9 @@ use serde::{Deserialize, Serialize}; -use utoipa::ToResponse; +use utoipa::{ToResponse, ToSchema}; use crate::machine_learning::name::MlModelName; -#[derive(Debug, Serialize, Deserialize, Clone, ToResponse)] +#[derive(Debug, Serialize, Deserialize, Clone, ToResponse, ToSchema)] #[serde(rename_all = "camelCase")] #[response(description = "Name of generated resource", example = json!({ "name": "ns:name" From e3b4b438b0508676951a1c4de2631552a9af8165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Wed, 22 Jan 2025 16:43:11 +0100 Subject: [PATCH 06/13] add MlModelNameResponse to response --- services/src/pro/api/handlers/machine_learning.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/src/pro/api/handlers/machine_learning.rs b/services/src/pro/api/handlers/machine_learning.rs index 7c13522f2..7168046fc 100644 --- a/services/src/pro/api/handlers/machine_learning.rs +++ b/services/src/pro/api/handlers/machine_learning.rs @@ -48,7 +48,7 @@ impl ResponseError for MachineLearningError { path = "/ml/models", request_body = MlModel, responses( - (status = 200) + (status = 200, body = [MlModelNameResponse]) ), security( ("session_token" = []) From 7955102e5a5049100a712e8fca5735846c6ad5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Wed, 22 Jan 2025 17:46:04 +0100 Subject: [PATCH 07/13] single model name --- services/src/api/apidoc.rs | 4 +++- services/src/api/handlers/machine_learning.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/services/src/api/apidoc.rs b/services/src/api/apidoc.rs index b92fc418a..64f93cf86 100644 --- a/services/src/api/apidoc.rs +++ b/services/src/api/apidoc.rs @@ -31,6 +31,7 @@ use crate::api::model::operators::{ UnixTimeStampType, VectorColumnInfo, VectorResultDescriptor, }; use crate::api::model::responses::datasets::DatasetNameResponse; +use crate::api::model::responses::ml_models::MlModelNameResponse; use crate::api::model::responses::{ BadRequestQueryResponse, ErrorResponse, IdResponse, PayloadTooLargeResponse, PngResponse, UnauthorizedAdminResponse, UnauthorizedUserResponse, UnsupportedMediaTypeForJsonResponse, @@ -423,7 +424,8 @@ use utoipa::{Modify, OpenApi}; MlModel, MlModelId, MlModelName, - MlModelMetadata + MlModelMetadata, + MlModelNameResponse ), ), modifiers(&SecurityAddon, &ApiDocInfo, &OpenApiServerInfo, &TransformSchemasWithTag), diff --git a/services/src/api/handlers/machine_learning.rs b/services/src/api/handlers/machine_learning.rs index ad2d68db4..60dcd24e6 100644 --- a/services/src/api/handlers/machine_learning.rs +++ b/services/src/api/handlers/machine_learning.rs @@ -48,7 +48,7 @@ impl ResponseError for MachineLearningError { path = "/ml/models", request_body = MlModel, responses( - (status = 200, body = [MlModelNameResponse]) + (status = 200, body = MlModelNameResponse) ), security( ("session_token" = []) From ae013e7509c5822d6910f060c98bc2dd31b7e58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Wed, 22 Jan 2025 20:00:04 +0100 Subject: [PATCH 08/13] better name in response --- services/src/api/model/responses/ml_models/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/src/api/model/responses/ml_models/mod.rs b/services/src/api/model/responses/ml_models/mod.rs index 47128cd73..05f5d8582 100644 --- a/services/src/api/model/responses/ml_models/mod.rs +++ b/services/src/api/model/responses/ml_models/mod.rs @@ -9,11 +9,11 @@ use crate::machine_learning::name::MlModelName; "name": "ns:name" }))] pub struct MlModelNameResponse { - pub dataset_name: MlModelName, + pub ml_model_name: MlModelName, } impl From for MlModelNameResponse { - fn from(dataset_name: MlModelName) -> Self { - Self { dataset_name } + fn from(ml_model_name: MlModelName) -> Self { + Self { ml_model_name } } } From c14eba81ce9a609e33ea3f0e6923555ec4e0907d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Wed, 29 Jan 2025 09:48:38 +0100 Subject: [PATCH 09/13] allow names in permission listing api --- datatypes/src/machine_learning.rs | 71 ++++-- services/src/api/apidoc.rs | 8 +- services/src/api/handlers/permissions.rs | 107 +++++++-- services/src/datasets/mod.rs | 2 +- services/src/datasets/name.rs | 97 +++++--- services/src/error.rs | 20 ++ services/src/permissions/mod.rs | 27 +-- .../src/pro/api/handlers/machine_learning.rs | 223 ++++++++++++++++++ 8 files changed, 439 insertions(+), 116 deletions(-) create mode 100644 services/src/pro/api/handlers/machine_learning.rs diff --git a/datatypes/src/machine_learning.rs b/datatypes/src/machine_learning.rs index 3d19ac1af..edc1a021e 100644 --- a/datatypes/src/machine_learning.rs +++ b/datatypes/src/machine_learning.rs @@ -1,11 +1,12 @@ -use std::path::PathBuf; - -use serde::{de::Visitor, Deserialize, Serialize}; - use crate::{ dataset::{is_invalid_name_char, SYSTEM_NAMESPACE}, raster::RasterDataType, }; +use serde::{de::Visitor, Deserialize, Serialize}; +use snafu::Snafu; +use std::path::PathBuf; +use std::str::FromStr; +use strum::IntoStaticStr; const NAME_DELIMITER: char = ':'; @@ -15,6 +16,18 @@ pub struct MlModelName { pub name: String, } +#[derive(Snafu, IntoStaticStr, Debug)] +#[snafu(visibility(pub(crate)))] +#[snafu(context(suffix(false)))] // disables default `Snafu` suffix +pub enum MlModelNameError { + #[snafu(display("MlModelName is empty"))] + IsEmpty, + #[snafu(display("invalid character '{invalid_char}' in named model"))] + InvalidCharacter { invalid_char: String }, + #[snafu(display("ml model name must consist of at most two parts"))] + TooManyParts, +} + impl MlModelName { /// Canonicalize a name that reflects the system namespace and model. fn canonicalize + PartialEq<&'static str>>( @@ -62,40 +75,29 @@ impl<'de> Deserialize<'de> for MlModelName { } } -struct MlModelNameDeserializeVisitor; - -impl Visitor<'_> for MlModelNameDeserializeVisitor { - type Value = MlModelName; - - /// always keep in sync with [`is_allowed_name_char`] - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!( - formatter, - "a string consisting of a namespace and name name, separated by a colon, only using alphanumeric characters, underscores & dashes" - ) - } +impl FromStr for MlModelName { + type Err = MlModelNameError; - fn visit_str(self, s: &str) -> Result - where - E: serde::de::Error, - { + fn from_str(s: &str) -> Result { let mut strings = [None, None]; let mut split = s.split(NAME_DELIMITER); for (buffer, part) in strings.iter_mut().zip(&mut split) { if part.is_empty() { - return Err(E::custom("empty part in named data")); + return Err(MlModelNameError::IsEmpty); } if let Some(c) = part.matches(is_invalid_name_char).next() { - return Err(E::custom(format!("invalid character '{c}' in named model"))); + return Err(MlModelNameError::InvalidCharacter { + invalid_char: c.to_string(), + }); } *buffer = Some(part.to_string()); } if split.next().is_some() { - return Err(E::custom("named model must consist of at most two parts")); + return Err(MlModelNameError::TooManyParts); } match strings { @@ -107,11 +109,32 @@ impl Visitor<'_> for MlModelNameDeserializeVisitor { namespace: None, name, }), - _ => Err(E::custom("empty named data")), + _ => Err(MlModelNameError::IsEmpty), } } } +struct MlModelNameDeserializeVisitor; + +impl Visitor<'_> for MlModelNameDeserializeVisitor { + type Value = MlModelName; + + /// always keep in sync with [`is_allowed_name_char`] + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + formatter, + "a string consisting of a namespace and name name, separated by a colon, only using alphanumeric characters, underscores & dashes" + ) + } + + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + MlModelName::from_str(s).map_err(|e| E::custom(e.to_string())) + } +} + // For now we assume all models are pixel-wise, i.e., they take a single pixel with multiple bands as input and produce a single output value. // To support different inputs, we would need a more sophisticated logic to produce the inputs for the model. #[derive(Debug, Clone, Hash, Eq, PartialEq, Deserialize, Serialize)] diff --git a/services/src/api/apidoc.rs b/services/src/api/apidoc.rs index 64f93cf86..9b51d3a61 100644 --- a/services/src/api/apidoc.rs +++ b/services/src/api/apidoc.rs @@ -1,6 +1,8 @@ use crate::api::handlers; use crate::api::handlers::datasets::VolumeFileLayersResponse; -use crate::api::handlers::permissions::{PermissionListOptions, PermissionRequest, Resource}; +use crate::api::handlers::permissions::{ + PermissionListOptions, PermissionListing, PermissionRequest, Resource, +}; use crate::api::handlers::plots::WrappedPlotOutput; use crate::api::handlers::spatial_references::{AxisOrder, SpatialReferenceSpecification}; use crate::api::handlers::tasks::{TaskAbortOptions, TaskResponse}; @@ -57,9 +59,7 @@ use crate::layers::listing::{ }; use crate::machine_learning::name::MlModelName; use crate::machine_learning::{MlModel, MlModelId, MlModelMetadata}; -use crate::permissions::{ - Permission, PermissionListing, ResourceId, Role, RoleDescription, RoleId, -}; +use crate::permissions::{Permission, ResourceId, Role, RoleDescription, RoleId}; use crate::projects::{ ColorParam, CreateProject, DerivedColor, DerivedNumber, LayerUpdate, LayerVisibility, LineSymbology, NumberParam, Plot, PlotUpdate, PointSymbology, PolygonSymbology, Project, diff --git a/services/src/api/handlers/permissions.rs b/services/src/api/handlers/permissions.rs index 67ad089b9..a5d934942 100644 --- a/services/src/api/handlers/permissions.rs +++ b/services/src/api/handlers/permissions.rs @@ -2,17 +2,22 @@ use crate::api::model::datatypes::LayerId; use crate::contexts::{ApplicationContext, SessionContext}; use crate::datasets::storage::DatasetDb; use crate::datasets::DatasetName; -use crate::error::{self, Result}; +use crate::error::{self, Error, Result}; use crate::layers::listing::LayerCollectionId; use crate::machine_learning::MlModelDb; -use crate::permissions::{Permission, PermissionDb, PermissionListing, ResourceId, RoleId}; +use crate::permissions::{ + Permission, PermissionDb, PermissionListing as DbPermissionListing, ResourceId, Role, RoleId, +}; use crate::pro::contexts::{ProApplicationContext, ProGeoEngineDb}; use crate::projects::ProjectId; use actix_web::{web, FromRequest, HttpResponse}; use geoengine_datatypes::error::BoxedResultExt; use geoengine_datatypes::machine_learning::MlModelName; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use snafu::ResultExt; +use std::str::FromStr; use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; pub(crate) fn init_permissions_routes(cfg: &mut web::ServiceConfig) where @@ -43,8 +48,29 @@ pub struct PermissionRequest { permission: Permission, } +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PermissionListing { + resource: Resource, + role: Role, + permission: Permission, +} + +impl PermissionListing { + fn wrap_permission_listing_and_resource( + resource: Resource, + db_permission_listing: DbPermissionListing, + ) -> PermissionListing { + Self { + resource, + role: db_permission_listing.role, + permission: db_permission_listing.permission, + } + } +} + /// A resource that is affected by a permission. -#[derive(Debug, PartialEq, Eq, Deserialize, Clone, ToSchema)] +#[derive(Debug, PartialEq, Eq, Deserialize, Clone, ToSchema, Serialize)] #[serde(rename_all = "camelCase", tag = "type", content = "id")] pub enum Resource { #[schema(title = "LayerResource")] @@ -60,15 +86,18 @@ pub enum Resource { } impl Resource { - pub async fn resolve_resource_id(self, db: &D) -> Result { + pub async fn resolve_resource_id( + &self, + db: &D, + ) -> Result { match self { - Resource::Layer(layer) => Ok(ResourceId::Layer(layer.into())), + Resource::Layer(layer) => Ok(ResourceId::Layer(layer.clone().into())), Resource::LayerCollection(layer_collection) => { - Ok(ResourceId::LayerCollection(layer_collection)) + Ok(ResourceId::LayerCollection(layer_collection.clone())) } - Resource::Project(project_id) => Ok(ResourceId::Project(project_id)), + Resource::Project(project_id) => Ok(ResourceId::Project(*project_id)), Resource::Dataset(dataset_name) => { - let dataset_id_option = db.resolve_dataset_name_to_id(&dataset_name).await?; + let dataset_id_option = db.resolve_dataset_name_to_id(dataset_name).await?; dataset_id_option .ok_or(error::Error::UnknownResource { kind: "Dataset".to_owned(), @@ -77,7 +106,7 @@ impl Resource { .map(ResourceId::DatasetId) } Resource::MlModel(model_name) => { - let actual_name = model_name.into(); + let actual_name = model_name.clone().into(); let model_id_option = db.resolve_model_name_to_id(&actual_name) .await @@ -95,6 +124,29 @@ impl Resource { } } +impl TryFrom<(String, String)> for Resource { + type Error = Error; + + /// Transform a tuple of `String` into a `Resource`. The first element is used as type and the second element as the id / name. + fn try_from(value: (String, String)) -> Result { + Ok(match value.0.as_str() { + "layer" => Resource::Layer(LayerId(value.1)), + "layerCollection" => Resource::LayerCollection(LayerCollectionId(value.1)), + "project" => { + Resource::Project(ProjectId(Uuid::from_str(&value.1).context(error::Uuid)?)) + } + "dataset" => Resource::Dataset(DatasetName::from_str(&value.1)?), + "mlModel" => Resource::MlModel(MlModelName::from_str(&value.1)?), + _ => { + return Err(Error::InvalidResourceId { + resource_type: value.0, + resource_id: value.1, + }) + } + }) + } +} + #[derive(Debug, PartialEq, Eq, Deserialize, Clone, IntoParams, ToSchema)] pub struct PermissionListOptions { pub limit: u32, @@ -127,15 +179,21 @@ async fn get_resource_permissions_handler( where <::SessionContext as SessionContext>::GeoEngineDB: ProGeoEngineDb, { - let resource_id = ResourceId::try_from(resource_id.into_inner())?; + let resource = Resource::try_from(resource_id.into_inner())?; + let db = app_ctx.session_context(session).db(); + let resource_id = resource.resolve_resource_id(&db).await?; let options = options.into_inner(); - let db = app_ctx.session_context(session).db(); let permissions = db .list_permissions(resource_id, options.offset, options.limit) .await .boxed_context(crate::error::PermissionDb)?; + let permissions = permissions + .into_iter() + .map(|p| PermissionListing::wrap_permission_listing_and_resource(resource.clone(), p)) + .collect(); + Ok(web::Json(permissions)) } @@ -346,11 +404,11 @@ mod tests { async fn it_lists_permissions(app_ctx: PostgresContext) { let admin_session = admin_login(&app_ctx).await; - let (dataset_id, _) = add_ndvi_to_datasets2(&app_ctx, true, true).await; + let (_dataset_id, dataset_name) = add_ndvi_to_datasets2(&app_ctx, true, true).await; let req = actix_web::test::TestRequest::get() .uri(&format!( - "/permissions/resources/dataset/{dataset_id}?offset=0&limit=10", + "/permissions/resources/dataset/{dataset_name}?offset=0&limit=10", )) .append_header((header::CONTENT_LENGTH, 0)) .append_header(( @@ -367,9 +425,9 @@ mod tests { res_body, json!([{ "permission":"Owner", - "resourceId": { - "id": dataset_id.to_string(), - "type": "DatasetId" + "resource": { + "id": dataset_name.to_string(), + "type": "dataset" }, "role": { "id": "d5328854-6190-4af9-ad69-4e74b0961ac9", @@ -378,9 +436,9 @@ mod tests { } }, { "permission": "Read", - "resourceId": { - "id": dataset_id.to_string(), - "type": "DatasetId" + "resource": { + "id": dataset_name.to_string(), + "type": "dataset" }, "role": { "id": "fd8e87bf-515c-4f36-8da6-1a53702ff102", @@ -388,14 +446,13 @@ mod tests { } }, { "permission": "Read", - "resourceId": { - "id": dataset_id.to_string(), - "type": "DatasetId" + "resource": { + "id": dataset_name.to_string(), + "type": "dataset", }, "role": { "id": "4e8081b6-8aa6-4275-af0c-2fa2da557d28", - "name": - "user" + "name": "user" } }] ) diff --git a/services/src/datasets/mod.rs b/services/src/datasets/mod.rs index b6f5b24bd..dd0a03575 100644 --- a/services/src/datasets/mod.rs +++ b/services/src/datasets/mod.rs @@ -11,5 +11,5 @@ pub(crate) use create_from_workflow::{ schedule_raster_dataset_from_workflow_task, RasterDatasetFromWorkflow, RasterDatasetFromWorkflowResult, }; -pub use name::{DatasetIdAndName, DatasetName}; +pub use name::{DatasetIdAndName, DatasetName, DatasetNameError}; pub use storage::AddDataset; diff --git a/services/src/datasets/name.rs b/services/src/datasets/name.rs index c9cd1df2b..5c71144be 100644 --- a/services/src/datasets/name.rs +++ b/services/src/datasets/name.rs @@ -1,6 +1,9 @@ use geoengine_datatypes::dataset::{DatasetId, NamedData}; use postgres_types::{FromSql, ToSql}; use serde::{de::Visitor, Deserialize, Serialize}; +use snafu::Snafu; +use std::str::FromStr; +use strum::IntoStaticStr; use utoipa::{IntoParams, ToSchema}; /// A (optionally namespaced) name for a `Dataset`. @@ -11,6 +14,18 @@ pub struct DatasetName { pub name: String, } +#[derive(Snafu, IntoStaticStr, Debug)] +#[snafu(visibility(pub(crate)))] +#[snafu(context(suffix(false)))] // disables default `Snafu` suffix +pub enum DatasetNameError { + #[snafu(display("DatasetName is empty"))] + IsEmpty, + #[snafu(display("invalid character '{invalid_char}' in named data"))] + InvalidCharacter { invalid_char: String }, + #[snafu(display("named data must consist of at most two parts"))] + TooManyParts, +} + impl DatasetName { /// Canonicalize a name that reflects the system namespace and provider. fn canonicalize + PartialEq<&'static str>>( @@ -44,6 +59,51 @@ impl std::fmt::Display for DatasetName { } } +impl FromStr for DatasetName { + type Err = DatasetNameError; + + fn from_str(s: &str) -> Result { + let mut strings = [None, None]; + let mut split = s.split(geoengine_datatypes::dataset::NAME_DELIMITER); + + for (buffer, part) in strings.iter_mut().zip(&mut split) { + if part.is_empty() { + return Err(DatasetNameError::IsEmpty); + } + + if let Some(c) = part + .matches(geoengine_datatypes::dataset::is_invalid_name_char) + .next() + { + return Err(DatasetNameError::InvalidCharacter { + invalid_char: c.to_string(), + }); + } + + *buffer = Some(part.to_string()); + } + + if split.next().is_some() { + return Err(DatasetNameError::TooManyParts); + } + + match strings { + [Some(namespace), Some(name)] => Ok(DatasetName { + namespace: DatasetName::canonicalize( + namespace, + geoengine_datatypes::dataset::SYSTEM_NAMESPACE, + ), + name, + }), + [Some(name), None] => Ok(DatasetName { + namespace: None, + name, + }), + _ => Err(DatasetNameError::IsEmpty), + } + } +} + impl Serialize for DatasetName { fn serialize(&self, serializer: S) -> Result where @@ -87,42 +147,7 @@ impl Visitor<'_> for DatasetNameDeserializeVisitor { where E: serde::de::Error, { - let mut strings = [None, None]; - let mut split = s.split(geoengine_datatypes::dataset::NAME_DELIMITER); - - for (buffer, part) in strings.iter_mut().zip(&mut split) { - if part.is_empty() { - return Err(E::custom("empty part in named data")); - } - - if let Some(c) = part - .matches(geoengine_datatypes::dataset::is_invalid_name_char) - .next() - { - return Err(E::custom(format!("invalid character '{c}' in named data"))); - } - - *buffer = Some(part.to_string()); - } - - if split.next().is_some() { - return Err(E::custom("named data must consist of at most two parts")); - } - - match strings { - [Some(namespace), Some(name)] => Ok(DatasetName { - namespace: DatasetName::canonicalize( - namespace, - geoengine_datatypes::dataset::SYSTEM_NAMESPACE, - ), - name, - }), - [Some(name), None] => Ok(DatasetName { - namespace: None, - name, - }), - _ => Err(E::custom("empty named data")), - } + DatasetName::from_str(s).map_err(|e| E::custom(e.to_string())) } } diff --git a/services/src/error.rs b/services/src/error.rs index 64f084645..72adfb9fb 100644 --- a/services/src/error.rs +++ b/services/src/error.rs @@ -522,6 +522,14 @@ pub enum Error { // TODO: make `source: MachineLearningError`, once pro features is removed source: Box, }, + + DatasetName { + source: crate::datasets::DatasetNameError, + }, + + MlModelName { + source: geoengine_datatypes::machine_learning::MlModelNameError, + }, } impl actix_web::error::ResponseError for Error { @@ -622,3 +630,15 @@ impl From for Error { Error::InvalidNotNanFloatKey { source } } } + +impl From for Error { + fn from(source: crate::datasets::DatasetNameError) -> Self { + Error::DatasetName { source } + } +} + +impl From for Error { + fn from(source: geoengine_datatypes::machine_learning::MlModelNameError) -> Self { + Error::MlModelName { source } + } +} diff --git a/services/src/permissions/mod.rs b/services/src/permissions/mod.rs index 7e15ca4a0..a1a6b577f 100644 --- a/services/src/permissions/mod.rs +++ b/services/src/permissions/mod.rs @@ -1,4 +1,4 @@ -use crate::error::{self, Error, Result}; +use crate::error::Result; use crate::identifier; use crate::layers::listing::LayerCollectionId; use crate::machine_learning::MlModelId; @@ -8,11 +8,9 @@ use async_trait::async_trait; use geoengine_datatypes::dataset::{DatasetId, LayerId}; use postgres_types::{FromSql, ToSql}; use serde::{Deserialize, Serialize}; -use snafu::ResultExt; use snafu::Snafu; use std::str::FromStr; use utoipa::ToSchema; -use uuid::Uuid; mod postgres_permissiondb; @@ -142,29 +140,6 @@ impl From for ResourceId { } } -impl TryFrom<(String, String)> for ResourceId { - type Error = Error; - - fn try_from(value: (String, String)) -> Result { - Ok(match value.0.as_str() { - "layer" => ResourceId::Layer(LayerId(value.1)), - "layerCollection" => ResourceId::LayerCollection(LayerCollectionId(value.1)), - "project" => { - ResourceId::Project(ProjectId(Uuid::from_str(&value.1).context(error::Uuid)?)) - } - "dataset" => { - ResourceId::DatasetId(DatasetId(Uuid::from_str(&value.1).context(error::Uuid)?)) - } - _ => { - return Err(Error::InvalidResourceId { - resource_type: value.0, - resource_id: value.1, - }) - } - }) - } -} - #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, ToSchema)] #[serde(rename_all = "camelCase")] pub struct PermissionListing { diff --git a/services/src/pro/api/handlers/machine_learning.rs b/services/src/pro/api/handlers/machine_learning.rs new file mode 100644 index 000000000..3eecb534e --- /dev/null +++ b/services/src/pro/api/handlers/machine_learning.rs @@ -0,0 +1,223 @@ +use actix_web::{web, FromRequest, HttpResponse, ResponseError}; + +use crate::{ + api::model::responses::ErrorResponse, + contexts::{ApplicationContext, SessionContext}, + machine_learning::{ + error::MachineLearningError, name::MlModelName, MlModel, MlModelDb, MlModelListOptions, + }, +}; + +pub(crate) fn init_ml_routes(cfg: &mut web::ServiceConfig) +where + C: ApplicationContext, + C::Session: FromRequest, +{ + cfg.service( + web::scope("/ml").service( + web::scope("/models") + .service( + web::resource("") + .route(web::post().to(add_ml_model::)) + .route(web::get().to(list_ml_models::)), + ) + .service(web::resource("/{model_name}").route(web::get().to(get_ml_model::))), + ), + ); +} + +impl ResponseError for MachineLearningError { + fn status_code(&self) -> actix_http::StatusCode { + match self { + MachineLearningError::Postgres { .. } | MachineLearningError::Bb8 { .. } => { + actix_http::StatusCode::INTERNAL_SERVER_ERROR + } + _ => actix_http::StatusCode::BAD_REQUEST, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(ErrorResponse::from(self)) + } +} + +/// Create a new ml model. +#[utoipa::path( + tag = "ML", + post, + path = "/ml/models", + request_body = MlModel, + responses( + (status = 200, body = MlModelNameResponse) + ), + security( + ("session_token" = []) + ) +)] + +pub(crate) async fn add_ml_model( + session: C::Session, + app_ctx: web::Data, + model: web::Json, +) -> Result>, MachineLearningError> { + let model = model.into_inner(); + let id_and_name = app_ctx + .session_context(session) + .db() + .add_model(model) + .await?; + Ok(web::Json(id_and_name.name.into()).finish()) +} + +/// List ml models. +#[utoipa::path( + tag = "ML", + get, + path = "/ml/models", + responses( + (status = 200, body = [MlModel]) + ), + security( + ("session_token" = []) + ) +)] + +pub(crate) async fn list_ml_models( + session: C::Session, + app_ctx: web::Data, + options: web::Query, +) -> Result>, MachineLearningError> { + let options = options.into_inner(); + let models = app_ctx + .session_context(session) + .db() + .list_models(&options) + .await?; + Ok(web::Json(models)) +} + +/// Get ml model by name. +#[utoipa::path( + tag = "ML", + get, + path = "/ml/models/{model_name}", + responses( + (status = 200, body = MlModel) + ), + params( + ("model_name" = MlModelName, description = "Ml Model Name") + ), + security( + ("session_token" = []) + ) +)] + +pub(crate) async fn get_ml_model( + session: C::Session, + app_ctx: web::Data, + model_name: web::Path, +) -> Result, MachineLearningError> { + let model_name = model_name.into_inner(); + + let models = app_ctx + .session_context(session) + .db() + .load_model(&model_name) + .await?; + Ok(web::Json(models)) +} + +#[cfg(test)] +mod tests { + use actix_http::header; + use actix_web::test; + use actix_web_httpauth::headers::authorization::Bearer; + use tokio_postgres::NoTls; + + use crate::{ + api::model::{datatypes::RasterDataType, responses::IdResponse}, + contexts::Session, + datasets::upload::UploadId, + machine_learning::MlModelMetadata, + pro::{ + contexts::ProPostgresContext, ge_context, users::UserAuth, + util::tests::send_pro_test_request, + }, + util::tests::{SetMultipartBody, TestDataUploads}, + }; + + use super::*; + + #[ge_context::test] + async fn it_stores_ml_models_for_application(app_ctx: ProPostgresContext) { + let mut test_data = TestDataUploads::default(); // remember created folder and remove them on drop + + let session = app_ctx.create_anonymous_session().await.unwrap(); + let session_id = session.id(); + + let body = vec![( + "model.onnx", + include_bytes!("../../../../../test_data/pro/ml/onnx/test_classification.onnx"), + )]; + + let req = test::TestRequest::post() + .uri("/upload") + .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string()))) + .set_multipart(body); + + let res = send_pro_test_request(req, app_ctx.clone()).await; + + assert_eq!(res.status(), 200); + + let upload: IdResponse = test::read_body_json(res).await; + test_data.uploads.push(upload.id); + + let model = MlModel { + name: MlModelName::new(Some(session.user.id.to_string()), "test_classification"), + display_name: "Test Classification".to_string(), + description: "Test Classification Model".to_string(), + upload: upload.id, + metadata: MlModelMetadata { + file_name: "model.onnx".to_string(), + input_type: RasterDataType::F32, + num_input_bands: 2, + output_type: RasterDataType::I64, + }, + }; + + let req = test::TestRequest::post() + .uri("/ml/models") + .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string()))) + .set_json(&model); + + let res = send_pro_test_request(req, app_ctx.clone()).await; + + assert_eq!(res.status(), 200); + + let req = test::TestRequest::get() + .uri("/ml/models?offset=0&limit=10") + .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string()))); + + let res = send_pro_test_request(req, app_ctx.clone()).await; + + assert_eq!(res.status(), 200); + + let models: Vec = test::read_body_json(res).await; + + assert_eq!(models.len(), 1); + + assert_eq!(model, models[0]); + + let req = test::TestRequest::get() + .uri(&format!("/ml/models/{}", model.name)) + .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string()))); + + let res = send_pro_test_request(req, app_ctx).await; + + assert_eq!(res.status(), 200); + + let res_model: MlModel = test::read_body_json(res).await; + + assert_eq!(model, res_model); + } +} From 2e70fa7a69273de8f399fdd1423add7a5d6d0413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Wed, 29 Jan 2025 12:18:18 +0100 Subject: [PATCH 10/13] more tests 1 --- datatypes/src/machine_learning.rs | 32 +++++++++++++++++++++++ services/src/contexts/postgres.rs | 42 +++++++++++++++++++++++++++++++ services/src/datasets/name.rs | 32 +++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/datatypes/src/machine_learning.rs b/datatypes/src/machine_learning.rs index edc1a021e..5bd41f5ec 100644 --- a/datatypes/src/machine_learning.rs +++ b/datatypes/src/machine_learning.rs @@ -145,3 +145,35 @@ pub struct MlModelMetadata { pub output_type: RasterDataType, // TODO: support multiple outputs, e.g. one band for the probability of prediction // TODO: output measurement, e.g. classification or regression, label names for classification. This would have to be provided by the model creator along the model file as it cannot be extracted from the model file(?) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ml_model_name_from_str() { + const ML_MODEL_NAME: &'static str = "myModelName"; + let mln = MlModelName::from_str(ML_MODEL_NAME).unwrap(); + assert_eq!(mln.name, ML_MODEL_NAME); + assert!(mln.namespace.is_none()); + } + + #[test] + fn ml_model_name_from_str_prefixed() { + const ML_MODEL_NAME: &'static str = "d5328854-6190-4af9-ad69-4e74b0961ac9:myModelName"; + let mln = MlModelName::from_str(ML_MODEL_NAME).unwrap(); + assert_eq!(mln.name, "myModelName".to_string()); + assert_eq!( + mln.namespace, + Some("d5328854-6190-4af9-ad69-4e74b0961ac9".to_string()) + ); + } + + #[test] + fn ml_model_name_from_str_system() { + const ML_MODEL_NAME: &'static str = "_:myModelName"; + let mln = MlModelName::from_str(ML_MODEL_NAME).unwrap(); + assert_eq!(mln.name, "myModelName".to_string()); + assert!(mln.namespace.is_none()) + } +} diff --git a/services/src/contexts/postgres.rs b/services/src/contexts/postgres.rs index 96aeb90c8..0bf204ab9 100644 --- a/services/src/contexts/postgres.rs +++ b/services/src/contexts/postgres.rs @@ -462,6 +462,7 @@ where #[cfg(test)] mod tests { use super::*; + use crate::api::model::datatypes::RasterDataType as ApiRasterDataType; use crate::config::QuotaTrackingMode; use crate::datasets::external::netcdfcf::NetCdfCfDataProviderDefinition; use crate::datasets::listing::{DatasetListOptions, DatasetListing, ProvenanceOutput}; @@ -483,6 +484,7 @@ mod tests { LayerDb, LayerProviderDb, LayerProviderListing, LayerProviderListingOptions, INTERNAL_PROVIDER_ID, }; + use crate::machine_learning::{MlModel, MlModelDb, MlModelIdAndName, MlModelMetadata}; use crate::permissions::{Permission, PermissionDb, Role, RoleDescription, RoleId}; use crate::projects::{ CreateProject, LayerUpdate, LoadVersion, OrderBy, Plot, PlotUpdate, PointSymbology, @@ -4882,4 +4884,44 @@ mod tests { async fn it_handles_oidc_tokens_with_encryption(app_ctx: PostgresContext) { it_handles_oidc_tokens(app_ctx).await; } + + #[ge_context::test] + #[allow(clippy::too_many_lines)] + async fn it_resolves_ml_model_names_to_ids(app_ctx: PostgresContext) { + let admin_session = UserSession::admin_session(); + let db = app_ctx.session_context(admin_session.clone()).db(); + + let upload_id = UploadId::new(); + let upload = Upload { + id: upload_id, + files: vec![], + }; + db.create_upload(upload).await.unwrap(); + + let model = MlModel { + description: "No real model here".to_owned(), + display_name: "my unreal model".to_owned(), + metadata: MlModelMetadata { + file_name: "myUnrealmodel.onnx".to_owned(), + input_type: ApiRasterDataType::F32, + num_input_bands: 17, + output_type: ApiRasterDataType::F64, + }, + name: MlModelName::new(None, "myUnrealModel"), + upload: upload_id, + }; + + let MlModelIdAndName { + id: model_id, + name: model_name, + } = db.add_model(model).await.unwrap(); + + assert_eq!( + db.resolve_model_name_to_id(&model_name) + .await + .unwrap() + .unwrap(), + model_id + ); + } } diff --git a/services/src/datasets/name.rs b/services/src/datasets/name.rs index 5c71144be..6a11c871d 100644 --- a/services/src/datasets/name.rs +++ b/services/src/datasets/name.rs @@ -216,3 +216,35 @@ pub struct DatasetIdAndName { pub id: DatasetId, pub name: DatasetName, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dataset_name_from_str() { + const DATASET_NAME: &'static str = "myDatasetName"; + let mln = DatasetName::from_str(DATASET_NAME).unwrap(); + assert_eq!(mln.name, DATASET_NAME); + assert!(mln.namespace.is_none()); + } + + #[test] + fn dataset_name_from_str_prefixed() { + const DATASET_NAME: &'static str = "d5328854-6190-4af9-ad69-4e74b0961ac9:myDatasetName"; + let mln = DatasetName::from_str(DATASET_NAME).unwrap(); + assert_eq!(mln.name, "myDatasetName".to_string()); + assert_eq!( + mln.namespace, + Some("d5328854-6190-4af9-ad69-4e74b0961ac9".to_string()) + ); + } + + #[test] + fn dataset_name_from_str_system() { + const DATASET_NAME: &'static str = "_:myDatasetName"; + let mln = DatasetName::from_str(DATASET_NAME).unwrap(); + assert_eq!(mln.name, "myDatasetName".to_string()); + assert!(mln.namespace.is_none()) + } +} From 1b9448a41665c82acebfe96e2f688e8f8c352bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Wed, 29 Jan 2025 12:44:23 +0100 Subject: [PATCH 11/13] clippy fix --- datatypes/src/machine_learning.rs | 8 ++++---- services/src/datasets/name.rs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/datatypes/src/machine_learning.rs b/datatypes/src/machine_learning.rs index 5bd41f5ec..b4cdf687f 100644 --- a/datatypes/src/machine_learning.rs +++ b/datatypes/src/machine_learning.rs @@ -152,7 +152,7 @@ mod tests { #[test] fn ml_model_name_from_str() { - const ML_MODEL_NAME: &'static str = "myModelName"; + const ML_MODEL_NAME: &str = "myModelName"; let mln = MlModelName::from_str(ML_MODEL_NAME).unwrap(); assert_eq!(mln.name, ML_MODEL_NAME); assert!(mln.namespace.is_none()); @@ -160,7 +160,7 @@ mod tests { #[test] fn ml_model_name_from_str_prefixed() { - const ML_MODEL_NAME: &'static str = "d5328854-6190-4af9-ad69-4e74b0961ac9:myModelName"; + const ML_MODEL_NAME: &str = "d5328854-6190-4af9-ad69-4e74b0961ac9:myModelName"; let mln = MlModelName::from_str(ML_MODEL_NAME).unwrap(); assert_eq!(mln.name, "myModelName".to_string()); assert_eq!( @@ -171,9 +171,9 @@ mod tests { #[test] fn ml_model_name_from_str_system() { - const ML_MODEL_NAME: &'static str = "_:myModelName"; + const ML_MODEL_NAME: &str = "_:myModelName"; let mln = MlModelName::from_str(ML_MODEL_NAME).unwrap(); assert_eq!(mln.name, "myModelName".to_string()); - assert!(mln.namespace.is_none()) + assert!(mln.namespace.is_none()); } } diff --git a/services/src/datasets/name.rs b/services/src/datasets/name.rs index 6a11c871d..0315a4033 100644 --- a/services/src/datasets/name.rs +++ b/services/src/datasets/name.rs @@ -223,7 +223,7 @@ mod tests { #[test] fn dataset_name_from_str() { - const DATASET_NAME: &'static str = "myDatasetName"; + const DATASET_NAME: &str = "myDatasetName"; let mln = DatasetName::from_str(DATASET_NAME).unwrap(); assert_eq!(mln.name, DATASET_NAME); assert!(mln.namespace.is_none()); @@ -231,7 +231,7 @@ mod tests { #[test] fn dataset_name_from_str_prefixed() { - const DATASET_NAME: &'static str = "d5328854-6190-4af9-ad69-4e74b0961ac9:myDatasetName"; + const DATASET_NAME: &str = "d5328854-6190-4af9-ad69-4e74b0961ac9:myDatasetName"; let mln = DatasetName::from_str(DATASET_NAME).unwrap(); assert_eq!(mln.name, "myDatasetName".to_string()); assert_eq!( @@ -242,9 +242,9 @@ mod tests { #[test] fn dataset_name_from_str_system() { - const DATASET_NAME: &'static str = "_:myDatasetName"; + const DATASET_NAME: &str = "_:myDatasetName"; let mln = DatasetName::from_str(DATASET_NAME).unwrap(); assert_eq!(mln.name, "myDatasetName".to_string()); - assert!(mln.namespace.is_none()) + assert!(mln.namespace.is_none()); } } From 916239e8fe8b296a894fb592079c40ad459e4864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Wed, 29 Jan 2025 16:36:48 +0100 Subject: [PATCH 12/13] more permission tests 2 --- services/src/api/handlers/permissions.rs | 229 ++++++++++++++++++++++- 1 file changed, 227 insertions(+), 2 deletions(-) diff --git a/services/src/api/handlers/permissions.rs b/services/src/api/handlers/permissions.rs index 6f28fc0b6..e85e858d3 100644 --- a/services/src/api/handlers/permissions.rs +++ b/services/src/api/handlers/permissions.rs @@ -279,18 +279,25 @@ mod tests { use super::*; use crate::{ + api::model::datatypes::RasterDataType as ApiRasterDataType, contexts::PostgresContext, + datasets::upload::{Upload, UploadDb, UploadId}, ge_context, + layers::{layer::AddLayer, listing::LayerCollectionProvider, storage::LayerDb}, + machine_learning::{MlModel, MlModelIdAndName, MlModelMetadata}, users::{UserAuth, UserCredentials, UserRegistration}, util::tests::{ add_ndvi_to_datasets2, add_ports_to_datasets, admin_login, read_body_string, send_test_request, }, + workflows::workflow::Workflow, }; use actix_http::header; use actix_web_httpauth::headers::authorization::Bearer; + use geoengine_datatypes::{primitives::Coordinate2D, util::Identifier}; use geoengine_operators::{ - engine::{RasterOperator, VectorOperator, WorkflowOperatorPath}, + engine::{RasterOperator, TypedOperator, VectorOperator, WorkflowOperatorPath}, + mock::{MockPointSource, MockPointSourceParams}, source::{GdalSource, GdalSourceParameters, OgrSource, OgrSourceParameters}, }; use serde_json::{json, Value}; @@ -394,7 +401,7 @@ mod tests { #[ge_context::test] #[allow(clippy::too_many_lines)] - async fn it_lists_permissions(app_ctx: PostgresContext) { + async fn it_lists_dataset_permissions(app_ctx: PostgresContext) { let admin_session = admin_login(&app_ctx).await; let (_dataset_id, dataset_name) = add_ndvi_to_datasets2(&app_ctx, true, true).await; @@ -451,4 +458,222 @@ mod tests { ) ); } + + #[ge_context::test] + #[allow(clippy::too_many_lines)] + async fn it_lists_ml_model_permissions(app_ctx: PostgresContext) { + let admin_session = admin_login(&app_ctx).await; + + let db = app_ctx.session_context(admin_session.clone()).db(); + + let upload_id = UploadId::new(); + let upload = Upload { + id: upload_id, + files: vec![], + }; + db.create_upload(upload).await.unwrap(); + + let model = MlModel { + description: "No real model here".to_owned(), + display_name: "my unreal model".to_owned(), + metadata: MlModelMetadata { + file_name: "myUnrealmodel.onnx".to_owned(), + input_type: ApiRasterDataType::F32, + num_input_bands: 17, + output_type: ApiRasterDataType::F64, + }, + name: MlModelName::new(None, "myUnrealModel").into(), + upload: upload_id, + }; + + let MlModelIdAndName { + id: _model_id, + name: model_name, + } = db.add_model(model).await.unwrap(); + + let req = actix_web::test::TestRequest::get() + .uri(&format!( + "/permissions/resources/mlModel/{model_name}?offset=0&limit=10", + )) + .append_header((header::CONTENT_LENGTH, 0)) + .append_header(( + header::AUTHORIZATION, + Bearer::new(admin_session.id.to_string()), + )); + let res = send_test_request(req, app_ctx).await; + + let res_status = res.status(); + let res_body = serde_json::from_str::(&read_body_string(res).await).unwrap(); + assert_eq!(res_status, 200, "{res_body}"); + + assert_eq!( + res_body, + json!([{ + "permission":"Owner", + "resource": { + "id": model_name.to_string(), + "type": "mlModel" + }, + "role": { + "id": "d5328854-6190-4af9-ad69-4e74b0961ac9", + "name": "admin" + } + }] + ) + ); + } + + #[ge_context::test] + #[allow(clippy::too_many_lines)] + async fn it_lists_layer_collection_permissions(app_ctx: PostgresContext) { + let admin_session = admin_login(&app_ctx).await; + + let db = app_ctx.session_context(admin_session.clone()).db(); + + let root_collection = &db.get_root_layer_collection_id().await.unwrap(); + + let req = actix_web::test::TestRequest::get() + .uri(&format!( + "/permissions/resources/layerCollection/{root_collection}?offset=0&limit=10", + )) + .append_header((header::CONTENT_LENGTH, 0)) + .append_header(( + header::AUTHORIZATION, + Bearer::new(admin_session.id.to_string()), + )); + let res = send_test_request(req, app_ctx).await; + + let res_status = res.status(); + let res_body = serde_json::from_str::(&read_body_string(res).await).unwrap(); + assert_eq!(res_status, 200, "{res_body}"); + + assert_eq!( + res_body, + json!([{ + "permission":"Owner", + "resource": { + "id": root_collection.to_string(), + "type": "layerCollection" + }, + "role": { + "id": "d5328854-6190-4af9-ad69-4e74b0961ac9", + "name": + "admin" + } + }, { + "permission": "Read", + "resource": { + "id": root_collection.to_string(), + "type": "layerCollection" + }, + "role": { + "id": "fd8e87bf-515c-4f36-8da6-1a53702ff102", + "name": "anonymous" + } + }, { + "permission": "Read", + "resource": { + "id": root_collection.to_string(), + "type": "layerCollection", + }, + "role": { + "id": "4e8081b6-8aa6-4275-af0c-2fa2da557d28", + "name": "user" + } + }] + ) + ); + } + + #[ge_context::test] + #[allow(clippy::too_many_lines)] + async fn it_lists_layer_permissions(app_ctx: PostgresContext) { + let admin_session = admin_login(&app_ctx).await; + + let db = app_ctx.session_context(admin_session.clone()).db(); + + let root_collection = &db.get_root_layer_collection_id().await.unwrap(); + + let layer = AddLayer { + name: "layer".to_string(), + description: "description".to_string(), + workflow: Workflow { + operator: TypedOperator::Vector( + MockPointSource { + params: MockPointSourceParams { + points: vec![Coordinate2D::new(1., 2.); 3], + }, + } + .boxed(), + ), + }, + symbology: None, + metadata: Default::default(), + properties: Default::default(), + }; + + let l_id = db.add_layer(layer, root_collection).await.unwrap(); + + let req = actix_web::test::TestRequest::get() + .uri(&format!( + "/permissions/resources/layer/{l_id}?offset=0&limit=10", + )) + .append_header((header::CONTENT_LENGTH, 0)) + .append_header(( + header::AUTHORIZATION, + Bearer::new(admin_session.id.to_string()), + )); + let res = send_test_request(req, app_ctx).await; + + let res_status = res.status(); + let res_body = serde_json::from_str::(&read_body_string(res).await).unwrap(); + assert_eq!(res_status, 200, "{res_body}"); + + assert_eq!( + res_body, + json!([{ + "permission":"Owner", + "resource": { + "id": l_id.to_string(), + "type": "layer" + }, + "role": { + "id": "d5328854-6190-4af9-ad69-4e74b0961ac9", + "name": + "admin" + } + } ] + ) + ); + } + + #[test] + fn resource_from_str_tuple() { + let test_uuid = Uuid::new_v4(); + + let layer_res = Resource::try_from(("layer".to_owned(), "cats".to_owned())).unwrap(); + assert_eq!(layer_res, Resource::Layer(LayerId("cats".to_owned()))); + + let layer_col_res = + Resource::try_from(("layerCollection".to_owned(), "cats".to_owned())).unwrap(); + assert_eq!( + layer_col_res, + Resource::LayerCollection(LayerCollectionId("cats".to_owned())) + ); + + let project_res = Resource::try_from(("project".to_owned(), test_uuid.into())).unwrap(); + assert_eq!(project_res, Resource::Project(ProjectId(test_uuid))); + + let dataset_res = Resource::try_from(("dataset".to_owned(), "cats".to_owned())).unwrap(); + assert_eq!( + dataset_res, + Resource::Dataset(DatasetName::new(None, "cats".to_owned())) + ); + + let ml_model_res = Resource::try_from(("mlModel".to_owned(), "cats".to_owned())).unwrap(); + assert_eq!( + ml_model_res, + Resource::MlModel(MlModelName::new(None, "cats".to_owned())) + ); + } } From 1b97fc4d124f4a1a81400088c82013cac89b8ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Fri, 31 Jan 2025 15:37:48 +0100 Subject: [PATCH 13/13] remove loft-over file vrom pro --- .../src/pro/api/handlers/machine_learning.rs | 223 ------------------ 1 file changed, 223 deletions(-) delete mode 100644 services/src/pro/api/handlers/machine_learning.rs diff --git a/services/src/pro/api/handlers/machine_learning.rs b/services/src/pro/api/handlers/machine_learning.rs deleted file mode 100644 index 3eecb534e..000000000 --- a/services/src/pro/api/handlers/machine_learning.rs +++ /dev/null @@ -1,223 +0,0 @@ -use actix_web::{web, FromRequest, HttpResponse, ResponseError}; - -use crate::{ - api::model::responses::ErrorResponse, - contexts::{ApplicationContext, SessionContext}, - machine_learning::{ - error::MachineLearningError, name::MlModelName, MlModel, MlModelDb, MlModelListOptions, - }, -}; - -pub(crate) fn init_ml_routes(cfg: &mut web::ServiceConfig) -where - C: ApplicationContext, - C::Session: FromRequest, -{ - cfg.service( - web::scope("/ml").service( - web::scope("/models") - .service( - web::resource("") - .route(web::post().to(add_ml_model::)) - .route(web::get().to(list_ml_models::)), - ) - .service(web::resource("/{model_name}").route(web::get().to(get_ml_model::))), - ), - ); -} - -impl ResponseError for MachineLearningError { - fn status_code(&self) -> actix_http::StatusCode { - match self { - MachineLearningError::Postgres { .. } | MachineLearningError::Bb8 { .. } => { - actix_http::StatusCode::INTERNAL_SERVER_ERROR - } - _ => actix_http::StatusCode::BAD_REQUEST, - } - } - - fn error_response(&self) -> HttpResponse { - HttpResponse::build(self.status_code()).json(ErrorResponse::from(self)) - } -} - -/// Create a new ml model. -#[utoipa::path( - tag = "ML", - post, - path = "/ml/models", - request_body = MlModel, - responses( - (status = 200, body = MlModelNameResponse) - ), - security( - ("session_token" = []) - ) -)] - -pub(crate) async fn add_ml_model( - session: C::Session, - app_ctx: web::Data, - model: web::Json, -) -> Result>, MachineLearningError> { - let model = model.into_inner(); - let id_and_name = app_ctx - .session_context(session) - .db() - .add_model(model) - .await?; - Ok(web::Json(id_and_name.name.into()).finish()) -} - -/// List ml models. -#[utoipa::path( - tag = "ML", - get, - path = "/ml/models", - responses( - (status = 200, body = [MlModel]) - ), - security( - ("session_token" = []) - ) -)] - -pub(crate) async fn list_ml_models( - session: C::Session, - app_ctx: web::Data, - options: web::Query, -) -> Result>, MachineLearningError> { - let options = options.into_inner(); - let models = app_ctx - .session_context(session) - .db() - .list_models(&options) - .await?; - Ok(web::Json(models)) -} - -/// Get ml model by name. -#[utoipa::path( - tag = "ML", - get, - path = "/ml/models/{model_name}", - responses( - (status = 200, body = MlModel) - ), - params( - ("model_name" = MlModelName, description = "Ml Model Name") - ), - security( - ("session_token" = []) - ) -)] - -pub(crate) async fn get_ml_model( - session: C::Session, - app_ctx: web::Data, - model_name: web::Path, -) -> Result, MachineLearningError> { - let model_name = model_name.into_inner(); - - let models = app_ctx - .session_context(session) - .db() - .load_model(&model_name) - .await?; - Ok(web::Json(models)) -} - -#[cfg(test)] -mod tests { - use actix_http::header; - use actix_web::test; - use actix_web_httpauth::headers::authorization::Bearer; - use tokio_postgres::NoTls; - - use crate::{ - api::model::{datatypes::RasterDataType, responses::IdResponse}, - contexts::Session, - datasets::upload::UploadId, - machine_learning::MlModelMetadata, - pro::{ - contexts::ProPostgresContext, ge_context, users::UserAuth, - util::tests::send_pro_test_request, - }, - util::tests::{SetMultipartBody, TestDataUploads}, - }; - - use super::*; - - #[ge_context::test] - async fn it_stores_ml_models_for_application(app_ctx: ProPostgresContext) { - let mut test_data = TestDataUploads::default(); // remember created folder and remove them on drop - - let session = app_ctx.create_anonymous_session().await.unwrap(); - let session_id = session.id(); - - let body = vec![( - "model.onnx", - include_bytes!("../../../../../test_data/pro/ml/onnx/test_classification.onnx"), - )]; - - let req = test::TestRequest::post() - .uri("/upload") - .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string()))) - .set_multipart(body); - - let res = send_pro_test_request(req, app_ctx.clone()).await; - - assert_eq!(res.status(), 200); - - let upload: IdResponse = test::read_body_json(res).await; - test_data.uploads.push(upload.id); - - let model = MlModel { - name: MlModelName::new(Some(session.user.id.to_string()), "test_classification"), - display_name: "Test Classification".to_string(), - description: "Test Classification Model".to_string(), - upload: upload.id, - metadata: MlModelMetadata { - file_name: "model.onnx".to_string(), - input_type: RasterDataType::F32, - num_input_bands: 2, - output_type: RasterDataType::I64, - }, - }; - - let req = test::TestRequest::post() - .uri("/ml/models") - .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string()))) - .set_json(&model); - - let res = send_pro_test_request(req, app_ctx.clone()).await; - - assert_eq!(res.status(), 200); - - let req = test::TestRequest::get() - .uri("/ml/models?offset=0&limit=10") - .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string()))); - - let res = send_pro_test_request(req, app_ctx.clone()).await; - - assert_eq!(res.status(), 200); - - let models: Vec = test::read_body_json(res).await; - - assert_eq!(models.len(), 1); - - assert_eq!(model, models[0]); - - let req = test::TestRequest::get() - .uri(&format!("/ml/models/{}", model.name)) - .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string()))); - - let res = send_pro_test_request(req, app_ctx).await; - - assert_eq!(res.status(), 200); - - let res_model: MlModel = test::read_body_json(res).await; - - assert_eq!(model, res_model); - } -}