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
data/lib/calmlens/errors.rb
CHANGED
|
@@ -13,11 +13,12 @@ module CalmLens
|
|
|
13
13
|
TRANSPORT_ERROR = 'sdk/transport-error'
|
|
14
14
|
# Connecting, sending or receiving took longer than the configured timeout.
|
|
15
15
|
TIMEOUT = 'sdk/timeout'
|
|
16
|
-
# A successful response could not be read
|
|
16
|
+
# A successful response could not be read: malformed JSON, or not what the API returns.
|
|
17
17
|
INVALID_RESPONSE = 'sdk/invalid-response'
|
|
18
|
-
# A response body was larger than the
|
|
18
|
+
# A response body was larger than the client's `max_response_bytes` (32 MiB by default).
|
|
19
19
|
RESPONSE_TOO_LARGE = 'sdk/response-too-large'
|
|
20
|
-
# An error response did not carry a CalmLens error code
|
|
20
|
+
# An error response did not carry a CalmLens error code, or the API answered with a redirect,
|
|
21
|
+
# which the SDK never follows.
|
|
21
22
|
HTTP_ERROR = 'sdk/http-error'
|
|
22
23
|
# A webhook signature or timestamp did not verify.
|
|
23
24
|
INVALID_WEBHOOK_SIGNATURE = 'sdk/invalid-webhook-signature'
|
|
@@ -31,12 +32,13 @@ module CalmLens
|
|
|
31
32
|
# @return [String] a stable identifier such as `asset/not-found` or `sdk/timeout`.
|
|
32
33
|
# Compare it with {ErrorCodes} or {SdkErrorCodes}, never with the message.
|
|
33
34
|
# @!attribute [r] details
|
|
34
|
-
# @return [Hash{String => Object}]
|
|
35
|
-
#
|
|
35
|
+
# @return [Hash{String => Object}, nil] the API's details, such as validation `issues`; nil when
|
|
36
|
+
# the error has no details object.
|
|
36
37
|
# @!attribute [r] status
|
|
37
38
|
# @return [Integer, nil] the HTTP status, or nil when no response was received.
|
|
38
39
|
# @!attribute [r] request_id
|
|
39
|
-
# @return [String, nil] the `X-Request-Id` response header
|
|
40
|
+
# @return [String, nil] the `X-Request-Id` response header, or the error body's `requestId`.
|
|
41
|
+
# Include it when contacting support.
|
|
40
42
|
# @!attribute [r] headers
|
|
41
43
|
# @return [Hash{String => String}] response headers with lowercase names (empty without a response).
|
|
42
44
|
class Error < StandardError
|
|
@@ -44,7 +46,7 @@ module CalmLens
|
|
|
44
46
|
|
|
45
47
|
# @param code [String]
|
|
46
48
|
# @param message [String, nil] defaults to the code.
|
|
47
|
-
def initialize(code, message = nil, details:
|
|
49
|
+
def initialize(code, message = nil, details: nil, status: nil, headers: {}, request_id: nil)
|
|
48
50
|
super("[calmlens] #{message || code}")
|
|
49
51
|
@code = code
|
|
50
52
|
@details = details
|
|
@@ -53,7 +55,8 @@ module CalmLens
|
|
|
53
55
|
@request_id = request_id || headers['x-request-id']
|
|
54
56
|
end
|
|
55
57
|
|
|
56
|
-
# @return [
|
|
58
|
+
# @return [Integer, nil] whole seconds to wait before retrying, from the `Retry-After` header
|
|
59
|
+
# (seconds, or an HTTP date counted from now).
|
|
57
60
|
def retry_after
|
|
58
61
|
Error.parse_retry_after(headers['retry-after'])
|
|
59
62
|
end
|
|
@@ -63,11 +66,19 @@ module CalmLens
|
|
|
63
66
|
status == 404 || code.end_with?('/not-found')
|
|
64
67
|
end
|
|
65
68
|
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
# Codes that may succeed when the same request is repeated, whatever the status.
|
|
70
|
+
RETRYABLE_CODES = [SdkErrorCodes::TRANSPORT_ERROR, SdkErrorCodes::TIMEOUT,
|
|
71
|
+
ErrorCodes::IDEMPOTENT_REQUEST_IN_PROGRESS].freeze
|
|
72
|
+
# HTTP statuses that may succeed when the same request is repeated.
|
|
73
|
+
RETRYABLE_STATUSES = [429, 502, 503, 504].freeze
|
|
74
|
+
private_constant :RETRYABLE_CODES, :RETRYABLE_STATUSES
|
|
75
|
+
|
|
76
|
+
# @return [Boolean] true when repeating the same request may succeed: connection failures
|
|
77
|
+
# (`sdk/transport-error`), timeouts (`sdk/timeout`, including {Assets#wait_for_decision}),
|
|
78
|
+
# `idempotency/request-in-progress`, throttling (429) and 502/503/504. Everything else,
|
|
79
|
+
# including other 5xx statuses, is false. Repeat a write only with the same idempotency key.
|
|
69
80
|
def retryable?
|
|
70
|
-
|
|
81
|
+
RETRYABLE_CODES.include?(code) || RETRYABLE_STATUSES.include?(status)
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
# Builds the most specific error class for a code and HTTP status.
|
|
@@ -88,9 +99,9 @@ module CalmLens
|
|
|
88
99
|
return nil if value.nil? || value.to_s.strip.empty?
|
|
89
100
|
|
|
90
101
|
text = value.to_s.strip
|
|
91
|
-
return
|
|
102
|
+
return Integer(text, 10) if text.match?(/\A\d+\z/)
|
|
92
103
|
|
|
93
|
-
[Time.httpdate(text) - Time.now, 0
|
|
104
|
+
[(Time.httpdate(text) - Time.now).ceil, 0].max
|
|
94
105
|
rescue ArgumentError
|
|
95
106
|
nil
|
|
96
107
|
end
|
|
@@ -129,9 +140,7 @@ module CalmLens
|
|
|
129
140
|
class ServerError < APIError; end
|
|
130
141
|
|
|
131
142
|
# No complete response arrived (`sdk/transport-error`). The original exception is in `cause`.
|
|
132
|
-
class ConnectionError < Error
|
|
133
|
-
def retryable? = true
|
|
134
|
-
end
|
|
143
|
+
class ConnectionError < Error; end
|
|
135
144
|
|
|
136
145
|
# Connecting, sending or receiving exceeded its timeout, or {Assets#wait_for_decision} had no
|
|
137
146
|
# decision in time (`sdk/timeout`).
|
data/lib/calmlens/events.rb
CHANGED
|
@@ -3,44 +3,47 @@
|
|
|
3
3
|
module CalmLens
|
|
4
4
|
# The project's events feed. Reach it through {ServerClient#events}.
|
|
5
5
|
class Events < Resource
|
|
6
|
-
# Lists events after a cursor, oldest first. Without `
|
|
7
|
-
# for the current time: save it, then pass each response's `
|
|
8
|
-
# @option options [String] :
|
|
9
|
-
# @option options [Integer] :
|
|
10
|
-
# @return [Hash] `{'
|
|
6
|
+
# Lists events after a cursor, oldest first. Without `cursor`, returns no events and a
|
|
7
|
+
# `nextCursor` for the current time: save it, then pass each response's `nextCursor` as `cursor`.
|
|
8
|
+
# @option options [String] :cursor the `nextCursor` of a previous response
|
|
9
|
+
# @option options [Integer] :page_size 1 to 100, default 50
|
|
10
|
+
# @return [Hash] `{'items', 'nextCursor', 'hasMore'}`; `nextCursor` is never nil
|
|
11
11
|
def list(options = {})
|
|
12
12
|
call(options) { |api, request| request('GET', project('events'), query: api, request_options: request) }
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Iterates pages from `
|
|
16
|
-
# events are handled, and resume from it later.
|
|
15
|
+
# Iterates pages from `cursor` until `hasMore` is false. Persist each page's `nextCursor` once its
|
|
16
|
+
# events are handled, and resume from it later. Raises `sdk/invalid-response` (with
|
|
17
|
+
# `details['cursor']`) instead of looping when a page with more to follow repeats a cursor.
|
|
17
18
|
# @yieldparam page [Hash]
|
|
18
19
|
# @return [Enumerator, nil] an Enumerator when no block is given.
|
|
19
20
|
def each_page(options = {})
|
|
20
21
|
return enum_for(:each_page, options) unless block_given?
|
|
21
22
|
|
|
22
23
|
api, request = call(options) { |*split| split }
|
|
24
|
+
sent = api.key?('cursor') ? [api['cursor']] : []
|
|
23
25
|
loop do
|
|
24
26
|
page = list(api.merge('request_options' => request))
|
|
25
|
-
unless page.is_a?(Hash) && page['
|
|
26
|
-
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Events page has no
|
|
27
|
+
unless page.is_a?(Hash) && page['items'].is_a?(Array) && page['nextCursor'].is_a?(String)
|
|
28
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Events page has no items or next cursor')
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
new_cursor!(sent, page['nextCursor']) if page['hasMore'] == true
|
|
29
32
|
yield page
|
|
30
33
|
break unless page['hasMore'] == true
|
|
31
34
|
|
|
32
|
-
api = api.merge('
|
|
35
|
+
api = api.merge('cursor' => page['nextCursor'])
|
|
33
36
|
end
|
|
34
37
|
nil
|
|
35
38
|
end
|
|
36
39
|
|
|
37
|
-
# Iterates the events after `
|
|
40
|
+
# Iterates the events after `cursor`, across pages. See {#each_page} to track the cursor.
|
|
38
41
|
# @yieldparam event [Hash]
|
|
39
42
|
# @return [Enumerator, nil] an Enumerator when no block is given.
|
|
40
43
|
def each(options = {}, &block)
|
|
41
44
|
return enum_for(:each, options) unless block
|
|
42
45
|
|
|
43
|
-
each_page(options) { |page| page['
|
|
46
|
+
each_page(options) { |page| page['items'].each(&block) }
|
|
44
47
|
end
|
|
45
48
|
end
|
|
46
49
|
end
|
data/lib/calmlens/folders.rb
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
module CalmLens
|
|
4
4
|
# Folders organise a project's assets. Reach it through {ServerClient#folders}.
|
|
5
5
|
class Folders < Resource
|
|
6
|
-
#
|
|
6
|
+
# Lists one page of child folders, sorted by name unless `ordering` says otherwise.
|
|
7
|
+
# @option options [String] :parent_id list this folder's children; omit for the project root
|
|
8
|
+
# @option options [String] :cursor the previous page's `nextCursor`
|
|
9
|
+
# @option options [Integer] :page_size 1 to 100, default 50
|
|
10
|
+
# @option options [Array<Hash>] :ordering one `{field: 'name' | 'createdAt', direction: 'asc' | 'desc'}`
|
|
11
|
+
# @option options [String] :search only children whose name contains it
|
|
12
|
+
# @return [Hash] a cursor page (items, nextCursor, hasMore, pageSize, ordering) with ancestors.
|
|
7
13
|
def list(options = {})
|
|
8
|
-
call(options) do |
|
|
9
|
-
response = request('GET', project('folders'), request_options: request)
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
call(options) do |api, request|
|
|
15
|
+
response = request('GET', project('folders'), query: api, request_options: request)
|
|
16
|
+
valid = response.is_a?(Hash) && response['items'].is_a?(Array) && response['ancestors'].is_a?(Array) &&
|
|
17
|
+
[true, false].include?(response['hasMore'])
|
|
18
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Invalid folder page') unless valid
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
response
|
|
14
21
|
end
|
|
15
22
|
end
|
|
16
23
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# Hash lists hold perceptual hashes of images. Image uploads are compared with every list in the
|
|
5
|
+
# project: a `flag` list reports matches, a `block` list also blocks close matches. Reach it through
|
|
6
|
+
# {ServerClient#hash_lists}.
|
|
7
|
+
class HashLists < Resource
|
|
8
|
+
# Lists one page of the project's hash lists, newest first.
|
|
9
|
+
# @option options [String] :cursor the previous page's `nextCursor`
|
|
10
|
+
# @option options [Integer] :page_size 1 to 100, default 50
|
|
11
|
+
# @return [Hash] a cursor page: items, nextCursor, hasMore, pageSize and ordering.
|
|
12
|
+
def list(options = {})
|
|
13
|
+
call(options) do |api, request|
|
|
14
|
+
response = request('GET', project('hash-lists'), query: api, request_options: request)
|
|
15
|
+
valid = response.is_a?(Hash) && response['items'].is_a?(Array)
|
|
16
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Invalid hash list page') unless valid
|
|
17
|
+
|
|
18
|
+
response
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @option options [String] :hash_list_id
|
|
23
|
+
# @return [Hash] the hash list
|
|
24
|
+
def retrieve(options)
|
|
25
|
+
call(options) { |api, request| request('GET', list_path(api), request_options: request) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @option options [String] :name
|
|
29
|
+
# @option options [String] :action `flag` or `block`
|
|
30
|
+
# @option options [String, nil] :description
|
|
31
|
+
# @return [Hash] the hash list
|
|
32
|
+
def create(options)
|
|
33
|
+
call(options) { |api, request| request('POST', project('hash-lists'), body: api, request_options: request) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Renames a list or changes what its matches do. The new action applies to later uploads.
|
|
37
|
+
# @option options [String] :hash_list_id
|
|
38
|
+
# @option options [String] :name
|
|
39
|
+
# @option options [String, nil] :description
|
|
40
|
+
# @option options [String] :action `flag` or `block`
|
|
41
|
+
# @return [Hash] the hash list
|
|
42
|
+
def update(options)
|
|
43
|
+
call(options) do |api, request|
|
|
44
|
+
request('PATCH', list_path(api), body: without(api, 'hashListId'), request_options: request)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Deletes the list and every entry in it. Later uploads are no longer compared with it.
|
|
49
|
+
# @option options [String] :hash_list_id
|
|
50
|
+
# @return [nil]
|
|
51
|
+
def delete(options)
|
|
52
|
+
call(options) do |api, request|
|
|
53
|
+
request('DELETE', list_path(api), request_options: request)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Lists one page of a list's entries, newest first.
|
|
59
|
+
# @option options [String] :hash_list_id
|
|
60
|
+
# @option options [String] :cursor the previous page's `nextCursor`
|
|
61
|
+
# @option options [Integer] :page_size 1 to 100, default 50
|
|
62
|
+
# @return [Hash] a cursor page: items, nextCursor, hasMore, pageSize and ordering.
|
|
63
|
+
def list_entries(options)
|
|
64
|
+
call(options) do |api, request|
|
|
65
|
+
request('GET', "#{list_path(api)}/entries", query: without(api, 'hashListId'), request_options: request)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Adds a PDQ hash, or an image asset's hash; a hash already in the list returns its entry.
|
|
70
|
+
# @option options [String] :hash_list_id
|
|
71
|
+
# @option options [String] :hash 64 hexadecimal characters; send this or `asset_id`
|
|
72
|
+
# @option options [Integer] :quality
|
|
73
|
+
# @option options [String] :asset_id an image asset whose hash to add
|
|
74
|
+
# @option options [String] :label
|
|
75
|
+
# @return [Hash] the entry
|
|
76
|
+
def add_entry(options)
|
|
77
|
+
call(options) do |api, request|
|
|
78
|
+
request('POST', "#{list_path(api)}/entries", body: without(api, 'hashListId'), request_options: request)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @option options [String] :entry_id
|
|
83
|
+
# @return [nil]
|
|
84
|
+
def remove_entry(options)
|
|
85
|
+
call(options) do |api, request|
|
|
86
|
+
request('DELETE', "/hash-list-entries/#{Internal.segment(api['entryId'])}", request_options: request)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def list_path(api) = "/hash-lists/#{Internal.segment(api['hashListId'])}"
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/calmlens/internal.rb
CHANGED
|
@@ -8,8 +8,10 @@ module CalmLens
|
|
|
8
8
|
# @api private
|
|
9
9
|
module Internal
|
|
10
10
|
# Per-call settings accepted under the `request_options:` option of any method.
|
|
11
|
-
REQUEST_OPTIONS = %i[idempotency_key timeout open_timeout max_retries].freeze
|
|
12
|
-
|
|
11
|
+
REQUEST_OPTIONS = %i[idempotency_key timeout open_timeout upload_timeout max_retries max_response_bytes].freeze
|
|
12
|
+
# A key the API can receive as a header value; the API checks the rest (1 to 255 printable
|
|
13
|
+
# ASCII characters without a space at either end) and answers `idempotency/invalid-key`.
|
|
14
|
+
IDEMPOTENCY_KEY = /\A[^\r\n\0]+\z/
|
|
13
15
|
HEADER_VALUE = /\A[^\r\n\0]*\z/
|
|
14
16
|
|
|
15
17
|
module_function
|
|
@@ -58,16 +60,19 @@ module CalmLens
|
|
|
58
60
|
def check_request_options(request)
|
|
59
61
|
key = request[:idempotency_key]
|
|
60
62
|
if !key.nil? && !(key.is_a?(String) && key.match?(IDEMPOTENCY_KEY))
|
|
61
|
-
raise invalid('idempotency_key must be
|
|
63
|
+
raise invalid('idempotency_key must be a non-empty String without line breaks')
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
%i[timeout open_timeout].each do |name|
|
|
66
|
+
%i[timeout open_timeout upload_timeout].each do |name|
|
|
65
67
|
next if request[name].nil? || positive?(request[name])
|
|
66
68
|
|
|
67
69
|
raise invalid("#{name} must be a positive number of seconds")
|
|
68
70
|
end
|
|
69
71
|
retries = request[:max_retries]
|
|
70
72
|
raise invalid('max_retries must be a non-negative Integer') unless retries.nil? || (retries.is_a?(Integer) && retries >= 0)
|
|
73
|
+
|
|
74
|
+
limit = request[:max_response_bytes]
|
|
75
|
+
raise invalid('max_response_bytes must be a positive Integer') unless limit.nil? || (limit.is_a?(Integer) && limit.positive?)
|
|
71
76
|
end
|
|
72
77
|
|
|
73
78
|
def positive?(value)
|
|
@@ -88,11 +93,12 @@ module CalmLens
|
|
|
88
93
|
raise invalid('Identifier is required') if text.empty?
|
|
89
94
|
raise invalid('Identifier cannot be "." or ".."') if %w[. ..].include?(text)
|
|
90
95
|
|
|
91
|
-
|
|
96
|
+
# Like JavaScript's encodeURIComponent.
|
|
97
|
+
text.encode(Encoding::UTF_8).b.gsub(/[^A-Za-z0-9\-_.!~*'()]/n) { |byte| format('%%%02X', byte.ord) }
|
|
92
98
|
end
|
|
93
99
|
|
|
94
|
-
# Splits an absolute URL into its parsed origin and its raw request target.
|
|
95
|
-
#
|
|
100
|
+
# Splits an absolute URL into its parsed origin and its raw request target. Only the origin is
|
|
101
|
+
# parsed, so the request target is sent exactly as the SDK built it.
|
|
96
102
|
# @return [Array(URI::Generic, String)]
|
|
97
103
|
def split_url(url)
|
|
98
104
|
match = %r{\A([A-Za-z][A-Za-z0-9+.-]*://[^/?#]*)([^#]*)}.match(url.to_s)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# Earlier posts in a conversation, sent with an upload as `context: { items: [{ text:, author_id: }] }`
|
|
5
|
+
# (oldest first) so CalmLens can tell who the content addresses and what it refers to. CalmLens
|
|
6
|
+
# moderates only the uploaded content; it uses the most recent {USED_ITEMS} posts that fit in
|
|
7
|
+
# {USED_CHARACTERS} characters, and the SDK sends only those.
|
|
8
|
+
module ModerationContext
|
|
9
|
+
# The most recent earlier posts CalmLens uses.
|
|
10
|
+
USED_ITEMS = 5
|
|
11
|
+
# The most characters of earlier posts CalmLens uses, counted as Unicode code points.
|
|
12
|
+
USED_CHARACTERS = 2_000
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# The earlier posts CalmLens uses: the most recent ones, newest first, while at most
|
|
17
|
+
# {USED_ITEMS} posts and {USED_CHARACTERS} characters allow, returned oldest first. The newest
|
|
18
|
+
# post is always kept, so the API can reject one that is too long.
|
|
19
|
+
# @param items [Array<Hash>] posts oldest first, each with `text` and optionally `author_id`
|
|
20
|
+
# (Symbol or String keys, snake_case or camelCase).
|
|
21
|
+
# @return [Array<Hash>] the kept posts as `{'text' => String, 'authorId' => String}`, without
|
|
22
|
+
# `authorId` when a post has none.
|
|
23
|
+
# @raise [InvalidArgumentError] when `items` is not an Array of posts with String text.
|
|
24
|
+
def select_items(items)
|
|
25
|
+
raise Internal.invalid('context.items must be an array of earlier posts') unless items.is_a?(Array)
|
|
26
|
+
|
|
27
|
+
selected = []
|
|
28
|
+
characters = 0
|
|
29
|
+
items.reverse_each do |item|
|
|
30
|
+
break if selected.size == USED_ITEMS
|
|
31
|
+
|
|
32
|
+
post = post(item)
|
|
33
|
+
length = post['text'].length
|
|
34
|
+
break if !selected.empty? && characters + length > USED_CHARACTERS
|
|
35
|
+
|
|
36
|
+
selected.unshift(post)
|
|
37
|
+
characters += length
|
|
38
|
+
end
|
|
39
|
+
selected
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# One post as the API takes it.
|
|
43
|
+
def post(item)
|
|
44
|
+
fields = item.is_a?(Hash) ? Internal.normalize(item) : nil
|
|
45
|
+
text = fields&.fetch('text', nil)
|
|
46
|
+
raise Internal.invalid('Each earlier post needs its text as a String') unless text.is_a?(String)
|
|
47
|
+
|
|
48
|
+
author = fields['authorId']
|
|
49
|
+
raise Internal.invalid("An earlier post's author_id must be a String") unless author.nil? || author.is_a?(String)
|
|
50
|
+
|
|
51
|
+
author.nil? ? { 'text' => text } : { 'text' => text, 'authorId' => author }
|
|
52
|
+
end
|
|
53
|
+
private_class_method :post
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/calmlens/resource.rb
CHANGED
|
@@ -30,22 +30,39 @@ module CalmLens
|
|
|
30
30
|
|
|
31
31
|
def without(options, *keys) = options.except(*keys)
|
|
32
32
|
|
|
33
|
-
# Fetches
|
|
33
|
+
# Fetches cursor pages lazily, from `start`, until a page has no next cursor, says there is no
|
|
34
|
+
# more or is empty. A page with more to follow that hands back a cursor this iteration already
|
|
35
|
+
# sent raises `sdk/invalid-response` rather than repeating pages forever.
|
|
34
36
|
def pages(start, &fetch)
|
|
35
37
|
Enumerator.new do |out|
|
|
36
|
-
|
|
38
|
+
cursor = start
|
|
39
|
+
sent = []
|
|
37
40
|
loop do
|
|
38
|
-
page = fetch.call(
|
|
41
|
+
page = fetch.call(cursor)
|
|
39
42
|
items = page.is_a?(Hash) ? page['items'] : nil
|
|
40
43
|
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Page has no items') unless items.is_a?(Array)
|
|
41
44
|
|
|
45
|
+
following = page['nextCursor']
|
|
46
|
+
more = following.is_a?(String) && !following.empty? && page['hasMore'] == true && !items.empty?
|
|
47
|
+
new_cursor!(sent, following) if more
|
|
42
48
|
out << page
|
|
43
|
-
break unless
|
|
49
|
+
break unless more
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
break unless index
|
|
51
|
+
cursor = following
|
|
47
52
|
end
|
|
48
53
|
end
|
|
49
54
|
end
|
|
55
|
+
|
|
56
|
+
# Records a cursor an iteration sends, raising `sdk/invalid-response` (with `details['cursor']`)
|
|
57
|
+
# when a page with more to follow hands back one it already sent, which would repeat pages forever.
|
|
58
|
+
def new_cursor!(sent, cursor)
|
|
59
|
+
if sent.include?(cursor)
|
|
60
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE,
|
|
61
|
+
'The API returned a cursor it had already returned, with more pages to follow',
|
|
62
|
+
details: { 'cursor' => cursor })
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sent << cursor
|
|
66
|
+
end
|
|
50
67
|
end
|
|
51
68
|
end
|
|
@@ -9,16 +9,31 @@ module CalmLens
|
|
|
9
9
|
# @example
|
|
10
10
|
# client = CalmLens::ServerClient.new(api_key: ENV.fetch('CALMLENS_API_KEY'),
|
|
11
11
|
# project_id: ENV.fetch('CALMLENS_PROJECT_ID'))
|
|
12
|
-
# asset = client.text.
|
|
12
|
+
# asset = client.text.classify(text: 'Hello world')
|
|
13
13
|
# client.close
|
|
14
14
|
class ServerClient
|
|
15
15
|
DEFAULT_BASE_URL = 'https://api.calmlens.com/v1'
|
|
16
16
|
DEFAULT_CDN_BASE_URL = 'https://cdn.calmlens.com/v1'
|
|
17
|
+
# Seconds allowed for each socket read or write.
|
|
18
|
+
DEFAULT_TIMEOUT = 120
|
|
19
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
20
|
+
DEFAULT_MAX_RETRIES = 2
|
|
21
|
+
# Seconds before the first retry; later retries wait twice as long each time, up to RETRY_MAX_DELAY.
|
|
22
|
+
DEFAULT_RETRY_BASE_DELAY = 0.5
|
|
23
|
+
RETRY_MAX_DELAY = 8
|
|
24
|
+
# A `Retry-After` longer than this many seconds is returned to the caller instead of waited out.
|
|
25
|
+
RETRY_AFTER_MAX = 60
|
|
26
|
+
# The largest response body read, in bytes (32 MiB).
|
|
27
|
+
DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
28
|
+
# The most lens definitions {#ensure_lenses} sends in one request.
|
|
29
|
+
ENSURE_LENSES_MAX_PER_REQUEST = 100
|
|
17
30
|
|
|
18
31
|
# @return [Assets]
|
|
19
32
|
attr_reader :assets
|
|
20
33
|
# @return [Folders]
|
|
21
34
|
attr_reader :folders
|
|
35
|
+
# @return [HashLists]
|
|
36
|
+
attr_reader :hash_lists
|
|
22
37
|
# @return [Text]
|
|
23
38
|
attr_reader :text
|
|
24
39
|
# @return [UploadTokens]
|
|
@@ -36,6 +51,11 @@ module CalmLens
|
|
|
36
51
|
# moderation decision. It is not a deadline for a whole transfer, so large uploads and downloads
|
|
37
52
|
# are not cut off. Override per call with `request_options: {timeout:}`.
|
|
38
53
|
# @param open_timeout [Numeric] seconds allowed to open a connection.
|
|
54
|
+
# @param upload_timeout [Numeric, nil] seconds allowed for each attempt of a binary upload (file
|
|
55
|
+
# content or a multipart part) as a whole. Uploads have no such deadline unless you set one; text
|
|
56
|
+
# content follows `timeout` like other requests.
|
|
57
|
+
# @param max_response_bytes [Integer] the largest response body read; a larger one fails with
|
|
58
|
+
# `sdk/response-too-large`, before it is read when its `Content-Length` says so.
|
|
39
59
|
# @param max_retries [Integer] retries of requests that are safe to repeat: GET/HEAD, and uploads,
|
|
40
60
|
# which carry an `Idempotency-Key`. Connection failures, timeouts, 429, 502, 503, 504 and
|
|
41
61
|
# `idempotency/request-in-progress` are retried with jittered exponential backoff, honouring
|
|
@@ -45,17 +65,22 @@ module CalmLens
|
|
|
45
65
|
# @param transport [#call, nil] a custom {Transport}; defaults to {Transport::NetHTTP}.
|
|
46
66
|
# @raise [ConfigurationError] `sdk/invalid-config` for a missing or malformed setting.
|
|
47
67
|
def initialize(api_key:, project_id:, base_url: DEFAULT_BASE_URL, cdn_base_url: DEFAULT_CDN_BASE_URL,
|
|
48
|
-
timeout:
|
|
68
|
+
timeout: DEFAULT_TIMEOUT, open_timeout: DEFAULT_OPEN_TIMEOUT, upload_timeout: nil,
|
|
69
|
+
max_retries: DEFAULT_MAX_RETRIES, retry_base_delay: DEFAULT_RETRY_BASE_DELAY,
|
|
70
|
+
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, transport: nil)
|
|
49
71
|
check_config(api_key, project_id, timeout, open_timeout, max_retries, retry_base_delay, transport)
|
|
72
|
+
check_limits(upload_timeout, max_response_bytes)
|
|
50
73
|
@project_id = project_id
|
|
51
74
|
@base_url = ServerClient.check_url(base_url, 'base_url')
|
|
52
75
|
@cdn_base_url = ServerClient.check_url(cdn_base_url, 'cdn_base_url')
|
|
53
76
|
@session = Session.new(api_key: api_key, project_id: project_id, base_url: @base_url,
|
|
54
77
|
cdn_base_url: @cdn_base_url, timeout: timeout, open_timeout: open_timeout,
|
|
55
78
|
max_retries: max_retries, retry_base_delay: retry_base_delay,
|
|
79
|
+
max_response_bytes: max_response_bytes, upload_timeout: upload_timeout,
|
|
56
80
|
transport: transport || Transport::NetHTTP.new)
|
|
57
81
|
@assets = Assets.new(@session)
|
|
58
82
|
@folders = Folders.new(@session)
|
|
83
|
+
@hash_lists = HashLists.new(@session)
|
|
59
84
|
@text = Text.new(@session, @assets)
|
|
60
85
|
@upload_tokens = UploadTokens.new(@session, api_key)
|
|
61
86
|
@events = Events.new(@session)
|
|
@@ -110,6 +135,15 @@ module CalmLens
|
|
|
110
135
|
|
|
111
136
|
private
|
|
112
137
|
|
|
138
|
+
def check_limits(upload_timeout, max_response_bytes)
|
|
139
|
+
unless upload_timeout.nil? || Internal.positive?(upload_timeout)
|
|
140
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'upload_timeout must be nil or a positive number of seconds')
|
|
141
|
+
end
|
|
142
|
+
return if max_response_bytes.is_a?(Integer) && max_response_bytes.positive?
|
|
143
|
+
|
|
144
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'max_response_bytes must be a positive Integer')
|
|
145
|
+
end
|
|
146
|
+
|
|
113
147
|
def check_config(api_key, project_id, timeout, open_timeout, max_retries, retry_base_delay, transport)
|
|
114
148
|
unless api_key.is_a?(String) && !api_key.empty? && api_key.match?(/\A[\x21-\x7e]+\z/)
|
|
115
149
|
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'api_key must be a non-empty printable ASCII String')
|