calmlens 0.1.1 → 0.18.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 +133 -58
- data/lib/calmlens/assets.rb +169 -50
- data/lib/calmlens/cdn.rb +29 -7
- data/lib/calmlens/error_codes.rb +82 -4
- data/lib/calmlens/errors.rb +26 -17
- data/lib/calmlens/events.rb +15 -12
- data/lib/calmlens/folders.rb +13 -6
- data/lib/calmlens/hash_lists.rb +95 -0
- data/lib/calmlens/internal.rb +13 -7
- data/lib/calmlens/moderation_context.rb +55 -0
- data/lib/calmlens/resource.rb +23 -6
- data/lib/calmlens/server_client.rb +36 -2
- data/lib/calmlens/session.rb +170 -68
- data/lib/calmlens/testing/classification.rb +21 -0
- data/lib/calmlens/testing/fake_api.rb +946 -233
- data/lib/calmlens/testing/mock_decision.rb +158 -0
- data/lib/calmlens/testing/placeholder.rb +111 -0
- data/lib/calmlens/testing.rb +282 -39
- data/lib/calmlens/text.rb +7 -5
- data/lib/calmlens/transforms.rb +37 -18
- data/lib/calmlens/upload_body.rb +20 -9
- data/lib/calmlens/upload_tokens.rb +14 -6
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens/webhooks.rb +127 -32
- data/lib/calmlens.rb +2 -0
- data/sig/calmlens.rbs +168 -39
- data/sig/error_codes.rbs +41 -2
- data/sig/qualifiers.rbs +141 -0
- data/sig/types.rbs +340 -162
- metadata +8 -2
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'classification'
|
|
4
|
+
|
|
5
|
+
module CalmLens
|
|
6
|
+
# A moderation decision for {MockServerClient}'s `moderate:` option or
|
|
7
|
+
# {MockServerClient#complete_processing}, started with {.of}: flag the categories it found, with
|
|
8
|
+
# their scores and qualifiers, and mark the categories it couldn't assess. Each call checks its
|
|
9
|
+
# names and scores and raises `sdk/invalid-argument` for one CalmLens doesn't have.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# CalmLens::MockDecision.of('blocked').flag('violence', 0.9, 'violence-threat' => 0.9).not_evaluated('profanity')
|
|
13
|
+
class MockDecision
|
|
14
|
+
# Where the mock's moderation leaves an upload: `pending` (still processing), `failed`
|
|
15
|
+
# (processing failed, retryable), or a decision with that moderation status (`purged` also
|
|
16
|
+
# removes the content).
|
|
17
|
+
STATUSES = %w[pending failed allowed limited age-restricted blocked purged].freeze
|
|
18
|
+
# The policy action that gives each decided status.
|
|
19
|
+
STATUS_ACTIONS = { 'allowed' => 'allow', 'limited' => 'limit', 'age-restricted' => 'age-restrict', 'blocked' => 'block',
|
|
20
|
+
'purged' => 'purge' }.freeze
|
|
21
|
+
private_constant :STATUS_ACTIONS
|
|
22
|
+
|
|
23
|
+
# @return [String]
|
|
24
|
+
attr_reader :status
|
|
25
|
+
|
|
26
|
+
# Starts a decision with this status.
|
|
27
|
+
# @param status [String] one of {STATUSES}
|
|
28
|
+
# @return [MockDecision]
|
|
29
|
+
def self.of(status) = new(status)
|
|
30
|
+
|
|
31
|
+
# A `moderate:` answer or {MockServerClient#complete_processing} decision as a checked decision.
|
|
32
|
+
# @api private
|
|
33
|
+
def self.from(value)
|
|
34
|
+
return value if value.is_a?(MockDecision)
|
|
35
|
+
return new(value) if value.is_a?(String)
|
|
36
|
+
|
|
37
|
+
raise Internal.invalid("A decision must be a status or a CalmLens::MockDecision (got #{value.inspect})")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param status [String] one of {STATUSES}
|
|
41
|
+
# @raise [InvalidArgumentError] for an unknown status
|
|
42
|
+
def initialize(status)
|
|
43
|
+
raise Internal.invalid("status must be one of #{STATUSES.join(', ')} (got #{status.inspect})") unless STATUSES.include?(status)
|
|
44
|
+
|
|
45
|
+
@status = status
|
|
46
|
+
@flags = {}
|
|
47
|
+
@not_evaluated = []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Records findings in `category` with its `score` (0 to 1) and, optionally, scores for its
|
|
51
|
+
# qualifiers, such as `{ 'violence-threat' => 0.9 }`. The policy acts on a category at 0.5 and
|
|
52
|
+
# above, so `flaggedCategories` lists it there unless the status is `allowed`.
|
|
53
|
+
# @param category [String]
|
|
54
|
+
# @param score [Numeric]
|
|
55
|
+
# @param qualifiers [Hash{String => Numeric}]
|
|
56
|
+
# @return [self]
|
|
57
|
+
# @raise [InvalidArgumentError] for an unknown category or qualifier, a score outside 0 to 1, a
|
|
58
|
+
# category already in the decision, or a `pending` or `failed` decision
|
|
59
|
+
def flag(category, score, qualifiers = {})
|
|
60
|
+
category = check_category(category)
|
|
61
|
+
raise Internal.invalid("#{category}'s score must be from 0 to 1") unless score?(score)
|
|
62
|
+
|
|
63
|
+
checked = qualifiers.to_h do |qualifier, qualifier_score|
|
|
64
|
+
qualifier = qualifier.to_s
|
|
65
|
+
known = MockClassification::QUALIFIERS[category].include?(qualifier)
|
|
66
|
+
raise Internal.invalid("#{qualifier} is not a qualifier of #{category}") unless known
|
|
67
|
+
raise Internal.invalid("#{qualifier}'s score must be from 0 to 1") unless score?(qualifier_score)
|
|
68
|
+
|
|
69
|
+
[qualifier, qualifier_score]
|
|
70
|
+
end
|
|
71
|
+
@flags[category] = { score: score, qualifiers: checked }
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Marks `category` as not assessed (`not-evaluated` for reason `not-supported`).
|
|
76
|
+
# @param category [String]
|
|
77
|
+
# @return [self]
|
|
78
|
+
# @raise [InvalidArgumentError] like {#flag}
|
|
79
|
+
def not_evaluated(category)
|
|
80
|
+
@not_evaluated << check_category(category)
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The report a decided asset gets, as the API sends it: every category (unflagged ones
|
|
85
|
+
# evaluated with score 0), the policy's actions and `flaggedCategories`.
|
|
86
|
+
# @return [Hash{String => Object}, nil] `categories`, `score` and `policy`; nil for `pending` and `failed`
|
|
87
|
+
def report
|
|
88
|
+
return if undecided?
|
|
89
|
+
|
|
90
|
+
evaluated = (MockClassification::CATEGORIES - @not_evaluated).map { |category| [category, @flags.dig(category, :score) || 0] }
|
|
91
|
+
{ 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] } },
|
|
92
|
+
'categories' => evaluated.to_h { |category, value| [category, { 'score' => value, 'action' => action(value) }] },
|
|
93
|
+
'explanationComplete' => true, 'source' => 'policy', 'automatedAction' => STATUS_ACTIONS[@status],
|
|
94
|
+
'regions' => [], 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def inspect = "#<#{self.class.name} #{@status}>"
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def undecided? = %w[pending failed].include?(@status)
|
|
102
|
+
|
|
103
|
+
def check_category(category)
|
|
104
|
+
category = category.to_s
|
|
105
|
+
raise Internal.invalid("#{category} is not a CalmLens category") unless MockClassification::CATEGORIES.include?(category)
|
|
106
|
+
raise Internal.invalid("a #{@status} decision has no findings") if undecided?
|
|
107
|
+
raise Internal.invalid("#{category} is already in this decision") if @flags.key?(category) || @not_evaluated.include?(category)
|
|
108
|
+
|
|
109
|
+
category
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def score?(score) = score.is_a?(Numeric) && score.real? && score.finite? && score >= 0 && score <= 1
|
|
113
|
+
|
|
114
|
+
def action(score) = @status != 'allowed' && score >= 0.5 ? STATUS_ACTIONS[@status] : 'allow'
|
|
115
|
+
|
|
116
|
+
# The API's likelihood for a score (ClassificationCategories.ts, scoreToLikelihood).
|
|
117
|
+
def likelihood(score)
|
|
118
|
+
if score >= 0.8 then 'very-likely'
|
|
119
|
+
elsif score >= 0.6 then 'likely'
|
|
120
|
+
elsif score >= 0.4 then 'possible'
|
|
121
|
+
elsif score >= 0.2 then 'unlikely'
|
|
122
|
+
else 'very-unlikely'
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def category_report(category)
|
|
127
|
+
return { 'status' => 'not-evaluated', 'reason' => 'not-supported' } if @not_evaluated.include?(category)
|
|
128
|
+
|
|
129
|
+
flag = @flags.fetch(category, { score: 0, qualifiers: {} })
|
|
130
|
+
result = { 'status' => 'evaluated', 'likelihood' => likelihood(flag[:score]), 'score' => flag[:score] }
|
|
131
|
+
unless flag[:qualifiers].empty?
|
|
132
|
+
result['qualifiers'] = flag[:qualifiers].to_h do |qualifier, some|
|
|
133
|
+
[qualifier, { 'status' => 'evaluated', 'someScore' => some, 'likelihood' => likelihood(some) }]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
result
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# What {MockServerClient}'s `moderate:` callback receives for each asset the mock moderates.
|
|
141
|
+
#
|
|
142
|
+
# - `asset` [Hash] the asset as stored, still processing (a copy).
|
|
143
|
+
# - `content` [Content] the uploaded bytes and, for a `plaintext-document`, their text.
|
|
144
|
+
# - `trigger` [String] why the mock is moderating it: `upload` (`assets.create`, `assets.classify`,
|
|
145
|
+
# `text.classify` or a final part), `retry` (`assets.retry`), `rerun` (`assets.explain` with
|
|
146
|
+
# `rerun: true`) or `complete` ({MockServerClient#complete_processing} without a decision).
|
|
147
|
+
# - `queued` [Boolean] true when the API would queue the work rather than answer with the
|
|
148
|
+
# decision: queued uploads, retries, reruns and `complete`.
|
|
149
|
+
MockModerationInput = Struct.new(:asset, :content, :trigger, :queued, keyword_init: true) do
|
|
150
|
+
# @return [Boolean]
|
|
151
|
+
def queued? = queued
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The content of a {MockModerationInput}: `bytes` is the uploaded bytes (binary; nil without
|
|
155
|
+
# stored content, such as a website or removed content, and multipart parts joined in order),
|
|
156
|
+
# and `text` is them decoded as UTF-8 for a `plaintext-document` (else nil).
|
|
157
|
+
MockModerationInput::Content = Struct.new(:bytes, :text, keyword_init: true)
|
|
158
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module CalmLens
|
|
6
|
+
module Testing
|
|
7
|
+
# Renders a ThumbHash as the small PNG the CDN serves for an asset's `placeholder` resource,
|
|
8
|
+
# byte for byte like the CDN's renderer: the ThumbHash decoded to RGBA, stored uncompressed.
|
|
9
|
+
# @api private
|
|
10
|
+
module Placeholder
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param hash [String] the base64 ThumbHash
|
|
14
|
+
# @return [String] PNG bytes
|
|
15
|
+
def png(hash)
|
|
16
|
+
width, height, rgba = rgba(hash.unpack1('m0').bytes)
|
|
17
|
+
encode(width, height, rgba)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Decodes ThumbHash bytes to its width, height and RGBA pixels (RGB not premultiplied).
|
|
21
|
+
def rgba(hash)
|
|
22
|
+
header = hash[0] | (hash[1] << 8) | (hash[2] << 16)
|
|
23
|
+
alpha = (header >> 23) == 1
|
|
24
|
+
landscape = ((hash[3] | (hash[4] << 8)) >> 15) == 1
|
|
25
|
+
short = alpha ? 5 : 7
|
|
26
|
+
lx = [3, landscape ? short : hash[3] & 7].max
|
|
27
|
+
ly = [3, landscape ? hash[3] & 7 : short].max
|
|
28
|
+
channels = channels(hash, header, alpha, lx, ly)
|
|
29
|
+
ratio = aspect_ratio(hash)
|
|
30
|
+
width = (ratio > 1 ? 32 : 32 * ratio).round
|
|
31
|
+
height = (ratio > 1 ? 32 / ratio : 32).round
|
|
32
|
+
pixels = []
|
|
33
|
+
height.times do |y|
|
|
34
|
+
width.times do |x|
|
|
35
|
+
fx = Array.new([lx, alpha ? 5 : 3].max) { |cx| Math.cos(Math::PI / width * (x + 0.5) * cx) }
|
|
36
|
+
fy = Array.new([ly, alpha ? 5 : 3].max) { |cy| Math.cos(Math::PI / height * (y + 0.5) * cy) }
|
|
37
|
+
pixels.concat(pixel(channels, fx, fy, lx, ly, alpha))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
[width, height, pixels]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The DC values and AC coefficients of the luminance, colour and alpha channels.
|
|
44
|
+
def channels(hash, header, alpha, lum_cols, lum_rows)
|
|
45
|
+
header16 = hash[3] | (hash[4] << 8)
|
|
46
|
+
index = 0
|
|
47
|
+
start = alpha ? 6 : 5
|
|
48
|
+
decode = lambda do |cols, rows, scale|
|
|
49
|
+
values = []
|
|
50
|
+
each_term(cols, rows) do
|
|
51
|
+
values << (((((hash[start + (index >> 1)] >> ((index & 1) << 2)) & 15) / 7.5) - 1) * scale)
|
|
52
|
+
index += 1
|
|
53
|
+
end
|
|
54
|
+
values
|
|
55
|
+
end
|
|
56
|
+
{ l_dc: (header & 63) / 63.0, p_dc: (((header >> 6) & 63) / 31.5) - 1, q_dc: (((header >> 12) & 63) / 31.5) - 1,
|
|
57
|
+
a_dc: alpha ? (hash[5] & 15) / 15.0 : 1.0,
|
|
58
|
+
l: decode.call(lum_cols, lum_rows, ((header >> 18) & 31) / 31.0), p: decode.call(3, 3, ((header16 >> 3) & 63) / 63.0 * 1.25),
|
|
59
|
+
q: decode.call(3, 3, ((header16 >> 9) & 63) / 63.0 * 1.25), a: alpha ? decode.call(5, 5, (hash[5] >> 4) / 15.0) : [] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Yields the index of each AC coefficient of an `nx` by `ny` channel with its cosine indexes.
|
|
63
|
+
def each_term(cols, rows)
|
|
64
|
+
index = 0
|
|
65
|
+
rows.times do |cy|
|
|
66
|
+
cx = cy.zero? ? 1 : 0
|
|
67
|
+
while cx * rows < cols * (rows - cy)
|
|
68
|
+
yield index, cx, cy
|
|
69
|
+
index += 1
|
|
70
|
+
cx += 1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def pixel(channels, cos_x, cos_y, lum_cols, lum_rows, alpha)
|
|
76
|
+
l = channels[:l_dc]
|
|
77
|
+
each_term(lum_cols, lum_rows) { |j, cx, cy| l += channels[:l][j] * cos_x[cx] * cos_y[cy] * 2 }
|
|
78
|
+
p = channels[:p_dc]
|
|
79
|
+
q = channels[:q_dc]
|
|
80
|
+
each_term(3, 3) do |j, cx, cy|
|
|
81
|
+
f = cos_x[cx] * cos_y[cy] * 2
|
|
82
|
+
p += channels[:p][j] * f
|
|
83
|
+
q += channels[:q][j] * f
|
|
84
|
+
end
|
|
85
|
+
a = channels[:a_dc]
|
|
86
|
+
each_term(5, 5) { |j, cx, cy| a += channels[:a][j] * cos_x[cx] * cos_y[cy] * 2 } if alpha
|
|
87
|
+
b = l - (2 / 3.0 * p)
|
|
88
|
+
r = ((3 * l) - b + q) / 2
|
|
89
|
+
[r, r - q, b, a].map { |value| [0, 255 * [1, value].min].max.to_i }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def aspect_ratio(hash)
|
|
93
|
+
short = hash[2].anybits?(128) ? 5 : 7
|
|
94
|
+
sides = hash[4].anybits?(128) ? [short, hash[3] & 7] : [hash[3] & 7, short]
|
|
95
|
+
sides[0].to_f / sides[1]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# An uncompressed PNG: one stored deflate block per scanline.
|
|
99
|
+
def encode(width, height, rgba)
|
|
100
|
+
row = (width * 4) + 1
|
|
101
|
+
lines = Array.new(height) { |y| "\0".b + rgba[y * width * 4, width * 4].pack('C*') }
|
|
102
|
+
data = "\x78\x01".b
|
|
103
|
+
lines.each_with_index { |line, y| data << [y + 1 < height ? 0 : 1, row, row ^ 0xffff].pack('Cvv') << line }
|
|
104
|
+
data << [Zlib.adler32(lines.join)].pack('N')
|
|
105
|
+
chunk = ->(type, body) { [body.bytesize].pack('N') + type.b + body + [Zlib.crc32(type.b + body)].pack('N') }
|
|
106
|
+
"\x89PNG\r\n\x1a\n".b + chunk.call('IHDR', [width, height, 8, 6, 0, 0, 0].pack('NNC5')) +
|
|
107
|
+
chunk.call('IDAT', data) + chunk.call('IEND', ''.b)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/calmlens/testing.rb
CHANGED
|
@@ -11,26 +11,42 @@ module CalmLens
|
|
|
11
11
|
# error handling, then reach an in-memory stand-in for the API and CDN instead of the network.
|
|
12
12
|
#
|
|
13
13
|
# By default the stand-in is stateful: uploads create assets you can retrieve, list, filter,
|
|
14
|
-
# move and delete, folders behave like the API's, the CDN serves stored bytes, and errors use
|
|
15
|
-
# the API's codes, HTTP statuses and body shape. It does not classify content:
|
|
16
|
-
#
|
|
17
|
-
#
|
|
14
|
+
# search, move and delete, folders and hash lists behave like the API's, the CDN serves stored bytes, and errors use
|
|
15
|
+
# the API's codes, HTTP statuses and body shape. It does not classify content: the `moderate:`
|
|
16
|
+
# callback decides each upload, retry and rerun with a status or a {MockDecision}, and the events
|
|
17
|
+
# feed gets the pipeline's `asset.uploaded`, `asset.classified`, `asset.<status>` and `asset.failed`
|
|
18
|
+
# events. Without it, queued uploads stay `pending` until {#complete_processing} and the rest are
|
|
19
|
+
# `allowed`. Content is kept only with `keepContentAfterProcessing: true`; otherwise it is removed
|
|
20
|
+
# once classification is `completed`, as in the API.
|
|
21
|
+
# A `purged` outcome removes the content with reason `policy`. The CDN answers like the real one:
|
|
22
|
+
# `asset/not-available` (403) for blocked, purged, failed or removed content,
|
|
23
|
+
# `viewer/age-confirmation-required` (403) for an age-restricted asset without
|
|
24
|
+
# `viewer_claims: {age_confirmed: true}`, whatever the key, and `asset/api-key-missing-permission` (403)
|
|
25
|
+
# for a private asset unless `api_key` comes from {.create_api_key} with an `assets:download`
|
|
26
|
+
# grant. The default key has no grants. {#decide} applies a reviewer's decision, and
|
|
27
|
+
# {Assets#wait_for_decision} takes no real time: a pending asset times out at once.
|
|
28
|
+
# Filters join comparisons with `;` (and), which binds tighter than `,` (or), and group them with
|
|
29
|
+
# `( … )`; list cursors resume after the last item returned. {Assets#search} is the API's keyword
|
|
30
|
+
# search: every query word (case-insensitive, up to 8) must appear in a searchable field, and
|
|
31
|
+
# results carry the API's `score` and `matchedFields`, ranked by score and then newest first.
|
|
18
32
|
#
|
|
19
33
|
# To script exact responses, enqueue them: queued responses and errors are used first, in order,
|
|
20
34
|
# before the stand-in answers.
|
|
21
35
|
#
|
|
22
36
|
# @example
|
|
23
|
-
# mock = CalmLens::MockServerClient.new
|
|
24
|
-
# asset = mock.assets.
|
|
25
|
-
# mock.assets.retrieve(asset_id: asset['id']).dig('moderation', 'status') # => "
|
|
37
|
+
# mock = CalmLens::MockServerClient.new(moderate: ->(input) { input.content.text&.include?('spam') ? 'blocked' : 'allowed' })
|
|
38
|
+
# asset = mock.assets.classify(name: 'note', content: {plain_text: 'hi', type: 'text/plain'})
|
|
39
|
+
# mock.assets.retrieve(asset_id: asset['id']).dig('classification', 'moderation', 'status') # => "allowed"
|
|
26
40
|
# mock.enqueue(status: 503, body: {code: 'server/unavailable', message: 'Try again'})
|
|
27
41
|
class MockServerClient < ServerClient
|
|
28
42
|
# A request the mock received.
|
|
29
43
|
#
|
|
30
|
-
# `path` is relative to `base_url` for API calls (`/projects/<id>/assets`) and to
|
|
31
|
-
# `cdn_base_url` for CDN requests. `query` holds the decoded query
|
|
32
|
-
# sent: JSON text, or the uploaded content (streams are read), or
|
|
33
|
-
|
|
44
|
+
# `path` is relative to `base_url` for API calls (`/projects/<id>/assets`, without `/v1`) and to
|
|
45
|
+
# `cdn_base_url` for CDN requests, with each segment decoded. `query` holds the decoded query
|
|
46
|
+
# string. `body` is the bytes sent: JSON text, or the uploaded content (streams are read), or
|
|
47
|
+
# nil. `status` is the HTTP status the mock answered with, or nil when the request failed
|
|
48
|
+
# without a response.
|
|
49
|
+
Request = Struct.new(:method, :url, :path, :query, :headers, :body, :status, keyword_init: true) do
|
|
34
50
|
# @return [Object] the body parsed as JSON.
|
|
35
51
|
def json = body && JSON.parse(body)
|
|
36
52
|
end
|
|
@@ -38,33 +54,193 @@ module CalmLens
|
|
|
38
54
|
# @return [Testing::State] the stored assets, folders, content and events.
|
|
39
55
|
attr_reader :state
|
|
40
56
|
|
|
57
|
+
# The prefix of mock API keys made by {.create_api_key}.
|
|
58
|
+
API_KEY_PREFIX = 'sk_test_'
|
|
59
|
+
# Simulated failures for {#fail_next_request}, besides API error codes and exceptions.
|
|
60
|
+
REQUEST_FAILURES = %w[transport-error timeout].freeze
|
|
61
|
+
# The methods {#fail_next_request} can target, by resource. Iterators are their list calls.
|
|
62
|
+
OPERATIONS = { assets: %i[search list retrieve list_children update create classify upload_part create_signed_url move
|
|
63
|
+
retry explain wait_for_decision delete_content delete batch],
|
|
64
|
+
folders: %i[list retrieve create update delete move],
|
|
65
|
+
hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
|
|
66
|
+
text: %i[classify], events: %i[list],
|
|
67
|
+
cdn: %i[serve] }.freeze
|
|
68
|
+
private_constant :OPERATIONS
|
|
69
|
+
# The operation names {#fail_next_request} accepts, in their camelCase form.
|
|
70
|
+
OPERATION_NAMES = (OPERATIONS.flat_map do |resource, methods|
|
|
71
|
+
methods.map { |name| "#{Internal.camelize(resource)}.#{Internal.camelize(name)}" }
|
|
72
|
+
end + %w[ensureLens ensureLenses]).freeze
|
|
73
|
+
private_constant :OPERATION_NAMES
|
|
74
|
+
|
|
75
|
+
# A mock API key carrying permission grants, such as
|
|
76
|
+
# `[{ actions: ['assets:download'], folderId: nil, includeSubfolders: true }]` for CDN reads of
|
|
77
|
+
# private and age-restricted assets.
|
|
78
|
+
# @param grants [Array<Hash>] snake_case keys are converted.
|
|
79
|
+
# @return [String]
|
|
80
|
+
def self.create_api_key(grants = [])
|
|
81
|
+
API_KEY_PREFIX + [JSON.generate(Internal.normalize(grants))].pack('m0').tr('+/', '-_').delete('=')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The grants a key from {.create_api_key} carries; none for any other key.
|
|
85
|
+
# @api private
|
|
86
|
+
def self.grants_of(api_key)
|
|
87
|
+
return [] unless api_key.start_with?(API_KEY_PREFIX)
|
|
88
|
+
|
|
89
|
+
encoded = api_key.delete_prefix(API_KEY_PREFIX).tr('-_', '+/')
|
|
90
|
+
grants = JSON.parse("#{encoded}#{'=' * (-encoded.size % 4)}".unpack1('m0'))
|
|
91
|
+
grants.is_a?(Array) ? grants.grep(Hash) : []
|
|
92
|
+
rescue ArgumentError, JSON::ParserError
|
|
93
|
+
[]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Signs an upload token like {UploadTokens#sign}, with the same checks, at a fixed time and with
|
|
97
|
+
# a fixed nonce, so tests can compare exact tokens.
|
|
98
|
+
# @param api_key [String]
|
|
99
|
+
# @param options [Hash] the options of {UploadTokens#sign}
|
|
100
|
+
# @param now [Integer] the signing time in milliseconds since the Unix epoch
|
|
101
|
+
# @param nonce [String]
|
|
102
|
+
# @return [Hash] `{'token', 'expiresAt'}`
|
|
103
|
+
def self.sign_upload_token(api_key, options, now:, nonce:)
|
|
104
|
+
UploadTokens.sign_at(api_key, options, now: now, nonce: nonce)
|
|
105
|
+
rescue KeyError, TypeError, ArgumentError, JSON::GeneratorError, EncodingError => e
|
|
106
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, e.message), cause: e
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
BASE62 = [*'0'..'9', *'A'..'Z', *'a'..'z'].join.freeze
|
|
110
|
+
private_constant :BASE62
|
|
111
|
+
|
|
112
|
+
# A new ID like the API's: `prefix`, `_` and 20 base62 characters from a CSPRNG (bytes 248 to
|
|
113
|
+
# 255 are drawn again, so every character is equally likely).
|
|
114
|
+
# @api private
|
|
115
|
+
def self.new_id(prefix)
|
|
116
|
+
body = +''
|
|
117
|
+
SecureRandom.random_bytes(40).each_byte { |byte| body << BASE62[byte % 62] if byte < 248 } while body.size < 20
|
|
118
|
+
"#{prefix}_#{body[0, 20]}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A clock in epoch milliseconds that follows the system time but never repeats or goes back.
|
|
122
|
+
# @api private
|
|
123
|
+
def self.monotonic_clock
|
|
124
|
+
last = 0
|
|
125
|
+
lock = Mutex.new
|
|
126
|
+
-> { lock.synchronize { last = [(Time.now.to_f * 1000).to_i, last + 1].max } }
|
|
127
|
+
end
|
|
128
|
+
|
|
41
129
|
# @param project_id [String]
|
|
42
|
-
# @param
|
|
43
|
-
# @param
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
130
|
+
# @param api_key [String] a key from {.create_api_key}; the default has no grants.
|
|
131
|
+
# @param now [#call] returns the current time in epoch milliseconds. The default clock reads the
|
|
132
|
+
# system time but advances at least 1 ms per reading, so records made in a row have strictly
|
|
133
|
+
# increasing `createdAt` and order predictably. A clock you pass is used as is.
|
|
134
|
+
# @param create_id [#call] receives the prefix of the kind of object (`asset`, `fldr`, `hl`, `hle`, `lens`,
|
|
135
|
+
# `evt` or `rev`) and returns a new ID. The default is like the API's: the prefix, `_` and 20 random base62
|
|
136
|
+
# characters, such as `asset_5Gm2Rk8XqT3wLp9VbN4c`.
|
|
137
|
+
# @param moderate [#call, nil] decides each asset the mock moderates. It receives a
|
|
138
|
+
# {MockModerationInput} (the stored asset, its content, the trigger and whether the API would
|
|
139
|
+
# queue the work) and returns a status from {MockDecision::STATUSES} or a {MockDecision}. It is
|
|
140
|
+
# called on uploads (`assets.create`, `assets.classify`, `text.classify` and a final part:
|
|
141
|
+
# trigger `upload`), `assets.retry` (`retry`), `assets.explain` with `rerun: true` (`rerun`) and
|
|
142
|
+
# {#complete_processing} without a decision (`complete`). `pending` leaves the asset processing.
|
|
143
|
+
# Without it, queued work other than `complete` stays `pending` and the rest is `allowed`. It
|
|
144
|
+
# runs while the mock handles a request, so it must not call the mock.
|
|
145
|
+
# @param max_retries [Integer] retries after a retryable failure; 0 by default, so a simulated
|
|
146
|
+
# failure reaches the caller.
|
|
51
147
|
# @param options [Hash] other {ServerClient#initialize} settings, except `api_key` and `transport`.
|
|
52
|
-
# Retries
|
|
53
|
-
def initialize(project_id: 'project-1', base_url: 'https://api.calmlens.invalid/v1',
|
|
54
|
-
cdn_base_url: 'https://cdn.calmlens.invalid/v1', now:
|
|
55
|
-
create_id: -> {
|
|
56
|
-
|
|
148
|
+
# Retries and decision checks wait no real time.
|
|
149
|
+
def initialize(project_id: 'project-1', api_key: MockServerClient.create_api_key, base_url: 'https://api.calmlens.invalid/v1',
|
|
150
|
+
cdn_base_url: 'https://cdn.calmlens.invalid/v1', now: MockServerClient.monotonic_clock,
|
|
151
|
+
create_id: ->(prefix) { MockServerClient.new_id(prefix) }, moderate: nil, max_retries: 0, **options)
|
|
152
|
+
raise Internal.invalid('moderate must respond to call') unless moderate.nil? || moderate.respond_to?(:call)
|
|
153
|
+
|
|
57
154
|
@mock_lock = Mutex.new
|
|
58
155
|
@queue = []
|
|
59
156
|
@received = []
|
|
60
|
-
|
|
61
|
-
|
|
157
|
+
@failures = []
|
|
158
|
+
@call_key = :"calmlens_mock_call_#{object_id}"
|
|
159
|
+
super(api_key: api_key, project_id: project_id, base_url: base_url, cdn_base_url: cdn_base_url, max_retries: max_retries,
|
|
160
|
+
**options, transport: method(:receive))
|
|
62
161
|
@fake = Testing::FakeAPI.new(project_id: project_id, base_url: self.base_url, cdn_base_url: self.cdn_base_url,
|
|
63
|
-
now: now, create_id: create_id,
|
|
64
|
-
analyzed_outcome: analyzed_outcome)
|
|
162
|
+
now: now, create_id: create_id, moderate: moderate, grants: MockServerClient.grants_of(api_key))
|
|
65
163
|
@state = @fake.state
|
|
164
|
+
# Like the API, the stand-in checks upload sizes itself, in the API's order.
|
|
165
|
+
@session.defer_upload_checks = true
|
|
166
|
+
OPERATIONS.each { |resource, methods| track(public_send(resource), Internal.camelize(resource), methods) }
|
|
167
|
+
# Retries and decision checks advance this clock instead of sleeping.
|
|
168
|
+
@waited = 0.0
|
|
169
|
+
@session.use_clock(monotonic: -> { @mock_lock.synchronize { @waited } },
|
|
170
|
+
pause: ->(seconds) { @mock_lock.synchronize { @waited += seconds } })
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Applies a reviewer's decision to a stored asset, as the portal or API would: the moderation
|
|
174
|
+
# status changes (`purged` also removes the content), the review joins `manualReviews`, and an
|
|
175
|
+
# `asset.<decision>` event with a `decided-by: reviewer` fact joins the events feed.
|
|
176
|
+
# @param asset_id [String]
|
|
177
|
+
# @param decision [String] `allowed`, `limited`, `age-restricted`, `blocked` or `purged`
|
|
178
|
+
# @return [Hash] the asset
|
|
179
|
+
# @raise [APIError] `asset/not-found` for an unknown asset
|
|
180
|
+
# @raise [InvalidArgumentError] for any other decision, such as `pending` or `failed`
|
|
181
|
+
def decide(asset_id, decision)
|
|
182
|
+
@mock_lock.synchronize { @fake.decide(asset_id, decision) } || raise(not_found)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Finishes processing an asset as the moderation pipeline would: with `decision`, or else what
|
|
186
|
+
# `moderate:` answers for the `complete` trigger (`allowed` without it). A decision adds the
|
|
187
|
+
# `asset.classified` and automated `asset.<status>` events (or `asset.failed`); `pending` leaves
|
|
188
|
+
# the asset processing. It records no manual review.
|
|
189
|
+
# @param asset_id [String]
|
|
190
|
+
# @param decision [String, MockDecision, nil] a status from {MockDecision::STATUSES} or a {MockDecision}
|
|
191
|
+
# @return [Hash] the asset
|
|
192
|
+
# @raise [APIError] `asset/not-found` for an unknown asset
|
|
193
|
+
# @raise [InvalidArgumentError] when the asset isn't processing, or for an unknown decision
|
|
194
|
+
def complete_processing(asset_id, decision = nil)
|
|
195
|
+
@mock_lock.synchronize { @fake.complete_processing(asset_id, decision) } || raise(not_found)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Changes the grants of the mock's API key, as editing the key in the portal would: later
|
|
199
|
+
# requests, such as {CDN#serve}, use them. The key itself stays the same, so idempotency keys it
|
|
200
|
+
# used still replay.
|
|
201
|
+
# @param grants [Array<Hash>] like {.create_api_key}'s
|
|
202
|
+
def api_key_grants=(grants)
|
|
203
|
+
normalized = JSON.parse(JSON.generate(Internal.normalize(grants)))
|
|
204
|
+
@mock_lock.synchronize { @fake.grants = normalized }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Makes the next request (or the next one `operation` sends) fail instead of running, as if it
|
|
208
|
+
# never completed. Each queued failure fails one request, so a client retry takes the next
|
|
209
|
+
# queued failure or reaches the API. Queue several to fail several requests in a row.
|
|
210
|
+
# @param failure [String, Exception] `transport-error` (`sdk/transport-error`), `timeout`
|
|
211
|
+
# (`sdk/timeout`), an API error code such as `upload/limit-exceeded` (answered with its HTTP
|
|
212
|
+
# status), or an exception for the transport to raise.
|
|
213
|
+
# @param operation [String, nil] a method such as `assets.create`, `assets.upload_part`,
|
|
214
|
+
# `cdn.serve` or `ensure_lens` (JavaScript names such as `assets.uploadPart` work too).
|
|
215
|
+
# @param details [Hash, nil] the API error's `details`, for an API error code.
|
|
216
|
+
# @param retry_after [Integer, nil] whole seconds for the API error's `Retry-After` header.
|
|
217
|
+
# @return [self]
|
|
218
|
+
# @raise [InvalidArgumentError] for an unknown failure or operation, or a `retry_after` that isn't
|
|
219
|
+
# a whole number of seconds
|
|
220
|
+
def fail_next_request(failure = 'transport-error', operation: nil, details: nil, retry_after: nil)
|
|
221
|
+
unless failure.is_a?(Exception) || REQUEST_FAILURES.include?(failure) || ErrorCodes::STATUSES.key?(failure)
|
|
222
|
+
raise Internal.invalid("Unknown failure #{failure.inspect}: use #{REQUEST_FAILURES.join(', ')}, an API error code or an exception")
|
|
223
|
+
end
|
|
224
|
+
unless retry_after.nil? || (retry_after.is_a?(Integer) && retry_after >= 0)
|
|
225
|
+
raise Internal.invalid('retry_after must be a whole number of seconds')
|
|
226
|
+
end
|
|
227
|
+
raise Internal.invalid('details must be a Hash') unless details.nil? || details.is_a?(Hash)
|
|
228
|
+
|
|
229
|
+
name = operation && operation.to_s.split('.').map { |part| Internal.camelize(part) }.join('.')
|
|
230
|
+
if name && !OPERATION_NAMES.include?(name)
|
|
231
|
+
raise Internal.invalid("Unknown operation #{operation.inspect}: use one of #{OPERATION_NAMES.join(', ')}")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
@mock_lock.synchronize { @failures << { failure: failure, operation: name, details: details, retry_after: retry_after } }
|
|
235
|
+
self
|
|
66
236
|
end
|
|
67
237
|
|
|
238
|
+
# @see ServerClient#ensure_lenses
|
|
239
|
+
def ensure_lenses(options) = operation('ensureLenses') { super }
|
|
240
|
+
|
|
241
|
+
# @see ServerClient#ensure_lens
|
|
242
|
+
def ensure_lens(options) = operation('ensureLens') { super }
|
|
243
|
+
|
|
68
244
|
# Queues a JSON response for the next request.
|
|
69
245
|
# @param status [Integer]
|
|
70
246
|
# @param body [Object] encoded as JSON
|
|
@@ -93,18 +269,28 @@ module CalmLens
|
|
|
93
269
|
end
|
|
94
270
|
|
|
95
271
|
# Adds an event to the mock's events feed. Fields you omit (`id`, `projectId`, `createdAt`,
|
|
96
|
-
# `severity`, `title`, `facts`) are filled in.
|
|
272
|
+
# `severity`, `title`, `facts`, and nil `description` and `assetId`) are filled in. An event
|
|
273
|
+
# with the `asset_id` of a stored asset carries that asset's snapshot as it is now, unless you
|
|
274
|
+
# pass `asset`.
|
|
97
275
|
# @param event [Hash] at least `type:`; snake_case keys are converted.
|
|
98
276
|
# @return [Hash] the stored event
|
|
99
277
|
def enqueue_event(event)
|
|
100
|
-
@mock_lock.synchronize { JSON.parse(JSON.generate(@fake.
|
|
278
|
+
@mock_lock.synchronize { JSON.parse(JSON.generate(@fake.enqueue_event(Internal.normalize(event)))) }
|
|
101
279
|
end
|
|
102
280
|
|
|
103
|
-
# @return [Array<Request>] copies of every request received
|
|
281
|
+
# @return [Array<Request>] copies of every request received since the mock was created or
|
|
282
|
+
# {#clear_requests} last ran, oldest first.
|
|
104
283
|
def requests
|
|
105
284
|
@mock_lock.synchronize { @received.map { |request| Request.new(**Marshal.load(Marshal.dump(request.to_h))) } }
|
|
106
285
|
end
|
|
107
286
|
|
|
287
|
+
# Forgets the requests recorded so far.
|
|
288
|
+
# @return [self]
|
|
289
|
+
def clear_requests
|
|
290
|
+
@mock_lock.synchronize { @received.clear }
|
|
291
|
+
self
|
|
292
|
+
end
|
|
293
|
+
|
|
108
294
|
# @return [Integer] queued responses and errors not used yet.
|
|
109
295
|
def remaining_responses
|
|
110
296
|
@mock_lock.synchronize { @queue.size }
|
|
@@ -114,22 +300,79 @@ module CalmLens
|
|
|
114
300
|
|
|
115
301
|
private
|
|
116
302
|
|
|
303
|
+
def not_found = APIError.class_for(404).new(ErrorCodes::ASSET_NOT_FOUND, 'Asset not found', status: 404)
|
|
304
|
+
|
|
305
|
+
# Wraps a resource's methods so requests know which call sent them, for {#fail_next_request}.
|
|
306
|
+
def track(resource, prefix, methods)
|
|
307
|
+
mock = self
|
|
308
|
+
resource.singleton_class.prepend(Module.new do
|
|
309
|
+
methods.each do |name|
|
|
310
|
+
define_method(name) do |*args, **options, &block|
|
|
311
|
+
mock.send(:operation, "#{prefix}.#{Internal.camelize(name)}") { super(*args, **options, &block) }
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Runs a call; calls it makes (text.classify through assets.classify) count as the outer one.
|
|
318
|
+
def operation(name)
|
|
319
|
+
return yield if Thread.current[@call_key]
|
|
320
|
+
|
|
321
|
+
begin
|
|
322
|
+
Thread.current[@call_key] = { operation: name }
|
|
323
|
+
yield
|
|
324
|
+
ensure
|
|
325
|
+
Thread.current[@call_key] = nil
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The next queued failure for this request, which it uses up.
|
|
330
|
+
def failure_for(call)
|
|
331
|
+
index = @failures.index { |pending| pending[:operation].nil? || pending[:operation] == call&.dig(:operation) }
|
|
332
|
+
index && @failures.delete_at(index)
|
|
333
|
+
end
|
|
334
|
+
|
|
117
335
|
# Returns the response rather than yielding it: the client accepts either from a transport.
|
|
118
336
|
def receive(request, &)
|
|
119
337
|
body = request.body.respond_to?(:read) ? read_stream(request.body) : request.body&.b
|
|
120
338
|
path, query = Internal.split_url(request.url).last.split('?', 2)
|
|
121
339
|
base = request.url.start_with?("#{cdn_base_url}/") ? URI(cdn_base_url).path : URI(base_url).path
|
|
340
|
+
call = Thread.current[@call_key]
|
|
122
341
|
@mock_lock.synchronize do
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
queued || @fake.handle(request.method, request.url, request.headers, body)
|
|
342
|
+
record = Request.new(method: request.method, url: request.url, path: decode_path(path.delete_prefix(base)),
|
|
343
|
+
query: URI.decode_www_form(query.to_s).to_h, headers: request.headers.dup, body: body)
|
|
344
|
+
@received << record
|
|
345
|
+
response = answer(request, body, failure_for(call))
|
|
346
|
+
record.status = response.status
|
|
347
|
+
response
|
|
130
348
|
end
|
|
131
349
|
end
|
|
132
350
|
|
|
351
|
+
def answer(request, body, failure)
|
|
352
|
+
return fail_request(failure) if failure
|
|
353
|
+
|
|
354
|
+
queued = @queue.shift
|
|
355
|
+
raise queued if queued.is_a?(Exception)
|
|
356
|
+
|
|
357
|
+
queued || @fake.handle(request.method, request.url, request.headers, body)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Each segment of a request path, percent-decoded.
|
|
361
|
+
def decode_path(path)
|
|
362
|
+
path.split('/', -1).map do |segment|
|
|
363
|
+
segment.b.gsub(/%(\h\h)/) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
|
|
364
|
+
end.join('/')
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def fail_request(pending)
|
|
368
|
+
failure = pending[:failure]
|
|
369
|
+
raise failure if failure.is_a?(Exception)
|
|
370
|
+
raise Errno::ECONNRESET, 'Simulated network failure' if failure == 'transport-error'
|
|
371
|
+
raise Net::ReadTimeout, 'Simulated timeout' if failure == 'timeout'
|
|
372
|
+
|
|
373
|
+
@fake.error(failure, details: pending[:details] && JSON.parse(JSON.generate(pending[:details])), retry_after: pending[:retry_after])
|
|
374
|
+
end
|
|
375
|
+
|
|
133
376
|
def read_stream(io)
|
|
134
377
|
buffer = String.new(encoding: Encoding::BINARY)
|
|
135
378
|
while (chunk = io.read(64 * 1024))
|