connect2axum-codegen 0.2.0

Protoc generators for REST, WebSocket, OpenAPI, and AsyncAPI wrappers over ConnectRPC services
Documentation
use flexstr::{SharedStr, ToOwnedFlexStr as _};
use uni_error::UniError;

use crate::error::{CodegenErrKind, CodegenResult};

const DEFAULT_BUFFA_MODULE: &str = "crate::proto";
const DEFAULT_CONNECT_MODULE: &str = "crate::connect";
const DEFAULT_RUNTIME_MODULE: &str = "::connect2axum";
const DEFAULT_STREAMING_CONTENT_TYPE: &str = "application/x-ndjson";
const DEFAULT_VALUE_SUFFIX: &str = "__";
const DEFAULT_TYPE_SUFFIX: &str = "__";
const DEFAULT_BODY_MESSAGE_SUFFIX: &str = "Body";
const DEFAULT_QUERY_MESSAGE_SUFFIX: &str = "Query";

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CodegenOptions {
    pub buffa_module: SharedStr,
    pub connect_module: SharedStr,
    pub runtime_module: SharedStr,
    pub streaming_content_type: SharedStr,
    pub value_suffix: SharedStr,
    pub type_suffix: SharedStr,
    pub body_message_suffix: SharedStr,
    pub query_message_suffix: SharedStr,
}

impl Default for CodegenOptions {
    fn default() -> Self {
        Self {
            buffa_module: DEFAULT_BUFFA_MODULE.into(),
            connect_module: DEFAULT_CONNECT_MODULE.into(),
            runtime_module: DEFAULT_RUNTIME_MODULE.into(),
            streaming_content_type: DEFAULT_STREAMING_CONTENT_TYPE.into(),
            value_suffix: DEFAULT_VALUE_SUFFIX.into(),
            type_suffix: DEFAULT_TYPE_SUFFIX.into(),
            body_message_suffix: DEFAULT_BODY_MESSAGE_SUFFIX.into(),
            query_message_suffix: DEFAULT_QUERY_MESSAGE_SUFFIX.into(),
        }
    }
}

impl CodegenOptions {
    pub fn parse(parameter: Option<&str>) -> CodegenResult<Self> {
        let mut options = Self::default();
        let Some(parameter) = parameter else {
            return Ok(options);
        };

        for raw_option in parameter.split(',') {
            let raw_option = raw_option.trim();
            if raw_option.is_empty() {
                continue;
            }

            let (name, value) = raw_option.split_once('=').ok_or_else(|| {
                invalid_option(format!(
                    "plugin option must use name=value syntax: {raw_option}"
                ))
            })?;
            let name = name.trim();
            let value = value.trim();

            match name {
                "buffa_module" => options.buffa_module = parse_non_empty(name, value)?,
                "connect_module" => options.connect_module = parse_non_empty(name, value)?,
                "runtime_module" => options.runtime_module = parse_non_empty(name, value)?,
                "streaming_content_type" => {
                    options.streaming_content_type = parse_non_empty(name, value)?;
                }
                "value_suffix" => options.value_suffix = value.to_owned_opt(),
                "type_suffix" => options.type_suffix = value.to_owned_opt(),
                "body_message_suffix" => {
                    options.body_message_suffix = parse_non_empty(name, value)?;
                }
                "query_message_suffix" => {
                    options.query_message_suffix = parse_non_empty(name, value)?;
                }
                _ => {
                    return Err(UniError::from_kind_context(
                        CodegenErrKind::UnknownPluginOption,
                        format!("unknown plugin option: {name}"),
                    ));
                }
            }
        }

        Ok(options)
    }
}

fn parse_non_empty(name: &str, value: &str) -> CodegenResult<SharedStr> {
    if value.is_empty() {
        Err(invalid_option(format!("{name} cannot be empty")))
    } else {
        Ok(value.to_owned_opt())
    }
}

fn invalid_option(context: String) -> uni_error::UniError<CodegenErrKind> {
    UniError::from_kind_context(CodegenErrKind::InvalidPluginOption, context)
}

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

    #[test]
    fn defaults_match_phase_2_contract() {
        let options = CodegenOptions::parse(None).unwrap();

        assert_eq!(options.buffa_module.as_ref(), "crate::proto");
        assert_eq!(options.connect_module.as_ref(), "crate::connect");
        assert_eq!(options.runtime_module.as_ref(), "::connect2axum");
        assert_eq!(
            options.streaming_content_type.as_ref(),
            "application/x-ndjson"
        );
        assert_eq!(options.value_suffix.as_ref(), "__");
        assert_eq!(options.type_suffix.as_ref(), "__");
        assert_eq!(options.body_message_suffix.as_ref(), "Body");
        assert_eq!(options.query_message_suffix.as_ref(), "Query");
    }

    #[test]
    fn parses_all_phase_2_options() {
        let options = CodegenOptions::parse(Some(
            "buffa_module=crate::generated::proto,\
             connect_module=crate::generated::connect,\
             runtime_module=crate::runtime,\
             streaming_content_type=application/json-seq,\
             value_suffix=_cx,\
             type_suffix=_Rest,\
             body_message_suffix=Payload,\
             query_message_suffix=Params",
        ))
        .unwrap();

        assert_eq!(options.buffa_module.as_ref(), "crate::generated::proto");
        assert_eq!(options.connect_module.as_ref(), "crate::generated::connect");
        assert_eq!(options.runtime_module.as_ref(), "crate::runtime");
        assert_eq!(
            options.streaming_content_type.as_ref(),
            "application/json-seq"
        );
        assert_eq!(options.value_suffix.as_ref(), "_cx");
        assert_eq!(options.type_suffix.as_ref(), "_Rest");
        assert_eq!(options.body_message_suffix.as_ref(), "Payload");
        assert_eq!(options.query_message_suffix.as_ref(), "Params");
    }

    #[test]
    fn rejects_unknown_options() {
        let err = CodegenOptions::parse(Some("surprise=true")).unwrap_err();
        assert!(err.to_string().contains("unknown plugin option: surprise"));
    }

    #[test]
    fn rejects_removed_options() {
        let err = CodegenOptions::parse(Some("openapi=true")).unwrap_err();
        assert!(err.to_string().contains("unknown plugin option: openapi"));

        let err = CodegenOptions::parse(Some("service_state=hello.v1.Greeter:crate::Greeter"))
            .unwrap_err();
        assert!(
            err.to_string()
                .contains("unknown plugin option: service_state")
        );
    }
}