Skip to content

Commit

Permalink
feat: 🌈 more CRUD
Browse files Browse the repository at this point in the history
  • Loading branch information
fungiboletus committed Aug 20, 2024
1 parent c72c78d commit b126dc1
Show file tree
Hide file tree
Showing 25 changed files with 600 additions and 47 deletions.
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,6 @@ utoipa = { version = "4.2", features = ["axum_extras"] }
utoipa-scalar = { version = "0.1", features = ["axum"] }
rrdcached-client = "0.1"
rustls = "0.23"
base64 = "0.22"
rot13 = "0.1"
time = "0.3"
113 changes: 113 additions & 0 deletions src/crud/list_cursor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* A list cursor is a string that contains two values:
* - the last created_at value,
* - the last uuid value.
*
* It is used to paginate the list of sensors.
*
* It is NOT a secret. But it doesn't have to be human readable.
*
* For simplicity, we use use a string serialisation, and the
* ASCII UNIT SEPARATOR (US) character to separate the two values.
* The string is then rot13 and base64+url encoded. Rot13 for fun.
*/
use anyhow::{bail, Result};

#[derive(Debug)]
pub struct ListCursor {
pub next_created_at: String,
pub next_uuid: String,
}

impl ListCursor {
pub fn new(next_created_at: String, next_uuid: String) -> Self {
Self {
next_created_at,
next_uuid,
}
}

/// Parse a list cursor from a string.
///
/// The string must be in the format:
/// - last_created_at
/// - last_uuid
///
/// The string is then rot13 and base64+url decoded.
pub fn parse(cursor: &str) -> Result<Self> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
let base64_data = rot13::rot13(cursor);
let data = URL_SAFE_NO_PAD.decode(base64_data)?;
let data_string = String::from_utf8(data)?;
// split on the separator
let parts: Vec<&str> = data_string.split('\u{001F}').collect();
if parts.len() != 2 {
bail!("Invalid cursor: must contain two parts");
}
Ok(Self {
next_created_at: parts[0].to_string(),
next_uuid: parts[1].to_string(),
})
}

/// Convert the list cursor to a string.
///
/// The string is then rot13 and base64+url encoded.
#[allow(clippy::inherent_to_string)]
pub fn to_string(&self) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
let data_string = format!("{}\u{001F}{}", self.next_created_at, self.next_uuid);
let data = data_string.as_bytes();
let base64_data = URL_SAFE_NO_PAD.encode(data);
rot13::rot13(&base64_data)
}
}

impl Default for ListCursor {
fn default() -> Self {
Self::new(
"-1".to_string(),
"00000000-0000-0000-0000-000000000000".to_string(),
)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_list_cursor() {
let cursor = ListCursor::new(
"2023-01-01T00:00:00Z".to_string(),
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".to_string(),
);
let string = cursor.to_string();

assert_eq!(
string,
"ZwNlZl0jZF0jZIDjZQbjZQbjZSbsLJSuLJSuLJRgLJSuLF1uLJSuYJSuLJRgLJSuLJSuLJSuLJSu"
);

let parsed = ListCursor::parse(&string).unwrap();
assert_eq!(parsed.next_created_at, "2023-01-01T00:00:00Z");
assert_eq!(parsed.next_uuid, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
}

#[test]
fn test_list_cursor_default() {
let cursor = ListCursor::default();
assert_eq!(cursor.next_created_at, "-1");
assert_eq!(cursor.next_uuid, "00000000-0000-0000-0000-000000000000");
}

#[test]
fn test_parsing_failures() {
assert!(ListCursor::parse("").is_err());
assert!(
ListCursor::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u{001F}")
.is_err()
);
assert!(ListCursor::parse("aaa\u{001F}aa\u{001F}aa").is_err());
}
}
2 changes: 2 additions & 0 deletions src/crud/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod list_cursor;
pub mod viewmodel;
1 change: 1 addition & 0 deletions src/crud/viewmodel/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod sensor_viewmodel;
33 changes: 33 additions & 0 deletions src/crud/viewmodel/sensor_viewmodel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize)]
pub struct SensorViewModel {
pub uuid: Uuid,
pub name: String,
pub created_at: Option<String>,
pub sensor_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
pub labels: BTreeMap<String, String>,
}

// From Sensor model to SensorViewModel
impl From<crate::datamodel::Sensor> for SensorViewModel {
fn from(sensor: crate::datamodel::Sensor) -> Self {
Self {
uuid: sensor.uuid,
name: sensor.name,
created_at: None, // non view Sensors do not have a created_at field
sensor_type: sensor.sensor_type.to_string(),
unit: sensor.unit.map(|unit| unit.name),
labels: sensor
.labels
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
}
}
}
27 changes: 14 additions & 13 deletions src/datamodel/sensor_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,20 @@ pub enum SensorType {
Blob = 80,
}

// Implement to_string() for SensorType
impl ToString for SensorType {
fn to_string(&self) -> String {
match self {
SensorType::Integer => "Integer".to_string(),
SensorType::Numeric => "Numeric".to_string(),
SensorType::Float => "Float".to_string(),
SensorType::String => "String".to_string(),
SensorType::Boolean => "Boolean".to_string(),
SensorType::Location => "Location".to_string(),
SensorType::Json => "JSON".to_string(),
SensorType::Blob => "Blob".to_string(),
}
// Implement Display for SensorType
impl std::fmt::Display for SensorType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let string = match self {
SensorType::Integer => "Integer",
SensorType::Numeric => "Numeric",
SensorType::Float => "Float",
SensorType::String => "String",
SensorType::Boolean => "Boolean",
SensorType::Location => "Location",
SensorType::Json => "JSON",
SensorType::Blob => "Blob",
};
write!(f, "{}", string)
}
}

Expand Down
60 changes: 53 additions & 7 deletions src/ingestors/http/crud.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,66 @@
use crate::crud::list_cursor::ListCursor;
use crate::crud::viewmodel::sensor_viewmodel::SensorViewModel;
use crate::ingestors::http::app_error::AppError;
use crate::ingestors::http::state::HttpServerState;
use axum::extract::State;
use anyhow::Result;
use axum::extract::{Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct ListSensorsQuery {
pub cursor: Option<String>,
pub limit: Option<usize>,
}

#[derive(Debug, Serialize)]
pub struct ListSensorsResponse {
pub sensors: Vec<SensorViewModel>,
pub cursor: Option<String>,
}

/// List all the sensors.
#[utoipa::path(
get,
path = "/sensors",
path = "/api/v1/sensors",
tag = "SensApp",
responses(
(status = 200, description = "List of sensors", body = Vec<String>)
)
(status = 200, description = "List of sensors", body = Vec<SensorViewModel>)
),
params(
("cursor" = Option<String>, Query, description = "Cursor to start listing from"),
("limit" = Option<u64>, Query, description = "Limit the number of sensors to return, 1000 by default", maximum = 100_000, minimum = 1),
),
)]
pub async fn list_sensors(
State(state): State<HttpServerState>,
) -> Result<Json<Vec<String>>, AppError> {
let sensors = state.storage.list_sensors().await?;
Ok(Json(sensors))
Query(query): Query<ListSensorsQuery>,
) -> Result<Json<ListSensorsResponse>, AppError> {
let cursor = query
.cursor
.map(|cursor| ListCursor::parse(&cursor))
.unwrap_or_else(|| Ok(ListCursor::default()))
.map_err(AppError::BadRequest)?;

let limit = query.limit.unwrap_or(1000);
if limit == 0 || limit > 100_000 {
return Err(AppError::BadRequest(anyhow::anyhow!(
"Limit must be between 1 and 100,000"
)));
}

let (sensors, next_cursor) =
state
.storage
.list_sensors(cursor, limit)
.await
.map_err(|error| {
eprintln!("Failed to list sensors: {:?}", error);
AppError::InternalServerError(error)
})?;

Ok(Json(ListSensorsResponse {
sensors,
cursor: next_cursor.map(|cursor| cursor.to_string()),
}))
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use tracing::event;
use tracing::Level;
mod bus;
mod config;
mod crud;
mod datamodel;
mod importers;
mod infer;
Expand Down
11 changes: 9 additions & 2 deletions src/storage/bigquery/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use crate::storage::storage::StorageInstance;
use crate::{
crud::{list_cursor::ListCursor, viewmodel::sensor_viewmodel::SensorViewModel},
storage::storage::StorageInstance,
};
use anyhow::{bail, Result};
use async_trait::async_trait;
use bigquery_publishers::{
Expand Down Expand Up @@ -233,7 +236,11 @@ impl StorageInstance for BigQueryStorage {
Ok(())
}

async fn list_sensors(&self) -> Result<Vec<String>> {
async fn list_sensors(
&self,
cursor: ListCursor,
limit: usize,
) -> Result<(Vec<SensorViewModel>, Option<ListCursor>)> {
unimplemented!();
}
}
Loading

0 comments on commit b126dc1

Please sign in to comment.