Class: Praxicraft::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/praxicraft/client.rb

Constant Summary collapse

DEFAULT_BASE_URL =
"https://assess.praxicraft.com"
DEFAULT_API_PREFIX =
"/api/v1/public"
DEFAULT_TIMEOUT_SECONDS =
30.0
DEFAULT_MAX_RETRIES =
2
RETRY_BASE_SECONDS =
0.5
RETRY_CAP_SECONDS =
8.0
RETRYABLE_STATUS_CODES =
[429, 500, 502, 503, 504].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = nil, **kwargs) ⇒ Client

Options keys: :api_key, :base_url, :timeout_seconds, :max_retries, :http_handler http_handler: callable(method, url, headers, body) -> { status:, headers:, body: }



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/praxicraft/client.rb', line 21

def initialize(options = nil, **kwargs)
  opts = {}
  opts.merge!(options.transform_keys(&:to_sym)) if options.is_a?(Hash)
  opts.merge!(kwargs)

  key = (opts[:api_key] || ENV["PRAXICRAFT_API_KEY"] || "").to_s.strip
  if key.empty?
    raise APIError.new(
      "No API key provided. Pass api_key or set PRAXICRAFT_API_KEY.",
      "MISSING_API_KEY"
    )
  end

  base = (opts[:base_url] || ENV["PRAXICRAFT_API_BASE_URL"] || DEFAULT_BASE_URL).to_s.strip
  base = base.sub(%r{/*\z}, "")
  if base.empty?
    raise APIError.new("baseUrl must be a non-empty URL.", "INVALID_BASE_URL")
  end

  @api_key = key
  @base_url = base
  @api_prefix = DEFAULT_API_PREFIX
  @timeout_seconds = (opts[:timeout_seconds] || DEFAULT_TIMEOUT_SECONDS).to_f
  @max_retries = [0, (opts[:max_retries] || DEFAULT_MAX_RETRIES).to_i].max
  @http_handler = opts[:http_handler]

  @org = Resources::Org.new(self)
  @assessments = Resources::Assessments.new(self)
  @invites = Resources::Invites.new(self)
  @results = Resources::Results.new(self)
  @webhooks = Resources::Webhooks.new(self)
  @pipelines = Resources::Pipelines.new(self)
end

Instance Attribute Details

#api_keyObject (readonly)

Returns the value of attribute api_key.



16
17
18
# File 'lib/praxicraft/client.rb', line 16

def api_key
  @api_key
end

#api_prefixObject (readonly)

Returns the value of attribute api_prefix.



16
17
18
# File 'lib/praxicraft/client.rb', line 16

def api_prefix
  @api_prefix
end

#assessmentsObject (readonly)

Returns the value of attribute assessments.



17
18
19
# File 'lib/praxicraft/client.rb', line 17

def assessments
  @assessments
end

#base_urlObject (readonly)

Returns the value of attribute base_url.



16
17
18
# File 'lib/praxicraft/client.rb', line 16

def base_url
  @base_url
end

#invitesObject (readonly)

Returns the value of attribute invites.



17
18
19
# File 'lib/praxicraft/client.rb', line 17

def invites
  @invites
end

#max_retriesObject (readonly)

Returns the value of attribute max_retries.



16
17
18
# File 'lib/praxicraft/client.rb', line 16

def max_retries
  @max_retries
end

#orgObject (readonly)

Returns the value of attribute org.



17
18
19
# File 'lib/praxicraft/client.rb', line 17

def org
  @org
end

#pipelinesObject (readonly)

Returns the value of attribute pipelines.



17
18
19
# File 'lib/praxicraft/client.rb', line 17

def pipelines
  @pipelines
end

#resultsObject (readonly)

Returns the value of attribute results.



17
18
19
# File 'lib/praxicraft/client.rb', line 17

def results
  @results
end

#timeout_secondsObject (readonly)

Returns the value of attribute timeout_seconds.



16
17
18
# File 'lib/praxicraft/client.rb', line 16

def timeout_seconds
  @timeout_seconds
end

#webhooksObject (readonly)

Returns the value of attribute webhooks.



17
18
19
# File 'lib/praxicraft/client.rb', line 17

def webhooks
  @webhooks
end

Class Method Details

.parse_retry_after_seconds(retry_after) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/praxicraft/client.rb', line 128

def self.parse_retry_after_seconds(retry_after)
  return nil if retry_after.nil? || retry_after.to_s.strip.empty?

  text = retry_after.to_s.strip
  return [0.0, text.to_f].max if text.match?(/\A\d+(\.\d+)?\z/)

  begin
    when_time = Time.httpdate(text)
    [0.0, when_time - Time.now].max
  rescue ArgumentError
    nil
  end
end

.path_segment(value, label = "id") ⇒ Object



75
76
77
78
79
80
81
82
83
# File 'lib/praxicraft/client.rb', line 75

def self.path_segment(value, label = "id")
  text = value.to_s.strip
  if text.empty?
    raise APIError.new("#{label} must be a non-empty string", "INVALID_PATH")
  end

  # Path-component encoding (spaces as %20), matching Node encodeURIComponent.
  URI.encode_www_form_component(text).gsub("+", "%20")
end

.raise_for_status(status_code, body, headers, raw) ⇒ Object



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/praxicraft/client.rb', line 150

def self.raise_for_status(status_code, body, headers, raw)
  error = {}
  if body.is_a?(Hash) && body["error"].is_a?(Hash)
    error = body["error"]
  end

  code = error["code"].is_a?(String) ? error["code"] : nil
  message = error["message"].is_a?(String) ? error["message"] : nil
  details = error["details"]
  required_plan = error["required_plan"].is_a?(String) ? error["required_plan"] : nil

  if message.nil? || message.empty?
    trimmed = raw.to_s.strip
    message = trimmed.empty? ? "API request failed with status #{status_code}." : trimmed[0, 500]
  end

  retry_after = parse_retry_after_seconds(headers["retry-after"])

  kwargs = {
    status_code: status_code,
    error_code: code,
    details: details,
    response_body: body,
    headers: headers,
    required_plan: required_plan,
    retry_after: retry_after
  }

  raise AuthenticationError.new(message, **kwargs) if status_code == 401
  raise InsufficientScopeError.new(message, **kwargs) if status_code == 403
  raise NotFoundError.new(message, **kwargs) if status_code == 404
  raise RateLimitError.new(message, **kwargs) if status_code == 429
  raise ValidationError.new(message, **kwargs) if status_code >= 400 && status_code < 500

  raise APIStatusError.new(message, **kwargs)
end

.retry_delay_seconds(retry_index, retry_after) ⇒ Object



142
143
144
145
146
147
148
# File 'lib/praxicraft/client.rb', line 142

def self.retry_delay_seconds(retry_index, retry_after)
  parsed = parse_retry_after_seconds(retry_after)
  return [parsed, RETRY_CAP_SECONDS].min unless parsed.nil?

  ceiling = [RETRY_CAP_SECONDS, RETRY_BASE_SECONDS * (2**retry_index)].min
  rand * ceiling
end

.should_retry_status?(status) ⇒ Boolean

Returns:

  • (Boolean)


124
125
126
# File 'lib/praxicraft/client.rb', line 124

def self.should_retry_status?(status)
  RETRYABLE_STATUS_CODES.include?(status)
end

Instance Method Details

#delete(path, json = nil) ⇒ Object



71
72
73
# File 'lib/praxicraft/client.rb', line 71

def delete(path, json = nil)
  request("DELETE", path, json: json)
end

#get(path, params = nil) ⇒ Object



55
56
57
# File 'lib/praxicraft/client.rb', line 55

def get(path, params = nil)
  request("GET", path, params: params)
end

#patch(path, json = nil) ⇒ Object



67
68
69
# File 'lib/praxicraft/client.rb', line 67

def patch(path, json = nil)
  request("PATCH", path, json: json)
end

#path_segment(value, label = "id") ⇒ Object



85
86
87
# File 'lib/praxicraft/client.rb', line 85

def path_segment(value, label = "id")
  self.class.path_segment(value, label)
end

#post(path, json = nil) ⇒ Object



59
60
61
# File 'lib/praxicraft/client.rb', line 59

def post(path, json = nil)
  request("POST", path, json: json)
end

#put(path, json = nil) ⇒ Object



63
64
65
# File 'lib/praxicraft/client.rb', line 63

def put(path, json = nil)
  request("PUT", path, json: json)
end

#request(method, path, params: nil, json: nil) ⇒ Object



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/praxicraft/client.rb', line 89

def request(method, path, params: nil, json: nil)
  attempts = @max_retries + 1
  last_error = nil

  attempts.times do |attempt|
    if attempt.positive?
      retry_after = nil
      if last_error.is_a?(APIStatusError)
        retry_after = last_error.headers["retry-after"]
      end
      sleep(self.class.retry_delay_seconds(attempt - 1, retry_after))
    end

    begin
      return request_once(method, path, params: params, json: json)
    rescue APIConnectionError => e
      last_error = e
      next if attempt < attempts - 1

      raise
    rescue APIStatusError => e
      last_error = e
      next if self.class.should_retry_status?(e.status_code) && attempt < attempts - 1

      raise
    end
  end

  raise last_error || APIConnectionError.new
end