Idk broq
This commit is contained in:
parent
dc1f4bd19d
commit
2c66fa50d8
9 changed files with 1125 additions and 960 deletions
38
src/db.rs
38
src/db.rs
|
|
@ -1,4 +1,4 @@
|
|||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use rusqlite::{Connection, OpenFlags, params};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
|
@ -56,7 +56,9 @@ impl Db {
|
|||
);",
|
||||
)
|
||||
.expect("failed to create tables");
|
||||
Self { conn: Arc::new(Mutex::new(conn)) }
|
||||
Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self, key: &str) -> Result<Record, AppError> {
|
||||
|
|
@ -64,10 +66,15 @@ impl Db {
|
|||
let key = key.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare_cached("SELECT key, volumes, size FROM kv WHERE key = ?1")?;
|
||||
let mut stmt =
|
||||
conn.prepare_cached("SELECT key, volumes, size FROM kv WHERE key = ?1")?;
|
||||
Ok(stmt.query_row(params![key], |row| {
|
||||
let vj: String = row.get(1)?;
|
||||
Ok(Record { key: row.get(0)?, volumes: parse_volumes(&vj), size: row.get(2)? })
|
||||
Ok(Record {
|
||||
key: row.get(0)?,
|
||||
volumes: parse_volumes(&vj),
|
||||
size: row.get(2)?,
|
||||
})
|
||||
})?)
|
||||
})
|
||||
.await
|
||||
|
|
@ -108,9 +115,8 @@ impl Db {
|
|||
.collect::<Result<Vec<String>, _>>()?
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT key FROM kv WHERE key >= ?1 ORDER BY key",
|
||||
)?;
|
||||
let mut stmt =
|
||||
conn.prepare_cached("SELECT key FROM kv WHERE key >= ?1 ORDER BY key")?;
|
||||
stmt.query_map(params![prefix], |row| row.get(0))?
|
||||
.collect::<Result<Vec<String>, _>>()?
|
||||
}
|
||||
|
|
@ -121,7 +127,12 @@ impl Db {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn put(&self, key: String, volumes: Vec<String>, size: Option<i64>) -> Result<(), AppError> {
|
||||
pub async fn put(
|
||||
&self,
|
||||
key: String,
|
||||
volumes: Vec<String>,
|
||||
size: Option<i64>,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().unwrap();
|
||||
|
|
@ -148,7 +159,10 @@ impl Db {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn bulk_put(&self, records: Vec<(String, Vec<String>, Option<i64>)>) -> Result<(), AppError> {
|
||||
pub async fn bulk_put(
|
||||
&self,
|
||||
records: Vec<(String, Vec<String>, Option<i64>)>,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().unwrap();
|
||||
|
|
@ -174,7 +188,11 @@ impl Db {
|
|||
let records = stmt
|
||||
.query_map([], |row| {
|
||||
let vj: String = row.get(1)?;
|
||||
Ok(Record { key: row.get(0)?, volumes: parse_volumes(&vj), size: row.get(2)? })
|
||||
Ok(Record {
|
||||
key: row.get(0)?,
|
||||
volumes: parse_volumes(&vj),
|
||||
size: row.get(2)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(records)
|
||||
|
|
|
|||
10
src/error.rs
10
src/error.rs
|
|
@ -4,8 +4,14 @@ use axum::response::{IntoResponse, Response};
|
|||
/// Errors from individual volume HTTP requests — used for logging, not HTTP responses.
|
||||
#[derive(Debug)]
|
||||
pub enum VolumeError {
|
||||
Request { url: String, source: reqwest::Error },
|
||||
BadStatus { url: String, status: reqwest::StatusCode },
|
||||
Request {
|
||||
url: String,
|
||||
source: reqwest::Error,
|
||||
},
|
||||
BadStatus {
|
||||
url: String,
|
||||
status: reqwest::StatusCode,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VolumeError {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ pub fn volumes_for_key(key: &str, volumes: &[String], count: usize) -> Vec<Strin
|
|||
})
|
||||
.collect();
|
||||
scored.sort_by_key(|(score, _)| *score);
|
||||
scored.into_iter().take(count).map(|(_, v)| v.clone()).collect()
|
||||
scored
|
||||
.into_iter()
|
||||
.take(count)
|
||||
.map(|(_, v)| v.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod hasher;
|
||||
pub mod server;
|
||||
pub mod rebalance;
|
||||
pub mod rebuild;
|
||||
pub mod server;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
|
|||
11
src/main.rs
11
src/main.rs
|
|
@ -6,7 +6,13 @@ struct Cli {
|
|||
#[arg(short, long, env = "MKV_DB", default_value = "/tmp/mkv/index.db")]
|
||||
db: String,
|
||||
|
||||
#[arg(short, long, env = "MKV_VOLUMES", required = true, value_delimiter = ',')]
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
env = "MKV_VOLUMES",
|
||||
required = true,
|
||||
value_delimiter = ','
|
||||
)]
|
||||
volumes: Vec<String>,
|
||||
|
||||
#[arg(short, long, env = "MKV_REPLICAS", default_value_t = 2)]
|
||||
|
|
@ -36,8 +42,7 @@ async fn shutdown_signal() {
|
|||
let ctrl_c = tokio::signal::ctrl_c();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut sigterm =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler");
|
||||
tokio::select! {
|
||||
_ = ctrl_c => tracing::info!("Received SIGINT, shutting down..."),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::db;
|
||||
use crate::Args;
|
||||
use crate::db;
|
||||
|
||||
pub struct KeyMove {
|
||||
pub key: String,
|
||||
|
|
@ -10,12 +10,25 @@ pub struct KeyMove {
|
|||
pub to_remove: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn plan_rebalance(records: &[db::Record], volumes: &[String], replication: usize) -> Vec<KeyMove> {
|
||||
pub fn plan_rebalance(
|
||||
records: &[db::Record],
|
||||
volumes: &[String],
|
||||
replication: usize,
|
||||
) -> Vec<KeyMove> {
|
||||
let mut moves = Vec::new();
|
||||
for record in records {
|
||||
let desired = crate::hasher::volumes_for_key(&record.key, volumes, replication);
|
||||
let to_add: Vec<String> = desired.iter().filter(|v| !record.volumes.contains(v)).cloned().collect();
|
||||
let to_remove: Vec<String> = record.volumes.iter().filter(|v| !desired.contains(v)).cloned().collect();
|
||||
let to_add: Vec<String> = desired
|
||||
.iter()
|
||||
.filter(|v| !record.volumes.contains(v))
|
||||
.cloned()
|
||||
.collect();
|
||||
let to_remove: Vec<String> = record
|
||||
.volumes
|
||||
.iter()
|
||||
.filter(|v| !desired.contains(v))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if !to_add.is_empty() || !to_remove.is_empty() {
|
||||
moves.push(KeyMove {
|
||||
|
|
@ -79,7 +92,12 @@ pub async fn run(args: &Args, dry_run: bool) {
|
|||
let dst_url = format!("{dst}/{}", m.key);
|
||||
match client.put(&dst_url).body(data).send().await {
|
||||
Ok(resp) if !resp.status().is_success() => {
|
||||
eprintln!(" ERROR copy {} to {}: status {}", m.key, dst, resp.status());
|
||||
eprintln!(
|
||||
" ERROR copy {} to {}: status {}",
|
||||
m.key,
|
||||
dst,
|
||||
resp.status()
|
||||
);
|
||||
copy_ok = false;
|
||||
errors += 1;
|
||||
}
|
||||
|
|
@ -92,7 +110,12 @@ pub async fn run(args: &Args, dry_run: bool) {
|
|||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
eprintln!(" ERROR read {} from {}: status {}", m.key, src, resp.status());
|
||||
eprintln!(
|
||||
" ERROR read {} from {}: status {}",
|
||||
m.key,
|
||||
src,
|
||||
resp.status()
|
||||
);
|
||||
copy_ok = false;
|
||||
errors += 1;
|
||||
}
|
||||
|
|
@ -104,9 +127,13 @@ pub async fn run(args: &Args, dry_run: bool) {
|
|||
}
|
||||
}
|
||||
|
||||
if !copy_ok { continue; }
|
||||
if !copy_ok {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.put(m.key.clone(), m.desired_volumes.clone(), m.size).await.expect("failed to update index");
|
||||
db.put(m.key.clone(), m.desired_volumes.clone(), m.size)
|
||||
.await
|
||||
.expect("failed to update index");
|
||||
|
||||
for old in &m.to_remove {
|
||||
let url = format!("{old}/{}", m.key);
|
||||
|
|
@ -131,7 +158,11 @@ mod tests {
|
|||
.map(|i| {
|
||||
let key = format!("key-{i}");
|
||||
let vols = crate::hasher::volumes_for_key(&key, &volumes, 2);
|
||||
db::Record { key, volumes: vols, size: Some(100) }
|
||||
db::Record {
|
||||
key,
|
||||
volumes: vols,
|
||||
size: Some(100),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -146,7 +177,11 @@ mod tests {
|
|||
.map(|i| {
|
||||
let key = format!("key-{i}");
|
||||
let vols = crate::hasher::volumes_for_key(&key, &volumes3, 2);
|
||||
db::Record { key, volumes: vols, size: Some(100) }
|
||||
db::Record {
|
||||
key,
|
||||
volumes: vols,
|
||||
size: Some(100),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use crate::db;
|
||||
use crate::Args;
|
||||
use crate::db;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct NginxEntry {
|
||||
|
|
@ -19,13 +19,22 @@ async fn list_volume_keys(volume_url: &str) -> Result<Vec<(String, i64)>, String
|
|||
|
||||
while let Some(prefix) = dirs.pop() {
|
||||
let url = format!("{volume_url}/{prefix}");
|
||||
let resp = http.get(&url).send().await.map_err(|e| format!("GET {url}: {e}"))?;
|
||||
let resp = http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GET {url}: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GET {url}: status {}", resp.status()));
|
||||
}
|
||||
let entries: Vec<NginxEntry> = resp.json().await.map_err(|e| format!("parse {url}: {e}"))?;
|
||||
let entries: Vec<NginxEntry> =
|
||||
resp.json().await.map_err(|e| format!("parse {url}: {e}"))?;
|
||||
for entry in entries {
|
||||
let full_path = if prefix.is_empty() { entry.name.clone() } else { format!("{prefix}{}", entry.name) };
|
||||
let full_path = if prefix.is_empty() {
|
||||
entry.name.clone()
|
||||
} else {
|
||||
format!("{prefix}{}", entry.name)
|
||||
};
|
||||
match entry.entry_type.as_str() {
|
||||
"directory" => dirs.push(format!("{full_path}/")),
|
||||
"file" => keys.push((full_path, entry.size.unwrap_or(0))),
|
||||
|
|
@ -58,14 +67,19 @@ pub async fn run(args: &Args) {
|
|||
for (key, size) in keys {
|
||||
let entry = index.entry(key).or_insert_with(|| (Vec::new(), size));
|
||||
entry.0.push(vol_url.clone());
|
||||
if size > entry.1 { entry.1 = size; }
|
||||
if size > entry.1 {
|
||||
entry.1 = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!(" Error scanning {vol_url}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let records: Vec<_> = index.into_iter().map(|(k, (v, s))| (k, v, Some(s))).collect();
|
||||
let records: Vec<_> = index
|
||||
.into_iter()
|
||||
.map(|(k, (v, s))| (k, v, Some(s)))
|
||||
.collect();
|
||||
let count = records.len();
|
||||
db.bulk_put(records).await.expect("bulk_put failed");
|
||||
eprintln!("Rebuilt index with {count} keys");
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ pub async fn get_key(
|
|||
.first()
|
||||
.ok_or_else(|| AppError::CorruptRecord { key: key.clone() })?;
|
||||
let location = format!("{vol}/{key}");
|
||||
Ok((StatusCode::FOUND, [(axum::http::header::LOCATION, location)]).into_response())
|
||||
Ok((
|
||||
StatusCode::FOUND,
|
||||
[(axum::http::header::LOCATION, location)],
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn put_key(
|
||||
|
|
@ -45,15 +49,22 @@ pub async fn put_key(
|
|||
let mut handles = Vec::with_capacity(target_volumes.len());
|
||||
for vol in &target_volumes {
|
||||
let url = format!("{vol}/{key}");
|
||||
let handle = tokio::spawn({
|
||||
let handle =
|
||||
tokio::spawn({
|
||||
let client = state.http.clone();
|
||||
let data = body.clone();
|
||||
async move {
|
||||
let resp = client.put(&url).body(data).send().await.map_err(|e| {
|
||||
VolumeError::Request { url: url.clone(), source: e }
|
||||
VolumeError::Request {
|
||||
url: url.clone(),
|
||||
source: e,
|
||||
}
|
||||
})?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(VolumeError::BadStatus { url, status: resp.status() });
|
||||
return Err(VolumeError::BadStatus {
|
||||
url,
|
||||
status: resp.status(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -85,7 +96,11 @@ pub async fn put_key(
|
|||
}
|
||||
|
||||
let size = Some(body.len() as i64);
|
||||
if let Err(e) = state.db.put(key.clone(), target_volumes.clone(), size).await {
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.put(key.clone(), target_volumes.clone(), size)
|
||||
.await
|
||||
{
|
||||
for vol in &target_volumes {
|
||||
let _ = state.http.delete(format!("{vol}/{key}")).send().await;
|
||||
}
|
||||
|
|
@ -106,11 +121,19 @@ pub async fn delete_key(
|
|||
let handle = tokio::spawn({
|
||||
let client = state.http.clone();
|
||||
async move {
|
||||
let resp = client.delete(&url).send().await.map_err(|e| {
|
||||
VolumeError::Request { url: url.clone(), source: e }
|
||||
let resp = client
|
||||
.delete(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| VolumeError::Request {
|
||||
url: url.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(VolumeError::BadStatus { url, status: resp.status() });
|
||||
return Err(VolumeError::BadStatus {
|
||||
url,
|
||||
status: resp.status(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -136,7 +159,10 @@ pub async fn head_key(
|
|||
let record = state.db.get(&key).await?;
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(size) = record.size {
|
||||
headers.insert(axum::http::header::CONTENT_LENGTH, size.to_string().parse().unwrap());
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_LENGTH,
|
||||
size.to_string().parse().unwrap(),
|
||||
);
|
||||
}
|
||||
Ok((StatusCode::OK, headers).into_response())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,12 +45,24 @@ async fn test_put_and_head() {
|
|||
let base = start_server().await;
|
||||
let c = client();
|
||||
|
||||
let resp = c.put(format!("{base}/hello")).body("world").send().await.unwrap();
|
||||
let resp = c
|
||||
.put(format!("{base}/hello"))
|
||||
.body("world")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = c.head(format!("{base}/hello")).send().await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(resp.headers().get("content-length").unwrap().to_str().unwrap(), "5");
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("content-length")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"5"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -58,14 +70,22 @@ async fn test_put_and_get_redirect() {
|
|||
let base = start_server().await;
|
||||
let c = client();
|
||||
|
||||
let resp = c.put(format!("{base}/redirect-test")).body("some data").send().await.unwrap();
|
||||
let resp = c
|
||||
.put(format!("{base}/redirect-test"))
|
||||
.body("some data")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = c.get(format!("{base}/redirect-test")).send().await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.starts_with("http://localhost:310"), "got: {location}");
|
||||
assert!(
|
||||
location.starts_with("http://localhost:310"),
|
||||
"got: {location}"
|
||||
);
|
||||
|
||||
let blob_resp = reqwest::get(location).await.unwrap();
|
||||
assert_eq!(blob_resp.status(), StatusCode::OK);
|
||||
|
|
@ -76,7 +96,11 @@ async fn test_put_and_get_redirect() {
|
|||
async fn test_get_nonexistent_returns_404() {
|
||||
let base = start_server().await;
|
||||
let c = client();
|
||||
let resp = c.get(format!("{base}/does-not-exist")).send().await.unwrap();
|
||||
let resp = c
|
||||
.get(format!("{base}/does-not-exist"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +109,12 @@ async fn test_put_get_delete_get() {
|
|||
let base = start_server().await;
|
||||
let c = client();
|
||||
|
||||
let resp = c.put(format!("{base}/delete-me")).body("temporary").send().await.unwrap();
|
||||
let resp = c
|
||||
.put(format!("{base}/delete-me"))
|
||||
.body("temporary")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = c.get(format!("{base}/delete-me")).send().await.unwrap();
|
||||
|
|
@ -102,7 +131,11 @@ async fn test_put_get_delete_get() {
|
|||
async fn test_delete_nonexistent_returns_404() {
|
||||
let base = start_server().await;
|
||||
let c = client();
|
||||
let resp = c.delete(format!("{base}/never-existed")).send().await.unwrap();
|
||||
let resp = c
|
||||
.delete(format!("{base}/never-existed"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +145,11 @@ async fn test_list_keys() {
|
|||
let c = client();
|
||||
|
||||
for name in ["docs/a", "docs/b", "docs/c", "other/x"] {
|
||||
c.put(format!("{base}/{name}")).body("data").send().await.unwrap();
|
||||
c.put(format!("{base}/{name}"))
|
||||
.body("data")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = c.get(format!("{base}/")).send().await.unwrap();
|
||||
|
|
@ -134,13 +171,29 @@ async fn test_put_overwrite() {
|
|||
let base = start_server().await;
|
||||
let c = client();
|
||||
|
||||
c.put(format!("{base}/overwrite")).body("version1").send().await.unwrap();
|
||||
c.put(format!("{base}/overwrite"))
|
||||
.body("version1")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = c.put(format!("{base}/overwrite")).body("version2").send().await.unwrap();
|
||||
let resp = c
|
||||
.put(format!("{base}/overwrite"))
|
||||
.body("version2")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = c.head(format!("{base}/overwrite")).send().await.unwrap();
|
||||
assert_eq!(resp.headers().get("content-length").unwrap().to_str().unwrap(), "8");
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("content-length")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"8"
|
||||
);
|
||||
|
||||
let resp = c.get(format!("{base}/overwrite")).send().await.unwrap();
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
|
|
@ -153,7 +206,11 @@ async fn test_replication_writes_to_multiple_volumes() {
|
|||
let base = start_server().await;
|
||||
let c = client();
|
||||
|
||||
c.put(format!("{base}/replicated")).body("replica-data").send().await.unwrap();
|
||||
c.put(format!("{base}/replicated"))
|
||||
.body("replica-data")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = c.head(format!("{base}/replicated")).send().await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue