calmlens 0.18.2 → 0.19.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 +137 -38
- data/lib/calmlens/cdn.rb +14 -7
- data/lib/calmlens/error_codes.rb +28 -2
- data/lib/calmlens/events.rb +5 -2
- data/lib/calmlens/folders.rb +9 -6
- data/lib/calmlens/hash_lists.rb +11 -8
- data/lib/calmlens/lenses.rb +5 -2
- data/lib/calmlens/option_keys.rb +66 -0
- data/lib/calmlens/policies.rb +100 -0
- data/lib/calmlens/resource.rb +26 -1
- data/lib/calmlens/server_client.rb +23 -6
- data/lib/calmlens/session.rb +6 -3
- data/lib/calmlens/testing/classification.rb +3 -0
- data/lib/calmlens/testing/fake_api.rb +138 -12
- data/lib/calmlens/testing/test_image_grid.rb +161 -0
- data/lib/calmlens/testing/test_input_scenario.rb +127 -0
- data/lib/calmlens/testing/test_inputs.rb +156 -0
- data/lib/calmlens/testing.rb +6 -1
- data/lib/calmlens/text.rb +5 -4
- data/lib/calmlens/transforms.rb +22 -29
- data/lib/calmlens/upload_body.rb +14 -0
- data/lib/calmlens/upload_tokens.rb +4 -1
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens.rb +2 -0
- data/sig/calmlens.rbs +57 -2
- data/sig/error_codes.rbs +14 -1
- data/sig/types.rbs +157 -16
- metadata +7 -2
data/lib/calmlens/hash_lists.rb
CHANGED
|
@@ -5,12 +5,15 @@ module CalmLens
|
|
|
5
5
|
# project: a `flag` list reports matches, a `block` list also blocks close matches. Reach it through
|
|
6
6
|
# {ServerClient#hash_lists}.
|
|
7
7
|
class HashLists < Resource
|
|
8
|
+
# The prefix of this resource's operations in Internal::OPTION_KEYS.
|
|
9
|
+
OPERATIONS = 'hashLists'
|
|
10
|
+
|
|
8
11
|
# Lists one page of the project's hash lists, newest first.
|
|
9
12
|
# @option options [String] :cursor the previous page's `nextCursor`
|
|
10
13
|
# @option options [Integer] :page_size 1 to 100, default 50
|
|
11
14
|
# @return [Hash] a cursor page: items, nextCursor, hasMore, pageSize and ordering.
|
|
12
15
|
def list(options = {})
|
|
13
|
-
call(options) do |api, request|
|
|
16
|
+
call(options, __method__) do |api, request|
|
|
14
17
|
response = request('GET', project('hash-lists'), query: api, request_options: request)
|
|
15
18
|
valid = response.is_a?(Hash) && response['items'].is_a?(Array)
|
|
16
19
|
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Invalid hash list page') unless valid
|
|
@@ -22,7 +25,7 @@ module CalmLens
|
|
|
22
25
|
# @option options [String] :hash_list_id
|
|
23
26
|
# @return [Hash] the hash list
|
|
24
27
|
def retrieve(options)
|
|
25
|
-
call(options) { |api, request| request('GET', list_path(api), request_options: request) }
|
|
28
|
+
call(options, __method__) { |api, request| request('GET', list_path(api), request_options: request) }
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
# @option options [String] :name
|
|
@@ -30,7 +33,7 @@ module CalmLens
|
|
|
30
33
|
# @option options [String, nil] :description
|
|
31
34
|
# @return [Hash] the hash list
|
|
32
35
|
def create(options)
|
|
33
|
-
call(options) { |api, request| request('POST', project('hash-lists'), body: api, request_options: request) }
|
|
36
|
+
call(options, __method__) { |api, request| request('POST', project('hash-lists'), body: api, request_options: request) }
|
|
34
37
|
end
|
|
35
38
|
|
|
36
39
|
# Renames a list or changes what its matches do. The new action applies to later uploads.
|
|
@@ -40,7 +43,7 @@ module CalmLens
|
|
|
40
43
|
# @option options [String] :action `flag` or `block`
|
|
41
44
|
# @return [Hash] the hash list
|
|
42
45
|
def update(options)
|
|
43
|
-
call(options) do |api, request|
|
|
46
|
+
call(options, __method__) do |api, request|
|
|
44
47
|
request('PATCH', list_path(api), body: without(api, 'hashListId'), request_options: request)
|
|
45
48
|
end
|
|
46
49
|
end
|
|
@@ -49,7 +52,7 @@ module CalmLens
|
|
|
49
52
|
# @option options [String] :hash_list_id
|
|
50
53
|
# @return [nil]
|
|
51
54
|
def delete(options)
|
|
52
|
-
call(options) do |api, request|
|
|
55
|
+
call(options, __method__) do |api, request|
|
|
53
56
|
request('DELETE', list_path(api), request_options: request)
|
|
54
57
|
nil
|
|
55
58
|
end
|
|
@@ -61,7 +64,7 @@ module CalmLens
|
|
|
61
64
|
# @option options [Integer] :page_size 1 to 100, default 50
|
|
62
65
|
# @return [Hash] a cursor page: items, nextCursor, hasMore, pageSize and ordering.
|
|
63
66
|
def list_entries(options)
|
|
64
|
-
call(options) do |api, request|
|
|
67
|
+
call(options, __method__) do |api, request|
|
|
65
68
|
request('GET', "#{list_path(api)}/entries", query: without(api, 'hashListId'), request_options: request)
|
|
66
69
|
end
|
|
67
70
|
end
|
|
@@ -74,7 +77,7 @@ module CalmLens
|
|
|
74
77
|
# @option options [String] :label
|
|
75
78
|
# @return [Hash] the entry
|
|
76
79
|
def add_entry(options)
|
|
77
|
-
call(options) do |api, request|
|
|
80
|
+
call(options, __method__) do |api, request|
|
|
78
81
|
request('POST', "#{list_path(api)}/entries", body: without(api, 'hashListId'), request_options: request)
|
|
79
82
|
end
|
|
80
83
|
end
|
|
@@ -82,7 +85,7 @@ module CalmLens
|
|
|
82
85
|
# @option options [String] :entry_id
|
|
83
86
|
# @return [nil]
|
|
84
87
|
def remove_entry(options)
|
|
85
|
-
call(options) do |api, request|
|
|
88
|
+
call(options, __method__) do |api, request|
|
|
86
89
|
request('DELETE', "/hash-list-entries/#{Internal.segment(api['entryId'])}", request_options: request)
|
|
87
90
|
nil
|
|
88
91
|
end
|
data/lib/calmlens/lenses.rb
CHANGED
|
@@ -4,12 +4,15 @@ module CalmLens
|
|
|
4
4
|
# Lens definitions, cached per client. Used through {ServerClient#ensure_lenses} and {ServerClient#ensure_lens}.
|
|
5
5
|
# @api private
|
|
6
6
|
class Lenses < Resource
|
|
7
|
+
# The prefix of this resource's operations in Internal::OPTION_KEYS.
|
|
8
|
+
OPERATIONS = nil
|
|
9
|
+
|
|
7
10
|
def ensure_lenses(options)
|
|
8
|
-
call(options) { |api, request| @session.ensure_lenses(api['lenses'], request) }
|
|
11
|
+
call(options, __method__) { |api, request| @session.ensure_lenses(api['lenses'], request) }
|
|
9
12
|
end
|
|
10
13
|
|
|
11
14
|
def ensure_lens(options)
|
|
12
|
-
call(options) { |api, request| @session.ensure_lenses([api], request).first }
|
|
15
|
+
call(options, __method__) { |api, request| @session.ensure_lenses([api], request).first }
|
|
13
16
|
end
|
|
14
17
|
end
|
|
15
18
|
private_constant :Lenses
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by packages/sdk-contract/generate.ts from packages/shared/src/PublicApiSchemas.ts.
|
|
4
|
+
# Do not edit by hand: run `bun packages/sdk-contract/generate.ts`.
|
|
5
|
+
module CalmLens
|
|
6
|
+
module Internal
|
|
7
|
+
# The camelCase option keys each method takes, besides `request_options`.
|
|
8
|
+
OPTION_KEYS = {
|
|
9
|
+
'assets.batch' => %w[assetIds operation].freeze,
|
|
10
|
+
'assets.classify' => %w[
|
|
11
|
+
authorId content context description externalId externalUrl folderId isFinal metadata name part
|
|
12
|
+
processingOptions sizeInBytes totalSizeInBytes uploadPresetId url visibility
|
|
13
|
+
].freeze,
|
|
14
|
+
'assets.create' => %w[
|
|
15
|
+
authorId content context description externalId externalUrl folderId isFinal metadata name part
|
|
16
|
+
processingOptions sizeInBytes totalSizeInBytes uploadPresetId url visibility
|
|
17
|
+
].freeze,
|
|
18
|
+
'assets.createSignedUrl' => %w[assetId expiresInSeconds lens resource transform viewerClaims].freeze,
|
|
19
|
+
'assets.decide' => %w[assetId confirmPurge decision note].freeze,
|
|
20
|
+
'assets.delete' => %w[assetId].freeze,
|
|
21
|
+
'assets.deleteContent' => %w[assetId].freeze,
|
|
22
|
+
'assets.each' => %w[cursor filter folderId ordering pageSize].freeze,
|
|
23
|
+
'assets.eachChild' => %w[assetId cursor ordering pageSize].freeze,
|
|
24
|
+
'assets.explain' => %w[assetId rerun].freeze,
|
|
25
|
+
'assets.list' => %w[cursor filter folderId ordering pageSize].freeze,
|
|
26
|
+
'assets.listChildren' => %w[assetId cursor ordering pageSize].freeze,
|
|
27
|
+
'assets.move' => %w[assetId folderId].freeze,
|
|
28
|
+
'assets.retrieve' => %w[assetId].freeze,
|
|
29
|
+
'assets.retry' => %w[assetId].freeze,
|
|
30
|
+
'assets.search' => %w[limit mode query].freeze,
|
|
31
|
+
'assets.update' => %w[assetId description externalId externalUrl metadata name tags visibility].freeze,
|
|
32
|
+
'assets.uploadPart' => %w[assetId part queue].freeze,
|
|
33
|
+
'ensureLens' => %w[active description identifier name transform type version].freeze,
|
|
34
|
+
'ensureLenses' => %w[lenses].freeze,
|
|
35
|
+
'events.each' => %w[cursor pageSize].freeze,
|
|
36
|
+
'events.eachPage' => %w[cursor pageSize].freeze,
|
|
37
|
+
'events.list' => %w[cursor pageSize].freeze,
|
|
38
|
+
'folders.create' => %w[name parentId].freeze,
|
|
39
|
+
'folders.delete' => %w[folderId].freeze,
|
|
40
|
+
'folders.list' => %w[cursor ordering pageSize parentId search].freeze,
|
|
41
|
+
'folders.move' => %w[folderId parentId].freeze,
|
|
42
|
+
'folders.retrieve' => %w[folderId].freeze,
|
|
43
|
+
'folders.update' => %w[folderId name].freeze,
|
|
44
|
+
'hashLists.addEntry' => %w[assetId hash hashListId label quality].freeze,
|
|
45
|
+
'hashLists.create' => %w[action description name].freeze,
|
|
46
|
+
'hashLists.delete' => %w[hashListId].freeze,
|
|
47
|
+
'hashLists.list' => %w[cursor ordering pageSize].freeze,
|
|
48
|
+
'hashLists.listEntries' => %w[cursor hashListId ordering pageSize].freeze,
|
|
49
|
+
'hashLists.removeEntry' => %w[entryId].freeze,
|
|
50
|
+
'hashLists.retrieve' => %w[hashListId].freeze,
|
|
51
|
+
'hashLists.update' => %w[action description hashListId name].freeze,
|
|
52
|
+
'policies.applyPreset' => %w[operation policyId presetVersion revision].freeze,
|
|
53
|
+
'policies.assign' => %w[policyId].freeze,
|
|
54
|
+
'policies.create' => %w[description name presetId settings].freeze,
|
|
55
|
+
'policies.delete' => %w[policyId].freeze,
|
|
56
|
+
'policies.list' => %w[].freeze,
|
|
57
|
+
'policies.retrieve' => %w[policyId].freeze,
|
|
58
|
+
'policies.setAutomaticUpdates' => %w[enabled policyId presetVersion revision].freeze,
|
|
59
|
+
'policies.update' => %w[description name policyId revision settings].freeze,
|
|
60
|
+
'text.classify' => %w[
|
|
61
|
+
authorId context description externalId externalUrl folderId metadata name processingOptions text
|
|
62
|
+
uploadPresetId visibility
|
|
63
|
+
].freeze
|
|
64
|
+
}.freeze
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# Moderation policies belong to the project's organisation and its projects share them. Every
|
|
5
|
+
# change sends the policy's `revision`, so a policy changed since is refused with
|
|
6
|
+
# `policy/revision-conflict`. Through a project's key you can change only policies no other
|
|
7
|
+
# project uses or owns. Reach it through {ServerClient#policies}.
|
|
8
|
+
class Policies < Resource
|
|
9
|
+
# The prefix of this resource's operations in Internal::OPTION_KEYS.
|
|
10
|
+
OPERATIONS = 'policies'
|
|
11
|
+
|
|
12
|
+
# Lists the organisation's policies, oldest first, and the ID of the one the project uses.
|
|
13
|
+
# @return [Hash] `policies` and `projectPolicyId`
|
|
14
|
+
def list(options = {})
|
|
15
|
+
call(options, __method__) { |_api, request| request('GET', project('policies'), request_options: request) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @option options [String] :policy_id
|
|
19
|
+
# @return [Hash] the policy
|
|
20
|
+
def retrieve(options)
|
|
21
|
+
call(options, __method__) { |api, request| request('GET', policy_path(api), request_options: request) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Creates a policy from `settings` (defining every category) with a `name`, or a copy of the
|
|
25
|
+
# latest release of a built-in preset with `preset_id`. It moderates nothing until it is
|
|
26
|
+
# assigned to a project or named on an upload preset.
|
|
27
|
+
# @option options [String] :name required with `settings`; a preset copy defaults to its name
|
|
28
|
+
# @option options [String] :description
|
|
29
|
+
# @option options [Hash] :settings
|
|
30
|
+
# @option options [String] :preset_id `general-community`, `children-platform`, `education-support`
|
|
31
|
+
# or `wholesome-community`
|
|
32
|
+
# @return [Hash] the policy
|
|
33
|
+
def create(options)
|
|
34
|
+
call(options, __method__) { |api, request| request('POST', project('policies'), body: api, request_options: request) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Changes the name, description or settings (complete, defining every category); later
|
|
38
|
+
# moderation uses them. Saving a policy that follows automatic updates pins it as a copy.
|
|
39
|
+
# @option options [String] :policy_id
|
|
40
|
+
# @option options [Integer] :revision the policy's current revision
|
|
41
|
+
# @option options [String] :name
|
|
42
|
+
# @option options [String] :description
|
|
43
|
+
# @option options [Hash] :settings
|
|
44
|
+
# @return [Hash] the policy
|
|
45
|
+
def update(options)
|
|
46
|
+
call(options, __method__) do |api, request|
|
|
47
|
+
request('PATCH', policy_path(api), body: without(api, 'policyId'), request_options: request)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Resets a preset copy to its pinned preset version, or updates an unchanged copy to the latest
|
|
52
|
+
# preset release.
|
|
53
|
+
# @option options [String] :policy_id
|
|
54
|
+
# @option options [String] :operation `reset` or `update`
|
|
55
|
+
# @option options [Integer] :revision the policy's current revision
|
|
56
|
+
# @option options [Integer] :preset_version `preset.version` to reset, `preset.latestVersion` to update
|
|
57
|
+
# @return [Hash] the policy
|
|
58
|
+
def apply_preset(options)
|
|
59
|
+
call(options, __method__) do |api, request|
|
|
60
|
+
request('POST', "#{policy_path(api)}/preset", body: without(api, 'policyId'), request_options: request)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Switches automatic updates on or off for the project's preset policy. Switching on replaces
|
|
65
|
+
# the saved copy with the latest preset settings under the same ID; switching off pins the
|
|
66
|
+
# current preset version as a copy with unchanged settings.
|
|
67
|
+
# @option options [String] :policy_id
|
|
68
|
+
# @option options [Boolean] :enabled
|
|
69
|
+
# @option options [Integer] :revision the policy's current revision
|
|
70
|
+
# @option options [Integer] :preset_version the preset's `latestVersion`
|
|
71
|
+
# @return [Hash] the policy
|
|
72
|
+
def set_automatic_updates(options) # rubocop:disable Naming/AccessorMethodName -- the shared SDK surface name
|
|
73
|
+
call(options, __method__) do |api, request|
|
|
74
|
+
request('PUT', "#{policy_path(api)}/automatic-updates", body: without(api, 'policyId'), request_options: request)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Deletes a policy that no project or upload preset uses.
|
|
79
|
+
# @option options [String] :policy_id
|
|
80
|
+
# @return [nil]
|
|
81
|
+
def delete(options)
|
|
82
|
+
call(options, __method__) do |api, request|
|
|
83
|
+
request('DELETE', policy_path(api), request_options: request)
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Chooses the policy the project moderates with. Later uploads use it, except those through an
|
|
89
|
+
# upload preset that names its own policy.
|
|
90
|
+
# @option options [String] :policy_id
|
|
91
|
+
# @return [Hash] the policy
|
|
92
|
+
def assign(options)
|
|
93
|
+
call(options, __method__) { |api, request| request('PUT', project('policy'), body: api, request_options: request) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def policy_path(api) = project("policies/#{Internal.segment(api['policyId'])}")
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/calmlens/resource.rb
CHANGED
|
@@ -15,8 +15,21 @@ module CalmLens
|
|
|
15
15
|
|
|
16
16
|
private
|
|
17
17
|
|
|
18
|
+
# The option keys of methods that send no request of their own, or take options of their own
|
|
19
|
+
# beside a request's; Internal::OPTION_KEYS, generated from the API, has the rest.
|
|
20
|
+
LOCAL_OPTION_KEYS = {
|
|
21
|
+
'assets.waitForDecision' => %w[assetId timeout].freeze,
|
|
22
|
+
'cdn.getAssetUrl' => %w[assetIdentifier lens resource transform viewerClaims].freeze,
|
|
23
|
+
'cdn.serve' => %w[assetIdentifier init lens resource transform viewerClaims].freeze,
|
|
24
|
+
'uploadTokens.sign' => %w[expiresInSeconds externalId uploadPresetId].freeze
|
|
25
|
+
}.freeze
|
|
26
|
+
private_constant :LOCAL_OPTION_KEYS
|
|
27
|
+
|
|
18
28
|
# Runs a public method: splits options, and raises every argument problem as {InvalidArgumentError}.
|
|
19
|
-
|
|
29
|
+
# `method` names the public method: an option it doesn't take, such as a misspelt one, raises
|
|
30
|
+
# `sdk/invalid-argument` instead of being silently dropped.
|
|
31
|
+
def call(options, method)
|
|
32
|
+
check_option_keys(options, [self.class::OPERATIONS, Internal.camelize(method)].compact.join('.'))
|
|
20
33
|
api, request = Internal.options(options)
|
|
21
34
|
yield api, request
|
|
22
35
|
rescue Error
|
|
@@ -25,6 +38,18 @@ module CalmLens
|
|
|
25
38
|
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, e.message), cause: e
|
|
26
39
|
end
|
|
27
40
|
|
|
41
|
+
def check_option_keys(options, operation)
|
|
42
|
+
return unless options.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
allowed = LOCAL_OPTION_KEYS.fetch(operation) { Internal::OPTION_KEYS.fetch(operation) }
|
|
45
|
+
unknown = options.keys.reject do |key|
|
|
46
|
+
%w[request_options requestOptions].include?(key.to_s) || allowed.include?(Internal.camelize(key))
|
|
47
|
+
end
|
|
48
|
+
return if unknown.empty?
|
|
49
|
+
|
|
50
|
+
raise Internal.invalid("Unknown option#{'s' if unknown.size > 1} for #{operation}: #{unknown.join(', ')}")
|
|
51
|
+
end
|
|
52
|
+
|
|
28
53
|
def request(...) = @session.request(...)
|
|
29
54
|
def project(path) = @session.project(path)
|
|
30
55
|
|
|
@@ -25,6 +25,11 @@ module CalmLens
|
|
|
25
25
|
RETRY_AFTER_MAX = 60
|
|
26
26
|
# The largest response body read, in bytes (32 MiB).
|
|
27
27
|
DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
28
|
+
# Content larger than this many bytes is uploaded in parts of this size (8 MiB).
|
|
29
|
+
DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024
|
|
30
|
+
# The smallest and largest `part_size_bytes`: every part but the last is at least 5 MiB.
|
|
31
|
+
MIN_PART_SIZE_BYTES = 5 * 1024 * 1024
|
|
32
|
+
MAX_PART_SIZE_BYTES = 64 * 1024 * 1024
|
|
28
33
|
# The most lens definitions {#ensure_lenses} sends in one request.
|
|
29
34
|
ENSURE_LENSES_MAX_PER_REQUEST = 100
|
|
30
35
|
|
|
@@ -34,6 +39,8 @@ module CalmLens
|
|
|
34
39
|
attr_reader :folders
|
|
35
40
|
# @return [HashLists]
|
|
36
41
|
attr_reader :hash_lists
|
|
42
|
+
# @return [Policies]
|
|
43
|
+
attr_reader :policies
|
|
37
44
|
# @return [Text]
|
|
38
45
|
attr_reader :text
|
|
39
46
|
# @return [UploadTokens]
|
|
@@ -62,14 +69,18 @@ module CalmLens
|
|
|
62
69
|
# `Retry-After`. Other writes are never retried. 0 disables retries.
|
|
63
70
|
# @param retry_base_delay [Numeric] seconds before the first retry when the API gives no
|
|
64
71
|
# `Retry-After`; later retries wait up to twice as long each time, at most 8 seconds.
|
|
72
|
+
# @param part_size_bytes [Integer] content larger than this many bytes whose size the client knows
|
|
73
|
+
# and can read again (a binary String, base64, or a File, StringIO or other seekable IO) is
|
|
74
|
+
# uploaded in parts of this size: each part is retried on its own, and calling again with the
|
|
75
|
+
# same `idempotency_key` resumes an interrupted upload. From 5 MiB to 64 MiB; default 8 MiB.
|
|
65
76
|
# @param transport [#call, nil] a custom {Transport}; defaults to {Transport::NetHTTP}.
|
|
66
77
|
# @raise [ConfigurationError] `sdk/invalid-config` for a missing or malformed setting.
|
|
67
78
|
def initialize(api_key:, project_id:, base_url: DEFAULT_BASE_URL, cdn_base_url: DEFAULT_CDN_BASE_URL,
|
|
68
79
|
timeout: DEFAULT_TIMEOUT, open_timeout: DEFAULT_OPEN_TIMEOUT, upload_timeout: nil,
|
|
69
80
|
max_retries: DEFAULT_MAX_RETRIES, retry_base_delay: DEFAULT_RETRY_BASE_DELAY,
|
|
70
|
-
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, transport: nil)
|
|
81
|
+
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, part_size_bytes: DEFAULT_PART_SIZE_BYTES, transport: nil)
|
|
71
82
|
check_config(api_key, project_id, timeout, open_timeout, max_retries, retry_base_delay, transport)
|
|
72
|
-
check_limits(upload_timeout, max_response_bytes)
|
|
83
|
+
check_limits(upload_timeout, max_response_bytes, part_size_bytes)
|
|
73
84
|
@project_id = project_id
|
|
74
85
|
@base_url = ServerClient.check_url(base_url, 'base_url')
|
|
75
86
|
@cdn_base_url = ServerClient.check_url(cdn_base_url, 'cdn_base_url')
|
|
@@ -77,10 +88,11 @@ module CalmLens
|
|
|
77
88
|
cdn_base_url: @cdn_base_url, timeout: timeout, open_timeout: open_timeout,
|
|
78
89
|
max_retries: max_retries, retry_base_delay: retry_base_delay,
|
|
79
90
|
max_response_bytes: max_response_bytes, upload_timeout: upload_timeout,
|
|
80
|
-
transport: transport || Transport::NetHTTP.new)
|
|
91
|
+
part_size_bytes: part_size_bytes, transport: transport || Transport::NetHTTP.new)
|
|
81
92
|
@assets = Assets.new(@session)
|
|
82
93
|
@folders = Folders.new(@session)
|
|
83
94
|
@hash_lists = HashLists.new(@session)
|
|
95
|
+
@policies = Policies.new(@session)
|
|
84
96
|
@text = Text.new(@session, @assets)
|
|
85
97
|
@upload_tokens = UploadTokens.new(@session, api_key)
|
|
86
98
|
@events = Events.new(@session)
|
|
@@ -107,11 +119,11 @@ module CalmLens
|
|
|
107
119
|
nil
|
|
108
120
|
end
|
|
109
121
|
|
|
110
|
-
# Creates or updates named lenses (saved
|
|
122
|
+
# Creates or updates named lenses (saved transforms) and returns them. Definitions
|
|
111
123
|
# this client already saved are not sent again. Supplying the same definition and version is
|
|
112
124
|
# idempotent; changing a lens needs a higher `version`. More than 100 new definitions are sent in
|
|
113
125
|
# consecutive requests, in order.
|
|
114
|
-
# @option options [Array<Hash>] :lenses `[{identifier:, name:, type:, version:,
|
|
126
|
+
# @option options [Array<Hash>] :lenses `[{identifier:, name:, type:, version:, transform:, ...}]`
|
|
115
127
|
# @return [Array<Hash>] the saved lenses, in the given order.
|
|
116
128
|
def ensure_lenses(options) = @lenses.ensure_lenses(options)
|
|
117
129
|
|
|
@@ -135,10 +147,15 @@ module CalmLens
|
|
|
135
147
|
|
|
136
148
|
private
|
|
137
149
|
|
|
138
|
-
def check_limits(upload_timeout, max_response_bytes)
|
|
150
|
+
def check_limits(upload_timeout, max_response_bytes, part_size_bytes)
|
|
139
151
|
unless upload_timeout.nil? || Internal.positive?(upload_timeout)
|
|
140
152
|
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'upload_timeout must be nil or a positive number of seconds')
|
|
141
153
|
end
|
|
154
|
+
unless part_size_bytes.is_a?(Integer) && part_size_bytes.between?(MIN_PART_SIZE_BYTES, MAX_PART_SIZE_BYTES)
|
|
155
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG,
|
|
156
|
+
"part_size_bytes must be an Integer from #{MIN_PART_SIZE_BYTES} (5 MiB) to " \
|
|
157
|
+
"#{MAX_PART_SIZE_BYTES} (64 MiB)")
|
|
158
|
+
end
|
|
142
159
|
return if max_response_bytes.is_a?(Integer) && max_response_bytes.positive?
|
|
143
160
|
|
|
144
161
|
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'max_response_bytes must be a positive Integer')
|
data/lib/calmlens/session.rb
CHANGED
|
@@ -25,13 +25,16 @@ module CalmLens
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
attr_reader :project_id, :base_url, :cdn_base_url
|
|
28
|
+
# Content larger than this many bytes that can be read again is uploaded in parts of this size.
|
|
29
|
+
attr_reader :part_size_bytes
|
|
28
30
|
# Leaves the upload size checks the API also makes (part and image sizes) to the API, so an
|
|
29
31
|
# offline stand-in answers them like the API does. {MockServerClient} sets it.
|
|
30
32
|
attr_accessor :defer_upload_checks
|
|
31
33
|
|
|
32
34
|
def initialize(api_key:, project_id:, base_url:, cdn_base_url:, timeout:, open_timeout:, max_retries:,
|
|
33
|
-
retry_base_delay:, max_response_bytes:, upload_timeout:, transport:)
|
|
35
|
+
retry_base_delay:, max_response_bytes:, upload_timeout:, part_size_bytes:, transport:)
|
|
34
36
|
@api_key = api_key
|
|
37
|
+
@part_size_bytes = part_size_bytes
|
|
35
38
|
@project_id = project_id
|
|
36
39
|
@base_url = base_url
|
|
37
40
|
@cdn_base_url = cdn_base_url
|
|
@@ -161,10 +164,10 @@ module CalmLens
|
|
|
161
164
|
private
|
|
162
165
|
|
|
163
166
|
# The fields that identify a lens definition, as canonical JSON: an unset description or
|
|
164
|
-
#
|
|
167
|
+
# transform is the same definition as an explicit null.
|
|
165
168
|
def lens_key(lens)
|
|
166
169
|
fields = { 'identifier' => lens['identifier'], 'name' => lens['name'], 'description' => lens['description'],
|
|
167
|
-
'type' => lens['type'], '
|
|
170
|
+
'type' => lens['type'], 'transform' => lens['transform'] }
|
|
168
171
|
%w[active version].each { |name| fields[name] = lens[name] if lens.key?(name) }
|
|
169
172
|
JSON.generate(canonical(fields))
|
|
170
173
|
end
|
|
@@ -17,5 +17,8 @@ module CalmLens
|
|
|
17
17
|
"personal-information" => ["personal-information-contact", "personal-information-email", "personal-information-phone", "personal-information-government-id", "personal-information-financial", "personal-information-location", "personal-information-identifiers", "personal-information-credentials", "personal-information-request", "personal-information-self-disclosure", "personal-information-third-party", "personal-information-historical", "personal-information-documentary", "personal-information-educational", "personal-information-fictional", "personal-information-support-prevention"].freeze,
|
|
18
18
|
"profanity" => ["profanity-mild", "profanity-strong", "profanity-slurs", "profanity-obfuscated", "profanity-historical", "profanity-documentary", "profanity-educational", "profanity-fictional", "profanity-support-prevention"].freeze,
|
|
19
19
|
}.freeze
|
|
20
|
+
|
|
21
|
+
# Qualifiers the input type sets, which a test input scenario cannot list.
|
|
22
|
+
INPUT_QUALIFIERS = ["sexual-text", "violence-text"].freeze
|
|
20
23
|
end
|
|
21
24
|
end
|
|
@@ -59,11 +59,15 @@ module CalmLens
|
|
|
59
59
|
MAX_DECISION_NOTE_LENGTH = 1499
|
|
60
60
|
RESOURCES = %w[original rendered placeholder].freeze
|
|
61
61
|
MIN_PART = 5 * 1024 * 1024
|
|
62
|
-
MIN_MULTIPART_IMAGE = 10 * 1024 * 1024
|
|
63
62
|
MAX_MULTIPART_IMAGE = 20 * 1024 * 1024
|
|
63
|
+
# Like the API: an upload has at most this many parts.
|
|
64
|
+
MAX_PARTS = 10_000
|
|
64
65
|
# The ThumbHash the mock gives every classified image, standing in for one computed from its
|
|
65
66
|
# pixels. The `placeholder` resource serves it as a PNG, like the CDN.
|
|
66
67
|
THUMBHASH = '1QcSHQRnh493V4dIh4eXh1h4kJUI'
|
|
68
|
+
# The built-in presets the mock knows, by display name.
|
|
69
|
+
PRESET_NAMES = { 'general-community' => 'General', 'children-platform' => 'Kids',
|
|
70
|
+
'education-support' => 'Learning and support', 'wholesome-community' => 'Teen-safe' }.freeze
|
|
67
71
|
# Like the API: 1 to 255 printable ASCII characters, without a space at either end.
|
|
68
72
|
IDEMPOTENCY_KEY = /\A[\x21-\x7e](?:[\x20-\x7e]{0,253}[\x21-\x7e])?\z/
|
|
69
73
|
# Content a browser could run as a document, which the CDN serves as a download.
|
|
@@ -83,6 +87,10 @@ module CalmLens
|
|
|
83
87
|
@grants = grants
|
|
84
88
|
@state = State.new(assets: {}, folders: {}, content: {}, events: [], lenses: {}, uploads: {}, idempotency: {},
|
|
85
89
|
hash_lists: {}, hash_list_entries: {})
|
|
90
|
+
# Stored policies (the API's shape without `projectIds` and `preset.latestVersion`, plus their
|
|
91
|
+
# reset `baseline`) and the one the project moderates with.
|
|
92
|
+
@policies = {}
|
|
93
|
+
@project_policy_id = nil
|
|
86
94
|
@request_count = 0
|
|
87
95
|
end
|
|
88
96
|
|
|
@@ -304,6 +312,10 @@ module CalmLens
|
|
|
304
312
|
in ['POST', ['folders'], _] then create_folder(json)
|
|
305
313
|
in ['GET', ['hash-lists'], _] then page(project_hash_lists, query, JSON.generate([@project_id]))
|
|
306
314
|
in ['POST', ['hash-lists'], _] then create_hash_list(json)
|
|
315
|
+
in ['GET', ['policies'], _] then policy_list
|
|
316
|
+
in ['POST', ['policies'], _] then create_policy(json)
|
|
317
|
+
in ['PUT', ['policy'], _] then show_policy(assign_policy(json))
|
|
318
|
+
in [_, ['policies', String => policy_id, *rest], _] then policy(method, policy_id, rest, json)
|
|
307
319
|
in ['GET', ['assets'], _] then list(query)
|
|
308
320
|
in ['GET', ['assets', 'search'], _] then search(query)
|
|
309
321
|
in ['POST', ['assets', 'batch'], _] then batch(json)
|
|
@@ -399,11 +411,12 @@ module CalmLens
|
|
|
399
411
|
def project_folders = @state.folders.values.select { |item| item['projectId'] == @project_id }
|
|
400
412
|
|
|
401
413
|
# Like the API: without a cursor, no events and a `nextCursor` for now; with one, up to `pageSize`
|
|
402
|
-
# events added since, oldest first. `nextCursor` is never nil.
|
|
414
|
+
# events added since, oldest first. `nextCursor` is never nil. The mock's key holds every permission,
|
|
415
|
+
# so no event type is excluded.
|
|
403
416
|
def events(query)
|
|
404
417
|
size = check_range(query.fetch('pageSize', DEFAULT_PAGE_SIZE), 'pageSize', MAX_PAGE_SIZE)
|
|
405
418
|
feed = @state.events
|
|
406
|
-
return { 'items' => [], 'nextCursor' => "mock_#{feed.size}", 'hasMore' => false } unless query['cursor']
|
|
419
|
+
return { 'items' => [], 'nextCursor' => "mock_#{feed.size}", 'hasMore' => false, 'excludedTypes' => [] } unless query['cursor']
|
|
407
420
|
|
|
408
421
|
match = query['cursor'].to_s.match(/\Amock_(\d+)\z/)
|
|
409
422
|
position = match && Integer(match[1], 10)
|
|
@@ -411,7 +424,7 @@ module CalmLens
|
|
|
411
424
|
|
|
412
425
|
taken = feed[position, size]
|
|
413
426
|
following = position + taken.size
|
|
414
|
-
{ 'items' => taken, 'nextCursor' => "mock_#{following}", 'hasMore' => following < feed.size }
|
|
427
|
+
{ 'items' => taken, 'nextCursor' => "mock_#{following}", 'hasMore' => following < feed.size, 'excludedTypes' => [] }
|
|
415
428
|
end
|
|
416
429
|
|
|
417
430
|
# Saves lenses in order, like the API: an unchanged definition returns the stored lens, a
|
|
@@ -429,7 +442,7 @@ module CalmLens
|
|
|
429
442
|
end
|
|
430
443
|
|
|
431
444
|
same = %w[name type active].all? { |key| old[key] == definition[key] } &&
|
|
432
|
-
old['description'] == definition['description'] && canonical(old['
|
|
445
|
+
old['description'] == definition['description'] && canonical(old['transform']) == canonical(definition['transform'])
|
|
433
446
|
return old if same && old['version'] == definition['version']
|
|
434
447
|
if definition['version'].to_i > old['version'].to_i
|
|
435
448
|
return @state.lenses[old['identifier']] =
|
|
@@ -533,12 +546,9 @@ module CalmLens
|
|
|
533
546
|
}
|
|
534
547
|
end
|
|
535
548
|
|
|
536
|
-
# Like the API: image uploads in parts total
|
|
537
|
-
def image_size_error(type, size,
|
|
538
|
-
|
|
539
|
-
return ErrorCodes::MULTIPART_IMAGE_TOO_LARGE if size > MAX_MULTIPART_IMAGE
|
|
540
|
-
|
|
541
|
-
ErrorCodes::MULTIPART_IMAGE_TOO_SMALL if final && size < MIN_MULTIPART_IMAGE
|
|
549
|
+
# Like the API: image uploads in parts total at most 20 MiB, at any size below that.
|
|
550
|
+
def image_size_error(type, size, _final)
|
|
551
|
+
ErrorCodes::MULTIPART_IMAGE_TOO_LARGE if type == 'image' && size > MAX_MULTIPART_IMAGE
|
|
542
552
|
end
|
|
543
553
|
|
|
544
554
|
# Like the API: the parts may not overshoot a declared total, and must reach it at the end.
|
|
@@ -793,7 +803,7 @@ module CalmLens
|
|
|
793
803
|
fail!(ErrorCodes::UPLOAD_NOT_OPEN) unless item['upload']['status'] == 'uploading'
|
|
794
804
|
number = query['partNumber']
|
|
795
805
|
expected = item['upload']['partsReceived'].to_i + 1
|
|
796
|
-
unless number == expected
|
|
806
|
+
unless number == expected && number <= MAX_PARTS
|
|
797
807
|
fail!(ErrorCodes::INVALID_UPLOAD_PART_NUMBER, "Expected part #{expected}",
|
|
798
808
|
details: { 'expectedPartNumber' => expected, 'receivedPartNumber' => number })
|
|
799
809
|
end
|
|
@@ -877,6 +887,122 @@ module CalmLens
|
|
|
877
887
|
touch(item.merge!('parentId' => parent_id))
|
|
878
888
|
end
|
|
879
889
|
|
|
890
|
+
# Policies: the project starts on its General preset policy, following automatic updates. The
|
|
891
|
+
# mock holds no built-in preset settings: a preset's settings are `{ 'categorySettings' => {} }`
|
|
892
|
+
# at version 1, and uploads are never decided by a policy.
|
|
893
|
+
def new_policy(name, settings, description: '', automatic_updates: false, preset: nil)
|
|
894
|
+
now = @now.call
|
|
895
|
+
item = { 'id' => @create_id.call('pol'), 'name' => name, 'description' => description, 'settings' => settings,
|
|
896
|
+
'revision' => 1, 'automaticUpdates' => automatic_updates, 'preset' => preset,
|
|
897
|
+
'baseline' => deep_copy(settings), 'createdAt' => now, 'updatedAt' => now }
|
|
898
|
+
@policies[item['id']] = item
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def ensure_policies
|
|
902
|
+
return if @project_policy_id
|
|
903
|
+
|
|
904
|
+
preset = { 'id' => 'general-community', 'version' => 1, 'projectId' => @project_id }
|
|
905
|
+
@project_policy_id = new_policy(PRESET_NAMES['general-community'], { 'categorySettings' => {} },
|
|
906
|
+
automatic_updates: true, preset: preset)['id']
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# A stored policy as the API shows it.
|
|
910
|
+
def show_policy(item)
|
|
911
|
+
shown = item.except('baseline')
|
|
912
|
+
shown['preset'] = item['preset'].merge('latestVersion' => 1) if item['preset']
|
|
913
|
+
shown.merge('projectIds' => item['id'] == @project_policy_id ? [@project_id] : [])
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def find_policy(id)
|
|
917
|
+
ensure_policies
|
|
918
|
+
@policies[id] || fail!(ErrorCodes::POLICY_NOT_FOUND)
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def policy_list
|
|
922
|
+
ensure_policies
|
|
923
|
+
{ 'policies' => @policies.values.map { |item| show_policy(item) }, 'projectPolicyId' => @project_policy_id }
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def create_policy(json)
|
|
927
|
+
ensure_policies
|
|
928
|
+
name, preset_id, settings = json.values_at('name', 'presetId', 'settings')
|
|
929
|
+
named = name.is_a?(String) && !name.strip.empty?
|
|
930
|
+
if settings.nil? == preset_id.nil? || (preset_id && !PRESET_NAMES.key?(preset_id)) || (preset_id.nil? && !named)
|
|
931
|
+
fail!(ErrorCodes::INVALID_VALUE, 'Send settings with a name, or presetId')
|
|
932
|
+
end
|
|
933
|
+
description = json['description'].is_a?(String) ? json['description'] : ''
|
|
934
|
+
item = if preset_id
|
|
935
|
+
origin = { 'id' => preset_id, 'version' => 1, 'projectId' => nil }
|
|
936
|
+
new_policy(named ? name.strip : PRESET_NAMES[preset_id], { 'categorySettings' => {} },
|
|
937
|
+
description: description, preset: origin)
|
|
938
|
+
else
|
|
939
|
+
new_policy(name.strip, settings, description: description)
|
|
940
|
+
end
|
|
941
|
+
# Like the API, a new policy is 201 Created.
|
|
942
|
+
respond(201, deep_copy(show_policy(item)))
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
def assign_policy(json)
|
|
946
|
+
item = find_policy(json['policyId'].to_s)
|
|
947
|
+
@project_policy_id = item['id']
|
|
948
|
+
item
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# Saves new values under the next revision, once `revision` is the current one.
|
|
952
|
+
def save_policy(item, revision, values)
|
|
953
|
+
fail!(ErrorCodes::POLICY_REVISION_CONFLICT) unless revision.is_a?(Integer) && revision == item['revision']
|
|
954
|
+
|
|
955
|
+
item.merge!(values, 'revision' => item['revision'] + 1, 'updatedAt' => @now.call)
|
|
956
|
+
show_policy(item)
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def policy(method, id, rest, json)
|
|
960
|
+
item = find_policy(id)
|
|
961
|
+
case [method, rest]
|
|
962
|
+
in ['GET', []] then show_policy(item)
|
|
963
|
+
in ['PATCH', []] then update_policy(item, json)
|
|
964
|
+
in ['DELETE', []]
|
|
965
|
+
fail!(ErrorCodes::POLICY_IN_USE) if item['id'] == @project_policy_id
|
|
966
|
+
@policies.delete(id)
|
|
967
|
+
{}
|
|
968
|
+
in ['POST', ['preset']] then apply_policy_preset(item, json)
|
|
969
|
+
in ['PUT', ['automatic-updates']] then set_policy_automatic_updates(item, json)
|
|
970
|
+
else route_not_found!(method, ['policies', id, *rest])
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def update_policy(item, json)
|
|
975
|
+
values = %w[name description].select { |key| json[key].is_a?(String) }
|
|
976
|
+
.to_h { |key| [key, key == 'name' ? json[key].strip : json[key]] }
|
|
977
|
+
values['settings'] = json['settings'] if json.key?('settings')
|
|
978
|
+
# Saving a policy that follows its preset pins it as a copy.
|
|
979
|
+
save_policy(item, json['revision'], values.merge('automaticUpdates' => false))
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
def apply_policy_preset(item, json)
|
|
983
|
+
preset = item['preset']
|
|
984
|
+
fail!(ErrorCodes::POLICY_NO_PRESET) if preset.nil? || item['automaticUpdates']
|
|
985
|
+
fail!(ErrorCodes::POLICY_PRESET_VERSION_CONFLICT) unless json['presetVersion'] == preset['version']
|
|
986
|
+
# The mock's presets have one release, so an update never has a newer one to apply.
|
|
987
|
+
fail!(ErrorCodes::POLICY_PRESET_UPDATE_UNAVAILABLE) if json['operation'] == 'update'
|
|
988
|
+
|
|
989
|
+
save_policy(item, json['revision'], 'settings' => deep_copy(item['baseline']))
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
def set_policy_automatic_updates(item, json)
|
|
993
|
+
enabled = json['enabled'] == true
|
|
994
|
+
preset = item['preset']
|
|
995
|
+
if preset.nil? || preset['projectId'] != @project_id || item['automaticUpdates'] == enabled
|
|
996
|
+
fail!(ErrorCodes::POLICY_AUTOMATIC_UPDATES_UNAVAILABLE)
|
|
997
|
+
end
|
|
998
|
+
fail!(ErrorCodes::POLICY_REVISION_CONFLICT) unless json['revision'] == item['revision']
|
|
999
|
+
fail!(ErrorCodes::POLICY_PRESET_VERSION_CONFLICT) unless json['presetVersion'] == 1
|
|
1000
|
+
|
|
1001
|
+
values = { 'automaticUpdates' => enabled }
|
|
1002
|
+
values.merge!('name' => PRESET_NAMES[preset['id']], 'description' => '', 'settings' => deep_copy(item['baseline'])) if enabled
|
|
1003
|
+
save_policy(item, json['revision'], values)
|
|
1004
|
+
end
|
|
1005
|
+
|
|
880
1006
|
# Hash lists and their entries. Uploads are not compared with them.
|
|
881
1007
|
def project_hash_lists = @state.hash_lists.values.select { |item| item['projectId'] == @project_id }
|
|
882
1008
|
|