kcode-k1-web-code-ktool-protocol 0.1.0

Strict model-call protocol for K1 Web code Ktools
Documentation
use kcode_k1_chat_boxes::BoxId;
use kcode_k1_transaction_id::TxId;
use kcode_k1_web_package::{AuthorityId, WebFamily, WebId};
use semver::Version;
use serde::{Deserialize, Deserializer};
use std::fmt::{Display, Formatter};
use std::str::FromStr;

pub const WEB_CODE_DOCS: &str = "WebCodeDocs";
pub const WEB_CODE_OPEN: &str = "WebCodeOpen";
pub const WEB_CODE_OVERWRITE: &str = "WebCodeOverwrite";
pub const WEB_CODE_CHECK: &str = "WebCodeCheck";
pub const WEB_CODE_PUBLISH: &str = "WebCodePublish";

pub const WEB_CODE_DOCS_METADATA_TYPE: &str = "k1.web-code-docs/v1";
pub const WEB_CODE_OPEN_METADATA_TYPE: &str = "k1.web-code-open/v1";
pub const WEB_CODE_OVERWRITE_METADATA_TYPE: &str = "k1.web-code-overwrite/v1";
pub const WEB_CODE_CHECK_METADATA_TYPE: &str = "k1.web-code-check/v1";
pub const WEB_CODE_PUBLISH_METADATA_TYPE: &str = "k1.web-code-publish/v1";

#[derive(Debug, Eq, PartialEq)]
pub enum Call {
    Docs(IdentityRequest),
    Open(OpenRequest),
    Overwrite(OverwriteRequest),
    Check(IdentityRequest),
    Publish(IdentityRequest),
}

#[derive(Debug, Eq, PartialEq)]
pub struct Identity(WebId);

impl Identity {
    pub fn authority(&self) -> AuthorityId {
        self.0.family().authority()
    }

    pub fn name(&self) -> &str {
        self.0.family().logical_name()
    }

    pub fn version(&self) -> &Version {
        self.0.version()
    }

    pub fn web_id(&self) -> &WebId {
        &self.0
    }
}

#[derive(Debug, Eq, PartialEq)]
pub struct IdentityRequest(Identity);

impl IdentityRequest {
    pub fn identity(&self) -> &Identity {
        &self.0
    }
}

#[derive(Debug, Eq, PartialEq)]
pub struct OpenRequest {
    identity: Identity,
    language: Option<Language>,
}

impl OpenRequest {
    pub fn identity(&self) -> &Identity {
        &self.identity
    }

    pub const fn language(&self) -> Option<Language> {
        self.language
    }
}

#[derive(Debug, Eq, PartialEq)]
pub struct OverwriteRequest {
    box_id: BoxId,
    contents: String,
}

impl OverwriteRequest {
    pub const fn box_id(&self) -> BoxId {
        self.box_id
    }

    pub fn contents(&self) -> &str {
        &self.contents
    }

    pub fn into_contents(self) -> String {
        self.contents
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Language {
    Javascript,
    Html,
    Css,
}

impl Language {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Javascript => "javascript",
            Self::Html => "html",
            Self::Css => "css",
        }
    }
}

impl Display for Language {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(self.as_str())
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ParseError {
    UnknownTool,
    InvalidArguments,
}

impl Display for ParseError {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::UnknownTool => formatter.write_str("unknown Web code Ktool"),
            Self::InvalidArguments => formatter.write_str("invalid Web code Ktool arguments"),
        }
    }
}

impl std::error::Error for ParseError {}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct IdentityWire {
    authority: String,
    name: String,
    version: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OpenWire {
    authority: String,
    name: String,
    version: String,
    #[serde(default, deserialize_with = "optional_string")]
    language: Option<String>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OverwriteWire {
    box_id: String,
    contents: String,
}

pub fn parse(name: &str, arguments: &str) -> Result<Call, ParseError> {
    match name {
        WEB_CODE_DOCS => parse_identity(arguments).map(Call::Docs),
        WEB_CODE_OPEN => parse_open(arguments).map(Call::Open),
        WEB_CODE_OVERWRITE => parse_overwrite(arguments).map(Call::Overwrite),
        WEB_CODE_CHECK => parse_identity(arguments).map(Call::Check),
        WEB_CODE_PUBLISH => parse_identity(arguments).map(Call::Publish),
        _ => Err(ParseError::UnknownTool),
    }
}

fn parse_identity(arguments: &str) -> Result<IdentityRequest, ParseError> {
    let wire: IdentityWire = serde_json::from_str(arguments).map_err(invalid)?;
    Ok(IdentityRequest(identity(
        wire.authority,
        wire.name,
        wire.version,
    )?))
}

fn parse_open(arguments: &str) -> Result<OpenRequest, ParseError> {
    let wire: OpenWire = serde_json::from_str(arguments).map_err(invalid)?;
    let language = wire
        .language
        .map(|value| match value.as_str() {
            "javascript" => Ok(Language::Javascript),
            "html" => Ok(Language::Html),
            "css" => Ok(Language::Css),
            _ => Err(ParseError::InvalidArguments),
        })
        .transpose()?;
    Ok(OpenRequest {
        identity: identity(wire.authority, wire.name, wire.version)?,
        language,
    })
}

fn parse_overwrite(arguments: &str) -> Result<OverwriteRequest, ParseError> {
    let wire: OverwriteWire = serde_json::from_str(arguments).map_err(invalid)?;
    if wire.box_id.is_empty() || !wire.box_id.bytes().all(|byte| byte.is_ascii_digit()) {
        return Err(ParseError::InvalidArguments);
    }
    let value = wire.box_id.parse::<u64>().map_err(invalid)?;
    Ok(OverwriteRequest {
        box_id: BoxId::new(value),
        contents: wire.contents,
    })
}

fn identity(authority: String, name: String, version: String) -> Result<Identity, ParseError> {
    let tx = TxId::from_str(&authority).map_err(invalid)?;
    if tx.to_string() != authority {
        return Err(ParseError::InvalidArguments);
    }
    let family = WebFamily::new(AuthorityId::new(tx), name).map_err(invalid)?;
    let parsed = Version::parse(&version).map_err(invalid)?;
    if parsed.to_string() != version {
        return Err(ParseError::InvalidArguments);
    }
    WebId::new(family, parsed).map(Identity).map_err(invalid)
}

fn optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
    D: Deserializer<'de>,
{
    String::deserialize(deserializer).map(Some)
}

fn invalid<T>(_: T) -> ParseError {
    ParseError::InvalidArguments
}

#[cfg(test)]
mod tests {
    use super::*;

    const AUTHORITY: &str = "010101010101010101010101";

    fn identity_json() -> String {
        format!(r#"{{"authority":"{AUTHORITY}","name":"alpha-2","version":"1.2.3"}}"#)
    }

    #[test]
    fn freezes_names_metadata_and_valid_calls() {
        assert_eq!(
            [
                WEB_CODE_DOCS,
                WEB_CODE_OPEN,
                WEB_CODE_OVERWRITE,
                WEB_CODE_CHECK,
                WEB_CODE_PUBLISH
            ],
            [
                "WebCodeDocs",
                "WebCodeOpen",
                "WebCodeOverwrite",
                "WebCodeCheck",
                "WebCodePublish"
            ]
        );
        assert_eq!(
            [
                WEB_CODE_DOCS_METADATA_TYPE,
                WEB_CODE_OPEN_METADATA_TYPE,
                WEB_CODE_OVERWRITE_METADATA_TYPE,
                WEB_CODE_CHECK_METADATA_TYPE,
                WEB_CODE_PUBLISH_METADATA_TYPE
            ],
            [
                "k1.web-code-docs/v1",
                "k1.web-code-open/v1",
                "k1.web-code-overwrite/v1",
                "k1.web-code-check/v1",
                "k1.web-code-publish/v1"
            ]
        );
        for name in [WEB_CODE_DOCS, WEB_CODE_CHECK, WEB_CODE_PUBLISH] {
            let call = parse(name, &identity_json()).unwrap();
            let identity = match call {
                Call::Docs(request) | Call::Check(request) | Call::Publish(request) => request.0,
                _ => unreachable!(),
            };
            assert_eq!(identity.name(), "alpha-2");
            assert_eq!(identity.version(), &Version::new(1, 2, 3));
            assert_eq!(identity.authority().to_string(), AUTHORITY);
        }
        for (suffix, expected) in [("", None), (r#","language":"html""#, Some(Language::Html))] {
            let arguments = format!(
                r#"{{"authority":"{AUTHORITY}","name":"alpha","version":"0.1.0"{suffix}}}"#
            );
            let call = parse(WEB_CODE_OPEN, &arguments).unwrap();
            assert!(matches!(call, Call::Open(request) if request.language() == expected));
        }
        let overwrite = parse(WEB_CODE_OVERWRITE, r#"{"box_id":"000","contents":"a\nāœ“"}"#).unwrap();
        assert!(
            matches!(overwrite, Call::Overwrite(request) if request.box_id().get() == 0 && request.contents() == "a\nāœ“")
        );
    }

    #[test]
    fn rejects_unknown_or_structurally_invalid_calls() {
        assert_eq!(parse("webcodedocs", "{}"), Err(ParseError::UnknownTool));
        let invalid_identity = [
            "[]".to_owned(),
            "{}".to_owned(),
            r#"{"authority":null,"name":"a","version":"1.0.0"}"#.to_owned(),
            format!(r#"{{"authority":"{AUTHORITY}","name":"a","version":"1.0.0","extra":1}}"#),
            format!(
                r#"{{"authority":"{AUTHORITY}","authority":"{AUTHORITY}","name":"a","version":"1.0.0"}}"#
            ),
            format!(r#"{{"authority":"{AUTHORITY}","name":"a","version":"1.0.0"}} trailing"#),
        ];
        for arguments in invalid_identity {
            assert_eq!(
                parse(WEB_CODE_DOCS, &arguments),
                Err(ParseError::InvalidArguments)
            );
        }
        for arguments in [
            r#"{"box_id":0,"contents":""}"#,
            r#"{"box_id":"","contents":""}"#,
            r#"{"box_id":"1x","contents":""}"#,
            r#"{"box_id":"18446744073709551616","contents":""}"#,
            r#"{"box_id":"1","contents":null}"#,
            r#"{"box_id":"1","contents":"","extra":true}"#,
        ] {
            assert_eq!(
                parse(WEB_CODE_OVERWRITE, arguments),
                Err(ParseError::InvalidArguments)
            );
        }
        let null_language = format!(
            r#"{{"authority":"{AUTHORITY}","name":"alpha","version":"1.2.3","language":null}}"#
        );
        assert_eq!(
            parse(WEB_CODE_OPEN, &null_language),
            Err(ParseError::InvalidArguments)
        );
    }

    #[test]
    fn rejects_noncanonical_identity_and_language_values() {
        for (authority, name, version) in [
            ("01010101010101010101010A", "alpha", "1.2.3"),
            ("0101", "alpha", "1.2.3"),
            (AUTHORITY, "Alpha", "1.2.3"),
            (AUTHORITY, "a--b", "1.2.3"),
            (AUTHORITY, "alpha", "01.2.3"),
            (AUTHORITY, "alpha", "1.2.3-pre"),
            (AUTHORITY, "alpha", "1.2.3+build"),
        ] {
            let arguments =
                format!(r#"{{"authority":"{authority}","name":"{name}","version":"{version}"}}"#);
            assert_eq!(
                parse(WEB_CODE_CHECK, &arguments),
                Err(ParseError::InvalidArguments)
            );
        }
        for language in ["JavaScript", "js", "", "javascript "] {
            let arguments = format!(
                r#"{{"authority":"{AUTHORITY}","name":"alpha","version":"1.2.3","language":"{language}"}}"#
            );
            assert_eq!(
                parse(WEB_CODE_OPEN, &arguments),
                Err(ParseError::InvalidArguments)
            );
        }
    }
}