slackblocks 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +7 -0
- data/LICENSE.BSD-3-Clause +29 -0
- data/README.md +44 -0
- data/lib/slackblocks/components.rb +59 -0
- data/lib/slackblocks/generated/limits.rb +9 -0
- data/lib/slackblocks/generated/models.rb +979 -0
- data/lib/slackblocks/generated/style_flags.rb +6 -0
- data/lib/slackblocks/generated/vocabulary.rb +7 -0
- data/lib/slackblocks/rich_text_style.rb +13 -0
- data/lib/slackblocks/validation_error.rb +18 -0
- data/lib/slackblocks/validator.rb +284 -0
- data/lib/slackblocks/value.rb +274 -0
- data/lib/slackblocks/version.rb +4 -0
- data/lib/slackblocks.rb +11 -0
- data/sig/slackblocks/generated.rbs +1268 -0
- data/sig/slackblocks.rbs +76 -0
- metadata +62 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Generated from spec/vocabulary.json. Do not edit by hand.
|
|
2
|
+
module Slackblocks
|
|
3
|
+
module Vocabulary
|
|
4
|
+
ICON_NAMES = Value.deep_freeze(["archive", "book", "bookmark", "bot", "bug", "calendar", "call", "caret-left", "caret-right", "check", "clipboard", "code", "comment", "compass", "copy", "cube", "download", "edit", "email", "eye-closed", "eye-open", "file", "flag", "folder", "gear", "globe", "heart", "help", "image", "info", "key", "lightbulb", "link", "map", "mobile", "new-window", "pin", "plus", "refine", "refresh", "rocket", "save", "screen", "share", "sparkle", "star", "star-filled", "tag", "thumbs-down", "thumbs-up", "trash", "upload", "user", "warning"])
|
|
5
|
+
SURFACE_BLOCK_TYPES = Value.deep_freeze({"home" => ["actions", "card", "carousel", "container", "context", "data_table", "data_visualization", "divider", "header", "image", "input", "rich_text", "section", "table", "video"], "message" => ["actions", "card", "carousel", "container", "context", "context_actions", "data_table", "data_visualization", "divider", "file", "header", "image", "input", "markdown", "plan", "rich_text", "section", "table", "task_card", "video"], "modal" => ["actions", "alert", "card", "context", "divider", "header", "image", "input", "rich_text", "section", "video"]})
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Slackblocks
|
|
2
|
+
class RichTextStyle
|
|
3
|
+
include Value
|
|
4
|
+
|
|
5
|
+
attr_reader(*StyleFlags::NAMES.map(&:to_sym), :additional_fields)
|
|
6
|
+
end
|
|
7
|
+
RichTextStyle.const_set(:WIRE_TYPE, "")
|
|
8
|
+
RichTextStyle.const_set(:FIELD_SPECS, Value.deep_freeze(StyleFlags::NAMES.map do |name|
|
|
9
|
+
{"name" => name, "wire" => name, "kind" => "boolean", "required" => false}
|
|
10
|
+
end))
|
|
11
|
+
RichTextStyle.const_set(:DEFAULTS, Value.deep_freeze({}))
|
|
12
|
+
RichTextStyle.singleton_class.prepend(KeywordOnly)
|
|
13
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Slackblocks
|
|
2
|
+
class ValidationError < StandardError
|
|
3
|
+
CATEGORIES = %w[
|
|
4
|
+
length-exceeded out-of-range mutually-exclusive
|
|
5
|
+
type-mismatch missing-required invalid-usage
|
|
6
|
+
].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :category, :path
|
|
9
|
+
|
|
10
|
+
def initialize(category, path, message)
|
|
11
|
+
raise ArgumentError, "unknown validation category: #{category}" unless CATEGORIES.include?(category)
|
|
12
|
+
|
|
13
|
+
@category = category
|
|
14
|
+
@path = path
|
|
15
|
+
super("#{path}: #{message}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
module Slackblocks
|
|
2
|
+
module Validator
|
|
3
|
+
def self.validate(klass, values)
|
|
4
|
+
path = klass.name.split("::").last
|
|
5
|
+
wire = Value.wire(klass, values)
|
|
6
|
+
check_limits(wire["text"], "text", "#{path}.text") if klass == PlainText || klass == MarkdownText
|
|
7
|
+
klass::FIELD_SPECS.each do |field|
|
|
8
|
+
next if values[field.fetch("name").to_sym].nil?
|
|
9
|
+
|
|
10
|
+
check_limits(wire[field.fetch("wire")], field["limits"], "#{path}.#{field.fetch("wire")}")
|
|
11
|
+
end
|
|
12
|
+
special(klass, wire, path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.limit(key)
|
|
16
|
+
Limits::VALUES.fetch(key)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.fail!(category, path, message)
|
|
20
|
+
Value.fail!(category, path, message)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.string_limit(value, key, path)
|
|
24
|
+
return unless value
|
|
25
|
+
|
|
26
|
+
fail!("length-exceeded", path, "exceeds #{key}") if value.length > limit(key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.text_limit(value, key, path)
|
|
30
|
+
return unless value
|
|
31
|
+
|
|
32
|
+
string_limit(value.fetch("text"), key, path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.max_items(items, key, path)
|
|
36
|
+
return unless items
|
|
37
|
+
|
|
38
|
+
fail!("length-exceeded", path, "exceeds #{key}") if items.length > limit(key)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.special(klass, wire, path)
|
|
42
|
+
case klass.name.split("::").last
|
|
43
|
+
when "SlackIcon"
|
|
44
|
+
unless Vocabulary::ICON_NAMES.include?(wire["name"])
|
|
45
|
+
fail!("type-mismatch", "#{path}.name", "unknown Slack icon")
|
|
46
|
+
end
|
|
47
|
+
when "Attachment"
|
|
48
|
+
if (color = wire["color"]) && !%w[good warning danger].include?(color) &&
|
|
49
|
+
!color.match?(/\A#[0-9a-fA-F]{6}\z/)
|
|
50
|
+
fail!("type-mismatch", "#{path}.color", "expected a six-digit hex color or alias")
|
|
51
|
+
end
|
|
52
|
+
when "SectionBlock"
|
|
53
|
+
fields = wire["fields"] || []
|
|
54
|
+
fail!("missing-required", path, "expected text or fields") if !wire.key?("text") && fields.empty?
|
|
55
|
+
fail!("missing-required", "#{path}.fields", "fields cannot be empty") if wire.key?("fields") && fields.empty?
|
|
56
|
+
fields.each_with_index do |field, index|
|
|
57
|
+
text_limit(field, "section.fields.item_max_length", "#{path}.fields[#{index}].text")
|
|
58
|
+
end
|
|
59
|
+
when "StaticSelectElement", "MultiStaticSelectElement"
|
|
60
|
+
if wire.key?("options") && wire.key?("option_groups")
|
|
61
|
+
fail!("mutually-exclusive", path, "options and option_groups cannot both be present")
|
|
62
|
+
end
|
|
63
|
+
when "CardBlock"
|
|
64
|
+
if !%w[hero_image title actions body].any? { |key| wire.key?(key) }
|
|
65
|
+
fail!("missing-required", path, "card needs content")
|
|
66
|
+
end
|
|
67
|
+
if wire.key?("icon") && wire.key?("slack_icon")
|
|
68
|
+
fail!("mutually-exclusive", path, "icon and slack_icon cannot both be present")
|
|
69
|
+
end
|
|
70
|
+
when "ContainerBlock"
|
|
71
|
+
unless wire.key?("title") || wire.key?("rich_text_title")
|
|
72
|
+
fail!("missing-required", path, "expected title")
|
|
73
|
+
end
|
|
74
|
+
if wire["default_collapsed"] && !wire["is_collapsible"]
|
|
75
|
+
fail!("invalid-usage", "#{path}.default_collapsed", "requires is_collapsible")
|
|
76
|
+
end
|
|
77
|
+
if wire["has_header_divider"] && wire["is_collapsible"]
|
|
78
|
+
fail!("invalid-usage", "#{path}.has_header_divider", "requires non-collapsible container")
|
|
79
|
+
end
|
|
80
|
+
when "ImageBlock", "ImageElement"
|
|
81
|
+
has_url, has_file = wire.key?("image_url"), wire.key?("slack_file")
|
|
82
|
+
fail!("missing-required", path, "expected an image source") unless has_url || has_file
|
|
83
|
+
fail!("mutually-exclusive", path, "expected one image source") if has_url && has_file
|
|
84
|
+
when "SlackFile"
|
|
85
|
+
if wire.key?("id") == wire.key?("url")
|
|
86
|
+
fail!("mutually-exclusive", path, "expected exactly one of id or url")
|
|
87
|
+
end
|
|
88
|
+
if wire["id"] && !wire["id"].match?(/\AF[A-Z0-9]{8,}\z/)
|
|
89
|
+
fail!("type-mismatch", "#{path}.id", "invalid Slack file ID")
|
|
90
|
+
end
|
|
91
|
+
when "ConversationFilter"
|
|
92
|
+
if wire.empty?
|
|
93
|
+
fail!("missing-required", path, "expected at least one filter field")
|
|
94
|
+
end
|
|
95
|
+
(wire["include"] || []).each_with_index do |item, index|
|
|
96
|
+
unless %w[im mpim private public].include?(item)
|
|
97
|
+
fail!("type-mismatch", "#{path}.include[#{index}]", "unknown conversation type")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
when "DispatchActionConfiguration"
|
|
101
|
+
fail!("missing-required", "#{path}.trigger_actions_on", "required") unless wire.key?("trigger_actions_on")
|
|
102
|
+
when "NumberInputElement"
|
|
103
|
+
if wire["min_value"] && wire["max_value"] && wire["min_value"] > wire["max_value"]
|
|
104
|
+
fail!("out-of-range", path, "min_value exceeds max_value")
|
|
105
|
+
end
|
|
106
|
+
when "DataTableBlock", "TableBlock"
|
|
107
|
+
table(wire, path, klass == DataTableBlock)
|
|
108
|
+
when "AxisConfig"
|
|
109
|
+
(wire["categories"] || []).each_with_index do |category, index|
|
|
110
|
+
string_limit(category, "data_visualization.category_label.max_length", "#{path}.categories[#{index}]")
|
|
111
|
+
end
|
|
112
|
+
if wire["categories"] && wire["categories"].uniq.length != wire["categories"].length
|
|
113
|
+
fail!("invalid-usage", "#{path}.categories", "duplicate category")
|
|
114
|
+
end
|
|
115
|
+
when "ChartSegment"
|
|
116
|
+
if wire["value"] <= limit("data_visualization.segment.value.exclusive_min")
|
|
117
|
+
fail!("out-of-range", "#{path}.value", "expected positive value")
|
|
118
|
+
end
|
|
119
|
+
when "LineChart", "BarChart", "AreaChart"
|
|
120
|
+
chart(wire, path)
|
|
121
|
+
when "PlanBlock"
|
|
122
|
+
ids = wire.fetch("tasks").map { |task| task["task_id"] }
|
|
123
|
+
fail!("invalid-usage", "#{path}.tasks", "duplicate task ID") if ids.uniq.length != ids.length
|
|
124
|
+
when "MessagePayload", "MessageResponse", "WebhookMessage"
|
|
125
|
+
message(wire, path)
|
|
126
|
+
when "ModalView", "HomeTabView"
|
|
127
|
+
surface(wire.fetch("blocks"), (klass == ModalView) ? "modal" : "home", "#{path}.blocks")
|
|
128
|
+
if klass == ModalView && !wire.key?("submit") && wire.fetch("blocks").any? { |block| block["type"] == "input" }
|
|
129
|
+
fail!("missing-required", "#{path}.submit", "required when modal contains an input")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.surface(blocks, name, path)
|
|
135
|
+
allowed = Vocabulary::SURFACE_BLOCK_TYPES.fetch(name)
|
|
136
|
+
blocks.each_with_index do |block, index|
|
|
137
|
+
type = block["type"]
|
|
138
|
+
fail!("type-mismatch", "#{path}[#{index}].type", "unsupported #{name} block") unless allowed.include?(type)
|
|
139
|
+
standalone_task(block, "#{path}[#{index}]")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.standalone_task(value, path)
|
|
144
|
+
case value
|
|
145
|
+
when Hash
|
|
146
|
+
if value["type"] == "task_card" && value["status"] == "pending"
|
|
147
|
+
fail!("type-mismatch", "#{path}.status", "pending is plan-only")
|
|
148
|
+
end
|
|
149
|
+
value.each do |key, nested|
|
|
150
|
+
next if key == "tasks" && value["type"] == "plan"
|
|
151
|
+
|
|
152
|
+
standalone_task(nested, "#{path}.#{key}")
|
|
153
|
+
end
|
|
154
|
+
when Array
|
|
155
|
+
value.each_with_index { |item, index| standalone_task(item, "#{path}[#{index}]") }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.message(wire, path)
|
|
160
|
+
surface(wire["blocks"] || [], "message", "#{path}.blocks")
|
|
161
|
+
totals = {"markdown" => 0, "data_table" => 0}
|
|
162
|
+
tally(wire, totals)
|
|
163
|
+
if totals["markdown"] > limit("markdown.total_text.max_length")
|
|
164
|
+
fail!("length-exceeded", path, "message markdown total exceeded")
|
|
165
|
+
end
|
|
166
|
+
if totals["data_table"] > limit("data_table.total_content.max_length")
|
|
167
|
+
fail!("length-exceeded", path, "message data table total exceeded")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.tally(node, totals)
|
|
172
|
+
case node
|
|
173
|
+
when Hash
|
|
174
|
+
if node["type"] == "markdown"
|
|
175
|
+
totals["markdown"] += node.fetch("text").length
|
|
176
|
+
elsif node["type"] == "data_table"
|
|
177
|
+
totals["data_table"] += characters(node.fetch("rows"))
|
|
178
|
+
else
|
|
179
|
+
node.each_value { |child| tally(child, totals) }
|
|
180
|
+
end
|
|
181
|
+
when Array
|
|
182
|
+
node.each { |child| tally(child, totals) }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.characters(node)
|
|
187
|
+
case node
|
|
188
|
+
when Array
|
|
189
|
+
node.sum { |child| characters(child) }
|
|
190
|
+
when Hash
|
|
191
|
+
node.sum do |key, child|
|
|
192
|
+
(key == "text" && child.is_a?(String)) ? child.length : characters(child)
|
|
193
|
+
end
|
|
194
|
+
else
|
|
195
|
+
0
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.table(wire, path, data_table)
|
|
200
|
+
rows = wire.fetch("rows")
|
|
201
|
+
maximum = data_table ? "data_table.columns.max_items" : "table.columns.max_items"
|
|
202
|
+
rows.each_with_index do |row, index|
|
|
203
|
+
row_path = "#{path}.rows[#{index}]"
|
|
204
|
+
max_items(row, maximum, row_path)
|
|
205
|
+
if data_table
|
|
206
|
+
fail!("length-exceeded", row_path, "row cannot be empty") if row.empty?
|
|
207
|
+
if row.length != rows.first.length
|
|
208
|
+
fail!("invalid-usage", row_path, "data table rows must be rectangular")
|
|
209
|
+
end
|
|
210
|
+
row.each_with_index do |cell, column|
|
|
211
|
+
cell_path = "#{row_path}[#{column}]"
|
|
212
|
+
if index.zero? && cell["type"] == "rich_text"
|
|
213
|
+
fail!("type-mismatch", cell_path, "header must be raw")
|
|
214
|
+
end
|
|
215
|
+
if %w[raw_text raw_number].include?(cell["type"]) && cell.fetch("text").empty?
|
|
216
|
+
fail!("length-exceeded", "#{cell_path}.text", "empty cell")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
if data_table
|
|
222
|
+
fail!("invalid-usage", "#{path}.column_settings", "unsupported") if wire.key?("column_settings")
|
|
223
|
+
if characters(rows) > limit("data_table.content.max_length")
|
|
224
|
+
fail!("length-exceeded", "#{path}.rows", "table content exceeded")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def self.chart(wire, path)
|
|
230
|
+
axis = wire.fetch("axis_config").fetch("categories")
|
|
231
|
+
# @type var names: Array[String]
|
|
232
|
+
names = []
|
|
233
|
+
wire.fetch("series").each_with_index do |series, index|
|
|
234
|
+
item_path = "#{path}.series[#{index}]"
|
|
235
|
+
names << series["name"]
|
|
236
|
+
labels = series.fetch("data").map { |point| point["label"] }
|
|
237
|
+
unless labels.length == axis.length && labels.sort == axis.sort
|
|
238
|
+
fail!("invalid-usage", "#{item_path}.data", "points must cover every category exactly once")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
fail!("invalid-usage", "#{path}.series", "duplicate series name") if names.uniq.length != names.length
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def self.check_limits(value, prefix, path)
|
|
245
|
+
return unless prefix
|
|
246
|
+
|
|
247
|
+
limits = Limits::VALUES
|
|
248
|
+
length = if value.is_a?(String)
|
|
249
|
+
value.length
|
|
250
|
+
elsif value.is_a?(Array)
|
|
251
|
+
value.length
|
|
252
|
+
elsif value.is_a?(Hash) && value["text"].is_a?(String)
|
|
253
|
+
value["text"].length
|
|
254
|
+
end
|
|
255
|
+
suffixes = value.is_a?(Array) ? %w[min_items max_items] : %w[min_length max_length]
|
|
256
|
+
if length
|
|
257
|
+
suffixes.each do |suffix|
|
|
258
|
+
bound = limits["#{prefix}.#{suffix}"]
|
|
259
|
+
next unless bound
|
|
260
|
+
|
|
261
|
+
if suffix.start_with?("min") && length < bound
|
|
262
|
+
fail!("length-exceeded", path, "expected at least #{bound}")
|
|
263
|
+
elsif suffix.start_with?("max") && length > bound
|
|
264
|
+
fail!("length-exceeded", path, "expected at most #{bound}")
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
if value.is_a?(Numeric)
|
|
269
|
+
%w[min max exclusive_min exclusive_max].each do |suffix|
|
|
270
|
+
bound = limits["#{prefix}.#{suffix}"]
|
|
271
|
+
next unless bound
|
|
272
|
+
|
|
273
|
+
invalid = case suffix
|
|
274
|
+
when "min" then value < bound
|
|
275
|
+
when "max" then value > bound
|
|
276
|
+
when "exclusive_min" then value <= bound
|
|
277
|
+
when "exclusive_max" then value >= bound
|
|
278
|
+
end
|
|
279
|
+
fail!("out-of-range", path, "outside #{suffix} #{bound}") if invalid
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Slackblocks
|
|
4
|
+
module KeywordOnly
|
|
5
|
+
def new(*args, **keywords)
|
|
6
|
+
raise ArgumentError, "use keyword arguments" unless args.empty?
|
|
7
|
+
|
|
8
|
+
super(**keywords)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def [](*args, **keywords)
|
|
12
|
+
new(*args, **keywords)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Value
|
|
17
|
+
def self.deep_freeze(value)
|
|
18
|
+
case value
|
|
19
|
+
when String
|
|
20
|
+
value.freeze
|
|
21
|
+
when Array
|
|
22
|
+
value.each { |item| deep_freeze(item) }
|
|
23
|
+
value.freeze
|
|
24
|
+
when Hash
|
|
25
|
+
value.each { |key, item|
|
|
26
|
+
deep_freeze(key)
|
|
27
|
+
deep_freeze(item)
|
|
28
|
+
}
|
|
29
|
+
value.freeze
|
|
30
|
+
else
|
|
31
|
+
value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.fail!(category, path, message)
|
|
36
|
+
raise ValidationError.new(category, path, message)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.string(value, path)
|
|
40
|
+
fail!("type-mismatch", path, "expected a String") unless value.is_a?(String)
|
|
41
|
+
fail!("type-mismatch", path, "invalid string encoding") unless value.valid_encoding?
|
|
42
|
+
|
|
43
|
+
value.encode(Encoding::UTF_8).freeze
|
|
44
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
45
|
+
fail!("type-mismatch", path, "cannot transcode to UTF-8")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.json_value(value, path, seen = {})
|
|
49
|
+
case value
|
|
50
|
+
when nil, true, false, Integer
|
|
51
|
+
value
|
|
52
|
+
when String
|
|
53
|
+
string(value, path)
|
|
54
|
+
when Float
|
|
55
|
+
fail!("out-of-range", path, "number must be finite") unless value.finite?
|
|
56
|
+
|
|
57
|
+
value
|
|
58
|
+
when Value
|
|
59
|
+
value
|
|
60
|
+
when Array
|
|
61
|
+
fail!("type-mismatch", path, "cyclic array") if seen[value.object_id]
|
|
62
|
+
|
|
63
|
+
seen[value.object_id] = true
|
|
64
|
+
copy = value.each_with_index.map { |item, index| json_value(item, "#{path}[#{index}]", seen) }.freeze
|
|
65
|
+
seen.delete(value.object_id)
|
|
66
|
+
copy
|
|
67
|
+
when Hash
|
|
68
|
+
fail!("type-mismatch", path, "cyclic map") if seen[value.object_id]
|
|
69
|
+
|
|
70
|
+
seen[value.object_id] = true
|
|
71
|
+
# @type var copy: Hash[String, untyped]
|
|
72
|
+
copy = {}
|
|
73
|
+
value.each do |key, item|
|
|
74
|
+
fail!("type-mismatch", path, "map key must be a string or symbol") unless key.is_a?(String) || key.is_a?(Symbol)
|
|
75
|
+
|
|
76
|
+
normalized = string(key.to_s, path)
|
|
77
|
+
fail!("invalid-usage", "#{path}.#{normalized}", "duplicate key") if copy.key?(normalized)
|
|
78
|
+
|
|
79
|
+
copy[normalized] = json_value(item, "#{path}.#{normalized}", seen)
|
|
80
|
+
end
|
|
81
|
+
seen.delete(value.object_id)
|
|
82
|
+
copy.freeze
|
|
83
|
+
else
|
|
84
|
+
fail!("type-mismatch", path, "value cannot be represented as JSON")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.normalize_field(field, value, path)
|
|
89
|
+
kind = field.fetch("kind")
|
|
90
|
+
target = field["target"] && Slackblocks.const_get(field["target"], false)
|
|
91
|
+
case kind
|
|
92
|
+
when "string"
|
|
93
|
+
string(value, path)
|
|
94
|
+
when "boolean"
|
|
95
|
+
fail!("type-mismatch", path, "expected a boolean") unless value == true || value == false
|
|
96
|
+
|
|
97
|
+
value
|
|
98
|
+
when "int", "long"
|
|
99
|
+
fail!("type-mismatch", path, "expected an Integer") unless value.is_a?(Integer)
|
|
100
|
+
|
|
101
|
+
value
|
|
102
|
+
when "double", "number"
|
|
103
|
+
fail!("type-mismatch", path, "expected an Integer or Float") unless value.is_a?(Integer) || value.instance_of?(Float)
|
|
104
|
+
fail!("out-of-range", path, "number must be finite") if value.is_a?(Float) && !value.finite?
|
|
105
|
+
|
|
106
|
+
value
|
|
107
|
+
when "enum"
|
|
108
|
+
allowed = target.constants(false).map { |name| target.const_get(name, false) }
|
|
109
|
+
fail!("type-mismatch", path, "unknown enum value") unless value.is_a?(Symbol) && allowed.include?(value)
|
|
110
|
+
|
|
111
|
+
value
|
|
112
|
+
when "map"
|
|
113
|
+
fail!("type-mismatch", path, "expected a Hash") unless value.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
json_value(value, path)
|
|
116
|
+
when "style"
|
|
117
|
+
fail!("type-mismatch", path, "expected RichTextStyle") unless value.is_a?(RichTextStyle)
|
|
118
|
+
|
|
119
|
+
used = value.to_h.select { |name, flag| name != :additional_fields && !flag.nil? }.keys.map(&:to_s) + value.additional_fields.keys
|
|
120
|
+
fail!("type-mismatch", path, "unsupported style flag") unless (used - field.fetch("flags")).empty?
|
|
121
|
+
|
|
122
|
+
value
|
|
123
|
+
when "object"
|
|
124
|
+
fail!("type-mismatch", path, "expected #{target}") unless value.is_a?(target)
|
|
125
|
+
|
|
126
|
+
value
|
|
127
|
+
when "text"
|
|
128
|
+
return Slackblocks.const_get((field["coerce"] == "plain_text") ? "PlainText" : "MarkdownText", false).new(text: value) if value.is_a?(String)
|
|
129
|
+
|
|
130
|
+
fail!("type-mismatch", path, "expected text") unless value.is_a?(target)
|
|
131
|
+
|
|
132
|
+
value
|
|
133
|
+
when "list", "rows", "textList", "stringList"
|
|
134
|
+
fail!("type-mismatch", path, "expected an Array") unless value.is_a?(Array)
|
|
135
|
+
|
|
136
|
+
value.each_with_index.map do |item, index|
|
|
137
|
+
item_path = "#{path}[#{index}]"
|
|
138
|
+
if kind == "rows"
|
|
139
|
+
fail!("type-mismatch", item_path, "expected a row Array") unless item.is_a?(Array)
|
|
140
|
+
|
|
141
|
+
item.each_with_index.map do |cell, column|
|
|
142
|
+
fail!("type-mismatch", "#{item_path}[#{column}]", "wrong cell type") unless cell.is_a?(target)
|
|
143
|
+
|
|
144
|
+
cell
|
|
145
|
+
end.freeze
|
|
146
|
+
elsif kind == "textList"
|
|
147
|
+
normalize_field({"kind" => "text", "target" => field["target"], "coerce" => field["coerce"]}, item, item_path)
|
|
148
|
+
elsif kind == "stringList"
|
|
149
|
+
string(item, item_path)
|
|
150
|
+
else
|
|
151
|
+
fail!("type-mismatch", item_path, "wrong item type") unless item.is_a?(target)
|
|
152
|
+
|
|
153
|
+
item
|
|
154
|
+
end
|
|
155
|
+
end.freeze
|
|
156
|
+
else
|
|
157
|
+
raise "unhandled model field kind: #{kind}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.construct(klass, keywords)
|
|
162
|
+
fields = klass::FIELD_SPECS
|
|
163
|
+
names = fields.map { |field| field.fetch("name").to_sym }
|
|
164
|
+
unknown = keywords.keys - names - [:additional_fields]
|
|
165
|
+
raise ArgumentError, "unknown keywords: #{unknown.inspect}" unless unknown.empty?
|
|
166
|
+
|
|
167
|
+
# @type var values: Hash[Symbol, untyped]
|
|
168
|
+
values = {}
|
|
169
|
+
fields.each do |field|
|
|
170
|
+
name = field.fetch("name")
|
|
171
|
+
symbol = name.to_sym
|
|
172
|
+
path = "#{klass.name.split("::").last}.#{field.fetch("wire")}"
|
|
173
|
+
value = if keywords.key?(symbol)
|
|
174
|
+
keywords[symbol]
|
|
175
|
+
else
|
|
176
|
+
default = klass::DEFAULTS.fetch(name, nil)
|
|
177
|
+
(field["kind"] == "enum" && default) ? default.to_sym : default
|
|
178
|
+
end
|
|
179
|
+
if value.nil?
|
|
180
|
+
fail!("missing-required", path, "required field is missing") if field["required"]
|
|
181
|
+
values[symbol] = nil
|
|
182
|
+
else
|
|
183
|
+
values[symbol] = normalize_field(field, value, path)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
raw_extra = keywords.fetch(:additional_fields, nil)
|
|
187
|
+
if raw_extra.nil?
|
|
188
|
+
values[:additional_fields] = deep_freeze({})
|
|
189
|
+
else
|
|
190
|
+
fail!("type-mismatch", "#{klass.name}.additional_fields", "expected a Hash") unless raw_extra.is_a?(Hash)
|
|
191
|
+
|
|
192
|
+
normalized = json_value(raw_extra, "#{klass.name}.additional_fields")
|
|
193
|
+
reserved = fields.map { |field| field.fetch("wire") } + ["type"]
|
|
194
|
+
normalized.each_key do |key|
|
|
195
|
+
fail!("invalid-usage", "#{klass.name}.#{key}", "field is reserved") if key.empty? || reserved.include?(key)
|
|
196
|
+
end
|
|
197
|
+
values[:additional_fields] = normalized
|
|
198
|
+
end
|
|
199
|
+
Validator.validate(klass, values)
|
|
200
|
+
values
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def self.detach(value)
|
|
204
|
+
case value
|
|
205
|
+
when Value
|
|
206
|
+
value.as_json
|
|
207
|
+
when Array
|
|
208
|
+
value.map { |item| detach(item) }
|
|
209
|
+
when Hash
|
|
210
|
+
value.to_h { |key, item| [key.dup, detach(item)] }
|
|
211
|
+
when String
|
|
212
|
+
value.dup
|
|
213
|
+
when Symbol
|
|
214
|
+
value.to_s
|
|
215
|
+
else
|
|
216
|
+
value
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def self.wire(klass, values)
|
|
221
|
+
# @type var wire: Hash[String, untyped]
|
|
222
|
+
wire = {}
|
|
223
|
+
wire["type"] = klass::WIRE_TYPE.dup unless klass::WIRE_TYPE.empty?
|
|
224
|
+
klass::FIELD_SPECS.each do |field|
|
|
225
|
+
value = values[field.fetch("name").to_sym]
|
|
226
|
+
wire[field.fetch("wire").dup] = detach(value) unless value.nil?
|
|
227
|
+
end
|
|
228
|
+
values.fetch(:additional_fields).each { |key, value| wire[key.dup] = detach(value) }
|
|
229
|
+
if klass.name == "Slackblocks::PlanBlock" && wire.key?("tasks")
|
|
230
|
+
wire["tasks"].each { |task| task.delete("type") }
|
|
231
|
+
elsif klass.name == "Slackblocks::Attachment" && wire["color"].is_a?(String)
|
|
232
|
+
color = wire["color"]
|
|
233
|
+
wire["color"] = "##{color}" if color.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
234
|
+
end
|
|
235
|
+
wire
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def initialize(**keywords)
|
|
239
|
+
Value.construct(self.class, keywords).each do |name, value|
|
|
240
|
+
instance_variable_set("@#{name}", value)
|
|
241
|
+
end
|
|
242
|
+
freeze
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def to_h
|
|
246
|
+
self.class.const_get(:FIELD_SPECS).to_h do |field|
|
|
247
|
+
name = field.fetch("name").to_sym
|
|
248
|
+
[name, public_send(name)]
|
|
249
|
+
end.merge(additional_fields: additional_fields)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def with(**changes)
|
|
253
|
+
changes.empty? ? self : self.class.public_send(:new, **to_h.merge(changes))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def ==(other)
|
|
257
|
+
other.instance_of?(self.class) && to_h == other.to_h
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
alias_method :eql?, :==
|
|
261
|
+
|
|
262
|
+
def hash
|
|
263
|
+
[self.class, to_h].hash
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def as_json(_options = nil)
|
|
267
|
+
Value.wire(self.class, to_h)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def to_json(options = nil)
|
|
271
|
+
options.nil? ? JSON.generate(as_json) : JSON.generate(as_json, options)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
data/lib/slackblocks.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "slackblocks/version"
|
|
3
|
+
require_relative "slackblocks/validation_error"
|
|
4
|
+
require_relative "slackblocks/value"
|
|
5
|
+
require_relative "slackblocks/validator"
|
|
6
|
+
require_relative "slackblocks/generated/style_flags"
|
|
7
|
+
require_relative "slackblocks/rich_text_style"
|
|
8
|
+
require_relative "slackblocks/generated/limits"
|
|
9
|
+
require_relative "slackblocks/generated/vocabulary"
|
|
10
|
+
require_relative "slackblocks/generated/models"
|
|
11
|
+
require_relative "slackblocks/components"
|