|
| 1 | +use crate::{ |
| 2 | + api::subscription::QueryParams, |
| 3 | + data_source::{postgres::to_json_value, Crud, SubscriptionCrud}, |
| 4 | + error::AppError, |
| 5 | +}; |
| 6 | +use async_trait::async_trait; |
| 7 | +use chrono::{DateTime, Utc}; |
| 8 | +use openleadr_wire::{ |
| 9 | + subscription::{ |
| 10 | + NotificationMechanism, Operation, Subscription, SubscriptionId, SubscriptionRequest, |
| 11 | + }, |
| 12 | + target::Target, |
| 13 | + ClientId, ObjectType, |
| 14 | +}; |
| 15 | +use sqlx::PgPool; |
| 16 | +use tracing::{error, trace, warn}; |
| 17 | + |
| 18 | +impl SubscriptionCrud for PgSubscriptionStorage {} |
| 19 | + |
| 20 | +pub(crate) struct PgSubscriptionStorage { |
| 21 | + db: PgPool, |
| 22 | +} |
| 23 | + |
| 24 | +impl From<PgPool> for PgSubscriptionStorage { |
| 25 | + fn from(db: PgPool) -> Self { |
| 26 | + Self { db } |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +#[derive(Debug)] |
| 31 | +pub(crate) struct PostgresSubscription { |
| 32 | + id: String, |
| 33 | + created_date_time: DateTime<Utc>, |
| 34 | + modification_date_time: DateTime<Utc>, |
| 35 | + client_id: ClientId, |
| 36 | + client_name: String, |
| 37 | + program_id: Option<String>, |
| 38 | + object_operations: Vec<SubscriptionObjectOperation>, |
| 39 | + targets: Vec<Target>, |
| 40 | +} |
| 41 | + |
| 42 | +impl TryFrom<PostgresSubscription> for Subscription { |
| 43 | + type Error = AppError; |
| 44 | + |
| 45 | + #[tracing::instrument(name = "TryFrom<PostgresResource> for Resource")] |
| 46 | + fn try_from(value: PostgresSubscription) -> Result<Self, Self::Error> { |
| 47 | + let attributes = match value.attributes { |
| 48 | + None => None, |
| 49 | + Some(t) => serde_json::from_value(t) |
| 50 | + .inspect_err(|err| { |
| 51 | + error!( |
| 52 | + ?err, |
| 53 | + "Failed to deserialize JSON from DB to `Vec<ValuesMap>`" |
| 54 | + ) |
| 55 | + }) |
| 56 | + .map_err(AppError::SerdeJsonInternalServerError)?, |
| 57 | + }; |
| 58 | + |
| 59 | + Ok(Self { |
| 60 | + id: value.id.parse()?, |
| 61 | + created_date_time: value.created_date_time, |
| 62 | + modification_date_time: value.modification_date_time, |
| 63 | + content: SubscriptionRequest { |
| 64 | + client_name: value.client_name, |
| 65 | + program_id: value |
| 66 | + .program_id |
| 67 | + .map(|program_id| program_id.parse()) |
| 68 | + .transpose()?, |
| 69 | + object_operations: (), |
| 70 | + targets: value.targets, |
| 71 | + }, |
| 72 | + }) |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +#[async_trait] |
| 77 | +impl Crud for PgSubscriptionStorage { |
| 78 | + type Type = Subscription; |
| 79 | + type Id = SubscriptionId; |
| 80 | + type NewType = SubscriptionRequest; |
| 81 | + type Error = AppError; |
| 82 | + type Filter = QueryParams; |
| 83 | + type PermissionFilter = Option<ClientId>; |
| 84 | + |
| 85 | + async fn create( |
| 86 | + &self, |
| 87 | + new: Self::NewType, |
| 88 | + _client_id: &Self::PermissionFilter, |
| 89 | + ) -> Result<Self::Type, Self::Error> { |
| 90 | + let resource: Subscription = sqlx::query_as!( |
| 91 | + PostgresSubscription, |
| 92 | + r#" |
| 93 | + INSERT INTO resource ( |
| 94 | + id, |
| 95 | + created_date_time, |
| 96 | + modification_date_time, |
| 97 | + resource_name, |
| 98 | + ven_id, |
| 99 | + attributes, |
| 100 | + targets |
| 101 | + ) |
| 102 | + VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4) |
| 103 | + RETURNING |
| 104 | + id, |
| 105 | + created_date_time, |
| 106 | + modification_date_time, |
| 107 | + resource_name, |
| 108 | + ven_id, |
| 109 | + attributes, |
| 110 | + targets as "targets:Vec<Target>" |
| 111 | + "#, |
| 112 | + new.resource_name, |
| 113 | + new.ven_id.as_str(), |
| 114 | + to_json_value(new.attributes)?, |
| 115 | + new.targets as _, |
| 116 | + ) |
| 117 | + .fetch_one(&self.db) |
| 118 | + .await? |
| 119 | + .try_into()?; |
| 120 | + |
| 121 | + Ok(resource) |
| 122 | + } |
| 123 | + |
| 124 | + async fn retrieve( |
| 125 | + &self, |
| 126 | + id: &Self::Id, |
| 127 | + client_id: &Self::PermissionFilter, |
| 128 | + ) -> Result<Self::Type, Self::Error> { |
| 129 | + let resource = sqlx::query_as!( |
| 130 | + PostgresSubscription, |
| 131 | + r#" |
| 132 | + SELECT |
| 133 | + r.id, |
| 134 | + r.created_date_time, |
| 135 | + r.modification_date_time, |
| 136 | + r.resource_name, |
| 137 | + r.ven_id, |
| 138 | + r.attributes, |
| 139 | + r.targets as "targets:Vec<Target>" |
| 140 | + FROM resource r |
| 141 | + JOIN ven v on r.ven_id = v.id |
| 142 | + WHERE r.id = $1 |
| 143 | + AND ($2::text IS NULL OR v.client_id = $2) |
| 144 | + "#, |
| 145 | + id.as_str(), |
| 146 | + client_id as _ |
| 147 | + ) |
| 148 | + .fetch_one(&self.db) |
| 149 | + .await? |
| 150 | + .try_into()?; |
| 151 | + |
| 152 | + Ok(resource) |
| 153 | + } |
| 154 | + |
| 155 | + async fn retrieve_all( |
| 156 | + &self, |
| 157 | + filter: &Self::Filter, |
| 158 | + client_id: &Self::PermissionFilter, |
| 159 | + ) -> Result<Vec<Self::Type>, Self::Error> { |
| 160 | + let res = sqlx::query_as!( |
| 161 | + PostgresSubscription, |
| 162 | + r#" |
| 163 | + SELECT |
| 164 | + r.id, |
| 165 | + r.created_date_time, |
| 166 | + r.modification_date_time, |
| 167 | + r.resource_name, |
| 168 | + r.ven_id, |
| 169 | + r.attributes, |
| 170 | + r.targets as "targets:Vec<Target>" |
| 171 | + FROM resource r |
| 172 | + JOIN ven v on r.ven_id = v.id |
| 173 | + WHERE ($1::text IS NULL OR r.ven_id = $1) |
| 174 | + AND ($2::text IS NULL OR r.resource_name = $2) |
| 175 | + AND ($3::text[] IS NULL OR r.targets @> $3) |
| 176 | + AND ($4::text IS NULL OR v.client_id = $4) |
| 177 | + ORDER BY r.created_date_time |
| 178 | + OFFSET $5 LIMIT $6 |
| 179 | + "#, |
| 180 | + filter.ven_id as _, |
| 181 | + filter.resource_name, |
| 182 | + filter.targets.as_deref() as _, |
| 183 | + client_id as _, |
| 184 | + filter.skip, |
| 185 | + filter.limit, |
| 186 | + ) |
| 187 | + .fetch_all(&self.db) |
| 188 | + .await? |
| 189 | + .into_iter() |
| 190 | + .map(TryInto::try_into) |
| 191 | + .collect::<Result<Vec<_>, _>>()?; |
| 192 | + |
| 193 | + trace!("retrieved {} resources", res.len()); |
| 194 | + |
| 195 | + Ok(res) |
| 196 | + } |
| 197 | + |
| 198 | + async fn update( |
| 199 | + &self, |
| 200 | + id: &Self::Id, |
| 201 | + new: Self::NewType, |
| 202 | + client_id: &Self::PermissionFilter, |
| 203 | + ) -> Result<Self::Type, Self::Error> { |
| 204 | + let mut tx = self.db.begin().await?; |
| 205 | + |
| 206 | + let old_ven_id = sqlx::query_scalar!( |
| 207 | + r#" |
| 208 | + SELECT ven_id FROM resource WHERE id = $1 |
| 209 | + "#, |
| 210 | + id.as_str() |
| 211 | + ) |
| 212 | + .fetch_one(&mut *tx) |
| 213 | + .await?; |
| 214 | + |
| 215 | + if old_ven_id != new.ven_id.as_str() { |
| 216 | + let error = "Tried to update `ven_id` of resource. \ |
| 217 | + This is not allowed in the current version of openLEADR as the specification is not quite \ |
| 218 | + clear about if that should be allowed. If you disagree with that interpretation, please open \ |
| 219 | + an issue on GitHub."; |
| 220 | + error!(resource_id = id.as_str(), "{}", error); |
| 221 | + return Err(Self::Error::BadRequest(error)); |
| 222 | + } |
| 223 | + |
| 224 | + let resource: Subscription = sqlx::query_as!( |
| 225 | + PostgresSubscription, |
| 226 | + r#" |
| 227 | + UPDATE resource r |
| 228 | + SET modification_date_time = now(), |
| 229 | + resource_name = $2, |
| 230 | + attributes = $3, |
| 231 | + targets = $4 |
| 232 | + FROM ven v |
| 233 | + WHERE r.ven_id = v.id |
| 234 | + AND r.id = $1 |
| 235 | + AND ($5::text IS NULL OR v.client_id = $5) |
| 236 | + RETURNING |
| 237 | + r.id, |
| 238 | + r.created_date_time, |
| 239 | + r.modification_date_time, |
| 240 | + r.resource_name, |
| 241 | + r.ven_id, |
| 242 | + r.attributes, |
| 243 | + r.targets as "targets:Vec<Target>" |
| 244 | + "#, |
| 245 | + id.as_str(), |
| 246 | + new.resource_name, |
| 247 | + to_json_value(new.attributes)?, |
| 248 | + new.targets as _, |
| 249 | + client_id as _ |
| 250 | + ) |
| 251 | + .fetch_one(&mut *tx) |
| 252 | + .await? |
| 253 | + .try_into()?; |
| 254 | + |
| 255 | + tx.commit().await?; |
| 256 | + |
| 257 | + Ok(resource) |
| 258 | + } |
| 259 | + |
| 260 | + async fn delete( |
| 261 | + &self, |
| 262 | + id: &Self::Id, |
| 263 | + client_id: &Self::PermissionFilter, |
| 264 | + ) -> Result<Self::Type, Self::Error> { |
| 265 | + Ok(sqlx::query_as!( |
| 266 | + PostgresSubscription, |
| 267 | + r#" |
| 268 | + DELETE FROM resource r |
| 269 | + USING ven v |
| 270 | + WHERE r.ven_id = v.id |
| 271 | + AND r.id = $1 |
| 272 | + AND ($2::text IS NULL OR v.client_id = $2) |
| 273 | + RETURNING |
| 274 | + r.id, |
| 275 | + r.created_date_time, |
| 276 | + r.modification_date_time, |
| 277 | + r.resource_name, |
| 278 | + r.ven_id, |
| 279 | + r.attributes, |
| 280 | + r.targets as "targets:Vec<Target>" |
| 281 | + "#, |
| 282 | + id.as_str(), |
| 283 | + client_id as _ |
| 284 | + ) |
| 285 | + .fetch_one(&self.db) |
| 286 | + .await? |
| 287 | + .try_into()?) |
| 288 | + } |
| 289 | +} |
| 290 | + |
| 291 | +#[cfg(test)] |
| 292 | +#[cfg(feature = "live-db-test")] |
| 293 | +mod test { |
| 294 | + use crate::{ |
| 295 | + api::subscription::QueryParams, |
| 296 | + data_source::{postgres::subscription::PgSubscriptionStorage, Crud}, |
| 297 | + }; |
| 298 | + use sqlx::PgPool; |
| 299 | + |
| 300 | + impl Default for QueryParams { |
| 301 | + fn default() -> Self { |
| 302 | + Self { |
| 303 | + program_id: None, |
| 304 | + objects: vec![], |
| 305 | + skip: 0, |
| 306 | + limit: 50, |
| 307 | + } |
| 308 | + } |
| 309 | + } |
| 310 | + |
| 311 | + impl QueryParams { |
| 312 | + fn program_id(program_id: &str) -> QueryParams { |
| 313 | + Self { |
| 314 | + program_id: Some(program_id.parse().unwrap()), |
| 315 | + ..Self::default() |
| 316 | + } |
| 317 | + } |
| 318 | + } |
| 319 | + |
| 320 | + #[sqlx::test(fixtures("users", "vens", "resources", "subscriptions"))] // FIXME remove unnecessary fixtures |
| 321 | + async fn retrieve_all(db: PgPool) { |
| 322 | + let repo = PgSubscriptionStorage::from(db.clone()); |
| 323 | + |
| 324 | + let subscription = repo |
| 325 | + .retrieve_all( |
| 326 | + &QueryParams::program_id("program-1"), |
| 327 | + &Some("ven-1-client-id".parse().unwrap()), |
| 328 | + ) |
| 329 | + .await |
| 330 | + .unwrap(); |
| 331 | + assert_eq!(subscription.len(), 2); |
| 332 | + |
| 333 | + let subscription = repo |
| 334 | + .retrieve_all( |
| 335 | + &QueryParams::program("program-2"), |
| 336 | + &Some("ven-2-client-id".parse().unwrap()), |
| 337 | + ) |
| 338 | + .await |
| 339 | + .unwrap(); |
| 340 | + assert_eq!(subscription.len(), 3); |
| 341 | + |
| 342 | + let filters = QueryParams { |
| 343 | + resource_name: Some("resource-1-name".to_string()), |
| 344 | + ven_id: Some("ven-1".parse().unwrap()), |
| 345 | + ..Default::default() |
| 346 | + }; |
| 347 | + |
| 348 | + let resources = repo |
| 349 | + .retrieve_all(&filters, &Some("ven-1-client-id".parse().unwrap())) |
| 350 | + .await |
| 351 | + .unwrap(); |
| 352 | + assert_eq!(resources.len(), 1); |
| 353 | + assert_eq!(resources[0].content.resource_name, "resource-1-name"); |
| 354 | + |
| 355 | + // Ensure a client cannot see resources of another client |
| 356 | + let resources = repo |
| 357 | + .retrieve_all( |
| 358 | + &QueryParams::program_id("program-2"), |
| 359 | + &Some("ven-1-client-id".parse().unwrap()), |
| 360 | + ) |
| 361 | + .await |
| 362 | + .unwrap(); |
| 363 | + assert_eq!(resources.len(), 0); |
| 364 | + } |
| 365 | +} |
0 commit comments