typesafe-sdk 0.2.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +359 -0
  5. data/lib/typesafe/sdk/answer_parser.rb +36 -0
  6. data/lib/typesafe/sdk/api_error.rb +43 -0
  7. data/lib/typesafe/sdk/api_error_factory.rb +36 -0
  8. data/lib/typesafe/sdk/api_response_validation_error.rb +20 -0
  9. data/lib/typesafe/sdk/api_timeout_error.rb +14 -0
  10. data/lib/typesafe/sdk/body_decoder.rb +18 -0
  11. data/lib/typesafe/sdk/choice.rb +39 -0
  12. data/lib/typesafe/sdk/choice_answer.rb +45 -0
  13. data/lib/typesafe/sdk/client.rb +108 -0
  14. data/lib/typesafe/sdk/configuration.rb +66 -0
  15. data/lib/typesafe/sdk/connection_pool.rb +82 -0
  16. data/lib/typesafe/sdk/error_message.rb +51 -0
  17. data/lib/typesafe/sdk/field_reader.rb +76 -0
  18. data/lib/typesafe/sdk/http_request.rb +30 -0
  19. data/lib/typesafe/sdk/http_response.rb +24 -0
  20. data/lib/typesafe/sdk/invalid_response_field.rb +14 -0
  21. data/lib/typesafe/sdk/json_value.rb +51 -0
  22. data/lib/typesafe/sdk/list_models_response.rb +41 -0
  23. data/lib/typesafe/sdk/model_metadata.rb +39 -0
  24. data/lib/typesafe/sdk/models.rb +37 -0
  25. data/lib/typesafe/sdk/net_http_transport.rb +53 -0
  26. data/lib/typesafe/sdk/noul.rb +49 -0
  27. data/lib/typesafe/sdk/noul_answer.rb +39 -0
  28. data/lib/typesafe/sdk/question_set.rb +55 -0
  29. data/lib/typesafe/sdk/rate_limit_error.rb +14 -0
  30. data/lib/typesafe/sdk/request_builder.rb +58 -0
  31. data/lib/typesafe/sdk/request_logger.rb +68 -0
  32. data/lib/typesafe/sdk/requester.rb +92 -0
  33. data/lib/typesafe/sdk/retry_after.rb +46 -0
  34. data/lib/typesafe/sdk/retry_policy.rb +96 -0
  35. data/lib/typesafe/sdk/score.rb +40 -0
  36. data/lib/typesafe/sdk/score_answer.rb +53 -0
  37. data/lib/typesafe/sdk/stderr_logger.rb +42 -0
  38. data/lib/typesafe/sdk/system_one_response.rb +48 -0
  39. data/lib/typesafe/sdk/usage.rb +37 -0
  40. data/lib/typesafe/sdk/version.rb +7 -0
  41. data/lib/typesafe/sdk.rb +47 -0
  42. metadata +108 -0
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class Noul
6
+ TYPE = "noul"
7
+
8
+ attr_reader :instructions, :criteria
9
+
10
+ def initialize(instructions: nil, criteria: nil)
11
+ unless criteria.nil? || criteria.is_a?(Hash)
12
+ raise(Error, "noul criteria must be a hash with true and false descriptions")
13
+ end
14
+
15
+ @instructions = instructions
16
+ @criteria = criteria
17
+ freeze
18
+ end
19
+
20
+ def type
21
+ TYPE
22
+ end
23
+
24
+ def to_h
25
+ hash = { "type" => TYPE }
26
+ hash["instructions"] = JsonValue.normalize(instructions, path: "instructions") unless instructions.nil?
27
+ hash["criteria"] = normalized_criteria unless criteria.nil?
28
+ hash
29
+ end
30
+
31
+ def ==(other)
32
+ other.is_a?(self.class) && other.to_h == to_h
33
+ end
34
+ alias eql? ==
35
+
36
+ def hash
37
+ [self.class, to_h].hash
38
+ end
39
+
40
+ private
41
+
42
+ def normalized_criteria
43
+ criteria.each_with_object({}) do |(key, value), result|
44
+ result[key.to_s] = JsonValue.normalize(value, path: "criteria.#{key}")
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class NoulAnswer
6
+ TYPE = "noul"
7
+
8
+ attr_reader :noul
9
+
10
+ class << self
11
+ def from_hash(hash, path:)
12
+ new(noul: FieldReader.number(hash: hash, key: "noul", path: path))
13
+ end
14
+ end
15
+
16
+ def initialize(noul:)
17
+ @noul = noul
18
+ freeze
19
+ end
20
+
21
+ def type
22
+ TYPE
23
+ end
24
+
25
+ def to_h
26
+ { type: TYPE, noul: noul }
27
+ end
28
+
29
+ def ==(other)
30
+ other.is_a?(self.class) && other.to_h == to_h
31
+ end
32
+ alias eql? ==
33
+
34
+ def hash
35
+ [self.class, to_h].hash
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class QuestionSet
6
+ QUESTION_CLASSES = [Noul, Choice, Score].freeze
7
+ TYPES_REQUIRING_CRITERIA = %w[choice score].freeze
8
+
9
+ class << self
10
+ def normalize(questions)
11
+ raise(Error, "questions must be a hash of names to questions") unless questions.is_a?(Hash)
12
+ raise(Error, "at least one question is required") if questions.empty?
13
+
14
+ questions.each_with_object({}) do |(name, question), result|
15
+ unless name.is_a?(String) || name.is_a?(Symbol)
16
+ raise(Error, "question names must be strings or symbols, got #{name.class}")
17
+ end
18
+
19
+ result[name.to_s] = normalize_question(name: name.to_s, question: question)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def normalize_question(name:, question:)
26
+ hash = question_hash(name: name, question: question)
27
+ type = hash["type"]
28
+ if TYPES_REQUIRING_CRITERIA.include?(type) && !hash.key?("criteria")
29
+ raise(Error, "question #{name.inspect} requires \"criteria\"")
30
+ end
31
+ validate_score_criteria(name: name, criteria: hash["criteria"]) if type == Score::TYPE
32
+ hash
33
+ end
34
+
35
+ def question_hash(name:, question:)
36
+ return question.to_h if QUESTION_CLASSES.any? { |klass| question.is_a?(klass) }
37
+
38
+ hash = question.is_a?(Hash) ? JsonValue.normalize(question, path: "questions.#{name}") : nil
39
+ return hash if hash && hash["type"].is_a?(String) && !hash["type"].empty?
40
+
41
+ raise(
42
+ Error,
43
+ "question #{name.inspect} must be a Noul, Choice, or Score, or a hash with a nonempty \"type\""
44
+ )
45
+ end
46
+
47
+ def validate_score_criteria(name:, criteria:)
48
+ return unless criteria.nil? || (criteria.respond_to?(:empty?) && criteria.empty?)
49
+
50
+ raise(Error, "score question #{name.inspect} has no criteria, at least one level is required")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class RateLimitError < APIError
6
+ attr_reader :retry_after_ms
7
+
8
+ def initialize(status:, body:, headers:, message: nil, endpoint: nil)
9
+ @retry_after_ms = RetryAfter.parse(headers)
10
+ super
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class RequestBuilder
6
+ JSON_CONTENT_TYPE = "application/json"
7
+ RUNTIME = "ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})".freeze
8
+
9
+ class << self
10
+ def build(configuration:, http_method:, path:, body:, timeout:, headers:)
11
+ merged = configuration.headers.merge("User-Agent" => configuration.user_agent)
12
+ merge_headers(into: merged, headers: headers || {})
13
+ delete_header(headers: merged, name: RETRY_COUNT_HEADER)
14
+ protected_headers(configuration).each { |name, value| assign_header(headers: merged, name: name, value: value) }
15
+ assign_header(headers: merged, name: "Content-Type", value: JSON_CONTENT_TYPE) unless body.nil?
16
+
17
+ HTTPRequest.new(
18
+ http_method: http_method,
19
+ url: "#{configuration.base_url}#{path}",
20
+ headers: merged,
21
+ body: body.nil? ? nil : encode(body),
22
+ timeout: Configuration.validate_timeout(timeout.nil? ? configuration.timeout : timeout)
23
+ )
24
+ end
25
+
26
+ def assign_header(headers:, name:, value:)
27
+ delete_header(headers: headers, name: name)
28
+ headers[name] = value
29
+ end
30
+
31
+ private
32
+
33
+ def protected_headers(configuration)
34
+ {
35
+ "Authorization" => configuration.authorization,
36
+ "Accept" => JSON_CONTENT_TYPE,
37
+ "X-TypeSafe-SDK" => "#{SDK_NAME}/#{VERSION}",
38
+ "X-TypeSafe-Runtime" => RUNTIME
39
+ }
40
+ end
41
+
42
+ def merge_headers(into:, headers:)
43
+ headers.each { |name, value| assign_header(headers: into, name: name.to_s, value: value.to_s) }
44
+ end
45
+
46
+ def delete_header(headers:, name:)
47
+ headers.delete_if { |existing, _value| existing.casecmp?(name) }
48
+ end
49
+
50
+ def encode(body)
51
+ JSON.generate(body)
52
+ rescue JSON::GeneratorError, EncodingError => e
53
+ raise(Error, "the request body could not be encoded as json: #{e.message}")
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class RequestLogger
6
+ SECRET_HEADERS = %w[authorization proxy-authorization x-api-key api-key cookie set-cookie].freeze
7
+
8
+ class << self
9
+ def redact(headers)
10
+ headers.to_h do |name, value|
11
+ [name, secret?(name) ? "***" : value]
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def secret?(name)
18
+ lowered = name.to_s.downcase
19
+ SECRET_HEADERS.include?(lowered) || lowered.include?("token") || lowered.include?("secret")
20
+ end
21
+ end
22
+
23
+ def initialize(logger)
24
+ @logger = logger
25
+ end
26
+
27
+ def sending(request)
28
+ return unless logger
29
+
30
+ logger.debug("sending #{describe(request)} headers=#{self.class.redact(request.headers)} body=#{request.body.inspect}")
31
+ end
32
+
33
+ def retrying(request:, retry_count:)
34
+ return unless logger
35
+
36
+ logger.info("retrying #{describe(request)} (retry #{retry_count})")
37
+ end
38
+
39
+ def failed(request:, error:)
40
+ return unless logger
41
+
42
+ logger.info("#{describe(request)} failed: #{error.message}")
43
+ end
44
+
45
+ def received(request:, response:, elapsed_ms:)
46
+ return unless logger
47
+
48
+ request_id = response.headers[REQUEST_ID_HEADER] || "-"
49
+ logger.info("#{describe(request)} returned #{response.status} in #{elapsed_ms.round}ms (request #{request_id})")
50
+ logger.debug("received #{describe(request)} headers=#{self.class.redact(response.headers)} body=#{response.body.inspect}")
51
+ end
52
+
53
+ def warn(message)
54
+ return unless logger
55
+
56
+ logger.warn(message)
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :logger
62
+
63
+ def describe(request)
64
+ "#{request.http_method} #{request.url}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class Requester
6
+ def initialize(transport:, logger:)
7
+ @transport = transport
8
+ @request_logger = RequestLogger.new(logger)
9
+ end
10
+
11
+ def perform(request:, response_class:, retry_policy:)
12
+ started_at = monotonic_now
13
+ attempt = 1
14
+ begin
15
+ attempt_once(request: request, response_class: response_class, retry_count: attempt - 1)
16
+ rescue StandardError => e
17
+ delay = retry_delay(policy: retry_policy, error: e, attempt: attempt, started_at: started_at)
18
+ raise if delay.nil?
19
+
20
+ Kernel.sleep(delay) if delay.positive?
21
+ attempt += 1
22
+ retry
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :transport, :request_logger
29
+
30
+ def retry_delay(policy:, error:, attempt:, started_at:)
31
+ return unless policy.retryable?(error)
32
+ return if attempt >= policy.max_attempts
33
+
34
+ delay = policy.delay_for(attempt: attempt, error: error)
35
+ return delay if policy.timeout.nil?
36
+ return delay if monotonic_now - started_at + delay < policy.timeout
37
+
38
+ nil
39
+ end
40
+
41
+ def attempt_once(request:, response_class:, retry_count:)
42
+ attempt_request = request
43
+ if retry_count.positive?
44
+ headers = request.headers.dup
45
+ RequestBuilder.assign_header(headers: headers, name: RETRY_COUNT_HEADER, value: retry_count.to_s)
46
+ attempt_request = request.with_headers(headers)
47
+ request_logger.retrying(request: request, retry_count: retry_count)
48
+ end
49
+ response = send_request(attempt_request)
50
+ parse(request: request, response: response, response_class: response_class)
51
+ end
52
+
53
+ def send_request(request)
54
+ request_logger.sending(request)
55
+ started_at = monotonic_now
56
+ response = transport.call(request)
57
+ request_logger.received(request: request, response: response, elapsed_ms: (monotonic_now - started_at) * 1000)
58
+ response
59
+ rescue APIConnectionError => e
60
+ request_logger.failed(request: request, error: e)
61
+ raise
62
+ end
63
+
64
+ def parse(request:, response:, response_class:)
65
+ raise(APIErrorFactory.build(response: response, endpoint: request.endpoint)) unless response.success?
66
+
67
+ body = parse_body(response)
68
+ response_class.from_body(body, request_logger: request_logger, http_response: response)
69
+ rescue InvalidResponseField => e
70
+ raise(
71
+ APIResponseValidationError.new(
72
+ status: response.status,
73
+ body: BodyDecoder.decode(response.body),
74
+ headers: response.headers,
75
+ field_path: e.field_path,
76
+ endpoint: request.endpoint
77
+ )
78
+ )
79
+ end
80
+
81
+ def parse_body(response)
82
+ JSON.parse(response.body.dup.force_encoding(Encoding::UTF_8))
83
+ rescue JSON::ParserError, EncodingError
84
+ raise(InvalidResponseField, "")
85
+ end
86
+
87
+ def monotonic_now
88
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class RetryAfter
6
+ HEADERS = [["retry-after-ms", 1], ["retry-after", 1000]].freeze
7
+
8
+ class << self
9
+ def parse(headers)
10
+ HEADERS.each do |name, multiplier|
11
+ raw = headers[name]
12
+ next if raw.nil?
13
+
14
+ stripped = raw.to_s.strip
15
+ value = Float(stripped.empty? ? "0" : stripped, exception: false)
16
+ if value.nil?
17
+ return from_http_date(stripped) if name == "retry-after" && http_date?(stripped)
18
+
19
+ next
20
+ end
21
+ next unless value.finite?
22
+ return if value.negative? && name == "retry-after"
23
+ next if value.negative?
24
+
25
+ delay = value * multiplier
26
+ return delay if delay.finite?
27
+ end
28
+ nil
29
+ end
30
+
31
+ private
32
+
33
+ def http_date?(value)
34
+ Time.httpdate(value)
35
+ true
36
+ rescue ArgumentError
37
+ false
38
+ end
39
+
40
+ def from_http_date(value)
41
+ [0.0, (Time.httpdate(value) - Time.now) * 1000].max
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class RetryPolicy
6
+ DEFAULT_HTTP_STATUSES = [408, 429, *500..599].freeze
7
+
8
+ attr_reader :max_retries, :backoff_initial, :backoff_max, :backoff_jitter, :http_statuses,
9
+ :respect_retry_after, :api_connection_error, :api_timeout_error, :exceptions, :predicate, :timeout
10
+
11
+ def initialize(
12
+ max_retries: 2,
13
+ backoff_initial: 0.5,
14
+ backoff_max: 5.0,
15
+ backoff_jitter: 0.25,
16
+ http_statuses: DEFAULT_HTTP_STATUSES,
17
+ respect_retry_after: true,
18
+ api_connection_error: true,
19
+ api_timeout_error: true,
20
+ exceptions: [],
21
+ predicate: nil,
22
+ timeout: 30.0
23
+ )
24
+ @max_retries = max_retries
25
+ @backoff_initial = backoff_initial
26
+ @backoff_max = backoff_max
27
+ @backoff_jitter = backoff_jitter
28
+ @http_statuses = http_statuses.to_a.freeze
29
+ @respect_retry_after = respect_retry_after
30
+ @api_connection_error = api_connection_error
31
+ @api_timeout_error = api_timeout_error
32
+ @exceptions = exceptions.to_a.freeze
33
+ @predicate = predicate
34
+ @timeout = timeout
35
+ validate
36
+ freeze
37
+ end
38
+
39
+ def max_attempts
40
+ max_retries + 1
41
+ end
42
+
43
+ def retryable?(error)
44
+ builtin_retryable?(error) ||
45
+ exceptions.any? { |klass| error.is_a?(klass) } ||
46
+ (!predicate.nil? && predicate.call(error) ? true : false)
47
+ end
48
+
49
+ def delay_for(attempt:, error:)
50
+ if respect_retry_after && error.is_a?(APIError)
51
+ retry_after_ms = RetryAfter.parse(error.headers)
52
+ return retry_after_ms / 1000.0 if retry_after_ms
53
+ end
54
+ backoff(attempt)
55
+ end
56
+
57
+ def backoff(attempt)
58
+ return 0.0 if backoff_initial.zero? || backoff_max.zero?
59
+
60
+ exponent = attempt - 1
61
+ capped = exponent >= Math.log2(backoff_max) - Math.log2(backoff_initial)
62
+ exponential = capped ? backoff_max.to_f : backoff_initial * (2**exponent)
63
+ jittered = exponential * (1 - (Random.rand * backoff_jitter))
64
+ [exponential, jittered.round(3)].min
65
+ end
66
+
67
+ private
68
+
69
+ def builtin_retryable?(error)
70
+ case error
71
+ when APITimeoutError then api_timeout_error
72
+ when APIConnectionError then api_connection_error
73
+ when APIError then http_statuses.include?(error.status)
74
+ else false
75
+ end
76
+ end
77
+
78
+ def validate
79
+ raise(Error, "max_retries must be a non-negative integer") unless max_retries.is_a?(Integer) && max_retries >= 0
80
+
81
+ validate_seconds(name: "backoff_initial", value: backoff_initial)
82
+ validate_seconds(name: "backoff_max", value: backoff_max)
83
+ unless backoff_jitter.is_a?(Numeric) && backoff_jitter.between?(0, 1)
84
+ raise(Error, "backoff_jitter must be between zero and one")
85
+ end
86
+ Configuration.validate_timeout(timeout) unless timeout.nil?
87
+ end
88
+
89
+ def validate_seconds(name:, value:)
90
+ return if value.is_a?(Numeric) && value.finite? && value >= 0
91
+
92
+ raise(Error, "#{name} must be a non-negative, finite number of seconds")
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class Score
6
+ TYPE = "score"
7
+
8
+ attr_reader :criteria, :instructions
9
+
10
+ def initialize(criteria:, instructions: nil)
11
+ raise(Error, "score criteria must be an array of level descriptions") unless criteria.is_a?(Array)
12
+ raise(Error, "score criteria must include at least one level") if criteria.empty?
13
+
14
+ @criteria = criteria
15
+ @instructions = instructions
16
+ freeze
17
+ end
18
+
19
+ def type
20
+ TYPE
21
+ end
22
+
23
+ def to_h
24
+ hash = { "type" => TYPE }
25
+ hash["instructions"] = JsonValue.normalize(instructions, path: "instructions") unless instructions.nil?
26
+ hash["criteria"] = JsonValue.normalize(criteria, path: "criteria")
27
+ hash
28
+ end
29
+
30
+ def ==(other)
31
+ other.is_a?(self.class) && other.to_h == to_h
32
+ end
33
+ alias eql? ==
34
+
35
+ def hash
36
+ [self.class, to_h].hash
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class ScoreAnswer
6
+ TYPE = "score"
7
+
8
+ attr_reader :score, :confidence, :legend, :probabilities
9
+
10
+ class << self
11
+ def from_hash(hash, path:)
12
+ new(
13
+ score: FieldReader.number(hash: hash, key: "score", path: path),
14
+ confidence: FieldReader.number(hash: hash, key: "confidence", path: path),
15
+ legend: FieldReader.integer_keys(
16
+ hash: FieldReader.content_map(hash: hash, key: "legend", path: path),
17
+ path: "#{path}.legend"
18
+ ),
19
+ probabilities: FieldReader.integer_keys(
20
+ hash: FieldReader.number_map(hash: hash, key: "probabilities", path: path),
21
+ path: "#{path}.probabilities"
22
+ )
23
+ )
24
+ end
25
+ end
26
+
27
+ def initialize(score:, confidence:, legend:, probabilities:)
28
+ @score = score
29
+ @confidence = confidence
30
+ @legend = legend.freeze
31
+ @probabilities = probabilities.freeze
32
+ freeze
33
+ end
34
+
35
+ def type
36
+ TYPE
37
+ end
38
+
39
+ def to_h
40
+ { type: TYPE, score: score, confidence: confidence, legend: legend, probabilities: probabilities }
41
+ end
42
+
43
+ def ==(other)
44
+ other.is_a?(self.class) && other.to_h == to_h
45
+ end
46
+ alias eql? ==
47
+
48
+ def hash
49
+ [self.class, to_h].hash
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ class StderrLogger
6
+ LEVELS = { "debug" => 0, "info" => 1, "warn" => 2, "warning" => 2, "error" => 3 }.freeze
7
+
8
+ attr_reader :level
9
+
10
+ def initialize(level: "warn", io: $stderr)
11
+ @level = LEVELS.fetch(level.to_s)
12
+ @io = io
13
+ end
14
+
15
+ def debug(message = nil)
16
+ write(severity: "debug", message: message)
17
+ end
18
+
19
+ def info(message = nil)
20
+ write(severity: "info", message: message)
21
+ end
22
+
23
+ def warn(message = nil)
24
+ write(severity: "warn", message: message)
25
+ end
26
+
27
+ def error(message = nil)
28
+ write(severity: "error", message: message)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :io
34
+
35
+ def write(severity:, message:)
36
+ return if LEVELS.fetch(severity) < level
37
+
38
+ io.puts("typesafe-sdk #{severity}: #{message}")
39
+ end
40
+ end
41
+ end
42
+ end