twilight_validate/component/
component_v2.rs

1//! Validates components V2.
2
3use super::{
4    ComponentValidationError, ComponentValidationErrorType, action_row, button,
5    component_custom_id, select_menu, text_input,
6};
7use twilight_model::channel::message::Component;
8use twilight_model::channel::message::component::{
9    ComponentType, Container, FileUpload, Label, MediaGallery, MediaGalleryItem, Section,
10    TextDisplay, Thumbnail,
11};
12
13/// Maximum length of text display content.
14pub const TEXT_DISPLAY_CONTENT_LENGTH_MAX: usize = 2000;
15
16/// Minimum amount of items in a media gallery.
17pub const MEDIA_GALLERY_ITEMS_MIN: usize = 1;
18
19/// Maximum amount of items in a media gallery.
20pub const MEDIA_GALLERY_ITEMS_MAX: usize = 10;
21
22/// Maximum length of a description of a media gallery item.
23pub const MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX: usize = 1024;
24
25/// Minimum amount of components in a section.
26pub const SECTION_COMPONENTS_MIN: usize = 1;
27
28/// Maximum amount of components in a section.
29pub const SECTION_COMPONENTS_MAX: usize = 3;
30
31/// Maximum length of a thumbnail description.
32pub const THUMBNAIL_DESCRIPTION_LENGTH_MAX: usize = 1024;
33
34/// Maximum length of the label text of a label component.
35pub const LABEL_LABEL_LENGTH_MAX: usize = 45;
36
37/// Maximum length of a label description.
38pub const LABEL_DESCRIPTION_LENGTH_MAX: usize = 100;
39
40/// Maximum value of [`FileUpload::max_values`].
41///
42/// [`FileUpload::max_values`]: FileUpload::max_values
43pub const FILE_UPLOAD_MAXIMUM_VALUES_LIMIT: u8 = 10;
44
45/// Maximum value of [`FileUpload::min_values`].
46///
47/// [`FileUpload::min_values`]: FileUpload::min_values
48pub const FILE_UPLOAD_MINIMUM_VALUES_LIMIT: u8 = 10;
49
50/// Ensure that a top-level request component is correct in V2.
51///
52/// Intended to ensure that a fully formed top-level component for requests
53/// is an action row.
54///
55/// Refer to other validators like [`button`] if you need to validate other
56/// components.
57///
58/// # Errors
59///
60/// Returns an error of type [`InvalidRootComponent`] if the component cannot be a root
61/// component in both modals and messages.
62///
63/// For other errors refer to the errors of the following functions:
64/// - [`action_row`]
65/// - [`label`]
66/// - [`button`]
67/// - [`container`]
68/// - [`media_gallery`]
69/// - [`section`]
70/// - [`select_menu`]
71/// - [`text_display`]
72/// - [`text_input`]
73/// - [`thumbnail`]
74///
75/// [`InvalidRootComponent`]: ComponentValidationErrorType::InvalidRootComponent
76pub fn component_v2(component: &Component) -> Result<(), ComponentValidationError> {
77    match component {
78        Component::ActionRow(ar) => action_row(ar, true)?,
79        Component::Label(l) => label(l)?,
80        Component::Button(b) => button(b)?,
81        Component::Container(c) => container(c)?,
82        Component::MediaGallery(mg) => media_gallery(mg)?,
83        Component::Section(s) => section(s)?,
84        Component::SelectMenu(sm) => select_menu(sm, true)?,
85        Component::TextDisplay(td) => text_display(td)?,
86        Component::TextInput(_) | Component::FileUpload(_) => {
87            return Err(ComponentValidationError {
88                kind: ComponentValidationErrorType::InvalidRootComponent {
89                    kind: ComponentType::TextInput,
90                },
91            });
92        }
93        Component::Thumbnail(t) => thumbnail(t)?,
94        Component::Separator(_) | Component::File(_) | Component::Unknown(_) => (),
95    }
96
97    Ok(())
98}
99
100/// Ensure that a label is correct.
101///
102/// # Errors
103///
104/// Returns an error of type [`InvalidChildComponent`] if the provided nested
105/// component is an [`ActionRow`] or a [`Label`]. Labels cannot contain other top-level
106/// components.
107///
108/// Returns an error of type [`DisallowedChildren`] if the label contains components
109/// that are disallowed in labels.
110///
111/// Refer to [`select_menu`] for potential errors when validating a select menu in the label.
112///
113/// Refer to [`text_input`] for potential errors when validating a text input in the label.
114///
115/// Refer to [`text_display`] for potential errors when validating a text display in the label.
116///
117/// [`InvalidChildComponent`]: ComponentValidationErrorType::InvalidChildComponent
118/// [`DisallowedChildren`]: ComponentValidationErrorType::DisallowedChildren
119/// [`ActionRow`]: twilight_model::channel::message::component::ActionRow
120pub fn label(label: &Label) -> Result<(), ComponentValidationError> {
121    self::label_label(&label.label)?;
122
123    if let Some(description) = &label.description {
124        self::label_description(description)?;
125    }
126
127    match &*label.component {
128        Component::ActionRow(_) | Component::Label(_) => Err(ComponentValidationError {
129            kind: ComponentValidationErrorType::InvalidChildComponent {
130                kind: label.component.kind(),
131            },
132        }),
133        Component::SelectMenu(select_menu) => self::select_menu(select_menu, false),
134        Component::TextInput(text_input) => self::text_input(text_input, false),
135        Component::FileUpload(file_upload) => self::file_upload(file_upload),
136        Component::Unknown(unknown) => Err(ComponentValidationError {
137            kind: ComponentValidationErrorType::InvalidChildComponent {
138                kind: ComponentType::Unknown(*unknown),
139            },
140        }),
141
142        Component::Button(_)
143        | Component::TextDisplay(_)
144        | Component::MediaGallery(_)
145        | Component::Separator(_)
146        | Component::File(_)
147        | Component::Section(_)
148        | Component::Container(_)
149        | Component::Thumbnail(_) => Err(ComponentValidationError {
150            kind: ComponentValidationErrorType::DisallowedChildren,
151        }),
152    }
153}
154
155/// Validates a text display component.
156///
157/// # Errors
158///
159/// This will error with [`TextDisplayContentTooLong`] if the content is longer
160/// than [`TEXT_DISPLAY_CONTENT_LENGTH_MAX`].
161///
162/// [`TextDisplayContentTooLong`]: ComponentValidationErrorType::TextDisplayContentTooLong
163pub const fn text_display(text_display: &TextDisplay) -> Result<(), ComponentValidationError> {
164    let content_len = text_display.content.len();
165    if content_len > TEXT_DISPLAY_CONTENT_LENGTH_MAX {
166        return Err(ComponentValidationError {
167            kind: ComponentValidationErrorType::TextDisplayContentTooLong { len: content_len },
168        });
169    }
170
171    Ok(())
172}
173
174/// Validates a media gallery component.
175///
176/// # Errors
177///
178/// This will error with [`MediaGalleryItemCountOutOfRange`] if the amount of
179/// media items is less than [`MEDIA_GALLERY_ITEMS_MIN`] or greater than
180/// [`MEDIA_GALLERY_ITEMS_MAX`].
181///
182/// For errors for validation of induvidual items see the documentation for [`media_gallery_item`].
183///
184/// [`MediaGalleryItemCountOutOfRange`]: ComponentValidationErrorType::MediaGalleryItemCountOutOfRange
185pub fn media_gallery(media_gallery: &MediaGallery) -> Result<(), ComponentValidationError> {
186    let items = media_gallery.items.len();
187    if !(MEDIA_GALLERY_ITEMS_MIN..=MEDIA_GALLERY_ITEMS_MAX).contains(&items) {
188        return Err(ComponentValidationError {
189            kind: ComponentValidationErrorType::MediaGalleryItemCountOutOfRange { count: items },
190        });
191    }
192
193    for item in &media_gallery.items {
194        media_gallery_item(item)?;
195    }
196
197    Ok(())
198}
199
200/// Validates a section component.
201///
202/// # Errors
203///
204/// This will error with [`SectionComponentCountOutOfRange`] if the amount of
205/// section components is less than [`SECTION_COMPONENTS_MIN`] or greater than
206/// [`SECTION_COMPONENTS_MAX`].
207///
208/// For validation of specific components see:
209/// - [`button`]
210/// - [`text_display`]
211/// - [`thumbnail`]
212///
213/// [`SectionComponentCountOutOfRange`]: ComponentValidationErrorType::SectionComponentCountOutOfRange
214pub fn section(section: &Section) -> Result<(), ComponentValidationError> {
215    let components = section.components.len();
216    if !(SECTION_COMPONENTS_MIN..=SECTION_COMPONENTS_MAX).contains(&components) {
217        return Err(ComponentValidationError {
218            kind: ComponentValidationErrorType::SectionComponentCountOutOfRange {
219                count: components,
220            },
221        });
222    }
223
224    for component in &section.components {
225        match component {
226            Component::TextDisplay(td) => text_display(td)?,
227            _ => {
228                return Err(ComponentValidationError {
229                    kind: ComponentValidationErrorType::DisallowedChildren,
230                });
231            }
232        }
233    }
234
235    match section.accessory.as_ref() {
236        Component::Button(b) => button(b)?,
237        Component::Thumbnail(t) => thumbnail(t)?,
238        _ => {
239            return Err(ComponentValidationError {
240                kind: ComponentValidationErrorType::DisallowedChildren,
241            });
242        }
243    }
244
245    Ok(())
246}
247
248/// Validates a container component.
249///
250/// The only allowed components that are allowed are: `action_row`, `file`,
251/// `media_gallery`, `section`, `separator` and `text_display`.
252///
253/// # Errors
254///
255/// For errors for specific components refer to the errors of the following functions:
256/// - [`action_row`]
257/// - [`media_gallery`]
258/// - [`text_display`]
259/// - [`section`]
260///
261/// If any except the allowed components are used if will fail with [`DisallowedChildren`].
262///
263/// [`DisallowedChildren`]: ComponentValidationErrorType::DisallowedChildren
264pub fn container(container: &Container) -> Result<(), ComponentValidationError> {
265    for component in &container.components {
266        match component {
267            Component::ActionRow(ar) => action_row(ar, true)?,
268            Component::TextDisplay(td) => text_display(td)?,
269            Component::Section(s) => section(s)?,
270            Component::MediaGallery(mg) => media_gallery(mg)?,
271            Component::Separator(_) | Component::File(_) => (),
272            _ => {
273                return Err(ComponentValidationError {
274                    kind: ComponentValidationErrorType::DisallowedChildren,
275                });
276            }
277        }
278    }
279
280    Ok(())
281}
282
283/// Validates a thumbnail component.
284///
285/// # Errors
286///
287/// This will error with [`ThumbnailDescriptionTooLong`] if the description is longer
288/// than [`THUMBNAIL_DESCRIPTION_LENGTH_MAX`].
289///
290/// [`ThumbnailDescriptionTooLong`]: ComponentValidationErrorType::ThumbnailDescriptionTooLong
291pub const fn thumbnail(thumbnail: &Thumbnail) -> Result<(), ComponentValidationError> {
292    let Some(Some(desc)) = thumbnail.description.as_ref() else {
293        return Ok(());
294    };
295
296    let len = desc.len();
297    if len > THUMBNAIL_DESCRIPTION_LENGTH_MAX {
298        return Err(ComponentValidationError {
299            kind: ComponentValidationErrorType::ThumbnailDescriptionTooLong { len },
300        });
301    }
302
303    Ok(())
304}
305
306/// Validates a file upload component.
307///
308/// # Errors
309///
310/// Returns an error of type [`ComponentCustomIdLength`] if the provided custom
311/// ID is too long.
312///
313/// Returns an error of type [`FileUploadMaximumValuesCount`] if the provided number
314/// of files that can be uploaded is larger than the maximum.
315///
316/// Returns an error of type [`FileUploadMinimumValuesCount`] if the provided number
317/// of files that must be uploaded is larger than the maximum.
318///
319/// [`ComponentCustomIdLength`]: ComponentValidationErrorType::ComponentCustomIdLength
320/// [`FileUploadMaximumValuesCount`]: ComponentValidationErrorType::FileUploadMaximumValuesCount
321/// [`FileUploadMaximumValuesCount`]: ComponentValidationErrorType::FileUploadMaximumValuesCount
322pub fn file_upload(file_upload: &FileUpload) -> Result<(), ComponentValidationError> {
323    component_custom_id(&file_upload.custom_id)?;
324
325    if let Some(min_values) = file_upload.min_values {
326        component_file_upload_min_values(min_values)?;
327    }
328
329    if let Some(max_value) = file_upload.max_values {
330        component_file_upload_max_values(max_value)?;
331    }
332
333    Ok(())
334}
335
336/// Validates a media gallery item
337///
338/// # Errors
339///
340/// This will error with [`MediaGalleryItemDescriptionTooLong`] if the description is longer
341/// than [`MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX`].
342///
343/// [`MediaGalleryItemDescriptionTooLong`]: ComponentValidationErrorType::MediaGalleryItemDescriptionTooLong
344pub const fn media_gallery_item(item: &MediaGalleryItem) -> Result<(), ComponentValidationError> {
345    let Some(desc) = item.description.as_ref() else {
346        return Ok(());
347    };
348
349    let len = desc.len();
350    if len > MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX {
351        return Err(ComponentValidationError {
352            kind: ComponentValidationErrorType::MediaGalleryItemDescriptionTooLong { len },
353        });
354    }
355
356    Ok(())
357}
358
359/// Ensure a [`Label::label`]'s length is correct.
360///
361/// # Errors
362///
363/// Returns an error of type [`LabelLabelTooLong`] if the length is invalid.
364///
365/// [`Label::label`]: twilight_model::channel::message::component::Label::label
366/// [`LabelLabelTooLong`]: ComponentValidationErrorType::LabelLabelTooLong
367fn label_label(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
368    let chars = value.as_ref().chars().count();
369
370    if chars <= LABEL_LABEL_LENGTH_MAX {
371        Ok(())
372    } else {
373        Err(ComponentValidationError {
374            kind: ComponentValidationErrorType::LabelLabelTooLong { len: chars },
375        })
376    }
377}
378
379/// Ensure a [`Label::description`]'s length is correct.
380///
381/// # Errors
382///
383/// Returns an error of type [`LabelDescriptionTooLong`] if the length is invalid.
384///
385/// [`Label::label`]: twilight_model::channel::message::component::Label::description
386/// [`LabelDescriptionTooLong`]: ComponentValidationErrorType::LabelDescriptionTooLong
387fn label_description(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
388    let chars = value.as_ref().chars().count();
389
390    if chars <= LABEL_DESCRIPTION_LENGTH_MAX {
391        Ok(())
392    } else {
393        Err(ComponentValidationError {
394            kind: ComponentValidationErrorType::LabelDescriptionTooLong { len: chars },
395        })
396    }
397}
398
399/// Validate a [`FileUpload::max_values`] amount.
400///
401/// # Errors
402///
403/// Returns an error of type [`FileUploadMaximumValuesCount`] if the provided number
404/// of files that can be uploaded is larger than
405/// [the maximum][`FILE_UPLOAD_MAXIMUM_VALUES_LIMIT`].
406///
407/// [`FileUpload::max_values`]: twilight_model::channel::message::component::FileUpload::max_values
408/// [`FileUploadMaximumValuesCount`]: ComponentValidationErrorType::FileUploadMaximumValuesCount
409const fn component_file_upload_max_values(count: u8) -> Result<(), ComponentValidationError> {
410    if count > FILE_UPLOAD_MAXIMUM_VALUES_LIMIT {
411        return Err(ComponentValidationError {
412            kind: ComponentValidationErrorType::FileUploadMaximumValuesCount { count },
413        });
414    }
415
416    Ok(())
417}
418
419/// Validate a [`FileUpload::min_values`] amount.
420///
421/// # Errors
422///
423/// Returns an error of type [`FileUploadMinimumValuesCount`] if the provided number
424/// of files that must be uploaded is larger than
425/// [the maximum][`FILE_UPLOAD_MINIMUM_VALUES_LIMIT`].
426///
427/// [`FileUpload::min_values`]: twilight_model::channel::message::component::FileUpload::min_values
428/// [`FileUploadMinimumValuesCount`]: ComponentValidationErrorType::FileUploadMinimumValuesCount
429const fn component_file_upload_min_values(count: u8) -> Result<(), ComponentValidationError> {
430    if count > FILE_UPLOAD_MINIMUM_VALUES_LIMIT {
431        return Err(ComponentValidationError {
432            kind: ComponentValidationErrorType::FileUploadMinimumValuesCount { count },
433        });
434    }
435
436    Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use std::iter;
443    use twilight_model::channel::message::Component;
444    use twilight_model::channel::message::component::{
445        Button, ButtonStyle, Label, SelectMenu, SelectMenuType, TextInput, TextInputStyle,
446    };
447
448    fn wrap_in_label(component: Component) -> Component {
449        Component::Label(Label {
450            id: None,
451            label: "label".to_owned(),
452            description: None,
453            component: Box::new(component),
454        })
455    }
456
457    #[test]
458    fn component_label() {
459        let button = Component::Button(Button {
460            custom_id: None,
461            disabled: false,
462            emoji: None,
463            label: Some("Press me".to_owned()),
464            style: ButtonStyle::Danger,
465            url: None,
466            sku_id: None,
467            id: None,
468        });
469
470        let text_display = Component::TextDisplay(TextDisplay {
471            id: None,
472            content: "Text display".to_owned(),
473        });
474
475        let valid_select_menu = SelectMenu {
476            channel_types: None,
477            custom_id: "my_select".to_owned(),
478            default_values: None,
479            disabled: false,
480            kind: SelectMenuType::User,
481            max_values: None,
482            min_values: None,
483            options: None,
484            placeholder: None,
485            id: None,
486            required: None,
487        };
488
489        let disabled_select_menu = SelectMenu {
490            disabled: true,
491            ..valid_select_menu.clone()
492        };
493
494        let valid_label = Label {
495            id: None,
496            label: "Label".to_owned(),
497            description: Some("This is a description".to_owned()),
498            component: Box::new(Component::SelectMenu(valid_select_menu)),
499        };
500
501        let label_invalid_child_button = Label {
502            component: Box::new(button),
503            ..valid_label.clone()
504        };
505
506        let label_invalid_child_text_display = Label {
507            id: None,
508            label: "Another label".to_owned(),
509            description: None,
510            component: Box::new(text_display),
511        };
512
513        let label_invalid_child_disabled_select = Label {
514            component: Box::new(Component::SelectMenu(disabled_select_menu)),
515            ..valid_label.clone()
516        };
517
518        let label_too_long_description = Label {
519            description: Some(iter::repeat_n('a', 101).collect()),
520            ..valid_label.clone()
521        };
522
523        let label_too_long_label = Label {
524            label: iter::repeat_n('a', 46).collect(),
525            ..valid_label.clone()
526        };
527
528        assert!(label(&valid_label).is_ok());
529        assert!(component_v2(&Component::Label(valid_label)).is_ok());
530        assert!(label(&label_invalid_child_button).is_err());
531        assert!(component_v2(&Component::Label(label_invalid_child_button)).is_err());
532        assert!(label(&label_invalid_child_text_display).is_err());
533        assert!(component_v2(&Component::Label(label_invalid_child_text_display)).is_err());
534        assert!(label(&label_invalid_child_disabled_select).is_err());
535        assert!(component_v2(&Component::Label(label_invalid_child_disabled_select)).is_err());
536        assert!(label(&label_too_long_description).is_err());
537        assert!(component_v2(&Component::Label(label_too_long_description)).is_err());
538        assert!(label(&label_too_long_label).is_err());
539        assert!(component_v2(&Component::Label(label_too_long_label)).is_err());
540    }
541
542    #[test]
543    fn no_text_input_label_in_label_component() {
544        #[allow(deprecated)]
545        let text_input_with_label = Component::TextInput(TextInput {
546            id: None,
547            custom_id: "The custom id".to_owned(),
548            label: Some("The text input label".to_owned()),
549            max_length: None,
550            min_length: None,
551            placeholder: None,
552            required: None,
553            style: TextInputStyle::Short,
554            value: None,
555        });
556
557        let invalid_label_component = Label {
558            id: None,
559            label: "Label".to_owned(),
560            description: None,
561            component: Box::new(text_input_with_label),
562        };
563
564        assert!(label(&invalid_label_component).is_err());
565        assert!(component_v2(&Component::Label(invalid_label_component)).is_err());
566    }
567
568    #[test]
569    fn component_file_upload() {
570        let valid = FileUpload {
571            id: Some(42),
572            custom_id: "custom_id".to_owned(),
573            max_values: Some(10),
574            min_values: Some(10),
575            required: None,
576        };
577
578        assert!(file_upload(&valid).is_ok());
579        assert!(component_v2(&wrap_in_label(Component::FileUpload(valid.clone()))).is_ok());
580
581        let invalid_custom_id = FileUpload {
582            custom_id: iter::repeat_n('a', 101).collect(),
583            ..valid.clone()
584        };
585
586        assert!(file_upload(&invalid_custom_id).is_err());
587        assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_custom_id))).is_err());
588
589        let invalid_min_values = FileUpload {
590            min_values: Some(11),
591            ..valid.clone()
592        };
593
594        assert!(file_upload(&invalid_min_values).is_err());
595        assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_min_values))).is_err());
596
597        let invalid_max_values_too_high = FileUpload {
598            max_values: Some(11),
599            ..valid
600        };
601
602        assert!(file_upload(&invalid_max_values_too_high).is_err());
603        assert!(
604            component_v2(&wrap_in_label(Component::FileUpload(
605                invalid_max_values_too_high
606            )))
607            .is_err()
608        );
609    }
610}