calmlens 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalmLens
4
+ # An HTTP response. {CDN#serve} returns one; custom transports and the mock produce them.
5
+ #
6
+ # The body is read at most once: either stream it with {#each_chunk} or read it with {#body}.
7
+ class Response
8
+ # @return [Integer] the HTTP status code.
9
+ attr_reader :status
10
+ # @return [Hash{String => String}] headers with lowercase names.
11
+ attr_reader :headers
12
+
13
+ # @param status [Integer]
14
+ # @param headers [Hash{String => String}]
15
+ # @param body [String, nil] a complete body. Give a block instead to stream chunks.
16
+ # @yield [chunk] a block that yields each body chunk to its block argument.
17
+ def initialize(status:, headers: {}, body: nil, &reader)
18
+ @status = Integer(status)
19
+ @headers = headers.to_h { |name, value| [name.to_s.downcase, value.is_a?(Array) ? value.join(', ') : value.to_s] }.freeze
20
+ @body = body&.b
21
+ @reader = reader
22
+ @consumed = false
23
+ end
24
+
25
+ # @return [String, nil] the `X-Request-Id` header.
26
+ def request_id = headers['x-request-id']
27
+ # @return [String, nil] the `Content-Type` header.
28
+ def content_type = headers['content-type']
29
+
30
+ # @return [Integer, nil] the `Content-Length` header.
31
+ def content_length
32
+ Integer(headers['content-length'], 10, exception: false)
33
+ end
34
+
35
+ # @return [Boolean] true for a 2xx status.
36
+ def success? = status.between?(200, 299)
37
+
38
+ # Streams the body in chunks without holding it in memory.
39
+ # @yieldparam chunk [String] binary bytes
40
+ # @return [void]
41
+ def each_chunk(&block)
42
+ return enum_for(:each_chunk) unless block
43
+
44
+ if @body
45
+ block.call(@body) unless @body.empty?
46
+ else
47
+ raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, 'The response body was already read') if @consumed
48
+
49
+ @reader&.call(&block)
50
+ end
51
+ @consumed = true
52
+ nil
53
+ end
54
+
55
+ # Reads the whole body into memory.
56
+ # @param limit [Integer, nil] maximum bytes; a longer body raises {InvalidResponseError}.
57
+ # @return [String] binary bytes
58
+ def body(limit: nil)
59
+ if @body
60
+ too_large!(limit) if limit && @body.bytesize > limit
61
+ return @body
62
+ end
63
+
64
+ buffer = String.new(capacity: [content_length || 0, 1 << 20].min, encoding: Encoding::BINARY)
65
+ each_chunk do |chunk|
66
+ buffer << chunk
67
+ too_large!(limit) if limit && buffer.bytesize > limit
68
+ end
69
+ @body = buffer
70
+ end
71
+
72
+ # @return [String]
73
+ def inspect = "#<#{self.class.name} status=#{status} content_type=#{content_type.inspect}>"
74
+
75
+ private
76
+
77
+ def too_large!(limit)
78
+ raise InvalidResponseError.new(SdkErrorCodes::RESPONSE_TOO_LARGE, "Response body exceeds #{limit} bytes",
79
+ headers: headers)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module CalmLens
6
+ # The CalmLens API client. Create one per process (or per project) and reuse it: it keeps
7
+ # keep-alive connections, is safe to share between threads, and reconnects after `fork`.
8
+ #
9
+ # @example
10
+ # client = CalmLens::ServerClient.new(api_key: ENV.fetch('CALMLENS_API_KEY'),
11
+ # project_id: ENV.fetch('CALMLENS_PROJECT_ID'))
12
+ # asset = client.text.moderate(text: 'Hello world')
13
+ # client.close
14
+ class ServerClient
15
+ DEFAULT_BASE_URL = 'https://api.calmlens.com/v1'
16
+ DEFAULT_CDN_BASE_URL = 'https://cdn.calmlens.com/v1'
17
+
18
+ # @return [Assets]
19
+ attr_reader :assets
20
+ # @return [Folders]
21
+ attr_reader :folders
22
+ # @return [Text]
23
+ attr_reader :text
24
+ # @return [UploadTokens]
25
+ attr_reader :upload_tokens
26
+ # @return [Events]
27
+ attr_reader :events
28
+ # @return [CDN]
29
+ attr_reader :cdn
30
+
31
+ # @param api_key [String] a project API key. Keep it on your server.
32
+ # @param project_id [String]
33
+ # @param base_url [String] the API base URL.
34
+ # @param cdn_base_url [String] the CDN base URL.
35
+ # @param timeout [Numeric] seconds allowed for each socket read or write, including waiting for a
36
+ # moderation decision. It is not a deadline for a whole transfer, so large uploads and downloads
37
+ # are not cut off. Override per call with `request_options: {timeout:}`.
38
+ # @param open_timeout [Numeric] seconds allowed to open a connection.
39
+ # @param max_retries [Integer] retries of requests that are safe to repeat: GET/HEAD, and uploads,
40
+ # which carry an `Idempotency-Key`. Connection failures, timeouts, 429, 502, 503, 504 and
41
+ # `idempotency/request-in-progress` are retried with jittered exponential backoff, honouring
42
+ # `Retry-After`. Other writes are never retried. 0 disables retries.
43
+ # @param retry_base_delay [Numeric] seconds before the first retry when the API gives no
44
+ # `Retry-After`; later retries wait up to twice as long each time, at most 8 seconds.
45
+ # @param transport [#call, nil] a custom {Transport}; defaults to {Transport::NetHTTP}.
46
+ # @raise [ConfigurationError] `sdk/invalid-config` for a missing or malformed setting.
47
+ def initialize(api_key:, project_id:, base_url: DEFAULT_BASE_URL, cdn_base_url: DEFAULT_CDN_BASE_URL,
48
+ timeout: 120, open_timeout: 10, max_retries: 2, retry_base_delay: 0.5, transport: nil)
49
+ check_config(api_key, project_id, timeout, open_timeout, max_retries, retry_base_delay, transport)
50
+ @project_id = project_id
51
+ @base_url = ServerClient.check_url(base_url, 'base_url')
52
+ @cdn_base_url = ServerClient.check_url(cdn_base_url, 'cdn_base_url')
53
+ @session = Session.new(api_key: api_key, project_id: project_id, base_url: @base_url,
54
+ cdn_base_url: @cdn_base_url, timeout: timeout, open_timeout: open_timeout,
55
+ max_retries: max_retries, retry_base_delay: retry_base_delay,
56
+ transport: transport || Transport::NetHTTP.new)
57
+ @assets = Assets.new(@session)
58
+ @folders = Folders.new(@session)
59
+ @text = Text.new(@session, @assets)
60
+ @upload_tokens = UploadTokens.new(@session, api_key)
61
+ @events = Events.new(@session)
62
+ @cdn = CDN.new(@session)
63
+ @lenses = Lenses.new(@session)
64
+ end
65
+
66
+ # @return [String]
67
+ attr_reader :project_id
68
+ # @return [String] the API base URL, without a trailing slash.
69
+ attr_reader :base_url
70
+ # @return [String] the CDN base URL, without a trailing slash.
71
+ attr_reader :cdn_base_url
72
+
73
+ # Never shows the API key, including in resource objects that reference the client.
74
+ def inspect = "#<#{self.class.name} project_id=#{@project_id.inspect} base_url=#{@base_url.inspect} api_key=[REDACTED]>"
75
+
76
+ def pretty_print(printer) = printer.text(inspect)
77
+
78
+ # Closes pooled connections. Requests made afterwards fail with `sdk/transport-error`.
79
+ # @return [void]
80
+ def close
81
+ @session.close
82
+ nil
83
+ end
84
+
85
+ # Creates or updates named lenses (saved transform pipelines) and returns them. Definitions
86
+ # this client already saved are not sent again. Supplying the same definition and version is
87
+ # idempotent; changing a lens needs a higher `version`. More than 100 new definitions are sent in
88
+ # consecutive requests, in order.
89
+ # @option options [Array<Hash>] :lenses `[{identifier:, name:, type:, version:, transforms:, ...}]`
90
+ # @return [Array<Hash>] the saved lenses, in the given order.
91
+ def ensure_lenses(options) = @lenses.ensure_lenses(options)
92
+
93
+ # Creates or updates one lens. Takes one lens definition; see {#ensure_lenses}.
94
+ # @return [Hash] the saved lens
95
+ def ensure_lens(options) = @lenses.ensure_lens(options)
96
+
97
+ # @api private
98
+ def self.check_url(value, name)
99
+ uri = URI.parse(value.to_s)
100
+ unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty? && uri.userinfo.nil? &&
101
+ uri.query.nil? && uri.fragment.nil?
102
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG,
103
+ "#{name} must be an http(s) URL without credentials, query or fragment")
104
+ end
105
+
106
+ value.to_s.sub(%r{/+\z}, '')
107
+ rescue URI::Error
108
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, "#{name} is not a valid URL")
109
+ end
110
+
111
+ private
112
+
113
+ def check_config(api_key, project_id, timeout, open_timeout, max_retries, retry_base_delay, transport)
114
+ unless api_key.is_a?(String) && !api_key.empty? && api_key.match?(/\A[\x21-\x7e]+\z/)
115
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'api_key must be a non-empty printable ASCII String')
116
+ end
117
+ unless project_id.is_a?(String) && !project_id.empty? && !%w[. ..].include?(project_id)
118
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'project_id is required')
119
+ end
120
+ unless Internal.positive?(timeout) && Internal.positive?(open_timeout)
121
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'timeout and open_timeout must be positive numbers of seconds')
122
+ end
123
+ unless max_retries.is_a?(Integer) && max_retries >= 0
124
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'max_retries must be a non-negative Integer')
125
+ end
126
+ unless retry_base_delay.is_a?(Numeric) && retry_base_delay.real? && retry_base_delay.to_f.finite? && retry_base_delay >= 0
127
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'retry_base_delay must be a non-negative number')
128
+ end
129
+ return if transport.nil? || transport.respond_to?(:call)
130
+
131
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'transport must respond to call')
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'timeout'
6
+ require 'uri'
7
+
8
+ module CalmLens
9
+ # Sends API and CDN requests: authentication, JSON encoding, retries and error mapping.
10
+ # The resources share one session per client. It never prints the API key.
11
+ # @api private
12
+ class Session
13
+ MAX_JSON_BYTES = 32 * 1024 * 1024
14
+ MAX_ERROR_BYTES = 1024 * 1024
15
+ MAX_RETRY_DELAY = 60
16
+ MAX_BACKOFF = 8
17
+ ENSURE_LENSES_MAX_PER_REQUEST = 100
18
+ USER_AGENT = "calmlens-ruby/#{VERSION} (ruby #{RUBY_VERSION})".freeze
19
+
20
+ # Raised through the transport so an exception from the caller's streaming block is
21
+ # re-raised unchanged instead of being mistaken for a network failure.
22
+ class CallerError < Exception # rubocop:disable Lint/InheritException
23
+ attr_reader :error
24
+
25
+ def initialize(error)
26
+ super(error.message)
27
+ @error = error
28
+ end
29
+ end
30
+
31
+ attr_reader :project_id, :base_url, :cdn_base_url
32
+
33
+ def initialize(api_key:, project_id:, base_url:, cdn_base_url:, timeout:, open_timeout:, max_retries:,
34
+ retry_base_delay:, transport:)
35
+ @api_key = api_key
36
+ @project_id = project_id
37
+ @base_url = base_url
38
+ @cdn_base_url = cdn_base_url
39
+ @timeout = timeout
40
+ @open_timeout = open_timeout
41
+ @max_retries = max_retries
42
+ @retry_base_delay = retry_base_delay
43
+ @transport = transport
44
+ @lenses = {}
45
+ @lens_lock = Mutex.new
46
+ end
47
+
48
+ def inspect = "#<#{self.class.name} project_id=#{@project_id.inspect} api_key=[REDACTED]>"
49
+
50
+ def close
51
+ @transport.close if @transport.respond_to?(:close)
52
+ end
53
+
54
+ def project(path) = "/projects/#{Internal.segment(@project_id)}/#{path}"
55
+
56
+ # Sends an API request and returns the parsed JSON body.
57
+ #
58
+ # @param idempotent [Boolean] send an `Idempotency-Key` (generated unless the caller gave one),
59
+ # reused across retries so the API runs the request at most once.
60
+ def request(method, path, request_options:, query: {}, body: nil, raw: nil, headers: {}, idempotent: false)
61
+ url = @base_url + path
62
+ params = Internal.query(query)
63
+ url += "?#{URI.encode_www_form(params)}" unless params.empty?
64
+ headers = headers.merge('Accept' => 'application/json')
65
+ unless body.nil?
66
+ raw = JSON.generate(body)
67
+ headers['Content-Type'] = 'application/json'
68
+ end
69
+ if raw.is_a?(UploadBody)
70
+ headers['Content-Length'] = raw.size.to_s
71
+ elsif raw
72
+ raw = UploadBody.new(raw)
73
+ end
74
+ headers['Idempotency-Key'] = request_options[:idempotency_key] || SecureRandom.uuid if idempotent || request_options[:idempotency_key]
75
+ execute(method, url, headers, raw, request_options) { |response| parse(response) }
76
+ end
77
+
78
+ # Sends a CDN request. With a block, yields the streaming {Response} and returns the block's
79
+ # value; otherwise returns the response with its body read.
80
+ def cdn(method, url, headers, request_options, &block)
81
+ execute(method, url, headers, nil, request_options, stream: !block.nil?) do |response|
82
+ next response.tap(&:body) unless block
83
+
84
+ streaming = Response.new(status: response.status, headers: response.headers) do |&sink|
85
+ response.each_chunk do |chunk|
86
+ sink.call(chunk)
87
+ rescue StandardError => e
88
+ raise CallerError, e
89
+ end
90
+ end
91
+ begin
92
+ block.call(streaming)
93
+ rescue CallerError
94
+ raise
95
+ rescue StandardError => e
96
+ raise CallerError, e
97
+ end
98
+ end
99
+ end
100
+
101
+ # Resolves lens definitions, sending only definitions this client has not saved before, at most
102
+ # ENSURE_LENSES_MAX_PER_REQUEST per request, in order.
103
+ def ensure_lenses(lenses, request_options)
104
+ if !lenses.is_a?(Array) || lenses.empty? || lenses.any? { |lens| !lens.is_a?(Hash) || lens['identifier'].to_s.empty? }
105
+ raise Internal.invalid('At least one lens with an identifier is required')
106
+ end
107
+
108
+ @lens_lock.synchronize do
109
+ keys = lenses.map { |lens| JSON.generate(lens.sort.to_h) }
110
+ missing = keys.zip(lenses).to_h.reject { |key, _| @lenses.key?(key) }
111
+ resolved = @lenses.dup
112
+ missing.each_slice(ENSURE_LENSES_MAX_PER_REQUEST) do |slice|
113
+ chunk = slice.to_h
114
+ returned = request('PUT', project('lenses'), body: { 'lenses' => chunk.values },
115
+ request_options: request_options)
116
+ returned = returned['lenses'] if returned.is_a?(Hash)
117
+ unless lenses_match?(returned, chunk.values)
118
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Unexpected lens response')
119
+ end
120
+
121
+ resolved.merge!(chunk.keys.zip(returned).to_h)
122
+ # A new version of a lens replaces the cached definition with the same identifier.
123
+ chunk.keys.zip(returned).each do |key, lens|
124
+ @lenses.delete_if { |_, saved| saved['identifier'] == lens['identifier'] }
125
+ @lenses[key] = lens
126
+ end
127
+ end
128
+ JSON.parse(JSON.generate(keys.map { |key| resolved.fetch(key) }))
129
+ end
130
+ end
131
+
132
+ # Whether the saved lenses line up, one for one and in order, with the definitions that were sent.
133
+ def lenses_match?(returned, sent)
134
+ returned.is_a?(Array) && returned.size == sent.size &&
135
+ returned.zip(sent).all? { |saved, definition| saved.is_a?(Hash) && saved['identifier'] == definition['identifier'] }
136
+ end
137
+
138
+ def headers_for(extra)
139
+ extra.reject { |name, _| %w[x-api-key user-agent].include?(name.downcase) }
140
+ .merge('X-Api-Key' => @api_key, 'User-Agent' => USER_AGENT)
141
+ end
142
+
143
+ private
144
+
145
+ def execute(method, url, headers, body, request_options, stream: false, &handler)
146
+ retries = request_options.fetch(:max_retries, @max_retries)
147
+ retry_allowed = %w[GET HEAD].include?(method) || headers.key?('Idempotency-Key')
148
+ request = Transport::Request.new(method: method, url: url, headers: headers_for(headers), body: body&.content,
149
+ open_timeout: request_options.fetch(:open_timeout, @open_timeout),
150
+ timeout: request_options.fetch(:timeout, @timeout))
151
+ attempt = 0
152
+ begin
153
+ body&.rewind if attempt.positive?
154
+ delivered = false
155
+ send_request(request) do |response|
156
+ delivered = true if stream
157
+ handler.call(response)
158
+ end
159
+ rescue Error => e
160
+ raise unless retry_allowed && !delivered && attempt < retries && e.retryable? && (body.nil? || body.replayable?)
161
+
162
+ delay = retry_delay(attempt, e.retry_after)
163
+ raise if delay.nil?
164
+
165
+ sleep(delay)
166
+ attempt += 1
167
+ retry
168
+ end
169
+ end
170
+
171
+ def send_request(request, &handler)
172
+ yielded = false
173
+ result = nil
174
+ returned = @transport.call(request) do |response|
175
+ yielded = true
176
+ result = handler.call(response)
177
+ end
178
+ if yielded
179
+ result
180
+ else
181
+ handler.call(returned.is_a?(Response) ? returned : adapt(returned))
182
+ end
183
+ rescue CallerError => e
184
+ raise e.error
185
+ rescue Error
186
+ raise
187
+ rescue ::Timeout::Error => e
188
+ raise TimeoutError.new(SdkErrorCodes::TIMEOUT, e.message), cause: e
189
+ rescue *Transport::ERRORS => e
190
+ raise ConnectionError.new(SdkErrorCodes::TRANSPORT_ERROR, e.message), cause: e
191
+ rescue ArgumentError, URI::Error => e
192
+ raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, e.message), cause: e
193
+ end
194
+
195
+ # Accepts any response object with a status (or code), headers and body.
196
+ def adapt(value)
197
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'The transport returned no response') if value.nil?
198
+
199
+ status = value.respond_to?(:status) ? value.status : value.code
200
+ headers = value.respond_to?(:to_hash) ? value.to_hash : (value.respond_to?(:headers) && value.headers) || {}
201
+ Response.new(status: status, headers: headers, body: value.body.to_s)
202
+ end
203
+
204
+ def retry_delay(attempt, retry_after)
205
+ return (retry_after <= MAX_RETRY_DELAY ? retry_after : nil) if retry_after
206
+
207
+ rand * [@retry_base_delay * (2**attempt), MAX_BACKOFF].min
208
+ end
209
+
210
+ def parse(response)
211
+ text = response.body(limit: response.success? ? MAX_JSON_BYTES : MAX_ERROR_BYTES).dup.force_encoding(Encoding::UTF_8)
212
+ begin
213
+ data = text.empty? ? nil : JSON.parse(text)
214
+ rescue JSON::ParserError
215
+ raise invalid_body(response) if response.success?
216
+
217
+ data = nil
218
+ end
219
+ return data if response.success?
220
+
221
+ raise api_error(response, data)
222
+ rescue InvalidResponseError
223
+ raise if response.success?
224
+
225
+ raise api_error(response, nil)
226
+ end
227
+
228
+ def invalid_body(response)
229
+ InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, "Invalid JSON response (HTTP #{response.status})",
230
+ headers: response.headers)
231
+ end
232
+
233
+ def api_error(response, data)
234
+ unless data.is_a?(Hash) && data['code'].is_a?(String)
235
+ return Error.build(SdkErrorCodes::HTTP_ERROR, "HTTP #{response.status}", status: response.status,
236
+ headers: response.headers)
237
+ end
238
+
239
+ details = (data['details'].is_a?(Hash) ? data['details'] : {}).merge('network' => true)
240
+ details['traceId'] = data['traceId'] if data.key?('traceId')
241
+ Error.build(data['code'], data['message'].is_a?(String) ? data['message'] : nil,
242
+ status: response.status, details: details, headers: response.headers)
243
+ end
244
+ end
245
+ private_constant :Session
246
+ end