refactor(admin): consolidate json_response, empty_response, and extract_query_params into admin utils (#6694)

The admin surface had accumulated one near-identical response helper per handler file. This folds the byte-equivalent ones into `rustfs/src/admin/utils.rs` so the wire shape of an admin JSON answer is pinned in one place instead of being re-derived twelve times.

Folded into `crate::admin::utils`:

- `json_response(status, &value)` — 9 local definitions removed: batch_job.rs, kms_backup.rs, oidc.rs, diagnostics.rs (identical signature), object_data_cache.rs and site_replication.rs (hard-coded `StatusCode::OK`, whose call sites now pass `StatusCode::OK` explicitly), ilm_transition.rs (arguments were `(&value, status)` and are swapped at every call site), and kms_key_metadata.rs / kms_key_lifecycle.rs (concrete response types now covered by the generic helper).
- `empty_response(status)` — 2 local definitions removed: site_replication.rs (`Body::empty()`) and table_catalog/mod.rs (`Body::default()`); `Body::empty()` is defined as `Body::default()`, so the two were already the same response.
- `extract_query_params(uri)` — 4 local definitions removed: kms_keys.rs (was `pub(super)`), replication.rs, batch_job.rs, config_admin.rs. All four bodies were behaviourally identical (`form_urlencoded::parse` over `uri.query()`, last-wins on repeated keys, valueless parameters kept as empty strings); they differed only in blank lines. kms_key_lifecycle.rs, which imported the kms_keys copy, now imports the shared one.

Intentionally left alone:

- heal.rs `json_response` — different shape: returns a bare `S3Response` (not `S3Result`) and additionally sets `CONTENT_LENGTH`.
- kms_rekey.rs `json_response` — same divergent shape as heal.rs: bare `S3Response` over already-serialized `Vec<u8>`.
- idp_compat.rs `json_response` — encrypts the payload via `encode_compatible_admin_payload`; it is not a duplicate of the plain JSON helper.
- scanner.rs `json_response` — takes raw `Vec<u8>`, and `ScannerCycleStateResetHandler` genuinely passes a byte literal rather than a serializable value, so the local helper stays.
- oidc.rs `extract_query_param` — singular, returns `Option<String>` for one key, hand-rolls its own splitting via the `urlencoding` crate; a different function, not a variant of the map builder.

Wire behaviour on the success path is byte-identical everywhere: same status, same `Content-Type: application/json` (every local copy spelled the same value, whether via a per-file `JSON_CONTENT_TYPE`/`CONTENT_TYPE_JSON` constant, `HeaderValue::from_static`, or `"application/json".parse()`), same serialized body bytes, and no other header. The only behavioural change is the message text on the serde-serialization-failure arm, which is now uniformly `failed to serialize response: {e}`; that arm is unreachable for these owned response structs and the acceptance criteria pin only status and content type.

No `include_str!` self-grep assertion needed updating: the affected tests in ilm_transition.rs, site_replication.rs, kms_keys.rs, kms_key_metadata.rs, kms_key_lifecycle.rs, object_data_cache.rs, and table_catalog/tests.rs are all bounded by handler `impl Operation` / entry-point markers that sit well after the removed helpers, and none of them assert on a `json_response`, `empty_response`, or `extract_query_params` string.

Tests: `rustfs/src/admin/utils.rs` gains `json_response_carries_status_content_type_and_serialized_body`, `json_response_reports_serialization_failure_as_internal_error`, `empty_response_has_no_body_and_no_headers`, `extract_query_params_decodes_percent_escapes`, and `extract_query_params_keeps_valueless_parameters_and_survives_no_query`. The percent-decoding coverage previously in batch_job's `extract_query_params_decodes_job_id` moves there, and batch_job keeps its own end-to-end coverage as `require_job_id_decodes_and_rejects_missing_and_empty`.

Reference: rustfs/backlog#1829 T6
This commit is contained in:
Zhengchao An
2026-08-27 05:57:13 +08:00
committed by GitHub
parent 030a87013c
commit 64739778c4
14 changed files with 314 additions and 272 deletions
+9 -29
View File
@@ -37,18 +37,16 @@
use crate::admin::auth::authorize_admin_request;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::utils::read_compatible_admin_body;
use crate::admin::utils::{extract_query_params, json_response, read_compatible_admin_body};
use crate::server::ADMIN_PREFIX;
use http::{HeaderMap, HeaderValue, Uri};
use http::Uri;
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_credentials::Credentials;
use rustfs_policy::policy::action::{Action, AdminAction};
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use serde::Serialize;
use std::collections::HashMap;
use tracing::warn;
/// Job types recognised by the MinIO batch-job admin API.
@@ -58,27 +56,10 @@ use tracing::warn;
/// (`NotImplemented`) from "unknown job type" (`InvalidRequest`).
const KNOWN_JOB_TYPES: &[&str] = &["replicate", "keyrotate", "expire"];
fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
let mut params = HashMap::new();
if let Some(query) = uri.query() {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
params.insert(key.into_owned(), value.into_owned());
}
}
params
}
async fn validate_batch_job_admin_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<Credentials> {
authorize_admin_request(req, vec![Action::AdminAction(action)]).await
}
fn json_response<T: Serialize>(status: StatusCode, value: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(value).map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("{e}")))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(S3Response::with_headers((status, Body::from(data)), headers))
}
/// Best-effort detection of the declared job type from a MinIO batch-job
/// definition body.
///
@@ -269,7 +250,7 @@ fn no_such_job(job_id: &str) -> S3Error {
#[cfg(test)]
mod tests {
use super::{detect_job_type, extract_query_params, require_job_id};
use super::{detect_job_type, require_job_id};
use http::Uri;
#[test]
@@ -302,17 +283,16 @@ mod tests {
assert_eq!(detect_job_type(b"transmogrify:\n foo: bar\n"), None);
}
/// `jobId` reaches the handler percent-decoded — the shared query parser
/// is covered in `crate::admin::utils`; this pins the endpoint's own use
/// of it, including the rejection of a missing or empty id.
#[test]
fn extract_query_params_decodes_job_id() {
let uri: Uri = "/rustfs/admin/v3/status-job?jobId=abc%2F123"
fn require_job_id_decodes_and_rejects_missing_and_empty() {
let encoded: Uri = "/rustfs/admin/v3/status-job?jobId=abc%2F123"
.parse()
.expect("uri should parse");
let params = extract_query_params(&uri);
assert_eq!(params.get("jobId"), Some(&"abc/123".to_string()));
}
assert_eq!(require_job_id(&encoded).expect("job id"), "abc/123");
#[test]
fn require_job_id_rejects_missing_and_empty() {
let missing: Uri = "/rustfs/admin/v3/status-job".parse().expect("uri should parse");
assert!(require_job_id(&missing).is_err());
+6 -15
View File
@@ -30,10 +30,12 @@ use crate::admin::storage_api::config::{
save_admin_server_config_snapshot,
};
use crate::admin::storage_api::contract::list::ListOperations as _;
use crate::admin::utils::{encode_compatible_admin_payload, is_compat_admin_request, read_compatible_admin_body};
use crate::admin::utils::{
encode_compatible_admin_payload, extract_query_params, is_compat_admin_request, read_compatible_admin_body,
};
use crate::error::ApiError;
use crate::server::ADMIN_PREFIX;
use http::{HeaderMap, HeaderValue, Uri};
use http::{HeaderMap, HeaderValue};
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_config::audit::{
@@ -81,7 +83,7 @@ use rustfs_policy::policy::action::{Action, AdminAction};
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use serde::Serialize;
use std::collections::{BTreeSet, HashMap};
use std::collections::BTreeSet;
use std::env;
use std::mem::size_of;
use time::OffsetDateTime;
@@ -664,18 +666,6 @@ pub fn register_config_route(r: &mut S3Router<AdminOperation>) -> std::io::Resul
Ok(())
}
fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
let mut params = HashMap::new();
if let Some(query) = uri.query() {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
params.insert(key.into_owned(), value.into_owned());
}
}
params
}
async fn validate_config_admin_request(req: &S3Request<Body>) -> S3Result<Credentials> {
// Pre-check keeps this endpoint's historical missing-credentials message;
// the shared gate reports "get cred failed".
@@ -2282,6 +2272,7 @@ impl Operation for SetConfigHandler {
#[cfg(test)]
mod tests {
use super::*;
use http::Uri;
use serial_test::serial;
use temp_env::with_vars;
+3 -10
View File
@@ -26,6 +26,7 @@
use crate::admin::auth::authorize_admin_request;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::storage_api::access::spawn_traced;
use crate::admin::utils::json_response;
use crate::server::ADMIN_PREFIX;
use crate::storage::storage_api::get_global_lock_clients;
use bytes::Bytes;
@@ -37,7 +38,7 @@ use rustfs_lock::{LockLeaseInfo, LockMode, LockType, ObjectKey, get_global_lock_
use rustfs_policy::policy::action::{Action, AdminAction};
use s3s::header::CONTENT_TYPE;
use s3s::stream::{ByteStream, DynByteStream};
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, StdError, s3_error};
use s3s::{Body, S3Error, S3Request, S3Response, S3Result, StdError, s3_error};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::pin::Pin;
@@ -48,7 +49,6 @@ use tokio::sync::{Semaphore, SemaphorePermit, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use tracing::warn;
const CONTENT_TYPE_JSON: &str = "application/json";
const CONTENT_TYPE_NDJSON: &str = "application/x-ndjson";
pub(crate) const CLIENT_DEVNULL_MAX_BYTES: u64 = 1024 * 1024 * 1024;
pub(crate) const CLIENT_DEVNULL_MAX_DURATION: Duration = Duration::from_secs(30);
@@ -143,14 +143,6 @@ async fn authorize(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
Ok(())
}
fn json_response<T: Serialize>(status: StatusCode, value: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(value)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize response: {e}")))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_JSON));
Ok(S3Response::with_headers((status, Body::from(data)), headers))
}
async fn read_body(input: Body) -> S3Result<Vec<u8>> {
let mut input = input;
let body = input
@@ -1061,6 +1053,7 @@ fn query_values(uri: &Uri, key: &str) -> Vec<String> {
mod tests {
use super::*;
use http::{Extensions, Uri};
use s3s::S3ErrorCode;
fn build_request(method: Method, uri: &'static str) -> S3Request<Body> {
S3Request {
+10 -22
View File
@@ -30,8 +30,9 @@ use crate::admin::storage_api::lifecycle::{
request_manual_transition_job_cancel, save_manual_transition_job_record, update_manual_transition_job_record,
};
use crate::admin::storage_api::runtime::ECStore;
use crate::admin::utils::json_response;
use crate::server::{ADMIN_PREFIX, RemoteAddr};
use http::{HeaderMap, HeaderValue};
use http::HeaderMap;
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
@@ -40,7 +41,6 @@ use rustfs_utils::{
MaskedAccessKey,
http::{AMZ_REQUEST_ID, REQUEST_ID_HEADER},
};
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -51,7 +51,6 @@ use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use uuid::Uuid;
const JSON_CONTENT_TYPE: &str = "application/json";
const DEFAULT_MANUAL_TRANSITION_MAX_OBJECTS: u64 = 10_000;
const MAX_MANUAL_TRANSITION_OBJECTS: u64 = 100_000;
const MAX_MANUAL_TRANSITION_DURATION_SECONDS: u64 = 3600;
@@ -629,17 +628,6 @@ fn map_manual_transition_job_load_error(err: StorageError, job_id: Uuid) -> S3Er
}
}
fn json_response<T: Serialize>(response: &T, status: StatusCode) -> S3Result<S3Response<(StatusCode, Body)>> {
let body = serde_json::to_vec(response).map_err(|err| {
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode manual transition response: {err}"))
})?;
let content_type = HeaderValue::from_str(JSON_CONTENT_TYPE)
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid content type: {err}")))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, content_type);
Ok(S3Response::with_headers((status, Body::from(body)), headers))
}
async fn update_manual_transition_job_record_if_owned(
store: Arc<ECStore>,
job_id: Uuid,
@@ -921,10 +909,10 @@ impl Operation for ManualTransitionRunHandler {
cancel_endpoint: Some(status_endpoint),
report: record.report,
};
return json_response(&response, StatusCode::ACCEPTED);
return json_response(StatusCode::ACCEPTED, &response);
}
StartManualTransitionJobResult::Conflict(response) => {
return json_response(&response, StatusCode::CONFLICT);
return json_response(StatusCode::CONFLICT, &response);
}
}
}
@@ -960,7 +948,7 @@ impl Operation for ManualTransitionRunHandler {
report,
};
json_response(&response, StatusCode::OK)
json_response(StatusCode::OK, &response)
}
}
@@ -1000,7 +988,7 @@ impl Operation for ManualTransitionJobStatusHandler {
release_manual_transition_admission(store, &record);
}
}
json_response(&manual_transition_job_response(record), StatusCode::OK)
json_response(StatusCode::OK, &manual_transition_job_response(record))
}
}
@@ -1022,7 +1010,7 @@ impl Operation for ManualTransitionJobCancelHandler {
{
cancel_token.cancel();
}
json_response(&manual_transition_job_response(record), StatusCode::OK)
json_response(StatusCode::OK, &manual_transition_job_response(record))
}
}
@@ -1039,7 +1027,7 @@ impl Operation for TransitionReconcileInspectHandler {
let status = inspect_transition_transaction_for_operator(store, transaction_id)
.await
.map_err(map_transition_operator_error)?;
json_response(&status, StatusCode::OK)
json_response(StatusCode::OK, &status)
}
}
@@ -1074,7 +1062,7 @@ impl Operation for TransitionReconcileApplyHandler {
"exact_delete_completed_journal_already_finalized"
};
log_transition_reconcile_applied(transaction_id, "delete_candidate", outcome, &request_id, &actor, &remote_addr);
json_response(&TransitionCandidateDeleteResponse { outcome, result }, StatusCode::OK)
json_response(StatusCode::OK, &TransitionCandidateDeleteResponse { outcome, result })
}
ValidatedTransitionReconcileAction::FinalizeMissing => {
finalize_missing_transition_transaction_for_operator(store, transaction_id)
@@ -1089,12 +1077,12 @@ impl Operation for TransitionReconcileApplyHandler {
&remote_addr,
);
json_response(
StatusCode::OK,
&TransitionFinalizeMissingResponse {
outcome: "journal_finalized",
journal_retained: false,
transaction_id,
},
StatusCode::OK,
)
}
}
+2 -9
View File
@@ -44,10 +44,11 @@ use super::kms_audit::{KmsAdminAudit, KmsAdminOperation};
use crate::admin::auth::validate_admin_request;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::{current_deployment_id, current_kms_runtime_service_manager};
use crate::admin::utils::json_response;
use crate::auth::{check_key_valid, get_session_token};
use crate::server::{ADMIN_PREFIX, RemoteAddr};
use base64_simd::STANDARD as BASE64;
use hyper::{HeaderMap, Method, StatusCode};
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_kms::backup::{
@@ -58,7 +59,6 @@ use rustfs_kms::backup::{
use rustfs_kms::config::{BackendConfig, KmsConfig, LocalConfig, VaultAuthMethod};
use rustfs_kms::{KmsBackend, KmsManager, KmsServiceManager, KmsServiceStatus};
use rustfs_policy::policy::action::{Action, KmsAction};
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
@@ -890,13 +890,6 @@ async fn backup_status() -> KmsBackupStatusResponse {
// HTTP plumbing
// ---------------------------------------------------------------------------
fn json_response<T: Serialize>(status: StatusCode, value: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(value).map_err(|e| s3_error!(InternalError, "failed to serialize response: {}", e))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().expect("static content type should parse"));
Ok(S3Response::with_headers((status, Body::from(data)), headers))
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
success: bool,
+3 -10
View File
@@ -15,13 +15,14 @@
//! KMS key lifecycle admin API handlers: enable, disable and rotate.
use super::kms_audit::KmsAdminAudit;
use super::kms_keys::{extract_query_params, scoped_key_id};
use super::kms_keys::scoped_key_id;
use crate::admin::auth::validate_admin_request_with_kms_key;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::current_kms_runtime_service_manager;
use crate::admin::utils::{extract_query_params, json_response};
use crate::auth::{check_key_valid, get_session_token};
use crate::server::{ADMIN_PREFIX, RemoteAddr};
use hyper::{HeaderMap, Method, StatusCode};
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_kms::{
@@ -29,7 +30,6 @@ use rustfs_kms::{
types::{DescribeKeyRequest, KeyMetadata, OperationContext},
};
use rustfs_policy::policy::action::{Action, KmsAction};
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -233,13 +233,6 @@ async fn execute_lifecycle(
}
}
fn json_response(status: StatusCode, response: &KmsKeyLifecycleResponse) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(response).map_err(|e| s3_error!(InternalError, "failed to serialize response: {}", e))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().expect("static content type should parse"));
Ok(S3Response::with_headers((status, Body::from(data)), headers))
}
fn unavailable_response(message: &str, key_id: String) -> S3Result<S3Response<(StatusCode, Body)>> {
json_response(
StatusCode::SERVICE_UNAVAILABLE,
@@ -23,9 +23,10 @@ use super::kms_keys::scoped_key_id;
use crate::admin::auth::validate_admin_request_with_kms_key;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::current_kms_runtime_service_manager;
use crate::admin::utils::json_response;
use crate::auth::{check_key_valid, get_session_token};
use crate::server::{ADMIN_PREFIX, RemoteAddr};
use hyper::{HeaderMap, Method, StatusCode};
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_kms::{
@@ -33,7 +34,6 @@ use rustfs_kms::{
types::{DescribeKeyRequest, KeyMetadata},
};
use rustfs_policy::policy::action::{Action, KmsAction};
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -268,13 +268,6 @@ async fn execute_metadata_update(
}
}
fn json_response(status: StatusCode, response: &KmsKeyMetadataResponse) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(response).map_err(|e| s3_error!(InternalError, "failed to serialize response: {}", e))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().expect("static content type should parse"));
Ok(S3Response::with_headers((status, Body::from(data)), headers))
}
fn unavailable_response(message: &str, key_id: String) -> S3Result<S3Response<(StatusCode, Body)>> {
json_response(
StatusCode::SERVICE_UNAVAILABLE,
+1 -16
View File
@@ -18,6 +18,7 @@ use super::kms_audit::{KmsAdminAudit, KmsAdminOperation};
use crate::admin::auth::{validate_admin_request, validate_admin_request_with_kms_key};
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::{current_kms_runtime_service_manager, current_or_init_kms_runtime_service_manager};
use crate::admin::utils::extract_query_params;
use crate::auth::{check_key_valid, get_session_token};
use crate::kms_deletion_gate::current_key_impact;
use crate::server::{ADMIN_PREFIX, RemoteAddr};
@@ -100,22 +101,6 @@ pub struct GenerateDataKeyApiResponse {
pub ciphertext_blob: String, // Base64 encoded
}
/// The query parameters of an admin KMS request.
///
/// Parsed with `form_urlencoded`, as the rest of the admin surface does, so a
/// parameter written without a value (`?status`) arrives as an empty value
/// rather than disappearing: a validated parameter must be able to tell "not
/// asked for" from "asked for, unreadable".
pub(super) fn extract_query_params(uri: &hyper::Uri) -> HashMap<String, String> {
let mut params = HashMap::new();
if let Some(query) = uri.query() {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
params.insert(key.into_owned(), value.into_owned());
}
}
params
}
/// Status values a `status` filter may name, spelled as the response spells
/// them.
const KEY_STATUS_FILTERS: &[(&str, KeyStatus)] = &[
+15 -21
View File
@@ -24,20 +24,17 @@
use crate::admin::auth::authorize_admin_request;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::current_object_data_cache;
use crate::admin::utils::json_response;
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::server::ADMIN_PREFIX;
use http::{HeaderMap, HeaderValue};
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_object_data_cache::{ObjectDataCacheIdentity, ObjectDataCacheInvalidationReason, ObjectDataCacheInvalidationResult};
use rustfs_policy::policy::action::{Action, AdminAction};
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
use serde::Serialize;
use std::sync::Arc;
const JSON_CONTENT_TYPE: &str = "application/json";
#[derive(Debug, Serialize)]
struct ObjectDataCacheStatsResponse {
mode: &'static str,
@@ -85,14 +82,6 @@ async fn authorize(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
Ok(())
}
fn json_response<T: Serialize>(body: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(body)
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode response: {err}")))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static(JSON_CONTENT_TYPE));
Ok(S3Response::with_headers((StatusCode::OK, Body::from(data)), headers))
}
fn query_value(req: &S3Request<Body>, key: &str) -> Option<String> {
req.uri.query().and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
@@ -146,7 +135,7 @@ impl Operation for ObjectDataCacheStatsHandler {
},
};
json_response(&response)
json_response(StatusCode::OK, &response)
}
}
@@ -181,19 +170,24 @@ impl Operation for ObjectDataCacheFlushHandler {
};
let (outcome, removed_keys) = invalidation_outcome(&result);
json_response(&ObjectDataCacheFlushResponse {
scope,
bucket,
object,
outcome,
removed_keys,
})
json_response(
StatusCode::OK,
&ObjectDataCacheFlushResponse {
scope,
bucket,
object,
outcome,
removed_keys,
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::HeaderMap;
use s3s::S3ErrorCode;
#[test]
fn flush_outcome_maps_removed_and_noop() {
+1 -10
View File
@@ -23,6 +23,7 @@ use crate::admin::service::federated_identity::DefaultFederatedSessionBinding;
use crate::admin::storage_api::config::{
read_admin_config_without_migrate, read_admin_server_config_snapshot, save_admin_server_config_snapshot,
};
use crate::admin::utils::json_response;
use crate::server::{ADMIN_PREFIX, CONSOLE_PREFIX, MINIO_ADMIN_PREFIX};
use http::StatusCode;
use hyper::Method;
@@ -881,16 +882,6 @@ async fn parse_json_body<T: DeserializeOwned>(req: &mut S3Request<Body>) -> S3Re
serde_json::from_slice(&body).map_err(|e| s3_error!(InvalidRequest, "invalid JSON: {}", e))
}
fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
let body = serde_json::to_vec(payload)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize error: {e}")))?;
let mut resp = S3Response::new((status, Body::from(body)));
resp.headers
.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/json"));
Ok(resp)
}
async fn load_server_config_from_store() -> S3Result<ServerConfig> {
let store = oidc_config_store()?;
+2 -14
View File
@@ -34,11 +34,11 @@ use crate::admin::storage_api::contract::bucket::{BucketOperations, BucketOption
use crate::admin::storage_api::contract::list::ListOperations as _;
use crate::admin::storage_api::error::StorageError;
use crate::admin::storage_api::runtime::PeerRestClient;
use crate::admin::utils::read_compatible_admin_body;
use crate::admin::utils::{extract_query_params, read_compatible_admin_body};
use crate::error::ApiError;
use crate::server::ADMIN_PREFIX;
use crate::storage::storage_api::lock_bucket_targets_metadata;
use http::{HeaderMap, HeaderValue, Uri};
use http::{HeaderMap, HeaderValue};
use hyper::{Method, StatusCode};
use jiff::Timestamp;
use matchit::Params;
@@ -116,18 +116,6 @@ fn site_endpoint_for(endpoint: &str, secure: bool) -> String {
}
}
fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
let mut params = HashMap::new();
if let Some(query) = uri.query() {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
params.insert(key.into_owned(), value.into_owned());
}
}
params
}
fn map_bucket_target_error(err: BucketTargetError) -> S3Error {
match err {
BucketTargetError::BucketRemoteTargetNotFound { .. }
+123 -102
View File
@@ -47,7 +47,7 @@ use crate::admin::storage_api::contract::bucket::{
};
use crate::admin::storage_api::error::{Error as StorageError, is_err_bucket_not_found};
use crate::admin::storage_api::runtime::ECStore;
use crate::admin::utils::{encode_compatible_admin_payload, read_compatible_admin_body};
use crate::admin::utils::{empty_response, encode_compatible_admin_payload, json_response, read_compatible_admin_body};
use crate::auth::constant_time_eq;
use crate::config::get_config_snapshot;
use crate::error::ApiError;
@@ -61,7 +61,7 @@ use base64_simd::URL_SAFE_NO_PAD;
use futures::StreamExt;
use hmac::{Hmac, Mac};
use http::header::{CONTENT_TYPE, HOST};
use http::{HeaderMap, HeaderValue, Uri};
use http::{HeaderMap, Uri};
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_config::{
@@ -969,14 +969,6 @@ fn reject_site_replicator_on_public_admin(cred: &rustfs_credentials::Credentials
Ok(())
}
fn json_response<T: Serialize>(value: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(value)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize response: {e}")))?;
let mut headers = HeaderMap::new();
headers.insert(s3s::header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(S3Response::with_headers((StatusCode::OK, Body::from(data)), headers))
}
fn go_gob_site_netperf_response(value: &SiteNetPerfNodeResult) -> S3Response<(StatusCode, Body)> {
let data = encode_go_gob_site_netperf_node_result(value);
S3Response::new((StatusCode::OK, Body::from(data)))
@@ -1058,10 +1050,6 @@ fn write_go_gob_uint(out: &mut Vec<u8>, value: u64) {
out.extend_from_slice(used);
}
fn empty_response(status: StatusCode) -> S3Response<(StatusCode, Body)> {
S3Response::new((status, Body::empty()))
}
async fn read_plain_admin_body(mut input: Body) -> S3Result<Vec<u8>> {
let body = input
.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE)
@@ -4272,7 +4260,7 @@ async fn execute_site_replication_repair_locked(
));
}
if existing.status == "success" {
return json_response(&site_replication_repair_operation_response(existing));
return json_response(StatusCode::OK, &site_replication_repair_operation_response(existing));
}
if !constant_time_eq(&existing.plan_token, &plan_token) {
return Err(S3Error::with_message(
@@ -4305,7 +4293,7 @@ async fn execute_site_replication_repair_locked(
})
.await?;
if operation.status == "success" {
return json_response(&site_replication_repair_operation_response(&operation));
return json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation));
}
let service_account_secret_key = site_replicator_service_account_secret(&state.service_account_access_key).await?;
@@ -4367,7 +4355,7 @@ async fn execute_site_replication_repair_locked(
summarize_site_replication_repair_operation(&mut operation);
persist_site_replication_repair_operation(&operation).await?;
json_response(&site_replication_repair_operation_response(&operation))
json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation))
}
pub async fn site_replication_make_bucket_hook(bucket: &str, lock_enabled: bool) -> S3Result<()> {
@@ -10182,13 +10170,16 @@ impl Operation for SiteReplicationAddHandler {
// response below (BUG2) rather than swallowed; they do not abort the overall add.
initial_sync_errors.extend(backfill_existing_buckets_after_add(&state, &local_peer, None).await);
json_response(&ReplicateAddStatus {
success: true,
status: SITE_REPL_ADD_SUCCESS.to_string(),
initial_sync_error_message: initial_sync_errors.render(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
})
json_response(
StatusCode::OK,
&ReplicateAddStatus {
success: true,
status: SITE_REPL_ADD_SUCCESS.to_string(),
initial_sync_error_message: initial_sync_errors.render(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
},
)
}
}
@@ -10255,7 +10246,7 @@ impl Operation for SiteReplicationRemoveHandler {
site_replication_remove_status(&peer_errors)
};
json_response(&status)
json_response(StatusCode::OK, &status)
}
}
@@ -10286,7 +10277,7 @@ impl Operation for SiteReplicationInfoHandler {
validate_site_replication_admin_request(&req, AdminAction::SiteReplicationInfoAction).await?;
let state = load_site_replication_state().await?;
let local_peer = current_local_peer(&req, &state);
json_response(&site_replication_info_for(&state, &local_peer))
json_response(StatusCode::OK, &site_replication_info_for(&state, &local_peer))
}
}
@@ -10300,7 +10291,7 @@ impl Operation for SiteReplicationMetaInfoHandler {
let local_peer = current_local_peer(&req, &state);
let opts = sr_status_options(&req.uri);
let info = filter_sr_info(build_sr_info(&state, &local_peer).await?, &opts);
json_response(&info)
json_response(StatusCode::OK, &info)
}
}
@@ -10313,7 +10304,7 @@ impl Operation for SiteReplicationStatusHandler {
let state = load_site_replication_state().await?;
let local_peer = current_local_peer(&req, &state);
let status = build_status_info(&state, &local_peer, &req.uri).await?;
json_response(&status)
json_response(StatusCode::OK, &status)
}
}
@@ -10568,7 +10559,7 @@ impl Operation for SRPeerJoinHandler {
result = "join_superseded",
"admin site replication state"
);
return json_response(&superseded_join_response(peer));
return json_response(StatusCode::OK, &superseded_join_response(peer));
}
};
// Fix 1 (receiving side): ensure the joining peer also sets up replication for any
@@ -10587,10 +10578,13 @@ impl Operation for SRPeerJoinHandler {
"admin site replication state"
);
}
json_response(&applied_join_response(
state.peers.get(&local_peer.deployment_id).cloned().unwrap_or(local_peer),
backfill_errors.render(),
))
json_response(
StatusCode::OK,
&applied_join_response(
state.peers.get(&local_peer.deployment_id).cloned().unwrap_or(local_peer),
backfill_errors.render(),
),
)
}
}
@@ -10757,7 +10751,7 @@ impl Operation for SRPeerGetIDPSettingsHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
validate_site_replication_admin_request(&req, AdminAction::SiteReplicationAddAction).await?;
json_response(&local_idp_settings())
json_response(StatusCode::OK, &local_idp_settings())
}
}
@@ -11036,12 +11030,15 @@ impl Operation for SiteReplicationEditHandler {
}
}
json_response(&ReplicateEditStatus {
success: true,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
})
json_response(
StatusCode::OK,
&ReplicateEditStatus {
success: true,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
},
)
}
}
@@ -11051,14 +11048,17 @@ pub struct SRPeerEditCapabilitiesHandler {}
impl Operation for SRPeerEditCapabilitiesHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
validate_site_replication_admin_request(&req, AdminAction::SiteReplicationOperationAction).await?;
json_response(&ReplicateEditStatus {
success: query_pairs(&req.uri)
.get("capability")
.is_some_and(|value| peer_edit_capability_supported(value)),
status: SITE_REPL_EDIT_SUCCESS.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
})
json_response(
StatusCode::OK,
&ReplicateEditStatus {
success: query_pairs(&req.uri)
.get("capability")
.is_some_and(|value| peer_edit_capability_supported(value)),
status: SITE_REPL_EDIT_SUCCESS.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
},
)
}
}
@@ -11173,30 +11173,39 @@ impl Operation for SRPeerEditHandler {
let service_account_access_key = match outcome {
PeerEditOutcome::Applied(service_account_access_key) => service_account_access_key,
PeerEditOutcome::Acked => {
return json_response(&ReplicateEditStatus {
success: true,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
});
return json_response(
StatusCode::OK,
&ReplicateEditStatus {
success: true,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
},
);
}
PeerEditOutcome::Rejected(err_detail) => {
return json_response(&ReplicateEditStatus {
success: false,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
err_detail: err_detail.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
});
return json_response(
StatusCode::OK,
&ReplicateEditStatus {
success: false,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
err_detail: err_detail.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
},
);
}
};
if endpoint_refresh_requested {
if service_account_access_key.is_empty() {
return json_response(&ReplicateEditStatus {
success: false,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
err_detail: "site replicator service account is not configured".to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
});
return json_response(
StatusCode::OK,
&ReplicateEditStatus {
success: false,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
err_detail: "site replicator service account is not configured".to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
},
);
}
let service_account_secret_key = site_replicator_service_account_secret(&service_account_access_key).await?;
let pending_id = refresh_id.unwrap_or_default();
@@ -11214,19 +11223,25 @@ impl Operation for SRPeerEditHandler {
})
.await?;
if !committed {
return json_response(&ReplicateEditStatus {
success: false,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
err_detail: "endpoint target refresh state changed during update".to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
});
return json_response(
StatusCode::OK,
&ReplicateEditStatus {
success: false,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
err_detail: "endpoint target refresh state changed during update".to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
},
);
}
return json_response(&ReplicateEditStatus {
success: true,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
});
return json_response(
StatusCode::OK,
&ReplicateEditStatus {
success: true,
status: SITE_REPL_EDIT_SUCCESS.to_string(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
},
);
}
Ok(empty_response(StatusCode::OK))
}
@@ -11433,7 +11448,7 @@ impl Operation for SiteReplicationResyncOpHandler {
.buckets
.sort_by(|left, right| left.bucket.cmp(&right.bucket).then(left.target_arn.cmp(&right.target_arn)));
let (limit, offset) = parse_site_resync_page(&query, &status)?;
json_response(&site_resync_page(&status, limit, offset)?)
json_response(StatusCode::OK, &site_resync_page(&status, limit, offset)?)
}
}
@@ -11479,17 +11494,20 @@ impl Operation for SiteReplicationRepairHandler {
if body.preflight_token.is_some() || body.operation_id.is_some() {
return Err(s3_error!(InvalidRequest, "dry-run does not accept preflightToken or operationId"));
}
return json_response(&SiteReplicationRepairPreflight {
mode: "dry-run",
status: "planned",
preflight_token,
retry_events: state
.retry_queue
.iter()
.filter(|event| retry_event_replayed_by_bootstrap(event))
.count(),
sites,
});
return json_response(
StatusCode::OK,
&SiteReplicationRepairPreflight {
mode: "dry-run",
status: "planned",
preflight_token,
retry_events: state
.retry_queue
.iter()
.filter(|event| retry_event_replayed_by_bootstrap(event))
.count(),
sites,
},
);
}
let supplied_token = body
@@ -11542,7 +11560,7 @@ impl Operation for SiteReplicationRepairStatusHandler {
.get(&operation_id)
.cloned()
.ok_or_else(|| s3_error!(InvalidRequest, "repair operation was not found"))?;
json_response(&site_replication_repair_operation_response(&operation))
json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation))
}
}
@@ -11696,17 +11714,20 @@ impl Operation for SRRotateServiceAccountHandler {
peer_errors.push("service account rotation is still pending".to_string());
}
json_response(&ReplicateEditStatus {
success: complete && peer_errors.is_empty(),
status: if complete && peer_errors.is_empty() {
"Success"
} else {
"Partial"
}
.to_string(),
err_detail: peer_errors.join("; "),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
})
json_response(
StatusCode::OK,
&ReplicateEditStatus {
success: complete && peer_errors.is_empty(),
status: if complete && peer_errors.is_empty() {
"Success"
} else {
"Partial"
}
.to_string(),
err_detail: peer_errors.join("; "),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
},
)
}
}
@@ -17,6 +17,7 @@ use crate::admin::runtime_sources::default_admin_usecase;
use crate::admin::storage_api::access::{ReqInfo, authorize_internal_object_request};
use crate::admin::storage_api::bucket::metadata::table_catalog_path_hash;
use crate::admin::storage_api::runtime::ECStore;
use crate::admin::utils::empty_response;
use crate::admin::{
auth::{AdminResourceScope, validate_admin_action_with_bucket_object_for_iam},
router::{AdminOperation, Operation, S3Router},
@@ -969,10 +970,6 @@ fn build_sensitive_json_response<T: Serialize>(status: StatusCode, body: &T) ->
Ok(response)
}
fn empty_response(status: StatusCode) -> S3Response<(StatusCode, Body)> {
S3Response::new((status, Body::default()))
}
fn duration_millis_u64(duration: StdDuration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
+136 -1
View File
@@ -13,8 +13,12 @@
// limitations under the License.
use crate::server::{MINIO_ADMIN_PREFIX, has_path_prefix};
use http::{HeaderMap, HeaderValue, StatusCode, Uri};
use rustfs_crypto::{decrypt_data, decrypt_stream_io, encrypt_stream_io};
use s3s::{Body, S3Result, s3_error};
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Error, S3ErrorCode, S3Response, S3Result, s3_error};
use serde::Serialize;
use std::collections::HashMap;
/// Returns `true` if `s` contains any whitespace character.
///
@@ -59,6 +63,48 @@ pub(crate) fn encode_compatible_admin_payload(path: &str, secret_key: &str, data
}
}
/// Serialize `value` as the JSON body of an admin response with `status`.
///
/// The admin surface answers almost every endpoint this way, so the shape is
/// pinned here rather than re-derived per handler: `Content-Type:
/// application/json`, no other header, and the serialized bytes verbatim as
/// the body. Serialization failure is reported as `InternalError`; the
/// response structs the admin handlers pass here are plain owned data, so that
/// arm is unreachable in practice.
pub(crate) fn json_response<T: Serialize>(status: StatusCode, value: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(value)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize response: {e}")))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(S3Response::with_headers((status, Body::from(data)), headers))
}
/// A bodiless admin response carrying only `status`.
///
/// Used by the endpoints whose success answer is the status code itself
/// (`204 No Content`, or a `200 OK` acknowledgement with nothing to report).
/// No `Content-Type` is set, because there is no content to type.
pub(crate) fn empty_response(status: StatusCode) -> S3Response<(StatusCode, Body)> {
S3Response::new((status, Body::empty()))
}
/// Collect a request URI's query string into a parameter map.
///
/// Parsed with `form_urlencoded`, as the rest of the admin surface does, so a
/// parameter written without a value (`?status`) arrives as an empty value
/// rather than disappearing: a validated parameter must be able to tell "not
/// asked for" from "asked for, unreadable". Percent escapes and `+` are
/// decoded, and a repeated key keeps its last occurrence.
pub(crate) fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
let mut params = HashMap::new();
if let Some(query) = uri.query() {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
params.insert(key.into_owned(), value.into_owned());
}
}
params
}
#[cfg(test)]
mod tests {
use super::*;
@@ -119,4 +165,93 @@ mod tests {
assert_eq!(decoded, payload);
}
async fn body_bytes(mut body: Body) -> Vec<u8> {
body.store_all_limited(64 * 1024).await.expect("body should read").to_vec()
}
/// The wire contract every admin endpoint that returns JSON depends on:
/// the requested status, `application/json`, and the serialized bytes
/// verbatim — nothing else.
#[tokio::test]
async fn json_response_carries_status_content_type_and_serialized_body() {
#[derive(Serialize)]
struct Payload {
success: bool,
message: &'static str,
}
let response = json_response(
StatusCode::ACCEPTED,
&Payload {
success: true,
message: "queued",
},
)
.expect("payload should serialize");
assert_eq!(response.output.0, StatusCode::ACCEPTED);
assert_eq!(
response.headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()),
Some("application/json")
);
assert_eq!(response.headers.len(), 1);
assert_eq!(body_bytes(response.output.1).await, br#"{"success":true,"message":"queued"}"#.to_vec());
}
/// A serialization failure must surface as `InternalError` rather than a
/// panic or a half-written body.
#[test]
fn json_response_reports_serialization_failure_as_internal_error() {
struct Unserializable;
impl Serialize for Unserializable {
fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("nope"))
}
}
let err = json_response(StatusCode::OK, &Unserializable).expect_err("serialization must fail");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert!(err.message().unwrap_or_default().contains("failed to serialize response"));
}
/// The bodiless answer must stay bodiless and must not claim a content
/// type: callers use it for `204 No Content` and bare acknowledgements.
#[tokio::test]
async fn empty_response_has_no_body_and_no_headers() {
for status in [StatusCode::OK, StatusCode::NO_CONTENT] {
let response = empty_response(status);
assert_eq!(response.output.0, status);
assert!(response.headers.is_empty());
assert!(body_bytes(response.output.1).await.is_empty());
}
}
/// Percent escapes must be decoded, so a job id or key id containing `/`
/// arrives whole rather than as its escape sequence.
#[test]
fn extract_query_params_decodes_percent_escapes() {
let uri: Uri = "/rustfs/admin/v3/status-job?jobId=abc%2F123"
.parse()
.expect("uri should parse");
let params = extract_query_params(&uri);
assert_eq!(params.get("jobId"), Some(&"abc/123".to_string()));
}
/// A parameter written without a value must arrive as an empty value, not
/// vanish: a validated parameter has to tell "not asked for" from "asked
/// for, unreadable". A missing query string yields no parameters at all.
#[test]
fn extract_query_params_keeps_valueless_parameters_and_survives_no_query() {
let valueless: Uri = "/rustfs/admin/v3/kms/keys?status".parse().expect("uri should parse");
let params = extract_query_params(&valueless);
assert_eq!(params.get("status"), Some(&String::new()));
let plus: Uri = "/rustfs/admin/v3/kms/keys?name=a+b".parse().expect("uri should parse");
assert_eq!(extract_query_params(&plus).get("name"), Some(&"a b".to_string()));
let bare: Uri = "/rustfs/admin/v3/kms/keys".parse().expect("uri should parse");
assert!(extract_query_params(&bare).is_empty());
}
}