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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +222 -0
- data/lib/calmlens/assets.rb +328 -0
- data/lib/calmlens/cdn.rb +63 -0
- data/lib/calmlens/error_codes.rb +133 -0
- data/lib/calmlens/errors.rb +154 -0
- data/lib/calmlens/events.rb +46 -0
- data/lib/calmlens/folders.rb +63 -0
- data/lib/calmlens/internal.rb +141 -0
- data/lib/calmlens/lenses.rb +16 -0
- data/lib/calmlens/resource.rb +51 -0
- data/lib/calmlens/response.rb +82 -0
- data/lib/calmlens/server_client.rb +134 -0
- data/lib/calmlens/session.rb +246 -0
- data/lib/calmlens/testing/fake_api.rb +570 -0
- data/lib/calmlens/testing.rb +141 -0
- data/lib/calmlens/text.rb +29 -0
- data/lib/calmlens/transforms.rb +104 -0
- data/lib/calmlens/transport.rb +129 -0
- data/lib/calmlens/upload_body.rb +149 -0
- data/lib/calmlens/upload_tokens.rb +52 -0
- data/lib/calmlens/version.rb +6 -0
- data/lib/calmlens/webhooks.rb +122 -0
- data/lib/calmlens.rb +26 -0
- data/sig/calmlens.rbs +273 -0
- data/sig/error_codes.rbs +67 -0
- data/sig/types.rbs +682 -0
- metadata +75 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative '../calmlens'
|
|
7
|
+
require_relative 'testing/fake_api'
|
|
8
|
+
|
|
9
|
+
module CalmLens
|
|
10
|
+
# An offline client for tests. Calls run the real client's validation, encoding, retries and
|
|
11
|
+
# error handling, then reach an in-memory stand-in for the API and CDN instead of the network.
|
|
12
|
+
#
|
|
13
|
+
# By default the stand-in is stateful: uploads create assets you can retrieve, list, filter,
|
|
14
|
+
# move and delete, folders behave like the API's, the CDN serves stored bytes, and errors use
|
|
15
|
+
# the API's codes, HTTP statuses and body shape. It does not classify content: assets moderated
|
|
16
|
+
# synchronously get `analyzed_outcome` and queued ones `queued_outcome`. Content is kept only with
|
|
17
|
+
# `keepContentAfterProcessing: true`; otherwise it is removed once processing is `done`, as in the API.
|
|
18
|
+
#
|
|
19
|
+
# To script exact responses, enqueue them: queued responses and errors are used first, in order,
|
|
20
|
+
# before the stand-in answers.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# mock = CalmLens::MockServerClient.new
|
|
24
|
+
# asset = mock.assets.moderate(name: 'note', content: {plain_text: 'hi', type: 'text/plain'})
|
|
25
|
+
# mock.assets.retrieve(asset_id: asset['id']).dig('moderation', 'status') # => "approved"
|
|
26
|
+
# mock.enqueue(status: 503, body: {code: 'server/unavailable', message: 'Try again'})
|
|
27
|
+
class MockServerClient < ServerClient
|
|
28
|
+
# A request the mock received.
|
|
29
|
+
#
|
|
30
|
+
# `path` is relative to `base_url` for API calls (`/projects/<id>/assets`) and to
|
|
31
|
+
# `cdn_base_url` for CDN requests. `query` holds the decoded query string. `body` is the bytes
|
|
32
|
+
# sent: JSON text, or the uploaded content (streams are read), or nil.
|
|
33
|
+
Request = Struct.new(:method, :url, :path, :query, :headers, :body, keyword_init: true) do
|
|
34
|
+
# @return [Object] the body parsed as JSON.
|
|
35
|
+
def json = body && JSON.parse(body)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Testing::State] the stored assets, folders, content and events.
|
|
39
|
+
attr_reader :state
|
|
40
|
+
|
|
41
|
+
# @param project_id [String]
|
|
42
|
+
# @param now [#call] returns the current time in epoch milliseconds.
|
|
43
|
+
# @param create_id [#call] returns a new ID.
|
|
44
|
+
# @param queued_outcome [String] the outcome of assets created with {Assets#create}: `pending`
|
|
45
|
+
# (processing, moderation pending), `approved` or `rejected` (processing done with that
|
|
46
|
+
# decision) or `failed` (processing failed, retryable).
|
|
47
|
+
# @param analyzed_outcome [String, Hash, #call] the result of {Assets#moderate}: an outcome, fields
|
|
48
|
+
# to deep-merge into the asset (`{'moderation' => {'status' => 'rejected', 'action' => 'hide'}}`;
|
|
49
|
+
# the outcome follows `moderation.status`, or is `failed` when `processing.status` is `failed`,
|
|
50
|
+
# and is `approved` otherwise), or a callable that receives a copy of the asset and returns either.
|
|
51
|
+
# @param options [Hash] other {ServerClient#initialize} settings, except `api_key` and `transport`.
|
|
52
|
+
# Retries wait no time unless a queued response sets `Retry-After`.
|
|
53
|
+
def initialize(project_id: 'project-1', base_url: 'https://api.calmlens.invalid/v1',
|
|
54
|
+
cdn_base_url: 'https://cdn.calmlens.invalid/v1', now: -> { (Time.now.to_f * 1000).to_i },
|
|
55
|
+
create_id: -> { SecureRandom.uuid }, queued_outcome: 'pending', analyzed_outcome: 'approved',
|
|
56
|
+
**options)
|
|
57
|
+
@mock_lock = Mutex.new
|
|
58
|
+
@queue = []
|
|
59
|
+
@received = []
|
|
60
|
+
super(api_key: 'mock-key', project_id: project_id, base_url: base_url, cdn_base_url: cdn_base_url,
|
|
61
|
+
retry_base_delay: 0, **options, transport: method(:receive))
|
|
62
|
+
@fake = Testing::FakeAPI.new(project_id: project_id, base_url: self.base_url, cdn_base_url: self.cdn_base_url,
|
|
63
|
+
now: now, create_id: create_id, queued_outcome: queued_outcome,
|
|
64
|
+
analyzed_outcome: analyzed_outcome)
|
|
65
|
+
@state = @fake.state
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Queues a JSON response for the next request.
|
|
69
|
+
# @param status [Integer]
|
|
70
|
+
# @param body [Object] encoded as JSON
|
|
71
|
+
# @param headers [Hash{String => String}]
|
|
72
|
+
# @return [self]
|
|
73
|
+
def enqueue(body:, status: 200, headers: {})
|
|
74
|
+
enqueue_raw(status: status, body: JSON.generate(body), headers: { 'content-type' => 'application/json' }.merge(headers))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Queues a response with a raw body, such as malformed JSON or binary CDN content.
|
|
78
|
+
# @return [self]
|
|
79
|
+
def enqueue_raw(body:, status: 200, headers: {})
|
|
80
|
+
response = Response.new(status: status, body: body.to_s.dup,
|
|
81
|
+
headers: { 'x-request-id' => "mock-#{SecureRandom.hex(8)}" }.merge(headers))
|
|
82
|
+
@mock_lock.synchronize { @queue << response }
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Fails the next request with an exception raised by the transport, such as
|
|
87
|
+
# `Net::ReadTimeout.new` (raised as `sdk/timeout`) or `Errno::ECONNRESET.new`
|
|
88
|
+
# (`sdk/transport-error`). The client wraps it exactly as it wraps real network failures.
|
|
89
|
+
# @return [self]
|
|
90
|
+
def enqueue_error(error)
|
|
91
|
+
@mock_lock.synchronize { @queue << error }
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Adds an event to the mock's events feed. Fields you omit (`id`, `projectId`, `createdAt`,
|
|
96
|
+
# `severity`, `title`, `facts`) are filled in.
|
|
97
|
+
# @param event [Hash] at least `type:`; snake_case keys are converted.
|
|
98
|
+
# @return [Hash] the stored event
|
|
99
|
+
def enqueue_event(event)
|
|
100
|
+
@mock_lock.synchronize { JSON.parse(JSON.generate(@fake.add_event(Internal.normalize(event)))) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Array<Request>] copies of every request received so far, oldest first.
|
|
104
|
+
def requests
|
|
105
|
+
@mock_lock.synchronize { @received.map { |request| Request.new(**Marshal.load(Marshal.dump(request.to_h))) } }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Integer] queued responses and errors not used yet.
|
|
109
|
+
def remaining_responses
|
|
110
|
+
@mock_lock.synchronize { @queue.size }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def inspect = "#<#{self.class.name} project_id=#{project_id.inspect}>"
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Returns the response rather than yielding it: the client accepts either from a transport.
|
|
118
|
+
def receive(request, &)
|
|
119
|
+
body = request.body.respond_to?(:read) ? read_stream(request.body) : request.body&.b
|
|
120
|
+
path, query = Internal.split_url(request.url).last.split('?', 2)
|
|
121
|
+
base = request.url.start_with?("#{cdn_base_url}/") ? URI(cdn_base_url).path : URI(base_url).path
|
|
122
|
+
@mock_lock.synchronize do
|
|
123
|
+
@received << Request.new(method: request.method, url: request.url, path: path.delete_prefix(base),
|
|
124
|
+
query: URI.decode_www_form(query.to_s).to_h, headers: request.headers.dup,
|
|
125
|
+
body: body)
|
|
126
|
+
queued = @queue.shift
|
|
127
|
+
raise queued if queued.is_a?(Exception)
|
|
128
|
+
|
|
129
|
+
queued || @fake.handle(request.method, request.url, request.headers, body)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def read_stream(io)
|
|
134
|
+
buffer = String.new(encoding: Encoding::BINARY)
|
|
135
|
+
while (chunk = io.read(64 * 1024))
|
|
136
|
+
buffer << chunk
|
|
137
|
+
end
|
|
138
|
+
buffer
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# Text moderation shortcuts. Reach it through {ServerClient#text}.
|
|
5
|
+
class Text < Resource
|
|
6
|
+
# @api private
|
|
7
|
+
def initialize(session, assets)
|
|
8
|
+
super(session)
|
|
9
|
+
@assets = assets
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Moderates a string as a `text/plain` asset and waits for the decision.
|
|
13
|
+
# @option options [String] :text
|
|
14
|
+
# @option options [String] :name defaults to `text`
|
|
15
|
+
# @return [Hash] the asset; publish only when `moderation.status` is `approved` and
|
|
16
|
+
# `moderation.action` is nil.
|
|
17
|
+
def moderate(options)
|
|
18
|
+
api, request = call(options) { |*split| split }
|
|
19
|
+
text = api['text']
|
|
20
|
+
unless text.is_a?(String) && !text.strip.empty?
|
|
21
|
+
raise Internal.invalid('Text to moderate must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@assets.moderate(without(api, 'text').merge('name' => api.fetch('name', 'text'),
|
|
25
|
+
'content' => { 'plainText' => text, 'type' => 'text/plain' },
|
|
26
|
+
'request_options' => request))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# CDN transform selectors: selector Strings and structured steps serialized the way every SDK does.
|
|
5
|
+
# @api private
|
|
6
|
+
module Transforms
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
TYPE = /\A[A-Za-z0-9_]+\z/
|
|
10
|
+
|
|
11
|
+
# Returns the selector the API and CDN accept for a transform: a selector String unchanged, or
|
|
12
|
+
# structured steps (`{type:, options:}` or an Array of them) serialized by {serialize}.
|
|
13
|
+
def selector(value)
|
|
14
|
+
value.nil? || value.is_a?(String) ? value : serialize(value)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Builds the CDN path of a transform. A selector String keeps its own separators and literal
|
|
18
|
+
# `[`/`]`, which the CDN matches; serialized steps are already path-safe.
|
|
19
|
+
def path(value)
|
|
20
|
+
return serialize(value) unless value.is_a?(String)
|
|
21
|
+
|
|
22
|
+
text = value.sub(%r{\A/+}, '')
|
|
23
|
+
raise Internal.invalid('Transform cannot contain "..", "?" or "#"') if text.include?('..') || text.match?(/[?#]/)
|
|
24
|
+
|
|
25
|
+
# Like JavaScript's encodeURI, except that brackets stay literal.
|
|
26
|
+
text.b.gsub(%r{[^A-Za-z0-9\-_.!~*'();/:@&=+$,\[\]]}n) { |byte| format('%%%02X', byte.ord) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Serializes structured transforms as a CDN selector such as `imageTransform[format=png,width=1024]`.
|
|
30
|
+
# Option keys are sorted so equal transforms share a URL; nested keys (`border[color]`) and values
|
|
31
|
+
# are percent-encoded.
|
|
32
|
+
def serialize(value)
|
|
33
|
+
steps = value.is_a?(Array) ? value : [value]
|
|
34
|
+
raise Internal.invalid('Transform needs at least one step') if steps.empty?
|
|
35
|
+
|
|
36
|
+
steps.map do |step|
|
|
37
|
+
raise Internal.invalid('Transform steps must be Hashes') unless step.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
step = step.transform_keys(&:to_s)
|
|
40
|
+
type = step['type']
|
|
41
|
+
unless type.is_a?(String) && type.match?(TYPE)
|
|
42
|
+
raise Internal.invalid('Transform type must contain only letters, digits and underscores')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
options = step['options'] || {}
|
|
46
|
+
raise Internal.invalid('Transform options must be a Hash') unless options.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
pairs = []
|
|
49
|
+
sorted_options(options).each { |key, item| append_pairs(pairs, key, item) }
|
|
50
|
+
"#{type}[#{pairs.join(',')}]"
|
|
51
|
+
end.join(',')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def sorted_options(options) = options.map { |key, item| [key.to_s, item] }.sort_by(&:first)
|
|
55
|
+
|
|
56
|
+
def append_pairs(pairs, key, value)
|
|
57
|
+
return if value.nil?
|
|
58
|
+
return sorted_options(value).each { |child, item| append_pairs(pairs, "#{key}[#{child}]", item) } if value.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
text = case value
|
|
61
|
+
when String, true, false, Integer then value.to_s
|
|
62
|
+
when Float
|
|
63
|
+
raise Internal.invalid("Transform option \"#{key}\" must be a finite number") unless value.finite?
|
|
64
|
+
|
|
65
|
+
js_number(value)
|
|
66
|
+
else raise Internal.invalid("Transform option \"#{key}\" must be a String, number, boolean or Hash")
|
|
67
|
+
end
|
|
68
|
+
pairs << "#{component(key)}=#{component(text)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Percent-encodes everything except RFC 3986 unreserved characters.
|
|
72
|
+
def component(text)
|
|
73
|
+
text.encode(Encoding::UTF_8).b.gsub(/[^A-Za-z0-9\-._~]/n) { |byte| format('%%%02X', byte.ord) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Formats a Float the way JavaScript's String(number) does, so every SDK builds the same URL.
|
|
77
|
+
def js_number(value)
|
|
78
|
+
return '0' if value.zero?
|
|
79
|
+
|
|
80
|
+
mantissa, exponent = value.abs.to_s.split('e')
|
|
81
|
+
whole, fraction = mantissa.split('.')
|
|
82
|
+
digits = "#{whole}#{fraction}"
|
|
83
|
+
point = whole.length + exponent.to_i
|
|
84
|
+
stripped = digits.sub(/\A0+/, '')
|
|
85
|
+
point -= digits.length - stripped.length
|
|
86
|
+
digits = stripped.sub(/0+\z/, '')
|
|
87
|
+
sign = value.negative? ? '-' : ''
|
|
88
|
+
"#{sign}#{js_digits(digits, point)}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Places a decimal point in significant `digits` whose value is 0.digits x 10^point.
|
|
92
|
+
def js_digits(digits, point)
|
|
93
|
+
size = digits.length
|
|
94
|
+
if point.between?(size, 21) then digits + ('0' * (point - size))
|
|
95
|
+
elsif point.positive? && point <= 21 then "#{digits[0, point]}.#{digits[point..]}"
|
|
96
|
+
elsif point > -6 && point <= 0 then "0.#{'0' * -point}#{digits}"
|
|
97
|
+
else
|
|
98
|
+
shown = size > 1 ? "#{digits[0]}.#{digits[1..]}" : digits
|
|
99
|
+
"#{shown}e#{point - 1 >= 0 ? '+' : '-'}#{(point - 1).abs}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
private_constant :Transforms
|
|
104
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'zlib'
|
|
7
|
+
|
|
8
|
+
module CalmLens
|
|
9
|
+
# The HTTP layer. Pass a custom transport to {ServerClient#initialize} to route requests elsewhere;
|
|
10
|
+
# it receives a {Transport::Request} and either returns a {Response} or yields one to the block
|
|
11
|
+
# it is given (and returns the block's result), which lets the body stream.
|
|
12
|
+
module Transport
|
|
13
|
+
# One outgoing HTTP request.
|
|
14
|
+
#
|
|
15
|
+
# @!attribute method [String] the HTTP method.
|
|
16
|
+
# @!attribute url [String] the absolute URL.
|
|
17
|
+
# @!attribute headers [Hash{String => String}]
|
|
18
|
+
# @!attribute body [String, #read, nil] the request body. A streamed body has a `Content-Length` header.
|
|
19
|
+
# @!attribute open_timeout [Numeric] seconds allowed to open a connection.
|
|
20
|
+
# @!attribute timeout [Numeric] seconds allowed for each read or write; not a whole-transfer deadline.
|
|
21
|
+
Request = Struct.new(:method, :url, :headers, :body, :open_timeout, :timeout, keyword_init: true) do
|
|
22
|
+
def inspect = "#<#{self.class.name} #{method} #{url}>"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Exceptions that mean the connection failed before a complete response arrived.
|
|
26
|
+
ERRORS = [IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::ProtocolError,
|
|
27
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error].freeze
|
|
28
|
+
|
|
29
|
+
# The default transport: Net::HTTP with a small per-origin pool of keep-alive connections.
|
|
30
|
+
# Redirects are never followed, so credentials never reach another host. After `fork`, the
|
|
31
|
+
# child process discards the inherited connections and opens its own.
|
|
32
|
+
class NetHTTP
|
|
33
|
+
MAX_IDLE_PER_ORIGIN = 8
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@lock = Mutex.new
|
|
37
|
+
@idle = {}
|
|
38
|
+
@closed = false
|
|
39
|
+
@pid = Process.pid
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param request [Request]
|
|
43
|
+
# @yieldparam response [Response] a response whose body streams from the socket.
|
|
44
|
+
def call(request)
|
|
45
|
+
uri, target = Internal.split_url(request.url)
|
|
46
|
+
origin = [uri.scheme, uri.host, uri.port]
|
|
47
|
+
connection = checkout(origin)
|
|
48
|
+
reusable = false
|
|
49
|
+
begin
|
|
50
|
+
connection ||= connect(uri, request.open_timeout)
|
|
51
|
+
connection.read_timeout = connection.write_timeout = request.timeout
|
|
52
|
+
result = nil
|
|
53
|
+
connection.request(build(target, request)) do |net|
|
|
54
|
+
response = Response.new(status: net.code, headers: net.to_hash) { |&chunk| net.read_body(&chunk) }
|
|
55
|
+
result = yield response
|
|
56
|
+
reusable = !net['connection'].to_s.casecmp?('close')
|
|
57
|
+
end
|
|
58
|
+
result
|
|
59
|
+
ensure
|
|
60
|
+
checkin(origin, connection, reusable) if connection
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Closes idle connections. Later requests fail.
|
|
65
|
+
def close
|
|
66
|
+
@lock.synchronize do
|
|
67
|
+
@closed = true
|
|
68
|
+
@idle.each_value { |connections| connections.each { |connection| finish(connection) } }
|
|
69
|
+
@idle.clear
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def inspect = "#<#{self.class.name}>"
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def build(target, request)
|
|
78
|
+
net = Net::HTTPGenericRequest.new(request.method, !request.body.nil?, request.method != 'HEAD',
|
|
79
|
+
target, request.headers)
|
|
80
|
+
if request.body.respond_to?(:read)
|
|
81
|
+
net.body_stream = request.body
|
|
82
|
+
elsif request.body
|
|
83
|
+
net.body = request.body
|
|
84
|
+
end
|
|
85
|
+
net
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def connect(uri, open_timeout)
|
|
89
|
+
connection = Net::HTTP.new(uri.host, uri.port)
|
|
90
|
+
connection.use_ssl = uri.scheme == 'https'
|
|
91
|
+
connection.open_timeout = open_timeout
|
|
92
|
+
# Retries are decided by the client, which knows which requests are safe to repeat.
|
|
93
|
+
connection.max_retries = 0
|
|
94
|
+
connection.start
|
|
95
|
+
connection
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def checkout(origin)
|
|
99
|
+
@lock.synchronize do
|
|
100
|
+
raise ConnectionError.new(SdkErrorCodes::TRANSPORT_ERROR, 'Client is closed') if @closed
|
|
101
|
+
|
|
102
|
+
# Sockets inherited across fork are shared with the parent: drop them without closing.
|
|
103
|
+
if @pid != Process.pid
|
|
104
|
+
@idle = {}
|
|
105
|
+
@pid = Process.pid
|
|
106
|
+
end
|
|
107
|
+
(@idle[origin] ||= []).pop
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def checkin(origin, connection, reusable)
|
|
112
|
+
kept = @lock.synchronize do
|
|
113
|
+
idle = (@idle[origin] ||= [])
|
|
114
|
+
if reusable && !@closed && @pid == Process.pid && idle.size < MAX_IDLE_PER_ORIGIN
|
|
115
|
+
idle << connection
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
finish(connection) unless kept
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def finish(connection)
|
|
123
|
+
connection.finish if connection.started?
|
|
124
|
+
rescue IOError, SystemCallError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# An upload payload: a binary String, or an IO streamed with an exact byte count.
|
|
5
|
+
# @api private
|
|
6
|
+
class UploadBody
|
|
7
|
+
PAYLOAD_KEYS = %w[plainText base64 data bytes].freeze
|
|
8
|
+
MIN_PART_BYTES = 5 * 1024 * 1024
|
|
9
|
+
|
|
10
|
+
attr_reader :size
|
|
11
|
+
|
|
12
|
+
# Builds the body from upload content options (`plainText`, `base64`, or `data`/`bytes` as a
|
|
13
|
+
# String or IO). IO input is streamed, never read into memory.
|
|
14
|
+
def self.from(content, multipart:)
|
|
15
|
+
raise Internal.invalid('Upload content must be a Hash', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless content.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
keys = PAYLOAD_KEYS.select { |key| content.key?(key) }
|
|
18
|
+
raise Internal.invalid('Supply exactly one payload representation', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless keys.size == 1
|
|
19
|
+
|
|
20
|
+
body = build(keys.first, content.fetch(keys.first), content['sizeInBytes'], multipart)
|
|
21
|
+
if content.key?('sizeInBytes') && content['sizeInBytes'] != body.size
|
|
22
|
+
raise Internal.invalid('sizeInBytes must match the payload byte length', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
23
|
+
end
|
|
24
|
+
if multipart && !content.fetch('isFinal', false) && body.size < MIN_PART_BYTES
|
|
25
|
+
raise Internal.invalid('Multipart upload parts must be at least 5MiB except the final part.',
|
|
26
|
+
ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
27
|
+
end
|
|
28
|
+
body
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.build(kind, value, declared, multipart)
|
|
32
|
+
case kind
|
|
33
|
+
when 'plainText'
|
|
34
|
+
raise Internal.invalid('plainText must be a String', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless value.is_a?(String)
|
|
35
|
+
|
|
36
|
+
new(value.encode(Encoding::UTF_8).b)
|
|
37
|
+
when 'base64' then new(decode64(value, multipart))
|
|
38
|
+
else
|
|
39
|
+
return new(value.b) if value.is_a?(String)
|
|
40
|
+
return new(IOReader.new(value, stream_size(value, declared))) if value.respond_to?(:read)
|
|
41
|
+
|
|
42
|
+
raise Internal.invalid('Expected a binary String or IO', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.decode64(value, multipart)
|
|
47
|
+
raise Internal.invalid('base64 must be a String', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless value.is_a?(String)
|
|
48
|
+
|
|
49
|
+
raw = value.split(';base64,').last.to_s.gsub(/\s/, '')
|
|
50
|
+
if multipart && raw.length % 4 != 0
|
|
51
|
+
raise Internal.invalid('Split bytes before base64-encoding each part', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
(raw + ('=' * ((4 - (raw.length % 4)) % 4))).unpack1('m0')
|
|
55
|
+
rescue ArgumentError
|
|
56
|
+
raise Internal.invalid('Invalid base64', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The bytes left in a sized stream (a File, Tempfile or StringIO), or the declared size.
|
|
60
|
+
def self.stream_size(io, declared)
|
|
61
|
+
return declared if declared.is_a?(Integer) && declared >= 0
|
|
62
|
+
|
|
63
|
+
remaining = io.size - io.pos if io.respond_to?(:size) && io.respond_to?(:pos)
|
|
64
|
+
return remaining if remaining.is_a?(Integer) && remaining >= 0
|
|
65
|
+
|
|
66
|
+
raise Internal.invalid('Pass sizeInBytes for a stream whose length is unknown', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
67
|
+
rescue IOError, SystemCallError, NoMethodError
|
|
68
|
+
raise Internal.invalid('Pass sizeInBytes for a stream whose length is unknown', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
69
|
+
end
|
|
70
|
+
private_class_method :build, :decode64, :stream_size
|
|
71
|
+
|
|
72
|
+
def initialize(source)
|
|
73
|
+
@source = source
|
|
74
|
+
@size = source.is_a?(String) ? source.bytesize : source.size
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [String, IOReader] what the transport sends.
|
|
78
|
+
def content
|
|
79
|
+
@source
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] whether the body can be sent again for a retry.
|
|
83
|
+
def replayable?
|
|
84
|
+
@source.is_a?(String) || @source.rewindable?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Prepares the body to be sent again.
|
|
88
|
+
def rewind
|
|
89
|
+
@source.rewind unless @source.is_a?(String)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The bytes, read fully. Used only by the offline mock.
|
|
93
|
+
def read_all
|
|
94
|
+
return @source if @source.is_a?(String)
|
|
95
|
+
|
|
96
|
+
@source.rewind if @source.rewindable?
|
|
97
|
+
@source.read || ''.b
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Reads exactly `size` bytes from an IO and fails if it holds fewer or more.
|
|
101
|
+
class IOReader
|
|
102
|
+
attr_reader :size
|
|
103
|
+
|
|
104
|
+
def initialize(io, size)
|
|
105
|
+
@io = io
|
|
106
|
+
@size = size
|
|
107
|
+
@remaining = size
|
|
108
|
+
@start = (io.pos if io.respond_to?(:pos) && io.respond_to?(:seek))
|
|
109
|
+
rescue IOError, SystemCallError
|
|
110
|
+
@start = nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def rewindable? = !@start.nil?
|
|
114
|
+
|
|
115
|
+
def rewind
|
|
116
|
+
@io.seek(@start)
|
|
117
|
+
@remaining = @size
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def read(length = nil, buffer = nil)
|
|
121
|
+
return (buffer ? buffer.replace(''.b) : ''.b) if length&.zero?
|
|
122
|
+
|
|
123
|
+
if @remaining.zero?
|
|
124
|
+
extra = @io.read(1)
|
|
125
|
+
unless extra.nil? || extra.empty?
|
|
126
|
+
raise Internal.invalid('The stream holds more bytes than sizeInBytes',
|
|
127
|
+
ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
buffer&.clear
|
|
131
|
+
return length.nil? || length.zero? ? ''.b : nil
|
|
132
|
+
end
|
|
133
|
+
chunk = @io.read([length || @remaining, @remaining].min)
|
|
134
|
+
if chunk.nil? || chunk.empty?
|
|
135
|
+
raise Internal.invalid('The stream ended before sizeInBytes bytes', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@remaining -= chunk.bytesize
|
|
139
|
+
chunk = chunk.b
|
|
140
|
+
buffer ? buffer.replace(chunk) : chunk
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def readpartial(length, buffer = nil)
|
|
144
|
+
read(length, buffer) || raise(EOFError)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
private_constant :UploadBody
|
|
149
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
module CalmLens
|
|
9
|
+
# Signs upload tokens, which let a browser upload through an upload preset. Reach it through
|
|
10
|
+
# {ServerClient#upload_tokens}. Signing happens locally with the API key; no request is made.
|
|
11
|
+
class UploadTokens < Resource
|
|
12
|
+
TOKEN_PREFIX = 'clut_'
|
|
13
|
+
SIGNING_CONTEXT = 'calmlens-upload-token:v1:'
|
|
14
|
+
DEFAULT_TTL_SECONDS = 600
|
|
15
|
+
MAX_TTL_SECONDS = 3600
|
|
16
|
+
|
|
17
|
+
# @api private
|
|
18
|
+
def initialize(session, api_key)
|
|
19
|
+
super(session)
|
|
20
|
+
@api_key = api_key
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Signs a single-use token. Hand it to the browser as its bearer token; give each upload its own.
|
|
24
|
+
# @option options [String] :upload_preset_id the preset the token may upload through
|
|
25
|
+
# @option options [String] :external_id the external ID the uploaded asset gets (not empty)
|
|
26
|
+
# @option options [Integer] :expires_in_seconds lifetime, 1 to 3600 (default 600)
|
|
27
|
+
# @return [Hash] `{'token' => String, 'expiresAt' => Integer}` (milliseconds since the Unix epoch)
|
|
28
|
+
def sign(options)
|
|
29
|
+
api, = call(options) { |*split| split }
|
|
30
|
+
preset_id = api['uploadPresetId']
|
|
31
|
+
Internal.segment(preset_id)
|
|
32
|
+
ttl = api.fetch('expiresInSeconds', DEFAULT_TTL_SECONDS)
|
|
33
|
+
unless ttl.is_a?(Integer) && ttl.between?(1, MAX_TTL_SECONDS)
|
|
34
|
+
raise Internal.invalid("expiresInSeconds must be a whole number from 1 to #{MAX_TTL_SECONDS}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
external_id = api['externalId']
|
|
38
|
+
if api.key?('externalId') && !(external_id.is_a?(String) && !external_id.empty?)
|
|
39
|
+
raise Internal.invalid('externalId must be a non-empty string')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
expires_at = (Time.now.to_f * 1000).to_i + (ttl * 1000)
|
|
43
|
+
claims = { 'v' => 1, 'u' => preset_id, 'k' => @api_key[0, @api_key.start_with?('clsk_') ? 13 : 8],
|
|
44
|
+
'e' => expires_at, 'n' => SecureRandom.uuid }
|
|
45
|
+
claims['x'] = external_id if external_id
|
|
46
|
+
body = [JSON.generate(claims)].pack('m0').tr('+/', '-_').delete('=')
|
|
47
|
+
secret = Digest::SHA256.hexdigest(@api_key)
|
|
48
|
+
signature = OpenSSL::HMAC.hexdigest('SHA256', secret, SIGNING_CONTEXT + body)
|
|
49
|
+
{ 'token' => "#{TOKEN_PREFIX}#{body}.#{signature}", 'expiresAt' => expires_at }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|