pub const CANONICAL_PROVISION_INTEGRATION: &str =
"https://trusttasks.org/spec/provision/integration/0.1";
pub const CANONICAL_PROVISION_INTEGRATION_RESULT: &str =
"https://trusttasks.org/spec/provision/integration/0.1#response";
pub const CANONICAL_PROVISION_INTEGRATION_0_2: &str =
"https://trusttasks.org/spec/provision/integration/0.2";
pub const CANONICAL_PROVISION_INTEGRATION_0_2_RESULT: &str =
"https://trusttasks.org/spec/provision/integration/0.2#response";
pub fn result_uri_for(request_uri: &str) -> &'static str {
if request_uri == CANONICAL_PROVISION_INTEGRATION_0_2 {
CANONICAL_PROVISION_INTEGRATION_0_2_RESULT
} else {
CANONICAL_PROVISION_INTEGRATION_RESULT
}
}
pub mod request {
pub use crate::provision_integration::http::{AssertionMode, ProvisionIntegrationRequest};
}
pub mod result {
pub use crate::provision_integration::http::{ProvisionIntegrationResponse, ProvisionSummary};
}
use serde_json::Value;
use crate::provision_integration::http::{
ProvisionIntegrationRequest, ProvisionIntegrationResponse,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProvisionSpecVersion {
V0_1,
V0_2,
}
impl ProvisionSpecVersion {
pub fn request_uri(self) -> &'static str {
match self {
ProvisionSpecVersion::V0_1 => CANONICAL_PROVISION_INTEGRATION,
ProvisionSpecVersion::V0_2 => CANONICAL_PROVISION_INTEGRATION_0_2,
}
}
}
fn is_v0_2(request_uri: &str) -> bool {
request_uri == CANONICAL_PROVISION_INTEGRATION_0_2
}
fn snake_to_lower_camel(key: &str) -> String {
delimited_to_lower_camel(key, '_')
}
fn kebab_to_lower_camel(value: &str) -> String {
delimited_to_lower_camel(value, '-')
}
fn delimited_to_lower_camel(s: &str, delim: char) -> String {
let mut parts = s.split(delim);
let mut out = String::with_capacity(s.len());
if let Some(first) = parts.next() {
out.push_str(first);
}
for p in parts {
let mut chars = p.chars();
if let Some(f) = chars.next() {
out.extend(f.to_uppercase());
out.push_str(chars.as_str());
}
}
out
}
fn recase_object_keys_shallow(map: &mut serde_json::Map<String, Value>) {
let renamed: Vec<(String, Value)> = std::mem::take(map)
.into_iter()
.map(|(k, v)| (snake_to_lower_camel(&k), v))
.collect();
map.extend(renamed);
}
pub fn request_body_for_version(
req: &ProvisionIntegrationRequest,
request_uri: &str,
) -> Result<Value, serde_json::Error> {
let mut v = serde_json::to_value(req)?;
if is_v0_2(request_uri)
&& let Value::Object(map) = &mut v
{
recase_object_keys_shallow(map);
if let Some(Value::String(a)) = map.get_mut("assertion") {
*a = kebab_to_lower_camel(a);
}
}
Ok(v)
}
pub fn response_body_for_version(
resp: &ProvisionIntegrationResponse,
request_uri: &str,
) -> Result<Value, serde_json::Error> {
let mut v = serde_json::to_value(resp)?;
if is_v0_2(request_uri)
&& let Some(Value::Object(summary)) = v.get_mut("summary")
{
recase_object_keys_shallow(summary);
}
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn result_uri_for_v0_1_request_emits_v0_1_response() {
assert_eq!(
result_uri_for(CANONICAL_PROVISION_INTEGRATION),
CANONICAL_PROVISION_INTEGRATION_RESULT
);
}
#[test]
fn result_uri_for_v0_2_request_emits_v0_2_response() {
assert_eq!(
result_uri_for(CANONICAL_PROVISION_INTEGRATION_0_2),
CANONICAL_PROVISION_INTEGRATION_0_2_RESULT
);
}
#[test]
fn result_uri_for_unknown_request_defaults_to_v0_1() {
assert_eq!(
result_uri_for("https://example.invalid/something-else"),
CANONICAL_PROVISION_INTEGRATION_RESULT
);
}
#[test]
fn canonical_uris_match_registry() {
assert_eq!(
CANONICAL_PROVISION_INTEGRATION,
"https://trusttasks.org/spec/provision/integration/0.1"
);
assert_eq!(
CANONICAL_PROVISION_INTEGRATION_RESULT,
"https://trusttasks.org/spec/provision/integration/0.1#response"
);
assert_eq!(
CANONICAL_PROVISION_INTEGRATION_0_2,
"https://trusttasks.org/spec/provision/integration/0.2"
);
assert_eq!(
CANONICAL_PROVISION_INTEGRATION_0_2_RESULT,
"https://trusttasks.org/spec/provision/integration/0.2#response"
);
}
#[test]
fn snake_and_kebab_to_lower_camel() {
assert_eq!(snake_to_lower_camel("client_did"), "clientDid");
assert_eq!(snake_to_lower_camel("bundle_id_hex"), "bundleIdHex");
assert_eq!(
snake_to_lower_camel("vc_validity_seconds"),
"vcValiditySeconds"
);
assert_eq!(snake_to_lower_camel("bundle"), "bundle"); assert_eq!(kebab_to_lower_camel("did-signed"), "didSigned");
assert_eq!(kebab_to_lower_camel("pinned-only"), "pinnedOnly");
}
async fn sample_request(
assertion: Option<crate::provision_integration::http::AssertionMode>,
vc_validity_seconds: Option<i64>,
create_context: bool,
) -> (ProvisionIntegrationRequest, Value) {
use crate::provision_integration::ProvisionRequestBuilder;
let (seed, pub_bytes) = crate::sealed_transfer::generate_ed25519_keypair();
let client_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&pub_bytes);
let vp = ProvisionRequestBuilder::new("didcomm-mediator")
.sign_with(&seed, &client_did)
.await
.expect("sign VP");
let vp_value = serde_json::to_value(&vp).expect("serialize VP");
let req = ProvisionIntegrationRequest {
request: vp,
context: Some("ctx".into()),
assertion,
vc_validity_seconds,
create_context,
};
(req, vp_value)
}
#[tokio::test]
async fn request_body_v0_1_stays_snake_case_and_kebab_assertion() {
let (req, _) = sample_request(
Some(crate::provision_integration::http::AssertionMode::DidSigned),
Some(3600),
true,
)
.await;
let v = request_body_for_version(&req, CANONICAL_PROVISION_INTEGRATION).unwrap();
assert_eq!(v["assertion"], "did-signed");
assert_eq!(v["vc_validity_seconds"], 3600);
assert_eq!(v["create_context"], true);
assert!(v.get("vcValiditySeconds").is_none());
}
#[tokio::test]
async fn request_body_v0_2_camelizes_opts_and_assertion_but_not_signed_vp() {
let (req, vp_value) = sample_request(
Some(crate::provision_integration::http::AssertionMode::PinnedOnly),
Some(60),
false,
)
.await;
let v = request_body_for_version(&req, CANONICAL_PROVISION_INTEGRATION_0_2).unwrap();
assert_eq!(v["assertion"], "pinnedOnly");
assert_eq!(v["vcValiditySeconds"], 60);
assert!(v.get("vc_validity_seconds").is_none());
assert!(v.get("createContext").is_none());
assert!(v.get("create_context").is_none());
assert_eq!(v["context"], "ctx");
assert_eq!(v["request"], vp_value);
}
#[test]
fn response_body_v0_1_stays_snake_case_v0_2_camelizes_summary() {
let resp = ProvisionIntegrationResponse {
bundle: "armored".into(),
digest: "deadbeef".into(),
summary: crate::provision_integration::http::ProvisionSummary {
client_did: "did:key:zClient".into(),
admin_did: "did:key:zAdmin".into(),
admin_rolled_over: true,
integration_did: Some("did:webvh:x".into()),
template_name: Some("tmpl".into()),
template_kind: Some("kind".into()),
admin_template_name: None,
bundle_id_hex: "abc".into(),
secret_count: 2,
output_count: 1,
webvh_server_id: None,
context_created: true,
},
};
let v01 = response_body_for_version(&resp, CANONICAL_PROVISION_INTEGRATION).unwrap();
assert_eq!(v01["summary"]["client_did"], "did:key:zClient");
assert_eq!(v01["summary"]["bundle_id_hex"], "abc");
assert!(v01["summary"].get("clientDid").is_none());
let v02 = response_body_for_version(&resp, CANONICAL_PROVISION_INTEGRATION_0_2).unwrap();
assert_eq!(v02["summary"]["clientDid"], "did:key:zClient");
assert_eq!(v02["summary"]["bundleIdHex"], "abc");
assert_eq!(v02["summary"]["secretCount"], 2);
assert_eq!(v02["summary"]["adminRolledOver"], true);
assert_eq!(v02["summary"]["contextCreated"], true);
assert!(v02["summary"].get("client_did").is_none());
assert_eq!(v02["bundle"], "armored");
assert_eq!(v02["digest"], "deadbeef");
}
}