diff --git a/slack-api-model/pom.xml b/slack-api-model/pom.xml index 5c5a51342..419f864ba 100644 --- a/slack-api-model/pom.xml +++ b/slack-api-model/pom.xml @@ -23,6 +23,11 @@ gson ${gson.version} + + com.google.guava + guava + 33.4.8-jre + diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java b/slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java new file mode 100644 index 000000000..33ee544c5 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java @@ -0,0 +1,14 @@ +package com.slack.api.model.block.composition; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ThirdPartyAuthObject { + Boolean enableDynamicAuth; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java new file mode 100644 index 000000000..0a0bcf2e7 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java @@ -0,0 +1,4 @@ +package com.slack.api.model.work_objects; + +public class ActionBlockPayload { +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java new file mode 100644 index 000000000..900c7f23c --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java @@ -0,0 +1,4 @@ +package com.slack.api.model.work_objects; + +public class ActionMenu { +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java new file mode 100644 index 000000000..ea9d0d4d5 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java @@ -0,0 +1,12 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.ImageBlock; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class AppIcons { + ImageBlock image36; + ImageBlock image128; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java new file mode 100644 index 000000000..9e01e9ab0 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java @@ -0,0 +1,44 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.composition.ConfirmationDialogObject; +import com.slack.api.model.block.composition.PlainTextObject; +import com.slack.api.model.block.composition.ThirdPartyAuthObject; +import com.slack.api.model.work_objects.external.ButtonProcessingState; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import lombok.Builder; +import lombok.Value; + +import java.util.List; + +import static com.google.common.base.Predicates.equalTo; +import static com.google.common.base.Predicates.instanceOf; + +/** + * Button Action Payload. + *

+ * Represents the payload sent to the server from a `button` action. + */ +@Value +@Builder +public class Button extends PrimaryActions { + @Required String blockId; + @Required String actionId; + @Required(validator = ButtonTypePredicate.class) String type; + String style; + @Required PlainTextObject text; + String value; + String url; + String accessibilityLabel; + ConfirmationDialogObject confirm; + ThirdPartyAuthObject thirdPartyAuth; + List visibleToUserIds; + ButtonProcessingState processingState; + + public static class ButtonTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(equalTo("button")).test(obj); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java new file mode 100644 index 000000000..83632987b --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java @@ -0,0 +1,29 @@ +package com.slack.api.model.work_objects; + +import com.google.common.base.Preconditions; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.IsValidChannelIdPredicate; +import lombok.Builder; +import lombok.Value; + +import static com.slack.api.util.predicate.IsValidChannelIdPredicate.CHANNEL_ID_REGEX; + +/** + * Representation of a Slack channel on a work object. + */ +@Value +public class Channel { + private static final IsValidChannelIdPredicate isValidChannelId = new IsValidChannelIdPredicate(); + + @Required(validator = IsValidChannelIdPredicate.class) + String channelId; + + @Builder + public Channel(String channelId) { + Preconditions.checkArgument( + isValidChannelId.test(channelId), + String.format("Invalid slack channelId %s, required format %s", channelId, CHANNEL_ID_REGEX) + ); + this.channelId = channelId; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java new file mode 100644 index 000000000..fc34a3c28 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java @@ -0,0 +1,14 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class CheckboxOption { + @Required String text; + @Required Boolean checked; + String description; +} + diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java new file mode 100644 index 000000000..b0afca205 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java @@ -0,0 +1,20 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class CompactLayout { + public static final String LAYOUT_TYPE = "compact"; + @Required String layoutType; + ImageBlock productIcon; + @Required Title title; + Title subtitle; + Title headerTitle; + Title hoverSubtitle; + Fields fields; + Integer updatedAt; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java new file mode 100644 index 000000000..db6671dce --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java @@ -0,0 +1,144 @@ +package com.slack.api.model.work_objects; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.gson.annotations.JsonAdapter; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.Value; + +import java.lang.reflect.Type; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * Combined date/time display data for calendar events. + */ +@Value +@Builder +@JsonAdapter(DateTimeRange.DateTimeRangeSerializer.class) +public class DateTimeRange { + /** + * Start as Unix timestamp or YYYY-MM-DD dates. + */ + DateTimeImpl start; + /** + * End as Unix timestamp or YYYY-MM-DD dates + */ + DateTimeImpl end; + + /** + * Whether this is an all-day event. + */ + Boolean allDay; + + /** + * Recurrence description text. + */ + String recurrence; + + /** + * Since java doesn't support union types, this class represents a datetime input that can either be a unix + * timestamp or a date string in YYYY-MM-DD format. + */ + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class DateTimeImpl { + private final Instant dateTime; + @Getter @Setter + private boolean inYearMonthDayFormat = false; + + public boolean isUnknown() { + return dateTime.equals(Instant.EPOCH); + } + + public static DateTimeImpl atStartOfDay() { + Instant startOfDay = LocalDate.now(ZoneOffset.UTC).atStartOfDay(ZoneOffset.UTC).toInstant(); + return new DateTimeImpl(startOfDay); + } + + public static DateTimeImpl atEndOfDay() { + Instant endOfDay = LocalDate.now(ZoneOffset.UTC).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant(); + return new DateTimeImpl(endOfDay); + } + + // Used during JSON serialization/deserialization when we can't infer what the date-time value is + public static DateTimeImpl unknown() { + return new DateTimeImpl(Instant.EPOCH); + } + + public static DateTimeImpl from(String in) { + // See if this is a number first + try { + return DateTimeImpl.from(Long.parseLong(in)); + } catch (NumberFormatException e) { + // Swallow - means the input is not a unix timestamp, so try to parse this as a string + } + + // Now make sure it's in YYYY-MM-DD format + try { + Instant dt = LocalDate.parse(in).atStartOfDay(ZoneOffset.UTC).toInstant(); + DateTimeImpl dateTime = new DateTimeImpl(dt); + dateTime.setInYearMonthDayFormat(true); + return dateTime; + } catch (DateTimeParseException e) { + throw new IllegalArgumentException(String.format("Datetime input string not in a recognized format %s", in)); + } + } + + public static DateTimeImpl from(int in) { + return from(Long.valueOf(in)); + } + + public static DateTimeImpl from(long in) { + Instant dateTime = Instant.ofEpochSecond(in); + return new DateTimeImpl(dateTime); + } + + public long getUnixTime() { + return dateTime.getEpochSecond(); + } + + public String getDateTime() { + return DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC).format(dateTime); + } + } + + public static class DateTimeRangeSerializer implements JsonSerializer { + @Override + public JsonElement serialize(DateTimeRange dateTimeRange, Type typeOfT, JsonSerializationContext context) { + JsonObject jsonObject = new JsonObject(); + if (dateTimeRange.getStart() != null) { + DateTimeImpl start = dateTimeRange.getStart(); + if (start.isInYearMonthDayFormat()) { + jsonObject.addProperty("start", start.getDateTime()); + } else { + jsonObject.addProperty("start", start.getUnixTime()); + } + } + if (dateTimeRange.getEnd() != null) { + DateTimeImpl end = dateTimeRange.getEnd(); + if (end.isInYearMonthDayFormat()) { + jsonObject.addProperty("end", end.getDateTime()); + } else { + jsonObject.addProperty("end", end.getUnixTime()); + } + } + if (dateTimeRange.getAllDay() != null) { + jsonObject.addProperty("all_day", dateTimeRange.getAllDay()); + } + if (dateTimeRange.getRecurrence() != null && !dateTimeRange.getRecurrence().isEmpty()) { + jsonObject.addProperty("recurrence", dateTimeRange.getRecurrence()); + } + + return jsonObject; + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java new file mode 100644 index 000000000..1d44f8c07 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java @@ -0,0 +1,32 @@ +package com.slack.api.model.work_objects; + +import com.google.gson.annotations.SerializedName; +import com.slack.api.model.block.composition.PlainTextObject; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Value; + +@Value +@Builder +public class DefaultAction extends PrimaryActions { + @Required Type type; + String style; + PlainTextObject text; + + @Getter + @RequiredArgsConstructor + public enum Type { + @SerializedName("add-to-todo") ADD_TO_TODO("add-to-todo"), + @SerializedName("open-in-app") OPEN_IN_APP("open-in-app"), + @SerializedName("share-link") SHARE_LINK("share-link"), + @SerializedName("copy-link") COPY_LINK("copy-link"), + @SerializedName("add-to-list") ADD_TO_LIST("add-to-list"), + @SerializedName("save-for-later") SAVE_FOR_LATER("save-for-later"), + @SerializedName("remind-me") REMIND_ME("remind-me"), + @SerializedName("add-to-folder") ADD_TO_FOLDER("add-to-folder"); + + private final String value; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java new file mode 100644 index 000000000..59eccda1e --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java @@ -0,0 +1,38 @@ +package com.slack.api.model.work_objects; + +import com.google.common.base.Preconditions; +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.IsValidAppIdPredicate; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class EntityReference { + private static final IsValidAppIdPredicate isValidAppId = new IsValidAppIdPredicate(); + + @Required + String entityId; + + @Required(validator = IsValidAppIdPredicate.class) + String appId; + + @Required + String entityUrl; + + String displayType; + + @Required + String title; + + ImageBlock icon; + + public static class EntityReferenceBuilder { + public EntityReferenceBuilder appId(String appId) { + Preconditions.checkArgument(isValidAppId.test(appId)); + this.appId = appId; + return this; + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java new file mode 100644 index 000000000..af6a85a2d --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java @@ -0,0 +1,37 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Value; + +@Value +@EqualsAndHashCode(callSuper = true) +public class ExternalUser extends User { + public static final String USER_TYPE = "external"; + @Required String text; + ImageBlock image; + String url; + String email; + String caption; + UserMetadata userMetadata; + + @Builder + public ExternalUser( + String text, + ImageBlock image, + String url, + String email, + String caption, + UserMetadata userMetadata + ) { + super(USER_TYPE); + this.text = text; + this.image = image; + this.url = url; + this.email = email; + this.caption = caption; + this.userMetadata = userMetadata; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java new file mode 100644 index 000000000..fbef375df --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java @@ -0,0 +1,121 @@ +package com.slack.api.model.work_objects; + +import com.google.gson.annotations.SerializedName; +import com.slack.api.model.block.ImageBlock; +import com.slack.api.model.block.InputBlock; +import com.slack.api.model.block.RichTextBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +import java.util.List; + +@Value +@Builder +public class Field { + @Required String type; + @Required String label; + @Required String fieldType; + + /** + * The name of the field from {@link com.slack.api.model.EntityMetadata}. + */ + String fieldName; + + /** + * Plain text. + */ + String text; + + /** + * Rich text block. + */ + RichTextBlock richText; + + /** + * List of Unix timestamps. + */ + List timestamp; + + /** + * List of image blocks. + */ + List image; + + /** + * Should the field take up the full width. + */ + @SerializedName("long") + Boolean isLong; + + /** + * List of Slack and external users. + */ + List user; + + /** + * List of Slack users. + * @deprecated - prefer {@link this#user} field instead since this represents both Slack and external users. + */ + @Deprecated + List slackUser; + + List tag; + + InputBlock input; + + /** + * Array of input blocks for editing. Used when a field has multiple edit inputs. + */ + List inputs; + + /*** + * List of "YYYY-MM-DD dates". + */ + List date; + + /** + * List of channels. + */ + List channel; + + /** + * List of entity references. + */ + List entityRef; + + /** + * List of checkbox options. + */ + List checkbox; + + /** + * List of email addresses. + */ + List email; + + /** + * List of links. + */ + List link; + + /** + * Header with optional badge. + */ + HeaderWithBadge headerWithBadge; + + /** + * List of files. + */ + List file; + + /** + * List of date time ranges. + */ + List dateTimeRange; + + /** + * Represents the native value for boolean field types. + */ + Boolean booleanValue; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java new file mode 100644 index 000000000..601e2362d --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java @@ -0,0 +1,26 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import java.util.List; + +import lombok.Builder; +import lombok.Value; + +import static com.google.common.base.Predicates.instanceOf; +import static com.google.common.base.Predicates.equalTo; + +@Value +@Builder +public class Fields { + @Required(validator = FieldsTypeValuePredicate.class) + String type; + @Required List elements; + + public static class FieldsTypeValuePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(equalTo("fields")).test(obj); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java new file mode 100644 index 000000000..03ddeefe8 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java @@ -0,0 +1,35 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import lombok.Builder; +import lombok.Value; + +import static com.google.common.base.Predicates.equalTo; +import static com.google.common.base.Predicates.instanceOf; + +@Value +@Builder +public class File { + @Required(validator = FileTypePredicate.class) String type; + @Required String fileId; + + public static class FileTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(equalTo("file")).test(obj); + } + } + + public static FileBuilder builder() { + return new CustomFileBuilder(); + } + + public static class CustomFileBuilder extends FileBuilder { + @Override + public File build() { + super.type("file"); + return super.build(); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java new file mode 100644 index 000000000..dc3a22757 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java @@ -0,0 +1,59 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.InputBlock; +import com.slack.api.model.work_objects.Title; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import lombok.Builder; +import lombok.Value; + +import java.util.List; + +import static com.google.common.base.Predicates.instanceOf; +import static java.util.function.Predicate.isEqual; + +@Value +@Builder +public class FullLayout { + public static final String LAYOUT_TYPE = "full"; + @Required(validator = FullLayoutTypePredicate.class) String layoutType; + Title headerTitle; + Title headerSubtitle; + /** + * A title field on an external work object. + */ + @Required FullLayout.Title title; + Subtitle subtitle; + /** + * When true, at least one of the fields in this schema can be edited by the user. + */ + Boolean editable; + Fields fields; + Actions actions; + + @Value + @Builder + public static class Title { + /** + * Plan text fallback of the field value. + */ + @Required String text; + InputBlock input; + } + + @Value + @Builder + public static class Actions { + @Required List primaryActions; + List primaryActionsMenu; + @Required OverflowActions overflowActions; + @Required ActionBlockPayload blockPayload; + } + + public static class FullLayoutTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(isEqual(LAYOUT_TYPE)).test(obj); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java new file mode 100644 index 000000000..a71a9687a --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java @@ -0,0 +1,32 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class HeaderWithBadge { + /** + * Plain text displayed as the header. + */ + @Required String text; + Badge badge; + + @Value + @Builder + public static class Badge { + /** + * Plain text displayed inside the badge. + */ + @Required String text; + /** + * Color of the text. + */ + String Color; + /** + * Color of the badge. + */ + String backgroundColor; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java new file mode 100644 index 000000000..88490e924 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java @@ -0,0 +1,16 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.composition.SlackFileObject; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Image { + @Required String type; + @Required String altText; + String imageUrl; + String title; + SlackFileObject slackFile; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java new file mode 100644 index 000000000..d3a35cd1e --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java @@ -0,0 +1,13 @@ +package com.slack.api.model.work_objects; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Layouts { + CompactLayout compact; + ExpandedLayout expanded; + FullLayout full; + MinimalLayout minimal; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java new file mode 100644 index 000000000..d1e4e36d5 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java @@ -0,0 +1,14 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +import java.util.Map; + +@Value +@Builder +public class LookupFunction { + @Required String functionId; + @Required Map inputs; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java new file mode 100644 index 000000000..1013b037b --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java @@ -0,0 +1,4 @@ +package com.slack.api.model.work_objects; + +public class OverflowActions { +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java new file mode 100644 index 000000000..a04dec710 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java @@ -0,0 +1,6 @@ +package com.slack.api.model.work_objects; + +/** + * Primary action buttons that appear in a work object's view. These can either be {@link Button} or {@link DefaultAction}. + */ +abstract public class PrimaryActions {} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java new file mode 100644 index 000000000..dbfdd07f4 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java @@ -0,0 +1,33 @@ +package com.slack.api.model.work_objects; + +import com.google.common.base.Preconditions; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.IsValidUserIdPredicate; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Value; + +import static com.slack.api.util.predicate.IsValidUserIdPredicate.USER_ID_REGEX; + +@Value +@EqualsAndHashCode(callSuper = true) +public class SlackUser extends User { + public static final String USER_TYPE = "slack"; + private static final IsValidUserIdPredicate isValidUserId = new IsValidUserIdPredicate(); + + @Required(validator = IsValidUserIdPredicate.class) + String userId; + + UserMetadata metadata; + + @Builder + public SlackUser(String userId, UserMetadata metadata) { + super(USER_TYPE); + Preconditions.checkArgument( + isValidUserId.test(userId), + String.format("Invalid slack userId %s, required format %s", userId, USER_ID_REGEX) + ); + this.userId = userId; + this.metadata = metadata; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java new file mode 100644 index 000000000..53453041a --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java @@ -0,0 +1,14 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Subtitle { + @Required String text; + String url; + ImageBlock image; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java new file mode 100644 index 000000000..713526a55 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java @@ -0,0 +1,15 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.work_objects.external.TagColor; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Tag { + @Required String text; + TagColor color; + String link; + String iconUrl; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java new file mode 100644 index 000000000..e9eb32c8d --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java @@ -0,0 +1,12 @@ +package com.slack.api.model.work_objects; + + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Title { + @Required String text; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java new file mode 100644 index 000000000..c267bc54e --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java @@ -0,0 +1,6 @@ +package com.slack.api.model.work_objects; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class UnknownAction extends PrimaryActions {} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java new file mode 100644 index 000000000..bce08422f --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java @@ -0,0 +1,14 @@ +package com.slack.api.model.work_objects; + +import lombok.Builder; + +public class UnknownUser extends User { + public UnknownUser() { + super("unknown"); + } + + @Builder + public UnknownUser(String userType) { + super(userType); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java new file mode 100644 index 000000000..90ce1a09d --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java @@ -0,0 +1,33 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static com.google.common.base.Predicates.equalTo; +import static com.google.common.base.Predicates.instanceOf; +import static com.google.common.base.Predicates.or; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class User { + @Required(validator = IsValidUserTypePredicate.class) + protected final String userType; + + public boolean isExternalUser() { + return getUserType().equals("external"); + } + + public boolean isSlackUser() { + return getUserType().equals("slack"); + } + + public static class IsValidUserTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(or(equalTo("external"), equalTo("slack"))).test(obj); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java new file mode 100644 index 000000000..9b98a3c3a --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java @@ -0,0 +1,20 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class UserMetadata { + String role; + OverlayIcon overlayIcon; + + @Value + @Builder + public static class OverlayIcon { + @Required String iconName; + ImageBlock icon; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java new file mode 100644 index 000000000..60cd71542 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java @@ -0,0 +1,48 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.work_objects.external.FullSizePreview; +import com.slack.api.model.work_objects.external.WorkObjectEntityType; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import com.slack.api.util.predicate.IsValidAppIdPredicate; +import lombok.Builder; +import lombok.Value; + +import static com.google.common.base.Predicates.instanceOf; +import static java.util.function.Predicate.isEqual; + +@Value +@Builder +public class WorkObjectUnfurl { + @Required(validator = WorkObjectTypePredicate.class) String workObjectType; + @Required String externalUrl; + @Required String entityId; + String relatedConversationsEntityId; + @Required(validator = IsValidAppIdPredicate.class) String appId; + String appName; + AppIcons appIcons; + String productName; + LookupFunction lookupFunction; + String authProviderKey; + String displayType; + Integer ts; + Layouts layouts; + WorkObjectEntityType workObjectEntityType; + FullSizePreview fullSizePreview; + String slackFileId; + User perspectiveUser; + CommentMetadata comments; + + @Value + @Builder + public static class CommentMetadata { + @Required Integer count; + } + + public static class WorkObjectTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(isEqual("external")).test(obj); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java new file mode 100644 index 000000000..96494c4a3 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java @@ -0,0 +1,12 @@ +package com.slack.api.model.work_objects.external; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class ButtonProcessingState { + @Required Boolean enabled; + String interstitialText; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java new file mode 100644 index 000000000..19a70135e --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java @@ -0,0 +1,24 @@ +package com.slack.api.model.work_objects.external; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class FullSizePreview { + @Required Boolean isSupported; + String previewUrl; + Boolean isAnimated; + String width; + String height; + String mimeType; + Error error; + + @Value + @Builder + public static class Error { + @Required String code; + String message; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java new file mode 100644 index 000000000..b26eaac1d --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java @@ -0,0 +1,22 @@ +package com.slack.api.model.work_objects.external; + +import com.google.gson.annotations.SerializedName; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TagColor { + @SerializedName("flamingo") FLAMINGO("flamingo"), + @SerializedName("honeycomb") HONEYCOMB("honeycomb"), + @SerializedName("grass") GRASS("grass"), + @SerializedName("gray") GREY("gray"), + @SerializedName("informative") INFORMATIVE("informative"), + @SerializedName("indigo") INDIGO("indigo"), + @SerializedName("lagooon") LAGOON("lagoon"), + @SerializedName("jade") JADE("jade"), + @SerializedName("horchata") HORCHATA("horchata"), + @SerializedName("aubergine") AUBERGINE("aubergine"); + + private final String color; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java new file mode 100644 index 000000000..1003979a5 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java @@ -0,0 +1,19 @@ +package com.slack.api.model.work_objects.external; + +import com.google.gson.annotations.SerializedName; +import lombok.RequiredArgsConstructor; +import lombok.Getter; + +@Getter +@RequiredArgsConstructor +public enum WorkObjectEntityType { + @SerializedName("slack#/entities/task") TASK("slack#/entities/task"), + @SerializedName("slack#/entities/file") FILE("slack#/entities/file"), + @SerializedName("slack#/entities/item") ITEM("slack#/entities/item"), + @SerializedName("slack#/entities/incident") INCIDENT("slack#/entities/incident"), + @SerializedName("slack#/entities/content_item") CONTENT_ITEM("slack#/entities/content_item"), + @SerializedName("slack#/entities/tableau_analytics") TABLEAU_ANALYTICS("slack#/entities/tableau_analytics"), + @SerializedName("slack#/entities/calendar_event") CALENDAR_EVENT("slack#/entities/calendar_event"); + + private final String type; +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java new file mode 100644 index 000000000..44b59ceb8 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java @@ -0,0 +1,88 @@ +package com.slack.api.util.json; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.slack.api.model.work_objects.DateTimeRange; +import com.slack.api.model.work_objects.DateTimeRange.DateTimeRangeBuilder; +import com.slack.api.model.work_objects.DateTimeRange.DateTimeImpl; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Type; + +/** + * Factory for serializing and deserializing {@link com.slack.api.model.work_objects.DateTimeRange} objects, namely + * to handle start and end time inputs that are allowed to be in unix time OR YYYY-MM-DD format. + */ +@RequiredArgsConstructor +public class GsonWorkObjectDateTimeDeserializer implements JsonDeserializer { + private final boolean failOnUnknownProperties; + + @Override + public DateTimeRange deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + final boolean isAllDay = jsonObject.has("all_day") && jsonObject.get("all_day").getAsBoolean(); + final DateTimeRangeBuilder builder = DateTimeRange.builder().allDay(isAllDay); + + // If we have an all-day event, and the start/end times haven't been provided, set them to the start/end + // of the today + if (isAllDay) { + if (!jsonObject.has("start")) { + builder.start(DateTimeImpl.atStartOfDay()); + } + if (!jsonObject.has("end")) { + builder.end(DateTimeImpl.atEndOfDay()); + } + } + + // If we don't have an all-day event, we need both start and end dates + if (!isAllDay && (!jsonObject.has("start") || !jsonObject.has("end"))) { + if (failOnUnknownProperties) { + throw new JsonParseException("DateTimeRange object missing start and/or end times"); + } + + // We can't really do anything in this case, just return null + return null; + } + + if (jsonObject.has("start")) { + JsonPrimitive startDate = jsonObject.getAsJsonPrimitive("start"); + builder.start(getDateTime(startDate)); + } + if (jsonObject.has("end")) { + JsonPrimitive endDate = jsonObject.getAsJsonPrimitive("end"); + builder.end(getDateTime(endDate)); + } + if (jsonObject.has("recurrence") && !jsonObject.get("recurrence").isJsonNull()) { + try { + builder.recurrence(jsonObject.getAsJsonPrimitive("recurrence").getAsString()); + } catch (AssertionError e) { + if (failOnUnknownProperties) { + throw new JsonParseException(e); + } + } + } + + return builder.build(); + } + + private DateTimeImpl getDateTime(JsonPrimitive prim) { + try { + if (prim.isNumber()) { + return DateTimeImpl.from(prim.getAsLong()); + } else if (prim.isString()) { + return DateTimeImpl.from(prim.getAsString()); + } + throw new IllegalArgumentException("Unrecognized JSON primitive type"); + } catch (IllegalArgumentException e) { + if (failOnUnknownProperties) { + throw new JsonParseException(e); + } + return DateTimeImpl.unknown(); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java new file mode 100644 index 000000000..9aea151f4 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java @@ -0,0 +1,65 @@ +package com.slack.api.util.json; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.slack.api.model.work_objects.Button; +import com.slack.api.model.work_objects.DefaultAction; +import com.slack.api.model.work_objects.PrimaryActions; +import com.slack.api.model.work_objects.UnknownAction; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Type; + +/** + * Factory for serializing and deserializing work object {@link PrimaryActions} into their appropriate concrete types, + * namely {@link Button} and {@link DefaultAction}. + *

+ * The discriminator is the {@code "type"} field in the JSON object. A value of {@code "button"} maps to + * {@link Button}, while any other value is treated as a {@link DefaultAction}. + */ +@RequiredArgsConstructor +public class GsonWorkObjectPrimaryActionsFactory implements JsonDeserializer, JsonSerializer { + private final boolean failOnUnknownProperties; + + @Override + public PrimaryActions deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + final JsonPrimitive typePrimitive = jsonObject.getAsJsonPrimitive("type"); + if (typePrimitive == null) { + if (failOnUnknownProperties) { + throw new JsonParseException("Missing 'type' field in PrimaryActions JSON object"); + } + return new UnknownAction(); + } + final String type = typePrimitive.getAsString(); + try { + return context.deserialize(jsonObject, getClassForType(type)); + } catch (JsonParseException e) { + if (failOnUnknownProperties) { + throw e; + } + + return new UnknownAction(); + } + } + + @Override + public JsonElement serialize(PrimaryActions src, Type typeOfSrc, JsonSerializationContext context) { + return context.serialize(src); + } + + private Class getClassForType(String type) { + if ("button".equals(type)) { + return Button.class; + } + + return DefaultAction.class; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java new file mode 100644 index 000000000..c7db58437 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java @@ -0,0 +1,52 @@ +package com.slack.api.util.json; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.slack.api.model.work_objects.ExternalUser; +import com.slack.api.model.work_objects.SlackUser; +import com.slack.api.model.work_objects.UnknownUser; +import com.slack.api.model.work_objects.User; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Type; + +/** + * Factory for serializing and deserializing work object {@link User} into their appropriate concrete types, namely + * {@link com.slack.api.model.work_objects.SlackUser} and {@link com.slack.api.model.work_objects.ExternalUser}. + */ +@RequiredArgsConstructor +public class GsonWorkObjectUserFactory implements JsonDeserializer, JsonSerializer { + private final boolean failOnUnknownProperties; + + @Override + public User deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + final String userType = jsonObject.getAsJsonPrimitive("user_type").getAsString(); + return context.deserialize(jsonObject, getUserClassForType(userType)); + } + + @Override + public JsonElement serialize(User src, Type typeOfSrc, JsonSerializationContext context) { + return context.serialize(src); + } + + private Class getUserClassForType(String userType) { + switch (userType) { + case SlackUser.USER_TYPE: + return SlackUser.class; + case ExternalUser.USER_TYPE: + return ExternalUser.class; + default: + if (failOnUnknownProperties) { + throw new JsonParseException("User type " + userType + " is not recognized"); + } + return UnknownUser.class; + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java index 41a17ed88..c49f6fc0c 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java @@ -66,17 +66,19 @@ public T read(JsonReader in) throws IOException { */ private List buildRequiredFieldEntries(Class clazz) { List entries = new ArrayList<>(); - for (Field field : clazz.getDeclaredFields()) { - Required annotation = field.getAnnotation(Required.class); - if (annotation != null) { - field.setAccessible(true); - try { - FieldPredicate predicate = annotation.validator().getDeclaredConstructor().newInstance(); - entries.add(new RequiredFieldEntry(field, predicate)); - } catch (NoSuchMethodException | InstantiationException | - IllegalAccessException | InvocationTargetException e) { - throw new JsonParseException( - "Cannot instantiate validator for field: " + field.getName(), e); + for (Class current = clazz; current != null && current != Object.class; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + Required annotation = field.getAnnotation(Required.class); + if (annotation != null) { + field.setAccessible(true); + try { + FieldPredicate predicate = annotation.validator().getDeclaredConstructor().newInstance(); + entries.add(new RequiredFieldEntry(field, predicate)); + } catch (NoSuchMethodException | InstantiationException | + IllegalAccessException | InvocationTargetException e) { + throw new JsonParseException( + "Cannot instantiate validator for field: " + field.getName(), e); + } } } } diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java new file mode 100644 index 000000000..ea55c5854 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java @@ -0,0 +1,20 @@ +package com.slack.api.util.predicate; + +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static com.google.common.base.Predicates.instanceOf; + +public class IsValidAppIdPredicate implements FieldPredicate, Predicate { + public static final Pattern APP_ID_REGEX = Pattern.compile("^A[A-Z0-9]+$"); + + @Override + public boolean test(String t) { + return APP_ID_REGEX.matcher(t).matches(); + } + + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(o -> test((String) o)).test(obj); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java new file mode 100644 index 000000000..90e4710d3 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java @@ -0,0 +1,23 @@ +package com.slack.api.util.predicate; + +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static com.google.common.base.Predicates.instanceOf; + +/** + * Predicate for validating that a given ID conforms to Slack's channel ID format. + */ +public class IsValidChannelIdPredicate implements FieldPredicate, Predicate { + public static final Pattern CHANNEL_ID_REGEX = Pattern.compile("^C[A-Z0-9]{2,}$"); + + @Override + public boolean test(String t) { + return CHANNEL_ID_REGEX.matcher(t).matches(); + } + + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(o -> test((String) o)).test(obj); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java new file mode 100644 index 000000000..343c960df --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java @@ -0,0 +1,22 @@ +package com.slack.api.util.predicate; + +import java.util.function.Predicate; +import java.util.regex.Pattern; +import static com.google.common.base.Predicates.instanceOf; + +/** + * Predicate for validating that a given ID conforms to Slack's user ID format. + */ +public final class IsValidUserIdPredicate implements FieldPredicate, Predicate { + public static final Pattern USER_ID_REGEX = Pattern.compile("^[WU][A-Z0-9]{8,}$"); + + @Override + public boolean test(String t) { + return USER_ID_REGEX.matcher(t).matches(); + } + + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(o -> test((String) o)).test(obj); + } +} diff --git a/slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java b/slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java new file mode 100644 index 000000000..7a65359df --- /dev/null +++ b/slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java @@ -0,0 +1,92 @@ +package test_locally.api.model.work_objects; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.slack.api.model.work_objects.DateTimeRange; +import com.slack.api.model.work_objects.DateTimeRange.DateTimeImpl; +import org.junit.Test; +import test_locally.unit.GsonFactory; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.*; + +public class DateTimeRangeTest { + final Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); + + @Test + public void testJsonSerialization() { + // An empty object shout output an empty json object + DateTimeRange dateTime = DateTimeRange.builder().build(); + assertThat(gson.toJson(dateTime), is("{}")); + + // Handles booleans + dateTime = DateTimeRange.builder().allDay(true).build(); + assertThat(gson.toJson(dateTime), is("{\"all_day\":true}")); + dateTime = DateTimeRange.builder().allDay(false).build(); + assertThat(gson.toJson(dateTime), is("{\"all_day\":false}")); + + // Handles the start/end times formatted in YYYY-MM-DD format + DateTimeImpl start = DateTimeImpl.from("2026-02-26"); + DateTimeImpl end = DateTimeImpl.from("2026-02-27"); + dateTime = DateTimeRange.builder().start(start).end(end).build(); + assertThat(gson.toJson(dateTime), is("{\"start\":\"2026-02-26\",\"end\":\"2026-02-27\"}")); + + // Handles the start/end times as string inputs in unixtime format + start = DateTimeImpl.from("1772215885694"); + end = DateTimeImpl.from("1772219309359"); + dateTime = DateTimeRange.builder().start(start).end(end).build(); + assertThat(gson.toJson(dateTime), is("{\"start\":1772215885694,\"end\":1772219309359}")); + + // Handles the start/end times as integer inputs in unixtime format + start = DateTimeImpl.from(1772215885694L); + end = DateTimeImpl.from(1772219309359L); + dateTime = DateTimeRange.builder().start(start).end(end).build(); + assertThat(gson.toJson(dateTime), is("{\"start\":1772215885694,\"end\":1772219309359}")); + } + + @Test + public void throwsOnInvalidDateTimeInput() { + assertThrows(IllegalArgumentException.class, () -> DateTimeImpl.from("this just isn't a date")); + } + + @Test + public void gracefullyHandles_malformedJson_whenFailOnUnknownProperties_isFalse() { + // We should return null when the json can't be converted to an actual date time value + String jsonMissingStartAndEnd = "{\"all_day\":false}"; + assertNull(gson.fromJson(jsonMissingStartAndEnd, DateTimeRange.class)); + + // Should add the start/end fields if the json just indicates it's an all-day event + String allDayOnly = "{\"all_day\":true}"; + DateTimeRange dateTime = gson.fromJson(allDayOnly, DateTimeRange.class); + assertThat(dateTime.getAllDay(), is(true)); + assertNotNull(dateTime.getStart()); + assertNotNull(dateTime.getEnd()); + + // We should skip adding the recurrence property if it can't be coerced to a string + String malformedRecurrence = "{\"all_day\":true, \"recurrence\": null}"; + dateTime = gson.fromJson(malformedRecurrence, DateTimeRange.class); + assertThat(dateTime.getAllDay(), is(true)); + assertNotNull(dateTime.getStart()); + assertNotNull(dateTime.getEnd()); + assertNull(dateTime.getRecurrence()); + + // We should use "unknown" start/end values when the input isn't a valid unixtime or date string + String malformedStartEnd = "{\"start\":hello, \"end\": world}"; + dateTime = gson.fromJson(malformedStartEnd, DateTimeRange.class); + assertTrue(dateTime.getStart().isUnknown()); + assertTrue(dateTime.getEnd().isUnknown()); + } + + @Test + public void throws_onInvalidJson_whenFailOnUnknownProperties_isTrue() { + final Gson strictGson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(true); + + // Can't do anything with an empty object + assertThrows(JsonParseException.class, () -> strictGson.fromJson("{}", DateTimeRange.class)); + + // Should throw when the start/end properties are malformed + String badJson = "{\"start\": hello, \"end\": world}"; + assertThrows(JsonParseException.class, () -> strictGson.fromJson(badJson, DateTimeRange.class)); + } +} diff --git a/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java b/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java new file mode 100644 index 000000000..8a11cf728 --- /dev/null +++ b/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java @@ -0,0 +1,87 @@ +package test_locally.api.model.work_objects; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.slack.api.model.work_objects.ExternalUser; +import com.slack.api.model.work_objects.SlackUser; +import com.slack.api.model.work_objects.UnknownUser; +import com.slack.api.model.work_objects.User; +import test_locally.unit.GsonFactory; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.equalToIgnoringCase; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class UserTest { + final Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); + + @Test + public void parseUser_withoutUserType_throwsException() { + String json = "{\"user_id\": \"U1234568\"}"; + assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); + } + + @Test + public void parseUser_withInvalidUserType_throwsException() { + String json = "{\"user_type\": \"test\"}"; + assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); + } + + @Test + public void parseExternalUser_withoutText_throwsException() { + String json = "{\"user_type\": \"external\"}"; + JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, ExternalUser.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'text' failed validation in ExternalUser using predicate IsNotNullFieldPredicate")); + } + + @Test + public void parseSlackUser_withoutUserId_throwsException() { + String json = "{\"user_type\": \"slack\"}"; + JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidUserIdPredicate")); + } + + @Test + public void parseSlackUser_withInvalidUserId_throwsException() { + String json = "{\"user_type\": \"slack\", \"user_id\": \"test\"}"; + JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidUserIdPredicate")); + } + + @Test + public void parseUsers_createsCorrectUserType() { + String slackUserJson = "{\"user_type\": \"slack\", \"user_id\": \"U12345678\"}"; + assertThat(gson.fromJson(slackUserJson, User.class), instanceOf(SlackUser.class)); + + String externalUserJson = "{\"user_type\":\"external\", \"text\": \"test\"}"; + assertThat(gson.fromJson(externalUserJson, User.class), instanceOf(ExternalUser.class)); + } + + @Test + public void parseUsers_withFailurePropertiesOff_returnsUnknownUser() { + Gson lenientGson = GsonFactory.createSnakeCase(false, false, false); + String badJson = "{\"user_type\": \"something we dont know\"}"; + User user = lenientGson.fromJson(badJson, User.class); + assertThat(user.getUserType(), is("something we dont know")); + assertThat(user, instanceOf(UnknownUser.class)); + } + + @Test + public void testUserBuilders() { + User slackUser = SlackUser.builder().userId("U12345678").build(); + User externalUser = ExternalUser.builder().text("test").build(); + assertTrue(slackUser.isSlackUser()); + assertFalse(slackUser.isExternalUser()); + assertTrue(externalUser.isExternalUser()); + assertFalse(externalUser.isSlackUser()); + assertThat(slackUser.getUserType(), is("slack")); + assertThat(externalUser.getUserType(), is("external")); + + assertThrows(IllegalArgumentException.class, () -> SlackUser.builder().userId("invalid!").build()); + } +} diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 9f6591b02..375aea9fb 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -13,6 +13,9 @@ import com.slack.api.model.block.element.RichTextElement; import com.slack.api.model.event.FunctionExecutedEvent; import com.slack.api.model.event.MessageChangedEvent; +import com.slack.api.model.work_objects.DateTimeRange; +import com.slack.api.model.work_objects.PrimaryActions; +import com.slack.api.model.work_objects.User; import com.slack.api.util.json.*; public class GsonFactory { @@ -28,7 +31,11 @@ public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOn } public static Gson createSnakeCaseWithRequiredPropertyDetection() { - return createSnakeCase(false, true, true); + return createSnakeCaseWithRequiredPropertyDetection(false); + } + + public static Gson createSnakeCaseWithRequiredPropertyDetection(boolean failOnUnknownProperties) { + return createSnakeCase(failOnUnknownProperties, true, true); } public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unknownPropertyDetection) { @@ -54,7 +61,10 @@ public static Gson createSnakeCase( .registerTypeAdapter(Attachment.VideoHtml.class, new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProperties)) .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, - new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties)); + new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties)) + .registerTypeAdapter(User.class, new GsonWorkObjectUserFactory(failOnUnknownProperties)) + .registerTypeAdapter(DateTimeRange.class, new GsonWorkObjectDateTimeDeserializer(failOnUnknownProperties)) + .registerTypeAdapter(PrimaryActions.class, new GsonWorkObjectPrimaryActionsFactory(failOnUnknownProperties)); if (unknownPropertyDetection) { builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory());