diff --git a/src/block_elements/mod.rs b/src/block_elements/mod.rs index 4099a62..cb31ffb 100644 --- a/src/block_elements/mod.rs +++ b/src/block_elements/mod.rs @@ -39,4 +39,3 @@ pub enum InputAttachment { PlainInput, RadioButtons, } - diff --git a/src/block_elements/select.rs b/src/block_elements/select.rs index 6bf3836..009240b 100644 --- a/src/block_elements/select.rs +++ b/src/block_elements/select.rs @@ -67,4 +67,3 @@ pub struct ConversationSelect {} /// public channels visible to the current user in the active workspace. #[derive(Clone, Default, Debug, Deserialize, Hash, PartialEq, Serialize)] pub struct ChannelSelect {} - diff --git a/src/blocks/actions.rs b/src/blocks/actions.rs index f0a4b7a..ab209cc 100644 --- a/src/blocks/actions.rs +++ b/src/blocks/actions.rs @@ -1,9 +1,9 @@ +use serde::{Deserialize, Serialize}; use std::convert::{TryFrom, TryInto}; use validator::Validate; -use serde::{Serialize, Deserialize}; -use crate::val_helpr::ValidationResult; use crate::block_elements; +use crate::val_helpr::ValidationResult; #[derive(Clone, Debug, Default, Deserialize, Hash, PartialEq, Serialize, Validate)] pub struct Contents { @@ -33,15 +33,33 @@ pub struct Contents { /// Maximum length for this field is 255 characters. /// /// [identify the source of the action 🔗]: https://api.slack.com/interactivity/handling#payloads + #[validate(length(max = 255))] block_id: Option, } impl Contents { - /// Create a empty Actions block + /// Create an empty Actions block + /// (uses `Default`) pub fn new() -> Self { Default::default() } + /// Set the `block_id` for interactions on an existing `actions::Contents` + /// + /// ``` + /// use slack_blocks::blocks::{Block, actions}; + /// + /// pub fn main() { + /// let actions = actions::Contents::new().with_block_id("tally_ho"); + /// let block: Block = actions.into(); + /// // < send block to slack's API > + /// } + /// ``` + pub fn with_block_id>(mut self, block_id: StrIsh) -> Self { + self.block_id = Some(block_id.as_ref().to_string()); + self + } + /// Populate an Actions block with a collection of `BlockElement`s that /// may not be supported by `Actions` blocks. /// @@ -54,9 +72,26 @@ impl Contents { /// For a list of `BlockElement` types that are, see `self::BlockElement`. /// /// ### Runtime Validation + /// /// **only** validates that the block elements are compatible with `Actions`, /// for full runtime model validation see the `validate` method. - pub fn from_elements>>(elements: Els) -> Result { + /// + /// ``` + /// use slack_blocks::blocks::{Block, actions}; + /// use slack_blocks::compose; + /// use slack_blocks::block_elements; + /// + /// pub fn main() -> Result<(), ()> { + /// let btn = block_elements::BlockElement::Button; + /// let actions = actions::Contents::from_elements(vec![btn])?; + /// let block: Block = actions.into(); + /// // < send block to slack's API > + /// Ok(()) + /// } + /// ``` + pub fn from_elements>>( + elements: Els, + ) -> Result { elements // Into .into() // Vec .try_into() // Result @@ -71,10 +106,25 @@ impl Contents { /// ### Runtime Validation /// **only** validates that the block elements are compatible with `Actions`, /// for full runtime model validation see the `validate` method. - pub fn from_action_elements>>(elements: Els) -> Self { - elements // Into - .into() // Vec - .into() // Contents + /// + /// ``` + /// use slack_blocks::blocks::{Block, actions}; + /// use slack_blocks::compose; + /// use slack_blocks::block_elements; + /// + /// pub fn main() { + /// let btn = actions::BlockElement::Button; + /// let actions = actions::Contents::from_action_elements(vec![btn]); + /// let block: Block = actions.into(); + /// // < send block to slack's API > + /// } + /// ``` + pub fn from_action_elements>>(elements: Els) -> Self { + elements + .into_iter() + .map(Into::::into) + .collect::>() + .into() } /// Validate the entire block and all of its @@ -88,7 +138,8 @@ impl Contents { impl TryFrom> for Contents { type Error = (); fn try_from(elements: Vec) -> Result { - elements.into_iter() + elements + .into_iter() // Try to convert the bag of "any block element" to "block element supported by Actions" .map(TryInto::try_into) // If we hit one that is not supported, stop and continue with err @@ -122,8 +173,8 @@ pub enum BlockElement { impl TryFrom for self::BlockElement { type Error = (); fn try_from(el: block_elements::BlockElement) -> Result { - use block_elements::BlockElement as El; use self::BlockElement::*; + use block_elements::BlockElement as El; match el { El::Button => Ok(Button), @@ -137,4 +188,3 @@ impl TryFrom for self::BlockElement { } } } - diff --git a/src/blocks/context.rs b/src/blocks/context.rs new file mode 100644 index 0000000..0be5769 --- /dev/null +++ b/src/blocks/context.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::compose::Compose; +use crate::val_helpr::ValidationResult; + +#[derive(Clone, Debug, Default, Deserialize, Hash, PartialEq, Serialize, Validate)] +pub struct Contents { + /// A collection of [image elements 🔗] and [text objects 🔗]. + /// + /// Maximum number of items is 10 + /// [image elements 🔗]: https://api.slack.com/reference/messaging/block-elements#image + /// [text objects 🔗]: https://api.slack.com/reference/messaging/composition-objects#text + #[validate(length(max = 10))] + elements: Vec, + + /// A string acting as a unique identifier for a block. + /// + /// You can use this `block_id` when you receive an + /// interaction payload to [identify the source of the action 🔗]. + /// + /// If not specified, a `block_id` will be generated. + /// + /// Maximum length for this field is 255 characters. + /// + /// [identify the source of the action 🔗]: https://api.slack.com/interactivity/handling#payloads + #[validate(length(max = 255))] + block_id: Option, +} + +impl Contents { + /// Construct a new empty Context block + /// (uses `Default`) + pub fn new() -> Self { + Default::default() + } + + /// Set the `block_id` for interactions on an existing `context::Contents` + /// + /// ``` + /// use slack_blocks::blocks::{Block, context}; + /// + /// pub fn main() { + /// let context = context::Contents::new().with_block_id("tally_ho"); + /// let block: Block = context.into(); + /// // < send block to slack's API > + /// } + /// ``` + pub fn with_block_id>(mut self, block_id: StrIsh) -> Self { + self.block_id = Some(block_id.as_ref().to_string()); + self + } + + /// Construct a new `context::Contents` from a bunch of + /// composition objects + /// + /// ``` + /// use slack_blocks::blocks::{Block, context}; + /// use slack_blocks::compose; + /// + /// pub fn main() { + /// let text = compose::Text::markdown("*s i c k*"); + /// let context = context::Contents::from_elements(vec![text]); + /// let block: Block = context.into(); + /// // < send block to slack's API > + /// } + /// ``` + pub fn from_elements>>(elements: Els) -> Self { + elements + .into_iter() + .map(Into::::into) + .collect::>() + .into() + } + + /// Validate that the model agrees with slack's validation + /// requirements + pub fn validate(&self) -> ValidationResult { + Validate::validate(self) + } +} + +// From impl backing the `from_elements` constructor +impl From> for Contents { + fn from(elements: Vec) -> Self { + Self { + elements, + ..Default::default() + } + } +} diff --git a/src/blocks/mod.rs b/src/blocks/mod.rs index 0304d4d..1f3b3f2 100644 --- a/src/blocks/mod.rs +++ b/src/blocks/mod.rs @@ -1,7 +1,10 @@ use serde::{Deserialize, Serialize}; -pub mod image; +use crate::impl_from_contents; + pub mod actions; +pub mod context; +pub mod image; pub mod section; type ValidationResult = Result<(), validator::ValidationErrors>; @@ -73,7 +76,7 @@ pub enum Block { /// /// [context_docs]: https://api.slack.com/reference/block-kit/blocks#context #[serde(rename = "context")] - Context {}, + Context(context::Contents), /// # Input Block /// @@ -136,11 +139,16 @@ impl Block { Section(contents) => contents.validate(), Image(contents) => contents.validate(), Actions(contents) => contents.validate(), + Context(contents) => contents.validate(), other => todo!("validation not implemented for {}", other), } } } +impl_from_contents!(Block, Section, section::Contents); +impl_from_contents!(Block, Actions, actions::Contents); +impl_from_contents!(Block, Context, context::Contents); + #[cfg(test)] mod tests { use test_case::test_case; @@ -169,26 +177,6 @@ mod tests { => matches Block::Section (section::Contents::Fields(_)); "section_fields" )] - #[test_case( - r#"{ "type": "context" }"# - => matches Block::Context { .. }; - "context" - )] - #[test_case( - r#"{ "type": "divider" }"# - => matches Block::Divider; - "divider" - )] - #[test_case( - r#"{ "type": "input" }"# - => matches Block::Input { .. }; - "input" - )] - #[test_case( - r#"{ "type": "file" }"# - => matches Block::File { .. }; - "file" - )] pub fn block_should_deserialize(json: &str) -> Block { // arrange diff --git a/src/compose.rs b/src/compose.rs index 6490ca2..3cb36d1 100644 --- a/src/compose.rs +++ b/src/compose.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::impl_from_contents; + pub mod validation { use crate::val_helpr::error; use validator::ValidationError; @@ -7,7 +9,9 @@ pub mod validation { pub fn text_is_plain(text: &super::Text) -> ValidationResult { match text { - super::Text::Markdown { .. } => Err(error("text_is_plain", "expected plain, got markdown")), + super::Text::Markdown { .. } => { + Err(error("text_is_plain", "expected plain, got markdown")) + } super::Text::Plain { .. } => Ok(()), } } @@ -16,7 +20,7 @@ pub mod validation { let len = text.text().chars().count(); if len > max_len { - let message = format!( + let message = format!( "Section#text has max len of {}, but got text of len {}.", max_len, len ); @@ -28,6 +32,19 @@ pub mod validation { } } +/// # Composition Objects +/// +/// Composition objects can be used inside of [block elements 🔗] and certain message payload fields. +/// +/// They are simply common JSON object patterns that you'll encounter frequently +/// when building blocks or composing messages. +#[derive(Clone, Debug, Deserialize, Hash, PartialEq, Serialize)] +pub enum Compose { + Text(Text), +} + +impl_from_contents!(Compose, Text, Text); + /// # Text Object /// [_slack api docs 🔗_](https://api.slack.com/reference/block-kit/composition-objects#text) /// @@ -36,7 +53,7 @@ pub mod validation { /// or using [`mrkdwn` 🔗](https://api.slack.com/reference/surfaces/formatting), /// our proprietary textual markup that's just different enough /// from Markdown to frustrate you. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Deserialize, Hash, PartialEq, Serialize)] #[serde(tag = "type")] pub enum Text { /// ## Markdown text @@ -116,13 +133,19 @@ pub enum Text { impl Default for Text { fn default() -> Self { - Text::Markdown { text: String::new(), verbatim: None, } + Text::Markdown { + text: String::new(), + verbatim: None, + } } } impl Text { pub fn plain>(text: StrIsh) -> Text { - Text::Plain { text: text.as_ref().to_string(), emoji: None } + Text::Plain { + text: text.as_ref().to_string(), + emoji: None, + } } pub fn markdown>(text: StrIsh) -> Text { diff --git a/src/lib.rs b/src/lib.rs index b3ffc5e..2a984fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,19 @@ #[macro_use] extern crate validator_derive; -pub mod blocks; pub mod block_elements; +pub mod blocks; pub mod compose; pub mod val_helpr; + +#[macro_export] +macro_rules! impl_from_contents { + ($enum_name:ident, $variant:ident, $contents_type:ty) => { + impl From<$contents_type> for $enum_name { + fn from(contents: $contents_type) -> Self { + $enum_name::$variant(contents) + } + } + } +} + diff --git a/src/val_helpr.rs b/src/val_helpr.rs index 2448522..3351d67 100644 --- a/src/val_helpr.rs +++ b/src/val_helpr.rs @@ -8,7 +8,10 @@ pub type ValidatorResult = Result<(), validator::ValidationError>; pub fn error>(kind: &'static str, msg: StrIsh) -> ValidationError { let mut error = ValidationError::new(kind); - error.add_param(Cow::from("message"), &serde_json::Value::String(msg.as_ref().to_string())); + error.add_param( + Cow::from("message"), + &serde_json::Value::String(msg.as_ref().to_string()), + ); error } diff --git a/tests/json.rs b/tests/json.rs index 2c501a6..14efad3 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -1,4 +1,4 @@ -use slack_blocks::{blocks::Block, blocks::image, compose}; +use slack_blocks::{blocks::image, blocks::Block, compose}; #[feature(concat_idents)] @@ -19,6 +19,7 @@ macro_rules! happy_json_test { happy_json_test!(image_should_deserialize: test_data::IMAGE_JSON => Block::Image { .. }); happy_json_test!(actions_should_deserialize: test_data::ACTIONS_JSON => Block::Actions { .. }); +happy_json_test!(context_should_deserialize: test_data::CONTEXT_JSON => Block::Context { .. }); mod test_data { use slack_blocks::{blocks, compose}; @@ -27,6 +28,11 @@ mod test_data { static ref SAMPLE_TEXT_PLAIN: compose::Text = compose::Text::plain("Sample Text"); static ref SAMPLE_TEXT_MRKDWN: compose::Text = compose::Text::markdown("Sample *_markdown_*"); + pub static ref CONTEXT_JSON: serde_json::Value = serde_json::json!({ + "type": "context", + "elements": [] + }); + pub static ref IMAGE_JSON: serde_json::Value = serde_json::json!({ "type": "image", "image_url": "http://cheese.com/favicon.png", diff --git a/tests/validation.rs b/tests/validation.rs index 4976611..dcdd0b7 100644 --- a/tests/validation.rs +++ b/tests/validation.rs @@ -1,4 +1,6 @@ -use slack_blocks::{blocks::Block::*, blocks::image, blocks::actions, compose::Text}; +use slack_blocks::{ + blocks::actions, blocks::context, blocks::image, blocks::Block, compose, compose::Text, +}; mod common; @@ -18,13 +20,13 @@ macro_rules! bad_blocks { Err(_) => (), } } - } + }; } // ===[ Image Block Validation ]=== bad_blocks!( image_with_long_url: - Image(image::Contents { + Block::Image(image::Contents { image_url: common::string_of_len(3001), ..Default::default() }) @@ -32,7 +34,7 @@ bad_blocks!( bad_blocks!( image_with_long_alt_text: - Image(image::Contents { + Block::Image(image::Contents { alt_text: common::string_of_len(2001), ..Default::default() }) @@ -40,7 +42,7 @@ bad_blocks!( bad_blocks!( image_with_long_block_id: - Image(image::Contents { + Block::Image(image::Contents { block_id: Some(common::string_of_len(256)), ..Default::default() }) @@ -48,7 +50,7 @@ bad_blocks!( bad_blocks!( image_with_long_title: - Image(image::Contents { + Block::Image(image::Contents { title: Some(Text::plain(common::string_of_len(2001))), ..Default::default() }) @@ -56,7 +58,7 @@ bad_blocks!( bad_blocks!( image_with_markdown_title: - Image(image::Contents { + Block::Image(image::Contents { title: Some(Text::markdown("*uh oh!* :flushed:")), ..Default::default() }) @@ -65,5 +67,36 @@ bad_blocks!( // ===[ Actions Block Validation ]=== bad_blocks!( actions_with_too_many_objects: - Actions(actions::Contents::from_action_elements(common::vec_of_len(actions::BlockElement::Button, 6))) + Block::Actions( + common::vec_of_len( + actions::BlockElement::Button, + 6 + ).into() + ) ); + +bad_blocks!( + actions_with_long_block_id: + Block::Actions( + actions::Contents::new().with_block_id(common::string_of_len(256)) + ) +); + +// ===[ Context Block Validation ]=== +bad_blocks!( + context_with_too_many_objects: + Block::Context( + common::vec_of_len( + compose::Compose::Text(Text::markdown("fart")), + 11 + ).into() + ) +); + +bad_blocks!( + context_with_long_block_id: + Block::Context( + context::Contents::new().with_block_id(common::string_of_len(256)) + ) +); +