calmlens 0.18.3 → 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.
@@ -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
@@ -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
@@ -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
- def call(options)
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 transform pipelines) and returns them. Definitions
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:, transforms:, ...}]`
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')
@@ -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
- # transforms list is the same definition as an explicit null.
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'], 'transforms' => lens['transforms'] }
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['transforms']) == canonical(definition['transforms'])
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 10 MiB (checked at the last part) to 20 MiB.
537
- def image_size_error(type, size, final)
538
- return unless type == 'image'
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