use std::{borrow::Cow, fmt, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
use crate::serde_utils::deserialize_present;
mod cbor;
mod response;
mod uri;
pub use cbor::MessageError;
pub use ciborium::Value;
pub const DEEPLINK_SCHEME: &str = "worldid";
const METHOD_PREFIX: &str = "worldid";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, bound(deserialize = "P: Deserialize<'de>"))]
pub struct Request<P> {
pub version: Version,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_present"
)]
pub id: Option<Id>,
pub method: MethodName,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_present"
)]
pub params: Option<P>,
}
impl<P> Request<P> {
#[must_use]
pub const fn new(id: Option<Id>, method: MethodName, params: Option<P>) -> Self {
Self {
version: Version::V1,
id,
method,
params,
}
}
pub fn encode(&self) -> Result<Vec<u8>, MessageError>
where
P: Serialize,
{
cbor::encode(self)
}
pub fn decode(bytes: &[u8], max_size: usize) -> Result<Self, MessageError>
where
P: serde::de::DeserializeOwned,
{
cbor::decode(bytes, max_size)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum Response<R, D = Value> {
Success {
version: Version,
id: Id,
result: R,
},
Error {
version: Version,
id: Option<Id>,
error: ErrorObject<D>,
},
}
impl<R, D> Response<R, D> {
#[must_use]
pub const fn success(id: Id, result: R) -> Self {
Self::Success {
version: Version::V1,
id,
result,
}
}
#[must_use]
pub const fn error(id: Option<Id>, error: ErrorObject<D>) -> Self {
Self::Error {
version: Version::V1,
id,
error,
}
}
pub fn encode(&self) -> Result<Vec<u8>, MessageError>
where
R: Serialize,
D: Serialize,
{
cbor::encode(self)
}
pub fn decode(bytes: &[u8], max_size: usize) -> Result<Self, MessageError>
where
R: serde::de::DeserializeOwned,
D: serde::de::DeserializeOwned,
{
cbor::decode(bytes, max_size)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(bound(deserialize = "D: Deserialize<'de>"))]
pub struct ErrorObject<D = Value> {
pub code: String,
pub message: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_present"
)]
pub data: Option<D>,
}
impl<D> ErrorObject<D> {
#[must_use]
pub fn new(code: impl Into<String>, message: impl Into<String>, data: Option<D>) -> Self {
Self {
code: code.into(),
message: message.into(),
data,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Version {
#[default]
#[serde(rename = "1.0")]
V1,
}
pub type Id = String;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct MethodName(Cow<'static, str>);
impl MethodName {
#[must_use]
pub const fn from_static(name: &'static str) -> Self {
assert!(is_valid_method_name(name), "invalid WIP-105 method name");
Self(Cow::Borrowed(name))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for MethodName {
type Err = InvalidMethodName;
fn from_str(name: &str) -> Result<Self, Self::Err> {
if !is_valid_method_name(name) {
return Err(InvalidMethodName);
}
Ok(Self(Cow::Owned(name.to_owned())))
}
}
impl fmt::Display for MethodName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for MethodName {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
if !is_valid_method_name(&name) {
return Err(D::Error::custom(InvalidMethodName));
}
Ok(Self(Cow::Owned(name)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("method name must be camelCase segments joined by `_`, starting with `worldid`")]
pub struct InvalidMethodName;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Deeplink {
namespace: String,
version: u32,
action: String,
params: Vec<(String, String)>,
}
impl Deeplink {
pub fn new(
namespace: impl Into<String>,
version: u32,
action: impl Into<String>,
) -> Result<Self, DeeplinkError> {
let namespace = namespace.into();
let action = action.into();
if !is_camel_case_segment(&namespace) {
return Err(DeeplinkError::InvalidNamespace);
}
if version == 0 {
return Err(DeeplinkError::InvalidVersion);
}
if !is_camel_case_segment(&action) {
return Err(DeeplinkError::InvalidAction);
}
Ok(Self {
namespace,
version,
action,
params: Vec::new(),
})
}
pub fn with_param(
mut self,
key: impl Into<String>,
value: impl Into<String>,
) -> Result<Self, DeeplinkError> {
let key = key.into();
if key.is_empty() {
return Err(DeeplinkError::MalformedParameter);
}
self.params.push((key, value.into()));
Ok(self)
}
#[must_use]
pub fn namespace(&self) -> &str {
&self.namespace
}
#[must_use]
pub const fn version(&self) -> u32 {
self.version
}
#[must_use]
pub fn action(&self) -> &str {
&self.action
}
#[must_use]
pub fn params(&self) -> &[(String, String)] {
&self.params
}
}
impl fmt::Display for Deeplink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{DEEPLINK_SCHEME}://{}/v{}/{}",
self.namespace, self.version, self.action
)?;
for (i, (key, value)) in self.params.iter().enumerate() {
let separator = if i == 0 { '?' } else { '&' };
write!(
f,
"{separator}{}={}",
uri::encode_query_component(key),
uri::encode_query_component(value)
)?;
}
Ok(())
}
}
impl FromStr for Deeplink {
type Err = DeeplinkError;
fn from_str(uri: &str) -> Result<Self, Self::Err> {
let rest = uri
.split_once("://")
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case(DEEPLINK_SCHEME))
.map(|(_, rest)| rest)
.ok_or(DeeplinkError::InvalidScheme)?;
if rest.contains('#') {
return Err(DeeplinkError::UnexpectedFragment);
}
let (path, query) = match rest.split_once('?') {
Some((path, query)) => (path, Some(query)),
None => (rest, None),
};
let mut segments = path.split('/');
let namespace = segments.next().unwrap_or_default();
let version = segments
.next()
.ok_or(DeeplinkError::InvalidVersion)
.and_then(parse_version)?;
let action = segments.next().ok_or(DeeplinkError::InvalidAction)?;
if segments.next().is_some() {
return Err(DeeplinkError::UnexpectedPathSegments);
}
let mut link = Self::new(namespace, version, action)?;
if let Some(query) = query {
link.params = query
.split('&')
.map(parse_param)
.collect::<Option<_>>()
.ok_or(DeeplinkError::MalformedParameter)?;
}
Ok(link)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum DeeplinkError {
#[error("deeplink must use the `worldid` scheme")]
InvalidScheme,
#[error("deeplink namespace must be a camelCase identifier")]
InvalidNamespace,
#[error("deeplink version must be `v` followed by a positive integer")]
InvalidVersion,
#[error("deeplink action must be a camelCase identifier")]
InvalidAction,
#[error("deeplink path must be `/{{version}}/{{action}}`")]
UnexpectedPathSegments,
#[error("deeplink must not have a fragment")]
UnexpectedFragment,
#[error("deeplink parameters must be percent-encoded `key=value` pairs")]
MalformedParameter,
}
const fn is_valid_method_name(name: &str) -> bool {
let bytes = name.as_bytes();
let prefix = METHOD_PREFIX.as_bytes();
if bytes.len() < prefix.len() {
return false;
}
let mut i = 0;
while i < prefix.len() {
if bytes[i] != prefix[i] {
return false;
}
i += 1;
}
while i < bytes.len() {
if bytes[i] != b'_' {
return false;
}
i += 1;
if i >= bytes.len() || !bytes[i].is_ascii_lowercase() {
return false;
}
i += 1;
while i < bytes.len() && bytes[i] != b'_' {
if !bytes[i].is_ascii_alphanumeric() {
return false;
}
i += 1;
}
}
true
}
fn is_camel_case_segment(segment: &str) -> bool {
let mut chars = segment.chars();
chars.next().is_some_and(|c| c.is_ascii_lowercase()) && chars.all(|c| c.is_ascii_alphanumeric())
}
fn parse_version(segment: &str) -> Result<u32, DeeplinkError> {
let digits = segment
.strip_prefix('v')
.filter(|digits| !digits.is_empty() && !digits.starts_with('0'))
.filter(|digits| digits.bytes().all(|b| b.is_ascii_digit()))
.ok_or(DeeplinkError::InvalidVersion)?;
digits.parse().map_err(|_| DeeplinkError::InvalidVersion)
}
fn parse_param(pair: &str) -> Option<(String, String)> {
let (key, value) = pair.split_once('=').filter(|(key, _)| !key.is_empty())?;
Some((
uri::decode_query_component(key)?,
uri::decode_query_component(value)?,
))
}
#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;
#[test_case("worldid"; "prefix")]
#[test_case("worldid_someMethod_v2"; "camel_case")]
fn accepts_valid_method_names(valid: &str) {
assert!(valid.parse::<MethodName>().is_ok(), "{valid}");
}
#[test_case(""; "empty")]
#[test_case("worldidx_auth"; "wrong_prefix")]
#[test_case("worldId_auth"; "uppercase_prefix")]
#[test_case("worldid__auth"; "empty_segment")]
#[test_case("worldid_auth_"; "trailing_separator")]
#[test_case("worldid_Auth"; "uppercase_segment")]
#[test_case("worldid_1auth"; "numeric_segment")]
#[test_case("worldid_au-th"; "hyphen")]
fn rejects_invalid_method_names(invalid: &str) {
assert!(invalid.parse::<MethodName>().is_err(), "{invalid}");
}
#[test]
fn deeplink_round_trips_with_encoded_params() {
let link = Deeplink::new("auth", 1, "register")
.unwrap()
.with_param("s", "a b&c=d")
.unwrap()
.with_param("b", "bridge.example.org")
.unwrap();
let uri = link.to_string();
assert_eq!(
uri,
"worldid://auth/v1/register?s=a%20b%26c%3Dd&b=bridge.example.org"
);
assert_eq!(uri.parse::<Deeplink>().unwrap(), link);
}
#[test]
fn deeplink_builder_requires_nonempty_keys_but_allows_empty_values() {
let link = Deeplink::new("auth", 1, "register").unwrap();
assert_eq!(
link.clone().with_param("", "value"),
Err(DeeplinkError::MalformedParameter)
);
let link = link.with_param("a&b", "").unwrap();
assert_eq!(link.to_string().parse::<Deeplink>().unwrap(), link);
}
#[test]
fn deeplink_keeps_duplicate_and_empty_params() {
let link: Deeplink = "worldid://auth/v12/register?a=1&a=&b=2".parse().unwrap();
assert_eq!(link.version(), 12);
assert_eq!(
link.params(),
[
("a".to_string(), "1".to_string()),
("a".to_string(), String::new()),
("b".to_string(), "2".to_string()),
]
);
}
#[test_case("https://auth/v1/register", DeeplinkError::InvalidScheme; "https")]
#[test_case("worldid:auth/v1/register", DeeplinkError::InvalidScheme; "missing_slashes")]
#[test_case("worldid://user@auth/v1/register",
DeeplinkError::InvalidNamespace; "user_info")]
#[test_case("worldid://auth:443/v1/register",
DeeplinkError::InvalidNamespace; "port")]
#[test_case("worldid:///v1/register", DeeplinkError::InvalidNamespace; "empty_namespace")]
#[test_case("worldid://Auth/v1/register",
DeeplinkError::InvalidNamespace; "uppercase_namespace")]
#[test_case("worldid://auth", DeeplinkError::InvalidVersion; "missing_version")]
#[test_case("worldid://auth/1/register", DeeplinkError::InvalidVersion; "missing_version_prefix")]
#[test_case("worldid://auth/v0/register", DeeplinkError::InvalidVersion; "zero_version")]
#[test_case("worldid://auth/v01/register", DeeplinkError::InvalidVersion; "leading_zero_version")]
#[test_case("worldid://auth/v99999999999/register",
DeeplinkError::InvalidVersion; "version_overflow")]
#[test_case("worldid://auth/v1", DeeplinkError::InvalidAction; "missing_action")]
#[test_case("worldid://auth/v1/", DeeplinkError::InvalidAction; "empty_action")]
#[test_case("worldid://auth/v1/register/",
DeeplinkError::UnexpectedPathSegments; "trailing_slash")]
#[test_case("worldid://auth/v1/register#x",
DeeplinkError::UnexpectedFragment; "fragment")]
#[test_case("worldid://auth/v1/register?",
DeeplinkError::MalformedParameter; "empty_query")]
#[test_case("worldid://auth/v1/register?a",
DeeplinkError::MalformedParameter; "missing_equals")]
#[test_case("worldid://auth/v1/register?=a",
DeeplinkError::MalformedParameter; "empty_key")]
#[test_case("worldid://auth/v1/register?a=1&&b=2",
DeeplinkError::MalformedParameter; "empty_parameter")]
#[test_case("worldid://auth/v1/register?a<b=1",
DeeplinkError::MalformedParameter; "raw_delimiter_in_key")]
fn deeplink_parsing_rejects_malformed_uris(uri: &str, expected: DeeplinkError) {
assert_eq!(uri.parse::<Deeplink>(), Err(expected), "{uri}");
}
#[test]
fn deeplink_scheme_is_case_insensitive() {
assert!("WorldID://auth/v1/register".parse::<Deeplink>().is_ok());
}
}