typesafe-ai-ruby 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/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +176 -0
- data/lib/typesafe/client.rb +56 -0
- data/lib/typesafe/configuration.rb +129 -0
- data/lib/typesafe/constants.rb +49 -0
- data/lib/typesafe/errors.rb +117 -0
- data/lib/typesafe/http/connection_manager.rb +82 -0
- data/lib/typesafe/http/net_http_transport.rb +74 -0
- data/lib/typesafe/http/request.rb +30 -0
- data/lib/typesafe/http/requestor.rb +153 -0
- data/lib/typesafe/http/response.rb +48 -0
- data/lib/typesafe/http/retrier.rb +57 -0
- data/lib/typesafe/instrumentation.rb +84 -0
- data/lib/typesafe/logging.rb +37 -0
- data/lib/typesafe/questions/choice.rb +33 -0
- data/lib/typesafe/questions/normalizer.rb +65 -0
- data/lib/typesafe/questions/noul.rb +36 -0
- data/lib/typesafe/questions/question.rb +48 -0
- data/lib/typesafe/questions/score.rb +32 -0
- data/lib/typesafe/request_options.rb +56 -0
- data/lib/typesafe/resources/models.rb +18 -0
- data/lib/typesafe/responses/answer.rb +143 -0
- data/lib/typesafe/responses/list_models_response.rb +93 -0
- data/lib/typesafe/responses/reader.rb +75 -0
- data/lib/typesafe/responses/system_one_response.rb +89 -0
- data/lib/typesafe/responses/usage.rb +37 -0
- data/lib/typesafe/retry_policy.rb +133 -0
- data/lib/typesafe/util.rb +156 -0
- data/lib/typesafe/version.rb +5 -0
- data/lib/typesafe-ai-ruby.rb +4 -0
- data/lib/typesafe.rb +114 -0
- data/sig/typesafe/http.rbs +77 -0
- data/sig/typesafe/questions.rbs +48 -0
- data/sig/typesafe/responses.rbs +131 -0
- data/sig/typesafe.rbs +261 -0
- metadata +97 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module HTTP
|
|
5
|
+
# Default transport built on Net::HTTP with keep-alive connections managed per thread.
|
|
6
|
+
#
|
|
7
|
+
# Any object responding to call(request) and returning a Response can replace it,
|
|
8
|
+
# which is how tests stub the network and how alternative HTTP stacks can be plugged in.
|
|
9
|
+
class NetHTTPTransport
|
|
10
|
+
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error].freeze
|
|
11
|
+
CONNECTION_ERRORS = [
|
|
12
|
+
SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
|
|
13
|
+
Net::HTTPBadResponse, Net::ProtocolError
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@managers = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(request)
|
|
22
|
+
manager = connection_manager
|
|
23
|
+
http = manager.connection_for(request.uri, timeout: request.timeout)
|
|
24
|
+
wrap_response(http.request(build_request(request)), request)
|
|
25
|
+
rescue *TIMEOUT_ERRORS => e
|
|
26
|
+
manager&.discard(request.uri)
|
|
27
|
+
raise timeout_error(request, e)
|
|
28
|
+
rescue *CONNECTION_ERRORS => e
|
|
29
|
+
manager&.discard(request.uri)
|
|
30
|
+
raise connection_error(request, e)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Close every connection held for any thread.
|
|
34
|
+
def close
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
@managers.each_value(&:clear)
|
|
37
|
+
@managers.clear
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The manager for the calling thread, creating it on first use and releasing
|
|
42
|
+
# managers whose threads have finished.
|
|
43
|
+
def connection_manager
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@managers.delete_if { |thread, manager| !thread.alive? && (manager.clear || true) }
|
|
46
|
+
@managers[Thread.current] ||= ConnectionManager.new
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def timeout_error(request, error)
|
|
53
|
+
APITimeoutError.new("#{request.endpoint} timed out after #{request.timeout}s (#{error.class})",
|
|
54
|
+
timeout: request.timeout)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def connection_error(request, error)
|
|
58
|
+
APIConnectionError.new("#{request.endpoint} failed: #{error.class}: #{error.message}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def wrap_response(raw, request)
|
|
62
|
+
Response.new(status: raw.code.to_i, headers: raw.each_header.to_h, body: raw.body, request: request)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_request(request)
|
|
66
|
+
klass = request.method == :get ? Net::HTTP::Get : Net::HTTP::Post
|
|
67
|
+
raw = klass.new(request.uri.request_uri)
|
|
68
|
+
request.headers.each { |name, value| raw[name] = value }
|
|
69
|
+
raw.body = request.body if request.body
|
|
70
|
+
raw
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module HTTP
|
|
5
|
+
# An outgoing HTTP request: method, absolute URI, headers, encoded body and per-attempt timeout.
|
|
6
|
+
class Request
|
|
7
|
+
attr_reader :method, :uri, :headers, :body, :timeout
|
|
8
|
+
|
|
9
|
+
def initialize(method:, uri:, headers:, body:, timeout:)
|
|
10
|
+
@method = method
|
|
11
|
+
@uri = uri
|
|
12
|
+
@headers = headers.freeze
|
|
13
|
+
@body = body
|
|
14
|
+
@timeout = timeout
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Method and URL without credentials, query, or fragment.
|
|
19
|
+
def endpoint
|
|
20
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
21
|
+
"#{method.to_s.upcase} #{uri.scheme}://#{uri.host}#{port}#{uri.path}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A copy with additional headers merged in.
|
|
25
|
+
def with_headers(extra)
|
|
26
|
+
self.class.new(method: method, uri: uri, headers: headers.merge(extra), body: body, timeout: timeout)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module HTTP
|
|
5
|
+
# Builds requests from a Configuration, sends them through a transport with retries,
|
|
6
|
+
# logs them, and turns non-2xx responses into APIErrors.
|
|
7
|
+
class Requestor
|
|
8
|
+
PROTECTED_HEADERS = [
|
|
9
|
+
Constants::Headers::AUTHORIZATION, Constants::Headers::ACCEPT, Constants::Headers::USER_AGENT,
|
|
10
|
+
Constants::Headers::SDK, Constants::Headers::RUNTIME, Constants::Headers::RETRY_COUNT
|
|
11
|
+
].map(&:downcase).freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :config, :transport
|
|
14
|
+
|
|
15
|
+
# sleeper and clock exist so tests can drive the retry loop without waiting.
|
|
16
|
+
def initialize(config, transport: nil, sleeper: nil, clock: nil)
|
|
17
|
+
@config = config
|
|
18
|
+
@transport = transport || config.transport || NetHTTPTransport.new
|
|
19
|
+
@sleeper = sleeper
|
|
20
|
+
@clock = clock
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Send a POST and return the successful response.
|
|
24
|
+
def post(path, body:, options: RequestOptions.new)
|
|
25
|
+
execute(build_request(:post, path, body: body, options: options), options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Send a GET and return the successful response.
|
|
29
|
+
def get(path, options: RequestOptions.new)
|
|
30
|
+
execute(build_request(:get, path, options: options), options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def close
|
|
34
|
+
transport.close if transport.respond_to?(:close)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def execute(request, options)
|
|
40
|
+
call = CallState.new(request)
|
|
41
|
+
Instrumentation.notify(:request_begin, call.begin_event)
|
|
42
|
+
response = retrier_for(options, call).run do |attempt|
|
|
43
|
+
send_once(attempt.zero? ? request : request.with_headers(Constants::Headers::RETRY_COUNT => attempt.to_s))
|
|
44
|
+
end
|
|
45
|
+
Instrumentation.notify(:request_end, call.end_event(response: response))
|
|
46
|
+
response
|
|
47
|
+
rescue APIError, APIConnectionError => e
|
|
48
|
+
Instrumentation.notify(:request_end, call.end_event(error: e))
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def retrier_for(options, call)
|
|
53
|
+
on_retry = lambda do |error, attempt, delay|
|
|
54
|
+
call.retried!
|
|
55
|
+
log_retry(error, attempt, delay)
|
|
56
|
+
end
|
|
57
|
+
Retrier.new(retry_policy_for(options), sleeper: @sleeper, clock: @clock, on_retry: on_retry)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Bookkeeping for one client call across its attempts, feeding the instrumentation events.
|
|
61
|
+
class CallState
|
|
62
|
+
attr_reader :begin_event
|
|
63
|
+
|
|
64
|
+
def initialize(request)
|
|
65
|
+
@request = request
|
|
66
|
+
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
@num_retries = 0
|
|
68
|
+
@begin_event = Instrumentation::RequestBeginEvent.new(method: request.method, path: request.uri.path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def retried!
|
|
72
|
+
@num_retries += 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def end_event(response: nil, error: nil)
|
|
76
|
+
status = response&.status || (error.status if error.is_a?(APIError))
|
|
77
|
+
request_id = response&.request_id || (error.request_id if error.is_a?(APIError))
|
|
78
|
+
Instrumentation::RequestEndEvent.new(
|
|
79
|
+
method: @request.method, path: @request.uri.path, http_status: status,
|
|
80
|
+
duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started,
|
|
81
|
+
num_retries: @num_retries, request_id: request_id, error: error, user_data: @begin_event.user_data
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def send_once(request)
|
|
87
|
+
log_request(request)
|
|
88
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
89
|
+
response = transport.call(request)
|
|
90
|
+
log_response(request, response, started)
|
|
91
|
+
raise APIError.from_response(response) unless response.success?
|
|
92
|
+
|
|
93
|
+
response
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def retry_policy_for(options)
|
|
97
|
+
RetryPolicy.from(options.retry_policy, base: config.retry_policy)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_request(method, path, body: nil, options: RequestOptions.new)
|
|
101
|
+
encoded = body.nil? ? nil : JSON.generate(body)
|
|
102
|
+
Request.new(
|
|
103
|
+
method: method,
|
|
104
|
+
uri: URI.parse("#{config.base_url}#{path}"),
|
|
105
|
+
headers: build_headers(options.headers, body: encoded),
|
|
106
|
+
body: encoded,
|
|
107
|
+
timeout: options.timeout || config.timeout
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_headers(extra, body:)
|
|
112
|
+
headers = strip_protected(config.headers).merge(strip_protected(extra))
|
|
113
|
+
headers[Constants::Headers::AUTHORIZATION] = "Bearer #{config.api_key}"
|
|
114
|
+
headers[Constants::Headers::ACCEPT] = Constants::JSON_CONTENT_TYPE
|
|
115
|
+
headers[Constants::Headers::USER_AGENT] = sdk_identifier
|
|
116
|
+
headers[Constants::Headers::SDK] = sdk_identifier
|
|
117
|
+
headers[Constants::Headers::RUNTIME] = Util.runtime
|
|
118
|
+
headers[Constants::Headers::CONTENT_TYPE] = Constants::JSON_CONTENT_TYPE if body
|
|
119
|
+
headers
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def strip_protected(headers)
|
|
123
|
+
headers.reject { |name, _| PROTECTED_HEADERS.include?(name.downcase) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def sdk_identifier
|
|
127
|
+
"#{Constants::SDK_NAME}/#{VERSION}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def log_request(request)
|
|
131
|
+
Logging.debug(config) do
|
|
132
|
+
"-> #{request.endpoint} headers={#{Logging.format_headers(request.headers)}} body=#{request.body.inspect}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def log_response(request, response, started)
|
|
137
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
138
|
+
Logging.info(config) do
|
|
139
|
+
"#{request.endpoint} -> #{response.status} (#{elapsed}ms) request_id=#{response.request_id || "-"}"
|
|
140
|
+
end
|
|
141
|
+
Logging.debug(config) do
|
|
142
|
+
"<- #{response.status} headers={#{Logging.format_headers(response.headers)}} body=#{response.body.inspect}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def log_retry(error, attempt, delay)
|
|
147
|
+
Logging.info(config) do
|
|
148
|
+
"retry #{attempt + 1} in #{delay.round(3)}s after #{error.class.name.split("::").last}: #{error.message}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module HTTP
|
|
5
|
+
# A raw HTTP response: status, lower-cased headers, body text, and the request that produced it.
|
|
6
|
+
class Response
|
|
7
|
+
attr_reader :status, :headers, :body, :request
|
|
8
|
+
|
|
9
|
+
def initialize(status:, headers:, body:, request: nil)
|
|
10
|
+
@status = status
|
|
11
|
+
@headers = Util.normalize_headers(headers).freeze
|
|
12
|
+
@body = body.to_s
|
|
13
|
+
@request = request
|
|
14
|
+
@json = nil
|
|
15
|
+
@json_parsed = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Case-insensitive header lookup.
|
|
19
|
+
def [](name)
|
|
20
|
+
headers[name.to_s.downcase]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The x-typesafe-request-id header.
|
|
24
|
+
def request_id
|
|
25
|
+
self[Constants::Headers::REQUEST_ID]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def success?
|
|
29
|
+
(200..299).cover?(status)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The parsed JSON body, or nil when the body is not valid JSON.
|
|
33
|
+
def json
|
|
34
|
+
return @json if @json_parsed
|
|
35
|
+
|
|
36
|
+
@json_parsed = true
|
|
37
|
+
@json = body.empty? ? nil : JSON.parse(body)
|
|
38
|
+
rescue JSON::ParserError
|
|
39
|
+
@json = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Server-requested wait in seconds, from retry-after-ms or Retry-After.
|
|
43
|
+
def retry_after
|
|
44
|
+
Util.parse_retry_after(headers)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module HTTP
|
|
5
|
+
# Runs a block until it succeeds or the RetryPolicy gives up. The block receives the
|
|
6
|
+
# attempt number (0 for the first try) and raises APIError or APIConnectionError on failure.
|
|
7
|
+
# The last error is re-raised when no more retries are allowed or the total budget
|
|
8
|
+
# would be exceeded by the next delay.
|
|
9
|
+
class Retrier
|
|
10
|
+
MONOTONIC = Process::CLOCK_MONOTONIC
|
|
11
|
+
|
|
12
|
+
attr_reader :policy
|
|
13
|
+
|
|
14
|
+
def initialize(policy, sleeper: nil, clock: nil, random: Kernel, on_retry: nil)
|
|
15
|
+
@policy = policy
|
|
16
|
+
@sleeper = sleeper || Kernel.method(:sleep)
|
|
17
|
+
@clock = clock || -> { Process.clock_gettime(MONOTONIC) }
|
|
18
|
+
@random = random
|
|
19
|
+
@on_retry = on_retry
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run
|
|
23
|
+
started = @clock.call
|
|
24
|
+
attempt = 0
|
|
25
|
+
loop do
|
|
26
|
+
return yield(attempt)
|
|
27
|
+
rescue APIError, APIConnectionError => e
|
|
28
|
+
delay = retry_delay(e, attempt, started)
|
|
29
|
+
raise if delay.nil?
|
|
30
|
+
|
|
31
|
+
@on_retry&.call(e, attempt, delay)
|
|
32
|
+
@sleeper.call(delay)
|
|
33
|
+
attempt += 1
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def retry_delay(error, attempt, started)
|
|
40
|
+
return nil unless attempt < policy.max_retries && retryable?(error)
|
|
41
|
+
|
|
42
|
+
delay = policy.delay_for(attempt, headers: error.respond_to?(:headers) ? error.headers : nil, random: @random)
|
|
43
|
+
return nil if policy.timeout && (@clock.call - started + delay) >= policy.timeout
|
|
44
|
+
|
|
45
|
+
delay
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def retryable?(error)
|
|
49
|
+
case error
|
|
50
|
+
when APIError then policy.retry_status?(error.status)
|
|
51
|
+
when APIConnectionError then policy.retry_error?(error)
|
|
52
|
+
else false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
# Hooks for observing requests, in the spirit of stripe-ruby's Instrumentation.
|
|
5
|
+
#
|
|
6
|
+
# TypeSafe::Instrumentation.subscribe(:request_end) do |event|
|
|
7
|
+
# StatsD.timing("typesafe.request", event.duration, tags: ["status:#{event.http_status}"])
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# :request_begin fires once per client call with a RequestBeginEvent; :request_end fires
|
|
11
|
+
# once per call after any retries with a RequestEndEvent, whether the call succeeded or
|
|
12
|
+
# raised. Anything stored in the begin event's user_data is available on the end event.
|
|
13
|
+
module Instrumentation
|
|
14
|
+
TOPICS = %i[request_begin request_end].freeze
|
|
15
|
+
|
|
16
|
+
class RequestBeginEvent
|
|
17
|
+
attr_reader :method, :path, :user_data
|
|
18
|
+
|
|
19
|
+
def initialize(method:, path:)
|
|
20
|
+
@method = method
|
|
21
|
+
@path = path
|
|
22
|
+
@user_data = {}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class RequestEndEvent
|
|
27
|
+
# HTTP method as a Symbol and the request path.
|
|
28
|
+
attr_reader :method, :path
|
|
29
|
+
# Final status code, or nil when no response was received.
|
|
30
|
+
attr_reader :http_status
|
|
31
|
+
# Wall time in seconds for the whole call including retries.
|
|
32
|
+
attr_reader :duration
|
|
33
|
+
# Retries performed after the first attempt.
|
|
34
|
+
attr_reader :num_retries
|
|
35
|
+
# The x-typesafe-request-id of the final response, when present.
|
|
36
|
+
attr_reader :request_id
|
|
37
|
+
# The error raised to the caller, or nil on success.
|
|
38
|
+
attr_reader :error
|
|
39
|
+
# The Hash from the matching RequestBeginEvent.
|
|
40
|
+
attr_reader :user_data
|
|
41
|
+
|
|
42
|
+
def initialize(method:, path:, http_status:, duration:, num_retries:, request_id:, error:, user_data:)
|
|
43
|
+
@method = method
|
|
44
|
+
@path = path
|
|
45
|
+
@http_status = http_status
|
|
46
|
+
@duration = duration
|
|
47
|
+
@num_retries = num_retries
|
|
48
|
+
@request_id = request_id
|
|
49
|
+
@error = error
|
|
50
|
+
@user_data = user_data
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@subscribers = TOPICS.to_h { |topic| [topic, {}] }
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Register a block for a topic. Returns the subscription name, which can be
|
|
60
|
+
# passed to unsubscribe. Subscribing again with the same name replaces the block.
|
|
61
|
+
def subscribe(topic, name = Object.new, &block)
|
|
62
|
+
raise ArgumentError, "unknown topic #{topic.inspect}; expected one of #{TOPICS.join(", ")}" unless
|
|
63
|
+
TOPICS.include?(topic)
|
|
64
|
+
raise ArgumentError, "a block is required" unless block
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize { @subscribers[topic][name] = block }
|
|
67
|
+
name
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def unsubscribe(topic, name)
|
|
71
|
+
@mutex.synchronize { @subscribers.fetch(topic).delete(name) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def notify(topic, event)
|
|
75
|
+
@mutex.synchronize { @subscribers.fetch(topic).values }.each { |block| block.call(event) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Remove every subscriber. Mostly useful in tests.
|
|
79
|
+
def reset!
|
|
80
|
+
@mutex.synchronize { @subscribers.each_value(&:clear) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
# Logging helpers. The client logs request summaries at info and full headers and bodies
|
|
5
|
+
# at debug. Credential headers are redacted; bodies are not, so keep debug logging out of
|
|
6
|
+
# production if state contains sensitive data.
|
|
7
|
+
module Logging
|
|
8
|
+
PROGNAME = "typesafe"
|
|
9
|
+
REDACTED = "[REDACTED]"
|
|
10
|
+
REDACTED_HEADERS = %w[authorization proxy-authorization x-api-key].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def log?(config, severity)
|
|
15
|
+
!config.logger.nil? && config.logger_severity <= severity
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def info(config, &)
|
|
19
|
+
config.logger.info(PROGNAME, &) if log?(config, Logger::INFO)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def debug(config, &)
|
|
23
|
+
config.logger.debug(PROGNAME, &) if log?(config, Logger::DEBUG)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A copy of the headers with credential values replaced.
|
|
27
|
+
def redact_headers(headers)
|
|
28
|
+
headers.to_h do |name, value|
|
|
29
|
+
[name, REDACTED_HEADERS.include?(name.to_s.downcase) ? REDACTED : value]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def format_headers(headers)
|
|
34
|
+
redact_headers(headers).map { |name, value| "#{name}: #{value}" }.join(", ")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Questions
|
|
5
|
+
# Picks one option from a set you define. The answer carries the chosen option,
|
|
6
|
+
# a probability per option, and a confidence.
|
|
7
|
+
#
|
|
8
|
+
# For example:
|
|
9
|
+
# TypeSafe::Choice.new(
|
|
10
|
+
# instructions: "What is the tone?",
|
|
11
|
+
# criteria: { calm: nil, frustrated: nil, angry: "Shouting, threats, or profanity" }
|
|
12
|
+
# )
|
|
13
|
+
class Choice < Question
|
|
14
|
+
TYPE = "choice"
|
|
15
|
+
|
|
16
|
+
def initialize(criteria:, instructions: nil)
|
|
17
|
+
super(TYPE, instructions: instructions, criteria: self.class.normalize_criteria(criteria))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.normalize_criteria(criteria)
|
|
21
|
+
raise ValidationError, "Choice criteria must be a non-empty Hash of option => description" unless
|
|
22
|
+
criteria.is_a?(Hash) && !criteria.empty?
|
|
23
|
+
|
|
24
|
+
criteria.each_with_object({}) do |(key, value), result|
|
|
25
|
+
name = Util.stringify_key(key)
|
|
26
|
+
raise ValidationError, "Choice option names must not be blank" if name.strip.empty?
|
|
27
|
+
|
|
28
|
+
result[name] = value.nil? ? nil : Util.deep_stringify(value)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Questions
|
|
5
|
+
# Turns user-supplied state and questions into the wire format, validating the obvious
|
|
6
|
+
# mistakes before a request is sent.
|
|
7
|
+
module Normalizer
|
|
8
|
+
TYPES = {
|
|
9
|
+
Noul::TYPE => Noul, Choice::TYPE => Choice, Score::TYPE => Score
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def state(state)
|
|
15
|
+
raise ValidationError, "state must not be nil; pass a String, Hash, or Array" if state.nil?
|
|
16
|
+
|
|
17
|
+
value = Util.deep_stringify(state)
|
|
18
|
+
return value if value.is_a?(String) || value.is_a?(Hash) || value.is_a?(Array)
|
|
19
|
+
|
|
20
|
+
raise ValidationError, "state must be a String, Hash, or Array, got #{state.class}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Questions in wire format.
|
|
24
|
+
def questions(questions)
|
|
25
|
+
raise ValidationError, "questions must be a non-empty Hash" unless questions.is_a?(Hash) && !questions.empty?
|
|
26
|
+
|
|
27
|
+
questions.each_with_object({}) do |(name, question), result|
|
|
28
|
+
key = Util.stringify_key(name)
|
|
29
|
+
raise ValidationError, "question names must not be blank" if key.strip.empty?
|
|
30
|
+
|
|
31
|
+
result[key] = question(question, key)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def question(question, name)
|
|
36
|
+
case question
|
|
37
|
+
when Question then question.to_h
|
|
38
|
+
when Hash then raw_question(question, name)
|
|
39
|
+
else
|
|
40
|
+
raise ValidationError,
|
|
41
|
+
"question #{name.inspect} must be a TypeSafe::Noul, Choice, Score, or a Hash with a \"type\" key"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Validate a raw Hash question. Known types get the same checks as the typed classes;
|
|
46
|
+
# unknown types and extra keys pass through untouched so new API fields work immediately.
|
|
47
|
+
def raw_question(hash, name)
|
|
48
|
+
wire = Util.deep_stringify(hash)
|
|
49
|
+
type = wire["type"]
|
|
50
|
+
unless type.is_a?(String) && !type.empty?
|
|
51
|
+
raise ValidationError,
|
|
52
|
+
"question #{name.inspect} is missing a \"type\""
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
klass = TYPES[type]
|
|
56
|
+
wire["criteria"] = klass.normalize_criteria(wire["criteria"]) if klass && requires_criteria?(klass, wire)
|
|
57
|
+
wire
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def requires_criteria?(klass, wire)
|
|
61
|
+
klass != Noul || wire.key?("criteria")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Questions
|
|
5
|
+
# A yes/no question. The answer is the probability of yes, from 0 to 1.
|
|
6
|
+
#
|
|
7
|
+
# For example:
|
|
8
|
+
# TypeSafe::Noul.new(
|
|
9
|
+
# instructions: "Is this message spam?",
|
|
10
|
+
# criteria: { true: "Unsolicited advertising", false: "A real conversation" }
|
|
11
|
+
# )
|
|
12
|
+
class Noul < Question
|
|
13
|
+
TYPE = "noul"
|
|
14
|
+
KEYS = %w[true false].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(instructions: nil, criteria: nil)
|
|
17
|
+
super(TYPE, instructions: instructions, criteria: self.class.normalize_criteria(criteria))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.normalize_criteria(criteria)
|
|
21
|
+
return nil if criteria.nil?
|
|
22
|
+
raise ValidationError, "Noul criteria must be a Hash with true/false keys" unless criteria.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
criteria.each_with_object({}) do |(key, value), result|
|
|
25
|
+
name = key.to_s
|
|
26
|
+
unless KEYS.include?(name)
|
|
27
|
+
raise ValidationError,
|
|
28
|
+
"Noul criteria keys must be true or false, got #{key.inspect}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
result[name] = value.nil? ? nil : Util.deep_stringify(value)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Questions
|
|
5
|
+
# Base class for the three typed questions. Instances are immutable value objects
|
|
6
|
+
# that serialize to the wire format through #to_h.
|
|
7
|
+
class Question
|
|
8
|
+
# "noul", "choice" or "score".
|
|
9
|
+
attr_reader :type
|
|
10
|
+
# What the model should decide.
|
|
11
|
+
attr_reader :instructions
|
|
12
|
+
# Type-specific rubric.
|
|
13
|
+
attr_reader :criteria
|
|
14
|
+
|
|
15
|
+
def initialize(type, instructions:, criteria:)
|
|
16
|
+
@type = type
|
|
17
|
+
@instructions = instructions.nil? ? nil : Util.deep_stringify(instructions)
|
|
18
|
+
@criteria = criteria
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The request body fragment for this question.
|
|
23
|
+
def to_h
|
|
24
|
+
hash = { "type" => type }
|
|
25
|
+
hash["instructions"] = instructions unless instructions.nil?
|
|
26
|
+
hash["criteria"] = criteria unless criteria.nil?
|
|
27
|
+
hash
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_json(*args)
|
|
31
|
+
JSON.generate(to_h, *args)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ==(other)
|
|
35
|
+
other.is_a?(Question) && to_h == other.to_h
|
|
36
|
+
end
|
|
37
|
+
alias eql? ==
|
|
38
|
+
|
|
39
|
+
def hash
|
|
40
|
+
to_h.hash
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect
|
|
44
|
+
"#<#{self.class.name} #{to_h.inspect}>"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|