From 074b985222de427bbcee57daf2a7cf9dca93cfbd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 28 May 2021 01:00:36 -0400 Subject: [PATCH] wip create w2l inference job --- Cargo.lock | 39 ++ Cargo.toml | 2 + .../up.sql | 2 +- .../2021-05-15-212913_inference_jobs/up.sql | 20 + .../w2l/enqueue_infer_w2l_with_uploads.rs | 347 ++++++++++++++++++ src/http_server/endpoints/w2l/mod.rs | 1 + src/http_server/web_utils/mod.rs | 1 + .../web_utils/read_multipart_field_bytes.rs | 66 ++++ src/util/mod.rs | 4 +- src/util/random_prefix_crockford_token.rs | 77 ++++ src/util/random_uuid.rs | 9 + 11 files changed, 566 insertions(+), 2 deletions(-) create mode 100644 src/http_server/endpoints/w2l/enqueue_infer_w2l_with_uploads.rs create mode 100644 src/http_server/web_utils/read_multipart_field_bytes.rs create mode 100644 src/util/random_prefix_crockford_token.rs create mode 100644 src/util/random_uuid.rs diff --git a/Cargo.lock b/Cargo.lock index 3b69872523..32a217cb85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,24 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-multipart" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "774bfeb11b54bf9c857a005b8ab893293da4eaff79261a66a9200dab7f5ab6e3" +dependencies = [ + "actix-service", + "actix-utils 2.0.0", + "actix-web", + "bytes 0.5.6", + "derive_more", + "futures-util", + "httparse", + "log", + "mime", + "twoway", +] + [[package]] name = "actix-router" version = "0.2.7" @@ -2967,6 +2985,7 @@ dependencies = [ "actix-cors", "actix-files", "actix-http", + "actix-multipart", "actix-rt 2.2.0", "actix-utils 3.0.0", "actix-web", @@ -3001,6 +3020,7 @@ dependencies = [ "time 0.2.26", "tokio 0.2.25", "toml", + "uuid", ] [[package]] @@ -3359,6 +3379,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "twoway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57ffb460d7c24cd6eda43694110189030a3d1dfe418416d9468fd1c1d290b47" +dependencies = [ + "memchr", + "unchecked-index", +] + [[package]] name = "typenum" version = "1.13.0" @@ -3371,6 +3401,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" +[[package]] +name = "unchecked-index" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c" + [[package]] name = "unicase" version = "2.6.0" @@ -3450,6 +3486,9 @@ name = "uuid" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.2", +] [[package]] name = "v_escape" diff --git a/Cargo.toml b/Cargo.toml index b303005d4f..1ff3ce6007 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ log = "0.4.14" rand = "0.8.3" regex = "1.5.4" time = "0.2.26" +uuid = { version = "0.8.2", features = ["v4"] } # Runtime # NB(1): We switched to Tokio from `async-std` for broader ecosystem support. @@ -79,6 +80,7 @@ tempdir = "0.3.7" actix-cors = "0.5.4" actix-files= "0.5" actix-http = "2.2.0" +actix-multipart = "0.3.0" # TODO: Ready for 0.4 upgrade actix-rt = "2.2.0" actix-utils = "3.0.0" actix-web = "3.3.2" diff --git a/migrations/2021-05-15-071545_asset_upload_jobs/up.sql b/migrations/2021-05-15-071545_asset_upload_jobs/up.sql index 63da573ff0..1224bdb9ec 100644 --- a/migrations/2021-05-15-071545_asset_upload_jobs/up.sql +++ b/migrations/2021-05-15-071545_asset_upload_jobs/up.sql @@ -104,7 +104,7 @@ CREATE TABLE w2l_template_upload_jobs ( -- Idempotency token uuid_idempotency_token VARCHAR(36) NOT NULL, - -- Foreign key to user + -- Foreign key to user creator_user_token VARCHAR(32) NOT NULL, -- For abuse tracking. diff --git a/migrations/2021-05-15-212913_inference_jobs/up.sql b/migrations/2021-05-15-212913_inference_jobs/up.sql index 5cf19f515a..6e5abdea93 100644 --- a/migrations/2021-05-15-212913_inference_jobs/up.sql +++ b/migrations/2021-05-15-212913_inference_jobs/up.sql @@ -3,6 +3,12 @@ CREATE TABLE tts_inference_jobs ( id BIGINT(20) NOT NULL AUTO_INCREMENT, + -- Effective "primary key" (PUBLIC) + token VARCHAR(32) NOT NULL, + + -- Idempotency token from client + uuid_idempotency_token VARCHAR(36) NOT NULL, + -- ========== INFERENCE DETAILS ========== -- The model to use. @@ -55,6 +61,8 @@ CREATE TABLE tts_inference_jobs ( -- INDICES -- PRIMARY KEY (id), + UNIQUE KEY (token), + UNIQUE KEY (uuid_idempotency_token), KEY fk_model_token (model_token), KEY fk_maybe_creator_user_token (maybe_creator_user_token), KEY index_status (status), @@ -65,6 +73,12 @@ CREATE TABLE tts_inference_jobs ( CREATE TABLE w2l_inference_jobs ( id BIGINT(20) NOT NULL AUTO_INCREMENT, + -- Effective "primary key" (PUBLIC) + token VARCHAR(32) NOT NULL, + + -- Idempotency token from client + uuid_idempotency_token VARCHAR(36) NOT NULL, + -- ========== INFERENCE DETAILS ========== -- The W2L template to use @@ -100,6 +114,10 @@ CREATE TABLE w2l_inference_jobs ( 'private' ) NOT NULL DEFAULT 'public', + -- Flags to disable branding + disable_end_bump BOOL NOT NULL DEFAULT false, + disable_watermark BOOL NOT NULL DEFAULT false, + -- ========== JOB SYSTEM DETAILS ========== -- Jobs begin as "pending", then transition to other states. @@ -124,6 +142,8 @@ CREATE TABLE w2l_inference_jobs ( -- INDICES -- PRIMARY KEY (id), + UNIQUE KEY (token), + UNIQUE KEY (uuid_idempotency_token), KEY fk_maybe_w2l_template_token (maybe_w2l_template_token), KEY fk_maybe_tts_inference_result_token (maybe_tts_inference_result_token), KEY fk_maybe_creator_user_token (maybe_creator_user_token), diff --git a/src/http_server/endpoints/w2l/enqueue_infer_w2l_with_uploads.rs b/src/http_server/endpoints/w2l/enqueue_infer_w2l_with_uploads.rs new file mode 100644 index 0000000000..23d44a2dfb --- /dev/null +++ b/src/http_server/endpoints/w2l/enqueue_infer_w2l_with_uploads.rs @@ -0,0 +1,347 @@ +//use bytes::{Bytes, BytesMut}; +use actix_http::http::header; +use actix_multipart::{Multipart, Field}; +use actix_web::body::ResponseBody::Body; +use actix_web::dev::HttpResponseBuilder; +use actix_web::http::StatusCode; +use actix_web::http::header::ContentDisposition; +use actix_web::web::{Data, Json, BytesMut}; +use actix_web::{middleware, web, App, Error, HttpResponse, HttpServer, HttpRequest, Either, ResponseError}; +use anyhow::anyhow; +use crate::buckets::bucket_client::BucketClient; +use crate::buckets::bucket_paths::hash_to_bucket_path; +use crate::http_server::web_utils::ip_address::get_request_ip; +use crate::http_server::web_utils::read_multipart_field_bytes::checked_read_multipart_bytes; +use crate::http_server::web_utils::read_multipart_field_bytes::read_multipart_field_as_boolean; +use crate::http_server::web_utils::read_multipart_field_bytes::read_multipart_field_as_text; +use crate::http_server::web_utils::read_multipart_field_bytes::read_multipart_field_bytes; +use crate::server_state::ServerState; +use crate::util::anyhow_result::AnyhowResult; +use crate::util::random_prefix_crockford_token::random_prefix_crockford_token; +use crate::util::random_token::random_token; +use crate::util::random_uuid::generate_random_uuid; +use derive_more::{Display, Error}; +use futures::{StreamExt, TryStreamExt}; +use log::{warn, info}; +use sqlx::MySqlPool; +use sqlx::error::Error::Database; +use std::io::Write; +use std::sync::Arc; +use uuid::Uuid; + +const BUCKET_AUDIO_FILE_NAME : &'static str = "input_audio_file"; +const BUCKET_IMAGE_FILE_NAME: &'static str = "input_image_file"; +const BUCKET_VIDEO_FILE_NAME : &'static str = "input_video_file"; + +const MIN_BYTES : usize = 10; +const MAX_BYTES : usize = 1024 * 1024 * 20; + + + +/// Just to query for existence +#[derive(Serialize)] +pub struct W2lTemplateExistenceRecord { + pub template_token: String, +} + +#[derive(Serialize)] +pub struct InferW2lWithUploadSuccessResponse { + pub success: bool, + pub job_token: String, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub success: bool, + pub error_reason: String, +} + +#[derive(Debug, Display)] +pub enum InferW2lWithUploadError { + BadInput(String), + EmptyFileUploaded, + ServerError, +} + +impl ResponseError for InferW2lWithUploadError { + fn status_code(&self) -> StatusCode { + match *self { + InferW2lWithUploadError::BadInput(_) => StatusCode::BAD_REQUEST, + InferW2lWithUploadError::EmptyFileUploaded => StatusCode::BAD_REQUEST, + InferW2lWithUploadError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + InferW2lWithUploadError::BadInput(reason) => reason.to_string(), + InferW2lWithUploadError::EmptyFileUploaded => "empty file uploaded".to_string(), + InferW2lWithUploadError::ServerError => "server error".to_string(), + }; + + let response = ErrorResponse { + success: false, + error_reason, + }; + + let body = match serde_json::to_string(&response) { + Ok(json) => json, + Err(_) => "{}".to_string(), + }; + + HttpResponseBuilder::new(self.status_code()) + .set_header(header::CONTENT_TYPE, "application/json") + .body(body) + } +} + +/// This handles audio uploads w/ W2L templates. +pub async fn enqueue_infer_w2l_with_uploads( + http_request: HttpRequest, + server_state: web::Data>, + mut payload: Multipart) + //-> Result, HttpResponse> + -> Result +{ + // ==================== TODO: READ IP AND RATE LIMIT ==================== // + + let ip_address = get_request_ip(&http_request); + + // TODO: Check IP address holds + // TODO: Rate Limiting! + /*let mut hold_exists = handler_state.redis_client.get_ip_hold(&ip_address) + .map_err(|err| HttpResponse::InternalServerError() + .body(format!("Error: {:?}", err)))?; + + match ip_address.as_ref() { + "127.0.0.1" => { + warn!("Allowing IP address hold bypass for {}", &ip_address); + hold_exists = false + }, + _ => {}, + } + + if hold_exists { + return Err(HttpResponse::TooManyRequests().body("you already have pending requests").into()) + }*/ + + // ==================== READ MULTIPART REQUEST ==================== // + + let mut maybe_uuid_idempotency_token: Option = None; + let mut maybe_template_token: Option = None; + let mut maybe_audio_file_name : Option = None; + + while let Ok(Some(mut field)) = payload.try_next().await { + let content_disposition: ContentDisposition = field.content_disposition().unwrap(); + let field_name = content_disposition.get_name().unwrap_or(""); + let filename = content_disposition.get_filename().unwrap_or(""); + + match field_name { + "uuid_idempotency_token" => { + // Form text field. + maybe_uuid_idempotency_token = read_multipart_field_as_text(&mut field).await + .map_err(|_| InferW2lWithUploadError::ServerError)?; + }, + "template_token" => { + // Form text field. + maybe_template_token = read_multipart_field_as_text(&mut field).await + .map_err(|_| InferW2lWithUploadError::ServerError)?; + }, + "audio" => { + // Form binary data. + maybe_audio_file_name = Some(filename.to_string()); + + let maybe_bytes = checked_read_multipart_bytes(&mut field).await + .map_err(|_| InferW2lWithUploadError::ServerError)?; + + let bytes = match maybe_bytes { + Some(bytes) => bytes, + None => { + return Err(InferW2lWithUploadError::EmptyFileUploaded); // Nothing was uploaded! + }, + }; + }, + _ => continue, + } + + info!("Saved file: {}.", &filename); + } + + // ==================== CHECK REQUEST ==================== // + + let template_token = match &maybe_template_token { + Some(ref token) => token.to_string(), + None => { + return Err(InferW2lWithUploadError::BadInput("No template selected".to_string())); + } + }; + + let uuid_idempotency_token = match maybe_uuid_idempotency_token { + Some(token) => token, + None => { + return Err(InferW2lWithUploadError::BadInput("No uuid idempotency token".to_string())); + } + }; + + let exists = check_template_exists(&template_token, &server_state.mysql_pool).await + .map_err(|e| { + warn!("error checking tmpl existence : {:?}", e); + InferW2lWithUploadError::ServerError + })?; + + if !exists { + return Err(InferW2lWithUploadError::BadInput("Template does not exist".to_string())); + } + + // ==================== READ SESSION ==================== // + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session(&http_request, &server_state.mysql_pool) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + InferW2lWithUploadError::ServerError + })?; + + let mut maybe_user_token : Option = maybe_user_session + .as_ref() + .map(|user_session| user_session.user_token.to_string()); + + + // ==================== TODO: UPLOAD AUDIO FILE ==================== // + + let upload_uuid = generate_random_uuid(); + + let audio_upload_bucket_path = hash_to_bucket_path( + &upload_uuid, + Some("/audio_uploads") // TODO: pull from configs + ).map_err(|e| { + warn!("Hash bucket path error: {:?}", e); + InferW2lWithUploadError::ServerError + })?; + + // TODO: Upload audio to bucket! + + // ==================== SAVE JOB RECORD ==================== // + + // This token is returned to the client. + let job_token = random_prefix_crockford_token("TIJ:", 32) + .map_err(|e| { + warn!("Error creating token"); + InferW2lWithUploadError::ServerError + })?; + + info!("Creating w2l inference job record..."); + + let query_result = sqlx::query!( + r#" +INSERT INTO w2l_inference_jobs +SET + token = ?, + uuid_idempotency_token = ?, + + maybe_w2l_template_token = ?, + maybe_audio_bucket_location = ?, + + maybe_creator_user_token = ?, + creator_ip_address = ?, + disable_end_bump = false, + creator_set_visibility = "public", + status = "pending" + "#, + job_token.to_string(), + uuid_idempotency_token.to_string(), + maybe_template_token.clone(), + Some(audio_upload_bucket_path.clone()), // TODO THIS ISN'T WIDE ENOUGH + maybe_user_token.clone(), + ip_address.to_string() + ) + .execute(&server_state.mysql_pool) + .await; + + let record_id = match query_result { + Ok(res) => { + res.last_insert_id() + }, + Err(err) => { + warn!("New w2l template upload creation DB error: {:?}", err); + + // NB: SQLSTATE[23000]: Integrity constraint violation + // NB: MySQL Error Code 1062: Duplicate key insertion (this is harder to access) + match err { + Database(err) => { + let maybe_code = err.code().map(|c| c.into_owned()); + /*match maybe_code.as_deref() { + Some("23000") => { + if err.message().contains("username") { + return Err(UsernameTaken); + } else if err.message().contains("email_address") { + return Err(EmailTaken); + } + } + _ => {}, + }*/ + }, + _ => {}, + } + return Err(InferW2lWithUploadError::ServerError); + } + }; + + // TODO: IP Address holds. + //handler_state.redis_client.set_ip_hold(&ip_address) + // .map_err(|err| HttpResponse::InternalServerError() + // .body(format!("Redis Err: {:?}", err)))?; + + let response = InferW2lWithUploadSuccessResponse { + success: true, + job_token: job_token.to_string(), + }; + + let body = serde_json::to_string(&response) + .map_err(|e| InferW2lWithUploadError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} + +async fn check_template_exists(template_token: &str, mysql_pool: &MySqlPool) -> AnyhowResult +{ + // NB: Lookup failure is Err(RowNotFound). + // NB: Since this is publicly exposed, we don't query sensitive data. + let maybe_template = sqlx::query_as!( + W2lTemplateExistenceRecord, + r#" +SELECT + token as template_token +FROM w2l_templates +WHERE token = ? +AND deleted_at IS NULL + "#, + &template_token + ) + .fetch_one(mysql_pool) + .await; // TODO: This will return error if it doesn't exist + + let record_exists = match maybe_template { + Ok(record) => { + true + }, + Err(err) => { + match err { + RowNotFound => { + false + }, + _ => { + warn!("Infer w2l query error: {:?}", err); + return Err(anyhow!("infer w2l query error: {:?}", err)); + } + } + } + }; + + Ok(record_exists) +} + diff --git a/src/http_server/endpoints/w2l/mod.rs b/src/http_server/endpoints/w2l/mod.rs index 7e1ee59b46..eb1514f0b4 100644 --- a/src/http_server/endpoints/w2l/mod.rs +++ b/src/http_server/endpoints/w2l/mod.rs @@ -1,4 +1,5 @@ pub mod enqueue_infer_w2l; +pub mod enqueue_infer_w2l_with_uploads; pub mod enqueue_upload_w2l_template; pub mod get_w2l_template; pub mod list_w2l_templates; \ No newline at end of file diff --git a/src/http_server/web_utils/mod.rs b/src/http_server/web_utils/mod.rs index 845d875119..53c5f9f362 100644 --- a/src/http_server/web_utils/mod.rs +++ b/src/http_server/web_utils/mod.rs @@ -1,3 +1,4 @@ pub mod cookie_manager; pub mod ip_address; +pub mod read_multipart_field_bytes; pub mod session_checker; \ No newline at end of file diff --git a/src/http_server/web_utils/read_multipart_field_bytes.rs b/src/http_server/web_utils/read_multipart_field_bytes.rs new file mode 100644 index 0000000000..57fa2fd81f --- /dev/null +++ b/src/http_server/web_utils/read_multipart_field_bytes.rs @@ -0,0 +1,66 @@ +//use bytes::BytesMut; +use actix_multipart::Field; +use actix_web::web::BytesMut; +use anyhow::anyhow; +use futures::{StreamExt, TryStreamExt}; +use log::warn; + +const MIN_BYTES : usize = 10; +const MAX_BYTES : usize = 1024 * 1024 * 20; + +/// Read any field from a multipart form as bytes, but do some error checking. +pub async fn checked_read_multipart_bytes(field: &mut Field) -> anyhow::Result> { + let bytes = read_multipart_field_bytes(field).await?; + + if bytes.is_empty() { + // This field is empty + return Ok(None); + } + + if bytes.len() < MIN_BYTES { + warn!("Bytes of upload too few: {}", bytes.len()); + return Err(anyhow!("Too few bytes")); + } + + if bytes.len() > MAX_BYTES { + warn!("Bytes of upload too much: {}", bytes.len()); + return Err(anyhow!("Too many bytes")); + } + + Ok(Some(bytes)) +} + +/// Read any field from a multipart form as bytes. +pub async fn read_multipart_field_bytes(field: &mut Field) -> anyhow::Result { + let mut bytes = BytesMut::new(); + while let Some(chunk) = field.next().await { + let chunk_data = chunk.map_err(|err| anyhow!("Error: {:?}", err))?; + bytes.extend_from_slice(&chunk_data); + } + Ok(bytes) +} + +/// This reads inputs, text boxes, etc. because form-multipart is hard. +pub async fn read_multipart_field_as_text(field: &mut Field) -> anyhow::Result> { + let bytes = read_multipart_field_bytes(field).await?; + let value = String::from_utf8(bytes.to_vec())?; + + if value.is_empty() || value == "none" { + return Ok(None); + } + + Ok(Some(value)) +} + +/// Read a html form boolean (eg. input type=checkbox); assumes default is false. +pub async fn read_multipart_field_as_boolean(field: &mut Field) -> anyhow::Result { + let result = read_multipart_field_as_text(field).await?; + Ok(match result.as_ref().map(String::as_str) { + Some("true") => true, + Some("TRUE") => true, + Some("false") => false, + Some("FALSE") => false, + None => false, + _ => true, + }) +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 99cdea2fa8..2104d3d1ba 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,4 +1,6 @@ pub mod anyhow_result; +pub mod email_to_gravatar; pub mod filesystem; +pub mod random_prefix_crockford_token; pub mod random_token; -pub mod email_to_gravatar; \ No newline at end of file +pub mod random_uuid; \ No newline at end of file diff --git a/src/util/random_prefix_crockford_token.rs b/src/util/random_prefix_crockford_token.rs new file mode 100644 index 0000000000..cbb134b1e5 --- /dev/null +++ b/src/util/random_prefix_crockford_token.rs @@ -0,0 +1,77 @@ +use rand::Rng; +use anyhow::anyhow; +use crate::util::anyhow_result::AnyhowResult; + +// Crockford characters +const CROCKFORD_CHARSET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +// Random part can't be any less than this. +const MIN_ENTROPY_LENGTH : usize = 8; + +pub fn random_prefix_crockford_token(prefix: &str, length: usize) -> AnyhowResult { + let mut prefix = prefix.trim().to_string(); + + if prefix.is_empty() { + return Err(anyhow!("prefix not set; use other crockford functions")); + } + + if !prefix.ends_with(":") { + prefix = format!("{}:", prefix); + } + + let entropy_length : usize = length.saturating_sub(prefix.len()); + + if entropy_length < MIN_ENTROPY_LENGTH { + return Err(anyhow!("not enough entropy in token")); + } + + let mut rng = rand::thread_rng(); + + let entropy_part: String = (0..entropy_length) + .map(|_| { + let idx = rng.gen_range(0..CROCKFORD_CHARSET.len()); + CROCKFORD_CHARSET[idx] as char + }) + .collect(); + + let token = format!("{}{}", prefix, entropy_part); + Ok(token) +} + +#[cfg(test)] +mod tests { + use crate::util::random_prefix_crockford_token::random_prefix_crockford_token; + + #[test] + fn no_prefix_error() { + assert!(random_prefix_crockford_token("", 0).is_err()); + assert!(random_prefix_crockford_token("", 10).is_err()); + assert!(random_prefix_crockford_token("", 32).is_err()); + } + + #[test] + fn not_enough_entropy_error() { + assert!(random_prefix_crockford_token("foo", 0).is_err()); + assert!(random_prefix_crockford_token("foo", 5).is_err()); + assert!(random_prefix_crockford_token("foo", 11).is_err()); + assert!(random_prefix_crockford_token("foo:", 11).is_err()); + } + + #[test] + fn starts_with() { + assert_eq!(random_prefix_crockford_token("w", 10).unwrap().starts_with("w:")); + assert_eq!(random_prefix_crockford_token("w", 10).unwrap().starts_with("w:")); + assert_eq!(random_prefix_crockford_token("prefix", 16).unwrap().starts_with("prefix:")); + assert_eq!(random_prefix_crockford_token("prefix:", 16).unwrap().starts_with("prefix:")); + } + + #[test] + fn length() { + assert_eq!(random_prefix_crockford_token("w", 10).len(), 10); + assert_eq!(random_prefix_crockford_token("w:", 10).len(), 10); + assert_eq!(random_prefix_crockford_token("prefix", 16).len(), 16); + assert_eq!(random_prefix_crockford_token("prefix:", 16).len(), 16); + assert_eq!(random_prefix_crockford_token("asdf:", 32).len(), 32); + assert_eq!(random_prefix_crockford_token("asdf", 32).len(), 32); + } +} diff --git a/src/util/random_uuid.rs b/src/util/random_uuid.rs new file mode 100644 index 0000000000..806fa27145 --- /dev/null +++ b/src/util/random_uuid.rs @@ -0,0 +1,9 @@ +use uuid::Uuid; + +pub fn generate_random_uuid() -> String { + let uuid = Uuid::new_v4(); + let mut buffer = Uuid::encode_buffer(); + let hyphenated_uuid = uuid.to_hyphenated() + .encode_lower(&mut buffer); + hyphenated_uuid.to_string() +}