use serde::{Deserialize, Serialize};
use crate::error::VtaError;
pub fn validate_service_url(url: &str) -> Result<url::Url, VtaError> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(VtaError::Validation("service URL is empty".into()));
}
let parsed = url::Url::parse(trimmed)
.map_err(|e| VtaError::Validation(format!("service URL is unparseable: {e}")))?;
if parsed.scheme() != "https" {
return Err(VtaError::Validation(format!(
"service URL must use https:// (got {})",
parsed.scheme()
)));
}
if parsed.fragment().is_some() {
return Err(VtaError::Validation(
"service URL must not contain a `#fragment`".into(),
));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(VtaError::Validation(
"service URL must not contain userinfo (user:password@)".into(),
));
}
if parsed.host().is_none() {
return Err(VtaError::Validation("service URL must have a host".into()));
}
Ok(parsed)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[must_use]
pub struct EnableRestRequest {
pub url: String,
}
impl EnableRestRequest {
pub fn new(url: impl Into<String>) -> Self {
Self { url: url.into() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[must_use]
pub struct UpdateRestRequest {
pub url: String,
}
impl UpdateRestRequest {
pub fn new(url: impl Into<String>) -> Self {
Self { url: url.into() }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[must_use]
pub struct DisableRestRequest {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[must_use]
pub struct RollbackRestRequest {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[must_use]
pub struct EnableWebauthnRequest {
pub url: String,
}
impl EnableWebauthnRequest {
pub fn new(url: impl Into<String>) -> Self {
Self { url: url.into() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[must_use]
pub struct UpdateWebauthnRequest {
pub url: String,
}
impl UpdateWebauthnRequest {
pub fn new(url: impl Into<String>) -> Self {
Self { url: url.into() }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[must_use]
pub struct DisableWebauthnRequest {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[must_use]
pub struct RollbackWebauthnRequest {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[must_use]
pub struct RollbackDidcommRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drain_ttl_secs: Option<u64>,
}
impl RollbackDidcommRequest {
pub fn new() -> Self {
Self::default()
}
pub fn drain_ttl_secs(mut self, secs: u64) -> Self {
self.drain_ttl_secs = Some(secs);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DrainListResponse {
pub entries: Vec<DrainEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DrainEntry {
pub mediator_did: String,
pub endpoint: String,
pub drains_until: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct RollbackResponse {
pub log_entry_version_id: String,
pub effective_at: String,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drain_until: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draining_mediator: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub vta_did: String,
#[serde(default)]
pub serverless: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ServicesListResponse {
pub services: Vec<ServiceState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum ServiceState {
Tsp {
enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
mediator_did: Option<String>,
},
Rest {
enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<String>,
},
Didcomm {
enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
mediator_did: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
routing_keys: Vec<String>,
},
Webauthn {
enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ServiceMutationResponse {
pub log_entry_version_id: String,
pub effective_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drain_until: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub vta_did: String,
#[serde(default)]
pub serverless: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rest_request_types_round_trip_through_json() {
let req = EnableRestRequest::new("https://vta.example.com");
let json = serde_json::to_string(&req).unwrap();
let restored: EnableRestRequest = serde_json::from_str(&json).unwrap();
assert_eq!(restored, req);
let req = UpdateRestRequest::new("https://vta-new.example.com");
let json = serde_json::to_string(&req).unwrap();
let restored: UpdateRestRequest = serde_json::from_str(&json).unwrap();
assert_eq!(restored, req);
let req = DisableRestRequest::default();
let json = serde_json::to_string(&req).unwrap();
let restored: DisableRestRequest = serde_json::from_str(&json).unwrap();
assert_eq!(restored, req);
let req = RollbackRestRequest::default();
let json = serde_json::to_string(&req).unwrap();
let restored: RollbackRestRequest = serde_json::from_str(&json).unwrap();
assert_eq!(restored, req);
}
#[test]
fn empty_request_bodies_serialize_as_empty_object() {
assert_eq!(
serde_json::to_string(&DisableRestRequest::default()).unwrap(),
"{}"
);
assert_eq!(
serde_json::to_string(&RollbackRestRequest::default()).unwrap(),
"{}"
);
}
#[test]
fn empty_request_bodies_accept_empty_object() {
let _: DisableRestRequest = serde_json::from_str("{}").unwrap();
let _: RollbackRestRequest = serde_json::from_str("{}").unwrap();
}
#[test]
fn rest_url_field_is_literal_url_on_wire() {
let json = serde_json::to_value(EnableRestRequest::new("https://x.example")).unwrap();
assert_eq!(json["url"], "https://x.example");
assert!(json.get("uri").is_none(), "must not rename url to uri");
let json = serde_json::to_value(UpdateRestRequest::new("https://y.example")).unwrap();
assert_eq!(json["url"], "https://y.example");
}
#[test]
fn service_mutation_response_round_trips_both_drain_states() {
let rest_response = ServiceMutationResponse {
log_entry_version_id: "1-zQm...A".into(),
effective_at: "2026-05-06T13:00:00Z".into(),
drain_until: None,
vta_did: "did:webvh:scid:host:vta".into(),
serverless: false,
};
let json = serde_json::to_value(&rest_response).unwrap();
assert_eq!(json["log_entry_version_id"], "1-zQm...A");
assert_eq!(json["effective_at"], "2026-05-06T13:00:00Z");
assert!(
json.get("drain_until").is_none(),
"drain_until must be omitted when None — wire bandwidth + reader convention",
);
let restored: ServiceMutationResponse = serde_json::from_value(json).unwrap();
assert_eq!(restored, rest_response);
let didcomm_response = ServiceMutationResponse {
log_entry_version_id: "2-zQm...B".into(),
effective_at: "2026-05-06T13:00:00Z".into(),
drain_until: Some("2026-05-07T13:00:00Z".into()),
vta_did: "did:webvh:scid:host:vta".into(),
serverless: true,
};
let json = serde_json::to_value(&didcomm_response).unwrap();
assert_eq!(json["drain_until"], "2026-05-07T13:00:00Z");
assert_eq!(json["vta_did"], "did:webvh:scid:host:vta");
assert_eq!(json["serverless"], true);
let restored: ServiceMutationResponse = serde_json::from_value(json).unwrap();
assert_eq!(restored, didcomm_response);
}
#[test]
fn service_mutation_response_decodes_legacy_payload() {
let legacy = r#"{
"log_entry_version_id": "1-zQm...A",
"effective_at": "2026-05-06T13:00:00Z"
}"#;
let r: ServiceMutationResponse = serde_json::from_str(legacy).unwrap();
assert_eq!(r.vta_did, "");
assert!(!r.serverless);
}
#[test]
fn validate_service_url_accepts_https() {
assert!(validate_service_url("https://vta.example.com").is_ok());
assert!(validate_service_url("https://vta.example.com/").is_ok());
assert!(validate_service_url("https://vta.example.com:8443").is_ok());
assert!(validate_service_url("https://vta.example.com/path/sub").is_ok());
}
#[test]
fn validate_service_url_accepts_localhost_and_ip_literals() {
assert!(validate_service_url("https://localhost:8443").is_ok());
assert!(validate_service_url("https://127.0.0.1:8443").is_ok());
assert!(validate_service_url("https://[::1]:8443").is_ok());
}
#[test]
fn validate_service_url_rejects_http() {
let err = validate_service_url("http://vta.example.com").unwrap_err();
match err {
VtaError::Validation(msg) => assert!(
msg.contains("https"),
"expected scheme-related rejection, got: {msg}",
),
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn validate_service_url_rejects_other_schemes() {
for bad in [
"ws://vta.example.com",
"ftp://x.example",
"file:///etc/passwd",
] {
assert!(
matches!(validate_service_url(bad), Err(VtaError::Validation(_))),
"expected rejection for {bad}",
);
}
}
#[test]
fn validate_service_url_rejects_fragment() {
let err = validate_service_url("https://vta.example.com/api#section").unwrap_err();
match err {
VtaError::Validation(msg) => {
assert!(
msg.contains("fragment"),
"expected fragment rejection, got: {msg}"
)
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn validate_service_url_rejects_userinfo() {
for bad in [
"https://user@vta.example.com",
"https://user:pass@vta.example.com",
"https://:pass@vta.example.com",
] {
let err = validate_service_url(bad).unwrap_err();
match err {
VtaError::Validation(msg) => assert!(
msg.contains("userinfo"),
"expected userinfo rejection for {bad}, got: {msg}",
),
other => panic!("expected Validation for {bad}, got {other:?}"),
}
}
}
#[test]
fn validate_service_url_rejects_unparseable() {
for bad in ["not a url at all", "://no-scheme", "https://", "🦀"] {
assert!(
matches!(validate_service_url(bad), Err(VtaError::Validation(_))),
"expected rejection for {bad:?}",
);
}
}
#[test]
fn validate_service_url_rejects_empty_and_whitespace() {
for bad in ["", " ", "\t\n"] {
let err = validate_service_url(bad).unwrap_err();
match err {
VtaError::Validation(msg) => assert!(
msg.contains("empty"),
"expected empty-URL rejection for {bad:?}, got: {msg}",
),
other => panic!("expected Validation for {bad:?}, got {other:?}"),
}
}
}
#[test]
fn validate_service_url_returns_parsed_url() {
let parsed = validate_service_url("https://vta.example.com:8443/api").unwrap();
assert_eq!(parsed.scheme(), "https");
assert_eq!(parsed.host_str(), Some("vta.example.com"));
assert_eq!(parsed.port(), Some(8443));
assert_eq!(parsed.path(), "/api");
}
#[test]
fn services_list_response_round_trips_both_kinds() {
let response = ServicesListResponse {
services: vec![
ServiceState::Didcomm {
enabled: true,
mediator_did: Some("did:peer:2.M".into()),
routing_keys: vec!["did:peer:2.K".into()],
},
ServiceState::Rest {
enabled: true,
url: Some("https://vta.example.com".into()),
},
],
};
let json = serde_json::to_string(&response).unwrap();
let restored: ServicesListResponse = serde_json::from_str(&json).unwrap();
assert_eq!(restored, response);
}
#[test]
fn service_state_disabled_omits_config_fields() {
let rest_off = ServiceState::Rest {
enabled: false,
url: None,
};
let json = serde_json::to_value(&rest_off).unwrap();
assert_eq!(json["kind"], "rest");
assert_eq!(json["enabled"], false);
assert!(
json.get("url").is_none(),
"url must be elided when None to keep the wire form clean",
);
let didcomm_off = ServiceState::Didcomm {
enabled: false,
mediator_did: None,
routing_keys: vec![],
};
let json = serde_json::to_value(&didcomm_off).unwrap();
assert_eq!(json["kind"], "didcomm");
assert_eq!(json["enabled"], false);
assert!(json.get("mediator_did").is_none());
assert!(json.get("routing_keys").is_none());
}
#[test]
fn service_state_discriminator_is_literal_kind() {
let json = serde_json::to_value(ServiceState::Rest {
enabled: true,
url: Some("https://x.example".into()),
})
.unwrap();
assert_eq!(json["kind"], "rest");
let json = serde_json::to_value(ServiceState::Didcomm {
enabled: true,
mediator_did: Some("did:peer:2.M".into()),
routing_keys: vec![],
})
.unwrap();
assert_eq!(json["kind"], "didcomm");
}
#[test]
fn service_mutation_response_accepts_explicit_null_drain_until() {
let with_null = r#"{
"log_entry_version_id": "1-zQm...A",
"effective_at": "2026-05-06T13:00:00Z",
"drain_until": null
}"#;
let r: ServiceMutationResponse = serde_json::from_str(with_null).unwrap();
assert_eq!(r.drain_until, None);
let elided = r#"{
"log_entry_version_id": "1-zQm...A",
"effective_at": "2026-05-06T13:00:00Z"
}"#;
let r: ServiceMutationResponse = serde_json::from_str(elided).unwrap();
assert_eq!(r.drain_until, None);
}
}