calmlens 0.19.0 → 0.21.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 +4 -4
- data/README.md +3 -1
- data/lib/calmlens/assets.rb +110 -100
- data/lib/calmlens/cdn.rb +197 -41
- data/lib/calmlens/error_codes.rb +80 -84
- data/lib/calmlens/errors.rb +2 -2
- data/lib/calmlens/events.rb +50 -1
- data/lib/calmlens/folders.rb +14 -9
- data/lib/calmlens/hash_lists.rb +29 -9
- data/lib/calmlens/internal.rb +19 -19
- data/lib/calmlens/lenses.rb +80 -6
- data/lib/calmlens/option_keys.rb +64 -20
- data/lib/calmlens/policies.rb +50 -18
- data/lib/calmlens/resource.rb +29 -5
- data/lib/calmlens/server_client.rb +8 -6
- data/lib/calmlens/session.rb +29 -12
- data/lib/calmlens/testing/asset_filters.rb +39 -0
- data/lib/calmlens/testing/classification.rb +8 -2
- data/lib/calmlens/testing/fake_api.rb +722 -348
- data/lib/calmlens/testing/mock_decision.rb +5 -4
- data/lib/calmlens/testing/rsql.rb +245 -0
- data/lib/calmlens/testing/test_input_detections.rb +61 -0
- data/lib/calmlens/testing/test_input_scenario.rb +6 -2
- data/lib/calmlens/testing/test_inputs.rb +33 -11
- data/lib/calmlens/testing.rb +40 -13
- data/lib/calmlens/transform_rules.rb +120 -0
- data/lib/calmlens/transform_schema.rb +236 -0
- data/lib/calmlens/transforms.rb +16 -0
- data/lib/calmlens/upload_mime_types.rb +51 -0
- data/lib/calmlens/upload_tokens.rb +3 -3
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens/webhooks.rb +58 -13
- data/lib/calmlens.rb +3 -0
- data/sig/calmlens.rbs +89 -14
- data/sig/error_codes.rbs +18 -20
- data/sig/qualifiers.rbs +2 -0
- data/sig/types.rbs +326 -185
- metadata +8 -2
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# The CDN's rules for transforms: options checked against the image transform schema
|
|
5
|
+
# (Internal::TRANSFORM_OPTIONS_SCHEMA), and selectors read as the CDN reads them.
|
|
6
|
+
# @api private
|
|
7
|
+
module TransformRules
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Checks transform options against the image transform schema, as the CDN does, and returns
|
|
11
|
+
# the dotted keys it doesn't know and those whose values it refuses (or that are required and
|
|
12
|
+
# missing). nil options are left out of a selector, so they are not checked.
|
|
13
|
+
# @return [Array(Array<String>, Array<String>)] the unknown keys and the invalid keys
|
|
14
|
+
def check(options)
|
|
15
|
+
unknown = []
|
|
16
|
+
invalid = []
|
|
17
|
+
check_rule(Internal::TRANSFORM_OPTIONS_SCHEMA, options, '', unknown, invalid)
|
|
18
|
+
[unknown, invalid]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def check_rule(schema, value, key, unknown, invalid)
|
|
22
|
+
rule = schema.key?('oneOf') ? schema['oneOf'].find { |branch| rule_type?(branch['type'], value) } : schema
|
|
23
|
+
return invalid << key unless rule && rule_allows?(rule, value)
|
|
24
|
+
return unless rule['type'] == 'object'
|
|
25
|
+
|
|
26
|
+
path = ->(name) { key.empty? ? name : "#{key}.#{name}" }
|
|
27
|
+
entries = value.to_h { |name, item| [name.to_s, item] }
|
|
28
|
+
rule.fetch('required', []).each { |name| invalid << path.call(name) if entries[name].nil? }
|
|
29
|
+
entries.keys.sort.each do |name|
|
|
30
|
+
next if entries[name].nil?
|
|
31
|
+
|
|
32
|
+
child = rule.fetch('properties', {})[name]
|
|
33
|
+
child ? check_rule(child, entries[name], path.call(name), unknown, invalid) : unknown << path.call(name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rule_allows?(rule, value)
|
|
38
|
+
rule_type?(rule['type'], value) &&
|
|
39
|
+
(!rule.key?('enum') || rule['enum'].include?(value)) &&
|
|
40
|
+
(!rule.key?('minimum') || value >= rule['minimum']) &&
|
|
41
|
+
(!rule.key?('maximum') || value <= rule['maximum'])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def rule_type?(type, value)
|
|
45
|
+
case type
|
|
46
|
+
when 'object' then value.is_a?(Hash)
|
|
47
|
+
when 'string' then value.is_a?(String)
|
|
48
|
+
when 'boolean' then [true, false].include?(value)
|
|
49
|
+
when 'number' then value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)
|
|
50
|
+
when 'integer' then value.is_a?(Integer) || (value.is_a?(Float) && value.finite? && value == value.floor)
|
|
51
|
+
else true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
NUMBER = /\A-?(?:0|[1-9]\d*)(?:\.\d+)?\z/
|
|
56
|
+
LENS = /\A[a-z0-9-]{1,50}\z/
|
|
57
|
+
|
|
58
|
+
# Reads a CDN selector, the path text after the resource as sent (still percent-encoded), as the
|
|
59
|
+
# CDN does: an optional first `@lens`, then `key=value` options with dotted keys for nested ones (a
|
|
60
|
+
# bare key is `true`). Values are numbers, `true`, `false`, hex colours (`#` added) for colour
|
|
61
|
+
# options, or text. Raises ArgumentError on anything the CDN refuses as `transform/invalid`.
|
|
62
|
+
# Used by the mock CDN.
|
|
63
|
+
# @return [Array(String, Hash)] the lens and the options, each nil when absent
|
|
64
|
+
def parse_selector(selector)
|
|
65
|
+
return [nil, nil] if selector.empty?
|
|
66
|
+
raise ArgumentError, "Invalid transform selector #{selector.inspect}" if selector.include?('/')
|
|
67
|
+
|
|
68
|
+
entries = selector.split(',', -1)
|
|
69
|
+
lens = nil
|
|
70
|
+
if entries.first.start_with?('@')
|
|
71
|
+
lens = selector_text(entries.shift.delete_prefix('@'), selector)
|
|
72
|
+
raise ArgumentError, "Invalid transform selector #{selector.inspect}" unless lens.match?(LENS)
|
|
73
|
+
end
|
|
74
|
+
return [lens, nil] if entries.empty?
|
|
75
|
+
|
|
76
|
+
options = {}
|
|
77
|
+
entries.each { |entry| add_selector_entry(options, entry, selector) }
|
|
78
|
+
[lens, options]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def add_selector_entry(options, entry, selector)
|
|
82
|
+
fail = -> { ArgumentError.new("Invalid transform selector #{selector.inspect}") }
|
|
83
|
+
raise fail.call if entry.empty? || entry.start_with?('@')
|
|
84
|
+
|
|
85
|
+
key, separator, text = entry.partition('=')
|
|
86
|
+
parts = key.split('.', -1)
|
|
87
|
+
raise fail.call unless parts.all? { |part| part.match?(Transforms::OPTION_NAME) }
|
|
88
|
+
|
|
89
|
+
value = separator.empty? || selector_value(key, selector_text(text, selector))
|
|
90
|
+
raise fail.call if value == ''
|
|
91
|
+
|
|
92
|
+
target = parts[0..-2].reduce(options) do |current, part|
|
|
93
|
+
raise fail.call unless current[part].nil? || current[part].is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
current[part] ||= {}
|
|
96
|
+
end
|
|
97
|
+
raise fail.call if target.key?(parts.last)
|
|
98
|
+
|
|
99
|
+
target[parts.last] = value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def selector_value(key, text)
|
|
103
|
+
return text if text.empty?
|
|
104
|
+
return "##{text.downcase}" if Transforms::COLOUR_KEYS.include?(key) && Transforms::HEX_COLOUR.match?("##{text}")
|
|
105
|
+
return text.include?('.') ? Float(text) : Integer(text, 10) if text.match?(NUMBER)
|
|
106
|
+
return text == 'true' if %w[true false].include?(text)
|
|
107
|
+
|
|
108
|
+
text
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Percent-decodes selector text, refusing malformed escapes and text that isn't UTF-8.
|
|
112
|
+
def selector_text(text, selector)
|
|
113
|
+
decoded = text.b.gsub(/%(\h\h)/n) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
|
|
114
|
+
raise ArgumentError, "Invalid transform selector #{selector.inspect}" if text.match?(/%(?!\h\h)/) || !decoded.valid_encoding?
|
|
115
|
+
|
|
116
|
+
decoded
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
private_constant :TransformRules
|
|
120
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by packages/sdk-contract/generate.ts from IMAGE_TRANSFORM_OPTIONS_SCHEMA; do not edit.
|
|
4
|
+
# The rules each transform option is checked against; see packages/sdk-contract/README.md.
|
|
5
|
+
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
module CalmLens
|
|
9
|
+
module Internal
|
|
10
|
+
# The image transform options' schema, with only the rules the SDK checks.
|
|
11
|
+
TRANSFORM_OPTIONS_SCHEMA = JSON.parse(<<~JSON).freeze
|
|
12
|
+
{
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"format": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": [
|
|
18
|
+
"jpg",
|
|
19
|
+
"png",
|
|
20
|
+
"webp",
|
|
21
|
+
"avif",
|
|
22
|
+
"gif"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"width": {
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"minimum": 1,
|
|
28
|
+
"maximum": 10000
|
|
29
|
+
},
|
|
30
|
+
"height": {
|
|
31
|
+
"type": "integer",
|
|
32
|
+
"minimum": 1,
|
|
33
|
+
"maximum": 10000
|
|
34
|
+
},
|
|
35
|
+
"dpr": {
|
|
36
|
+
"type": "number",
|
|
37
|
+
"minimum": 1,
|
|
38
|
+
"maximum": 3
|
|
39
|
+
},
|
|
40
|
+
"anim": {
|
|
41
|
+
"type": "boolean"
|
|
42
|
+
},
|
|
43
|
+
"background": {
|
|
44
|
+
"type": "string"
|
|
45
|
+
},
|
|
46
|
+
"blur": {
|
|
47
|
+
"type": "number"
|
|
48
|
+
},
|
|
49
|
+
"redact": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"text": {
|
|
53
|
+
"type": "boolean"
|
|
54
|
+
},
|
|
55
|
+
"personalInformation": {
|
|
56
|
+
"type": "boolean"
|
|
57
|
+
},
|
|
58
|
+
"faces": {
|
|
59
|
+
"type": "boolean"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"border": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"color": {
|
|
67
|
+
"type": "string"
|
|
68
|
+
},
|
|
69
|
+
"width": {
|
|
70
|
+
"type": "number"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"brightness": {
|
|
75
|
+
"type": "number"
|
|
76
|
+
},
|
|
77
|
+
"contrast": {
|
|
78
|
+
"type": "number"
|
|
79
|
+
},
|
|
80
|
+
"quality": {
|
|
81
|
+
"oneOf": [
|
|
82
|
+
{
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"minimum": 1,
|
|
85
|
+
"maximum": 100
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"type": "string",
|
|
89
|
+
"enum": [
|
|
90
|
+
"low",
|
|
91
|
+
"medium",
|
|
92
|
+
"high"
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
"fit": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"enum": [
|
|
100
|
+
"scale-down",
|
|
101
|
+
"contain",
|
|
102
|
+
"pad",
|
|
103
|
+
"squeeze",
|
|
104
|
+
"cover",
|
|
105
|
+
"crop"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
"flip": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"enum": [
|
|
111
|
+
"h",
|
|
112
|
+
"v",
|
|
113
|
+
"hv"
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
"gamma": {
|
|
117
|
+
"type": "number"
|
|
118
|
+
},
|
|
119
|
+
"segment": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"enum": [
|
|
122
|
+
"foreground"
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
"gravity": {
|
|
126
|
+
"oneOf": [
|
|
127
|
+
{
|
|
128
|
+
"type": "string",
|
|
129
|
+
"enum": [
|
|
130
|
+
"face",
|
|
131
|
+
"left",
|
|
132
|
+
"right",
|
|
133
|
+
"top",
|
|
134
|
+
"bottom",
|
|
135
|
+
"center",
|
|
136
|
+
"auto",
|
|
137
|
+
"entropy"
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"type": "object",
|
|
142
|
+
"properties": {
|
|
143
|
+
"mode": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"enum": [
|
|
146
|
+
"remainder",
|
|
147
|
+
"box-center"
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
"x": {
|
|
151
|
+
"type": "number"
|
|
152
|
+
},
|
|
153
|
+
"y": {
|
|
154
|
+
"type": "number"
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"required": [
|
|
158
|
+
"mode"
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
"rotate": {
|
|
164
|
+
"type": "integer",
|
|
165
|
+
"enum": [
|
|
166
|
+
0,
|
|
167
|
+
90,
|
|
168
|
+
180,
|
|
169
|
+
270
|
|
170
|
+
]
|
|
171
|
+
},
|
|
172
|
+
"saturation": {
|
|
173
|
+
"type": "number"
|
|
174
|
+
},
|
|
175
|
+
"sharpen": {
|
|
176
|
+
"type": "number"
|
|
177
|
+
},
|
|
178
|
+
"trim": {
|
|
179
|
+
"oneOf": [
|
|
180
|
+
{
|
|
181
|
+
"type": "string",
|
|
182
|
+
"enum": [
|
|
183
|
+
"border"
|
|
184
|
+
]
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"type": "object",
|
|
188
|
+
"properties": {
|
|
189
|
+
"top": {
|
|
190
|
+
"type": "number"
|
|
191
|
+
},
|
|
192
|
+
"bottom": {
|
|
193
|
+
"type": "number"
|
|
194
|
+
},
|
|
195
|
+
"left": {
|
|
196
|
+
"type": "number"
|
|
197
|
+
},
|
|
198
|
+
"right": {
|
|
199
|
+
"type": "number"
|
|
200
|
+
},
|
|
201
|
+
"width": {
|
|
202
|
+
"type": "number"
|
|
203
|
+
},
|
|
204
|
+
"height": {
|
|
205
|
+
"type": "number"
|
|
206
|
+
},
|
|
207
|
+
"border": {
|
|
208
|
+
"oneOf": [
|
|
209
|
+
{
|
|
210
|
+
"type": "boolean"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"type": "object",
|
|
214
|
+
"properties": {
|
|
215
|
+
"color": {
|
|
216
|
+
"type": "string"
|
|
217
|
+
},
|
|
218
|
+
"tolerance": {
|
|
219
|
+
"type": "number"
|
|
220
|
+
},
|
|
221
|
+
"keep": {
|
|
222
|
+
"type": "number"
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
JSON
|
|
235
|
+
end
|
|
236
|
+
end
|
data/lib/calmlens/transforms.rb
CHANGED
|
@@ -42,6 +42,7 @@ module CalmLens
|
|
|
42
42
|
def serialize(value)
|
|
43
43
|
raise Internal.invalid('Transform options must be a Hash') unless value.is_a?(Hash)
|
|
44
44
|
|
|
45
|
+
assert_valid(value)
|
|
45
46
|
pairs = []
|
|
46
47
|
sorted_options(value).each { |key, item| append_pairs(pairs, key, item) }
|
|
47
48
|
raise Internal.invalid('A transform needs at least one option') if pairs.empty?
|
|
@@ -49,6 +50,21 @@ module CalmLens
|
|
|
49
50
|
pairs.join(',')
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# Raises `sdk/invalid-argument` naming each transform option the CDN would refuse, with
|
|
54
|
+
# `details` `{'unknownKeys', 'invalidKeys'}`.
|
|
55
|
+
def assert_valid(options)
|
|
56
|
+
unknown, invalid = TransformRules.check(options)
|
|
57
|
+
return if unknown.empty? && invalid.empty?
|
|
58
|
+
|
|
59
|
+
problems = []
|
|
60
|
+
problems << "unknown option#{'s' if unknown.size > 1} #{unknown.join(', ')}" unless unknown.empty?
|
|
61
|
+
problems << "invalid value#{'s' if invalid.size > 1} for #{invalid.join(', ')}" unless invalid.empty?
|
|
62
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT,
|
|
63
|
+
"Transform options are invalid: #{problems.join('; ')}. Pass the options " \
|
|
64
|
+
"themselves, such as { width: 640, format: 'webp' }.",
|
|
65
|
+
details: { 'unknownKeys' => unknown, 'invalidKeys' => invalid })
|
|
66
|
+
end
|
|
67
|
+
|
|
52
68
|
def sorted_options(options, prefix = '')
|
|
53
69
|
options.map do |key, item|
|
|
54
70
|
name = key.to_s
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by packages/sdk-contract/generate.ts from packages/shared/src/UploadMimeTypes.ts; do not edit.
|
|
4
|
+
# After lower-casing and dropping parameters, a type in REJECTED is refused; otherwise one
|
|
5
|
+
# that starts with a PREFIXES entry, ends with a SUFFIXES entry or is in TYPES is accepted,
|
|
6
|
+
# and any other type is refused with upload/unsupported-type.
|
|
7
|
+
|
|
8
|
+
module CalmLens
|
|
9
|
+
module Internal
|
|
10
|
+
UPLOAD_MIME_REJECTED = [
|
|
11
|
+
'application/octet-stream',
|
|
12
|
+
'multipart/form-data',
|
|
13
|
+
'multipart/mixed',
|
|
14
|
+
'multipart/alternative',
|
|
15
|
+
'multipart/related',
|
|
16
|
+
'multipart/signed',
|
|
17
|
+
'multipart/encrypted'
|
|
18
|
+
].freeze
|
|
19
|
+
UPLOAD_MIME_PREFIXES = [
|
|
20
|
+
'image/',
|
|
21
|
+
'video/',
|
|
22
|
+
'audio/',
|
|
23
|
+
'text/'
|
|
24
|
+
].freeze
|
|
25
|
+
UPLOAD_MIME_SUFFIXES = [
|
|
26
|
+
'+json',
|
|
27
|
+
'+xml'
|
|
28
|
+
].freeze
|
|
29
|
+
UPLOAD_MIME_TYPES = [
|
|
30
|
+
'application/pdf',
|
|
31
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
32
|
+
'application/msword',
|
|
33
|
+
'application/rtf',
|
|
34
|
+
'application/vnd.oasis.opendocument.text',
|
|
35
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
36
|
+
'application/vnd.ms-excel.sheet.macroenabled.12',
|
|
37
|
+
'application/vnd.ms-excel',
|
|
38
|
+
'application/vnd.oasis.opendocument.spreadsheet',
|
|
39
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
40
|
+
'application/vnd.ms-powerpoint.presentation.macroenabled.12',
|
|
41
|
+
'application/vnd.ms-powerpoint',
|
|
42
|
+
'application/vnd.oasis.opendocument.presentation',
|
|
43
|
+
'application/javascript',
|
|
44
|
+
'application/json',
|
|
45
|
+
'application/x-javascript',
|
|
46
|
+
'application/x-yaml',
|
|
47
|
+
'application/xml',
|
|
48
|
+
'application/yaml'
|
|
49
|
+
].freeze
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -26,7 +26,7 @@ module CalmLens
|
|
|
26
26
|
# Signs a single-use token. Hand it to the browser as its bearer token; give each upload its own.
|
|
27
27
|
# @option options [String] :upload_preset_id the preset the token may upload through
|
|
28
28
|
# @option options [String] :external_id the external ID the uploaded asset gets (not empty)
|
|
29
|
-
# @option options [Integer] :
|
|
29
|
+
# @option options [Integer] :expires_in lifetime in seconds, 1 to 3600 (default 600)
|
|
30
30
|
# @return [Hash] `{'token' => String, 'expiresAt' => Integer}` (milliseconds since the Unix epoch)
|
|
31
31
|
def sign(options)
|
|
32
32
|
call(options, __method__) { UploadTokens.sign_at(@api_key, options, now: (Time.now.to_f * 1000).to_i, nonce: SecureRandom.uuid) }
|
|
@@ -39,9 +39,9 @@ module CalmLens
|
|
|
39
39
|
api, = Internal.options(options)
|
|
40
40
|
preset_id = api['uploadPresetId']
|
|
41
41
|
Internal.segment(preset_id)
|
|
42
|
-
ttl = api.fetch('
|
|
42
|
+
ttl = api.fetch('expiresIn', DEFAULT_TTL_SECONDS)
|
|
43
43
|
unless ttl.is_a?(Integer) && ttl.between?(1, MAX_TTL_SECONDS)
|
|
44
|
-
raise Internal.invalid("
|
|
44
|
+
raise Internal.invalid("expiresIn must be a whole number from 1 to #{MAX_TTL_SECONDS}")
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
external_id = api['externalId']
|
data/lib/calmlens/version.rb
CHANGED
data/lib/calmlens/webhooks.rb
CHANGED
|
@@ -15,25 +15,34 @@ module CalmLens
|
|
|
15
15
|
WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 300
|
|
16
16
|
|
|
17
17
|
# A verified webhook delivery. The body has the shape of an {Events#list} item:
|
|
18
|
-
# `{id, projectId, type, severity, title, description, assetId, asset, facts, createdAt}`.
|
|
18
|
+
# `{id, projectId, actor, type, severity, title, description, assetId, assetSequence, asset, facts, createdAt}`.
|
|
19
19
|
# Fields CalmLens adds later are kept in {#payload}, as are unknown asset and fact fields.
|
|
20
20
|
#
|
|
21
21
|
# @!attribute id [String] the event ID.
|
|
22
22
|
# @!attribute project_id [String]
|
|
23
|
+
# @!attribute actor [Hash, nil] who caused the event: `{'type', 'id'}`, with `type` `user`, `api-key`,
|
|
24
|
+
# `automated` or `system`, and `id` the user's or API key's ID, or nil when it was not recorded.
|
|
23
25
|
# @!attribute type [String] such as `asset.allowed`. New types may appear: ignore unknown ones.
|
|
24
26
|
# @!attribute severity [String, nil] `low` or `high`.
|
|
25
27
|
# @!attribute title [String, nil]
|
|
26
28
|
# @!attribute description [String, nil]
|
|
27
29
|
# @!attribute asset_id [String, nil]
|
|
30
|
+
# @!attribute asset_sequence [Integer, nil] increases with each event about the same asset; nil for
|
|
31
|
+
# events not about an asset. Deliveries can arrive out of order: see {#newer_than?}.
|
|
28
32
|
# @!attribute asset [Hash, nil] the asset's status when the event was created.
|
|
29
33
|
# @!attribute facts [Array<Hash>] `{'key', 'value', 'url'?}` entries registered for the event type.
|
|
30
34
|
# @!attribute created_at [Integer] milliseconds since the epoch.
|
|
31
35
|
# @!attribute payload [Hash{String => Object}] the delivery body as received, with every field.
|
|
32
|
-
WebhookEvent = Struct.new(:id, :project_id, :type, :severity, :title, :description, :asset_id, :
|
|
33
|
-
:created_at, :payload, keyword_init: true) do
|
|
36
|
+
WebhookEvent = Struct.new(:id, :project_id, :actor, :type, :severity, :title, :description, :asset_id, :asset_sequence, :asset,
|
|
37
|
+
:facts, :created_at, :payload, keyword_init: true) do
|
|
34
38
|
# @return [Boolean] true for a test send from the portal or CLI.
|
|
35
39
|
def test? = Webhooks.test_event?(self)
|
|
36
40
|
|
|
41
|
+
# @param last_sequence [Integer, nil] the highest `assetSequence` applied for this event's asset
|
|
42
|
+
# @return [Boolean] whether to apply this event after it.
|
|
43
|
+
# @see Webhooks.newer_asset_event?
|
|
44
|
+
def newer_than?(last_sequence) = Webhooks.newer_asset_event?(self, last_sequence)
|
|
45
|
+
|
|
37
46
|
# @return [Hash{String => Object}] the delivery body as received, with every field.
|
|
38
47
|
def to_payload = payload
|
|
39
48
|
end
|
|
@@ -79,16 +88,34 @@ module CalmLens
|
|
|
79
88
|
facts.is_a?(Array) && facts.any? { |fact| fact.is_a?(Hash) && fact['key'] == 'test' && fact['value'] == true }
|
|
80
89
|
end
|
|
81
90
|
|
|
91
|
+
# Whether to apply `event` after `last_sequence`, the highest `assetSequence` you have applied
|
|
92
|
+
# for its asset (nil when none). Deliveries can arrive out of order: an event about an asset is
|
|
93
|
+
# newer only with a higher `assetSequence`, so a retried or repeated delivery of an earlier one
|
|
94
|
+
# returns false. Events that aren't about an asset have no sequence and are always new. Store
|
|
95
|
+
# the highest `assetSequence` applied per `assetId` and ignore lower ones.
|
|
96
|
+
# @param event [WebhookEvent, Hash] a parsed delivery, or a delivery body with string keys
|
|
97
|
+
# @param last_sequence [Integer, nil]
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def newer_asset_event?(event, last_sequence)
|
|
100
|
+
sequence = event.is_a?(Hash) ? event['assetSequence'] : event.asset_sequence
|
|
101
|
+
sequence.nil? || last_sequence.nil? || sequence > last_sequence
|
|
102
|
+
end
|
|
103
|
+
|
|
82
104
|
# Converts an event from {Events#list} to the JSON body of its webhook delivery, which has the
|
|
83
|
-
# same fields: `description`, `assetId` and `asset` are nil when absent
|
|
105
|
+
# same fields: `description`, `assetId`, `assetSequence` and `asset` are nil when absent, and
|
|
106
|
+
# `actor` is left out when the event has none.
|
|
84
107
|
# @param event [Hash] an event with string keys, as {Events#list} returns it
|
|
85
|
-
# @return [Hash] `{ 'id', 'projectId', 'type', 'severity', 'title', 'description', 'assetId',
|
|
108
|
+
# @return [Hash] `{ 'id', 'projectId', 'actor', 'type', 'severity', 'title', 'description', 'assetId',
|
|
109
|
+
# 'assetSequence', 'asset', 'facts', 'createdAt' }`
|
|
86
110
|
def to_payload(event)
|
|
87
111
|
raise Internal.invalid('event must be a Hash') unless event.is_a?(Hash)
|
|
88
112
|
|
|
89
|
-
{ 'id' => event['id'], 'projectId' => event['projectId']
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
payload = { 'id' => event['id'], 'projectId' => event['projectId'] }
|
|
114
|
+
payload['actor'] = event['actor'] if event.key?('actor')
|
|
115
|
+
payload.merge('type' => event['type'], 'severity' => event['severity'], 'title' => event['title'],
|
|
116
|
+
'description' => event['description'], 'assetId' => event['assetId'],
|
|
117
|
+
'assetSequence' => event['assetSequence'], 'asset' => event['asset'], 'facts' => event['facts'],
|
|
118
|
+
'createdAt' => event['createdAt'])
|
|
92
119
|
end
|
|
93
120
|
|
|
94
121
|
# Checks a delivery's signature and freshness in constant time. Pass the `signature` and
|
|
@@ -149,9 +176,10 @@ module CalmLens
|
|
|
149
176
|
body = JSON.parse(payload.dup.force_encoding(Encoding::UTF_8))
|
|
150
177
|
raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_PAYLOAD, 'Webhook body is not a CalmLens event') unless event?(body)
|
|
151
178
|
|
|
152
|
-
WebhookEvent.new(id: body['id'], project_id: body['projectId'], type: body['type'], severity: body['severity'],
|
|
179
|
+
WebhookEvent.new(id: body['id'], project_id: body['projectId'], actor: body['actor'], type: body['type'], severity: body['severity'],
|
|
153
180
|
title: body['title'], description: body['description'], asset_id: body['assetId'],
|
|
154
|
-
asset: body['asset'], facts: body.fetch('facts', []),
|
|
181
|
+
asset_sequence: body['assetSequence'], asset: body['asset'], facts: body.fetch('facts', []),
|
|
182
|
+
created_at: body['createdAt'], payload: body)
|
|
155
183
|
rescue JSON::ParserError, EncodingError => e
|
|
156
184
|
raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_PAYLOAD, 'Webhook body is not JSON'), cause: e
|
|
157
185
|
end
|
|
@@ -163,9 +191,17 @@ module CalmLens
|
|
|
163
191
|
body['projectId'].is_a?(String) && body['createdAt'].is_a?(Numeric)
|
|
164
192
|
|
|
165
193
|
optional = ->(key, *types) { !body.key?(key) || types.any? { |type| type.nil? ? body[key].nil? : body[key].is_a?(type) } }
|
|
166
|
-
asset = body['asset']
|
|
167
194
|
optional.call('severity', String) && optional.call('title', String) && optional.call('description', String, nil) &&
|
|
168
|
-
optional.call('assetId', String, nil) && (
|
|
195
|
+
optional.call('assetId', String, nil) && optional.call('assetSequence', Numeric, nil) && nested?(body)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The nested fields, when present: `actor` is `{type, id}` with a String type and a String or null
|
|
199
|
+
# ID, `asset` has a String `id` and `facts` lists `{key, value, url?}` entries.
|
|
200
|
+
def nested?(body)
|
|
201
|
+
actor = body.fetch('actor', { 'type' => '' })
|
|
202
|
+
asset = body['asset']
|
|
203
|
+
actor.is_a?(Hash) && actor['type'].is_a?(String) && (actor['id'].nil? || actor['id'].is_a?(String)) &&
|
|
204
|
+
(asset.nil? || (asset.is_a?(Hash) && asset['id'].is_a?(String))) &&
|
|
169
205
|
(!body.key?('facts') || (body['facts'].is_a?(Array) && body['facts'].all? { |fact| fact?(fact) }))
|
|
170
206
|
end
|
|
171
207
|
|
|
@@ -191,7 +227,7 @@ module CalmLens
|
|
|
191
227
|
raise Internal.invalid("Webhook payload must be the raw body String, not a #{payload.class}. " \
|
|
192
228
|
'Read request.raw_post (Rails) or request.body.read (Rack, Sinatra) before parsing.')
|
|
193
229
|
end
|
|
194
|
-
private_class_method :decode_event, :event?, :fact?, :header, :raw_body
|
|
230
|
+
private_class_method :decode_event, :event?, :nested?, :fact?, :header, :raw_body
|
|
195
231
|
end
|
|
196
232
|
|
|
197
233
|
# @see Webhooks.signature_header
|
|
@@ -214,4 +250,13 @@ module CalmLens
|
|
|
214
250
|
|
|
215
251
|
# @see Webhooks.test_event?
|
|
216
252
|
def self.test_webhook_event?(...) = Webhooks.test_event?(...)
|
|
253
|
+
|
|
254
|
+
# @see Webhooks.newer_asset_event?
|
|
255
|
+
def self.newer_asset_event?(...) = Webhooks.newer_asset_event?(...)
|
|
256
|
+
|
|
257
|
+
# @see EventFacts.value
|
|
258
|
+
def self.get_event_fact(...) = EventFacts.value(...)
|
|
259
|
+
|
|
260
|
+
# @see EventFacts.values
|
|
261
|
+
def self.get_event_facts(...) = EventFacts.values(...)
|
|
217
262
|
end
|
data/lib/calmlens.rb
CHANGED
|
@@ -9,8 +9,11 @@ require_relative 'calmlens/version'
|
|
|
9
9
|
require_relative 'calmlens/error_codes'
|
|
10
10
|
require_relative 'calmlens/errors'
|
|
11
11
|
require_relative 'calmlens/option_keys'
|
|
12
|
+
require_relative 'calmlens/transform_schema'
|
|
13
|
+
require_relative 'calmlens/upload_mime_types'
|
|
12
14
|
require_relative 'calmlens/internal'
|
|
13
15
|
require_relative 'calmlens/transforms'
|
|
16
|
+
require_relative 'calmlens/transform_rules'
|
|
14
17
|
require_relative 'calmlens/response'
|
|
15
18
|
require_relative 'calmlens/transport'
|
|
16
19
|
require_relative 'calmlens/upload_body'
|