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.
@@ -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
@@ -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] :expires_in_seconds lifetime, 1 to 3600 (default 600)
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('expiresInSeconds', DEFAULT_TTL_SECONDS)
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("expiresInSeconds must be a whole number from 1 to #{MAX_TTL_SECONDS}")
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']
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.19.0'
5
+ VERSION = '0.21.0'
6
6
  end
@@ -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, :asset, :facts,
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', 'asset', 'facts', 'createdAt' }`
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'], 'type' => event['type'], 'severity' => event['severity'],
90
- 'title' => event['title'], 'description' => event['description'], 'assetId' => event['assetId'],
91
- 'asset' => event['asset'], 'facts' => event['facts'], 'createdAt' => event['createdAt'] }
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', []), created_at: body['createdAt'], payload: body)
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) && (asset.nil? || (asset.is_a?(Hash) && asset['id'].is_a?(String))) &&
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'