misardev 1.0.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 +34 -0
- data/LICENSE +21 -0
- data/README.md +317 -0
- data/lib/misardev/client.rb +63 -0
- data/lib/misardev/core/http.rb +109 -0
- data/lib/misardev/core/pagination.rb +49 -0
- data/lib/misardev/core/sse.rb +101 -0
- data/lib/misardev/core/transport.rb +315 -0
- data/lib/misardev/errors.rb +117 -0
- data/lib/misardev/generated/account.rb +72 -0
- data/lib/misardev/generated/api_keys.rb +51 -0
- data/lib/misardev/generated/audit.rb +28 -0
- data/lib/misardev/generated/billing.rb +74 -0
- data/lib/misardev/generated/builds.rb +29 -0
- data/lib/misardev/generated/chat.rb +57 -0
- data/lib/misardev/generated/checkpoints.rb +52 -0
- data/lib/misardev/generated/connectors.rb +106 -0
- data/lib/misardev/generated/domains.rb +29 -0
- data/lib/misardev/generated/gallery.rb +53 -0
- data/lib/misardev/generated/github.rb +38 -0
- data/lib/misardev/generated/notifications.rb +52 -0
- data/lib/misardev/generated/organizations.rb +120 -0
- data/lib/misardev/generated/profile.rb +53 -0
- data/lib/misardev/generated/projects.rb +490 -0
- data/lib/misardev/generated/rag.rb +52 -0
- data/lib/misardev/generated/reports.rb +29 -0
- data/lib/misardev/generated/resources.rb +124 -0
- data/lib/misardev/generated/search.rb +40 -0
- data/lib/misardev/generated/skills.rb +39 -0
- data/lib/misardev/generated/templates.rb +97 -0
- data/lib/misardev/generated/usage.rb +50 -0
- data/lib/misardev/version.rb +5 -0
- data/lib/misardev.rb +17 -0
- metadata +115 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module MisarDev
|
|
6
|
+
# One Server-Sent Event. +data+ is the JSON-decoded payload (string keys), or
|
|
7
|
+
# the raw string when it is not JSON; +event+ and +id+ are the `event:` and
|
|
8
|
+
# `id:` fields when the server sends them.
|
|
9
|
+
StreamEvent = Struct.new(:event, :data, :id, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
module Core
|
|
12
|
+
# Server-Sent Events parsing.
|
|
13
|
+
#
|
|
14
|
+
# Frames are separated by a blank line (\n\n or \r\n\r\n) and may span
|
|
15
|
+
# several `data:` lines, which are joined with newlines per the SSE spec.
|
|
16
|
+
# Comment lines (`:`) are keep-alives and are skipped. The terminator
|
|
17
|
+
# payload — `[DONE]` on the OpenAI-compatible /v1/chat/completions stream —
|
|
18
|
+
# ends iteration and is never yielded. A nil terminator reads to EOF.
|
|
19
|
+
class SSE
|
|
20
|
+
SEPARATOR = /\r?\n\r?\n/.freeze
|
|
21
|
+
|
|
22
|
+
# Parse one complete frame (the text between blank lines).
|
|
23
|
+
# @return [Hash] { event:, data:, id: } — data is nil when the frame had none
|
|
24
|
+
def self.parse_frame(frame)
|
|
25
|
+
event = nil
|
|
26
|
+
id = nil
|
|
27
|
+
data = []
|
|
28
|
+
frame.split(/\r?\n/).each do |line|
|
|
29
|
+
next if line.empty? || line.start_with?(":")
|
|
30
|
+
|
|
31
|
+
idx = line.index(":")
|
|
32
|
+
field = idx ? line[0...idx] : line
|
|
33
|
+
value = idx ? line[(idx + 1)..] : ""
|
|
34
|
+
value = value[1..] if value.start_with?(" ")
|
|
35
|
+
case field
|
|
36
|
+
when "data" then data << value
|
|
37
|
+
when "event" then event = value
|
|
38
|
+
when "id" then id = value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
{ event: event, data: data.empty? ? nil : data.join("\n"), id: id }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# JSON-decode a payload when possible, else hand back the raw string.
|
|
45
|
+
def self.decode(raw)
|
|
46
|
+
JSON.parse(raw)
|
|
47
|
+
rescue JSON::ParserError
|
|
48
|
+
raw
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(terminator = "[DONE]")
|
|
52
|
+
@terminator = terminator
|
|
53
|
+
@buffer = +""
|
|
54
|
+
@done = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# True once the terminator has been seen; stop reading.
|
|
58
|
+
def done?
|
|
59
|
+
@done
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Feed a chunk; yields each StreamEvent completed by it.
|
|
63
|
+
def feed(chunk)
|
|
64
|
+
return if @done
|
|
65
|
+
|
|
66
|
+
@buffer << chunk
|
|
67
|
+
while (m = SEPARATOR.match(@buffer))
|
|
68
|
+
frame = @buffer[0...m.begin(0)]
|
|
69
|
+
@buffer = @buffer[m.end(0)..]
|
|
70
|
+
event = to_event(frame)
|
|
71
|
+
return if @done
|
|
72
|
+
next if event.nil?
|
|
73
|
+
|
|
74
|
+
yield event
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Flush a final frame the server never terminated with a blank line.
|
|
79
|
+
def finish
|
|
80
|
+
return if @done
|
|
81
|
+
|
|
82
|
+
event = to_event(@buffer)
|
|
83
|
+
@buffer = +""
|
|
84
|
+
yield event if event && !@done
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def to_event(frame)
|
|
90
|
+
parsed = self.class.parse_frame(frame)
|
|
91
|
+
return nil if parsed[:data].nil?
|
|
92
|
+
|
|
93
|
+
if @terminator && parsed[:data].strip == @terminator
|
|
94
|
+
@done = true
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
StreamEvent.new(event: parsed[:event], data: self.class.decode(parsed[:data]), id: parsed[:id])
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "../version"
|
|
8
|
+
require_relative "http"
|
|
9
|
+
require_relative "sse"
|
|
10
|
+
|
|
11
|
+
module MisarDev
|
|
12
|
+
module Core
|
|
13
|
+
# HTTP transport shared by the generated resource layer.
|
|
14
|
+
#
|
|
15
|
+
# Everything the SDK does goes through here, and it authenticates exactly
|
|
16
|
+
# one way: the account API key (mdk_…), sent as a bearer token. There is no
|
|
17
|
+
# second credential path. What a key may do — and how much of it — is
|
|
18
|
+
# decided server-side from the subscription behind that key; the SDK carries
|
|
19
|
+
# the key and surfaces the answer rather than deciding locally.
|
|
20
|
+
class Transport
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.misar.io/dev"
|
|
22
|
+
|
|
23
|
+
# Statuses worth retrying. 429 only when it is NOT a plan limit.
|
|
24
|
+
RETRYABLE = [429, 500, 502, 503, 504].freeze
|
|
25
|
+
|
|
26
|
+
# A 5xx or a dropped connection on a POST may already have done its work —
|
|
27
|
+
# started a build, charged an allowance. Only requests that are safe to
|
|
28
|
+
# repeat are retried on those; a 429 is always safe because the server
|
|
29
|
+
# refused it before doing anything.
|
|
30
|
+
IDEMPOTENT = %w[GET HEAD OPTIONS PUT DELETE].freeze
|
|
31
|
+
|
|
32
|
+
MAX_BACKOFF = 60.0
|
|
33
|
+
|
|
34
|
+
attr_reader :base_url, :max_retries, :timeout
|
|
35
|
+
|
|
36
|
+
def initialize(api_key = nil, base_url: nil, max_retries: 3, timeout: 60, http_client: nil, headers: {})
|
|
37
|
+
key = present(api_key) || present(ENV["MISARDEV_API_KEY"])
|
|
38
|
+
if key.nil?
|
|
39
|
+
raise MisarDevError.new(
|
|
40
|
+
0,
|
|
41
|
+
"A MisarDev API key is required. Pass it to the client or set MISARDEV_API_KEY. " \
|
|
42
|
+
"Create one at https://www.misar.dev/settings/api-keys.",
|
|
43
|
+
code: "missing_api_key"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@api_key = key
|
|
48
|
+
@base_url = (present(base_url) || present(ENV["MISARDEV_BASE_URL"]) || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
49
|
+
@max_retries = [1, max_retries.to_i].max
|
|
50
|
+
@timeout = timeout
|
|
51
|
+
@http = http_client || NetHttpClient.new
|
|
52
|
+
@headers = headers || {}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def inspect
|
|
56
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect}>"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ── Helpers ───────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
UNRESERVED = /[^A-Za-z0-9\-._~]/.freeze
|
|
62
|
+
|
|
63
|
+
# Percent-encode everything but RFC 3986 unreserved characters:
|
|
64
|
+
# "a b/c" → "a%20b%2Fc".
|
|
65
|
+
def self.encode(value)
|
|
66
|
+
value.to_s.b.gsub(UNRESERVED) { |c| format("%%%02X", c.ord) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Path-segment encoding for a path parameter.
|
|
70
|
+
def self.segment(value)
|
|
71
|
+
encode(value)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Build a query string. nil values are dropped, booleans are sent as
|
|
75
|
+
# true/false, an Array value repeats the key.
|
|
76
|
+
def self.build_query(query)
|
|
77
|
+
return "" if query.nil? || query.empty?
|
|
78
|
+
|
|
79
|
+
pairs = []
|
|
80
|
+
query.each do |key, value|
|
|
81
|
+
(value.is_a?(Array) ? value : [value]).each do |v|
|
|
82
|
+
next if v.nil?
|
|
83
|
+
|
|
84
|
+
pairs << "#{encode(key)}=#{encode(v)}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
pairs.empty? ? "" : "?#{pairs.join('&')}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Copy of +body+ with "stream" => true, whatever key style the caller used.
|
|
91
|
+
def self.force_stream(body)
|
|
92
|
+
(body || {}).reject { |k, _| k.to_s == "stream" }.merge("stream" => true)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Seconds to wait before the next attempt: Retry-After when sent, else
|
|
96
|
+
# exponential (250 ms · 2^attempt) with jitter; capped at 60 s.
|
|
97
|
+
def self.backoff(attempt, retry_after = nil)
|
|
98
|
+
hinted = retry_after_seconds(retry_after)
|
|
99
|
+
return [hinted, MAX_BACKOFF].min if hinted
|
|
100
|
+
|
|
101
|
+
base = 0.25 * (2**attempt)
|
|
102
|
+
[base + (rand * base * 0.25), MAX_BACKOFF].min
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Parse a Retry-After header — delta seconds or an HTTP-date — into seconds.
|
|
106
|
+
def self.retry_after_seconds(value)
|
|
107
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
108
|
+
|
|
109
|
+
s = value.to_s.strip
|
|
110
|
+
if s.match?(/\A\d+(\.\d+)?\z/)
|
|
111
|
+
s.to_f
|
|
112
|
+
else
|
|
113
|
+
[Time.httpdate(s) - Time.now, 0].max.to_f
|
|
114
|
+
end
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# True when a body is the plan-limit refusal.
|
|
120
|
+
def self.plan_limit_body?(body)
|
|
121
|
+
body.is_a?(Hash) && body["code"] == "plan_limit_exceeded"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Pull a human message out of the API's several error shapes.
|
|
125
|
+
def self.message_of(body, fallback)
|
|
126
|
+
if body.is_a?(Hash)
|
|
127
|
+
return body["error"] if body["error"].is_a?(String)
|
|
128
|
+
# /v1/* answers in the OpenAI shape: { error: { message, type, code } }
|
|
129
|
+
return body["error"]["message"] if body["error"].is_a?(Hash) && body["error"]["message"].is_a?(String)
|
|
130
|
+
return body["message"] if body["message"].is_a?(String)
|
|
131
|
+
end
|
|
132
|
+
return body.strip[0, 500] if body.is_a?(String) && !body.strip.empty?
|
|
133
|
+
|
|
134
|
+
fallback
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.code_of(body)
|
|
138
|
+
return nil unless body.is_a?(Hash)
|
|
139
|
+
return body["code"] if body["code"].is_a?(String)
|
|
140
|
+
return body["error"]["code"] if body["error"].is_a?(Hash) && body["error"]["code"].is_a?(String)
|
|
141
|
+
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Map an error response to the right typed error. Plan limits first, by code.
|
|
146
|
+
def self.error_for(status, headers, body, reason = "")
|
|
147
|
+
headers ||= {}
|
|
148
|
+
request_id = headers["x-request-id"]
|
|
149
|
+
message = message_of(body, reason.to_s.empty? ? "HTTP #{status}" : reason.to_s)
|
|
150
|
+
code = code_of(body)
|
|
151
|
+
common = { code: code, body: body, request_id: request_id }
|
|
152
|
+
|
|
153
|
+
if plan_limit_body?(body)
|
|
154
|
+
return PlanLimitError.new(
|
|
155
|
+
status, message,
|
|
156
|
+
offer: body["upgrade"].is_a?(Hash) ? body["upgrade"] : nil,
|
|
157
|
+
retry_after: retry_after_seconds(headers["retry-after"]),
|
|
158
|
+
body: body, request_id: request_id
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
case status
|
|
163
|
+
when 400, 422 then ValidationError.new(status, message, **common)
|
|
164
|
+
when 401 then AuthenticationError.new(status, message, **common)
|
|
165
|
+
when 403 then PermissionDeniedError.new(status, message, **common)
|
|
166
|
+
when 404 then NotFoundError.new(status, message, **common)
|
|
167
|
+
when 409 then ConflictError.new(status, message, **common)
|
|
168
|
+
when 429 then RateLimitError.new(status, message, retry_after: retry_after_seconds(headers["retry-after"]), **common)
|
|
169
|
+
else
|
|
170
|
+
status >= 500 ? ServerError.new(status, message, **common) : MisarDevError.new(status, message, **common)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# 204/empty → nil; JSON → decoded (string keys); anything else → the raw string.
|
|
175
|
+
def self.decode_body(status, text)
|
|
176
|
+
return nil if status == 204 || text.nil? || text.empty?
|
|
177
|
+
|
|
178
|
+
JSON.parse(text)
|
|
179
|
+
rescue JSON::ParserError
|
|
180
|
+
text
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ── Requests ──────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
# Send one request, retrying throttles and (for idempotent verbs)
|
|
186
|
+
# transient failures.
|
|
187
|
+
#
|
|
188
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
189
|
+
# @raise [MisarDevError]
|
|
190
|
+
def request(method, path, query: nil, body: nil, headers: {})
|
|
191
|
+
verb = method.to_s.upcase
|
|
192
|
+
req = Request.new(
|
|
193
|
+
method: verb,
|
|
194
|
+
url: "#{@base_url}#{path}#{self.class.build_query(query)}",
|
|
195
|
+
headers: headers_for("application/json", headers),
|
|
196
|
+
body: encode_body(body),
|
|
197
|
+
timeout: @timeout
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
attempt = 0
|
|
201
|
+
loop do
|
|
202
|
+
last = attempt >= @max_retries - 1
|
|
203
|
+
begin
|
|
204
|
+
res = @http.call(req)
|
|
205
|
+
rescue MisarDevError
|
|
206
|
+
raise
|
|
207
|
+
rescue StandardError => e
|
|
208
|
+
if !last && IDEMPOTENT.include?(verb)
|
|
209
|
+
sleep(self.class.backoff(attempt))
|
|
210
|
+
attempt += 1
|
|
211
|
+
next
|
|
212
|
+
end
|
|
213
|
+
raise NetworkError, e.message.to_s.empty? ? "Network error" : e.message
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
decoded = self.class.decode_body(res.status, res.body)
|
|
217
|
+
return decoded if res.status >= 200 && res.status < 300
|
|
218
|
+
|
|
219
|
+
# Read the body BEFORE deciding to retry: a 429 from the rate limiter
|
|
220
|
+
# and a 429 from a spent plan allowance share a status, and only the
|
|
221
|
+
# first is worth retrying.
|
|
222
|
+
error = self.class.error_for(res.status, res.headers, decoded, res.reason)
|
|
223
|
+
raise error if error.is_a?(PlanLimitError)
|
|
224
|
+
|
|
225
|
+
retryable = RETRYABLE.include?(res.status) && (res.status == 429 || IDEMPOTENT.include?(verb))
|
|
226
|
+
raise error unless retryable && !last
|
|
227
|
+
|
|
228
|
+
sleep(self.class.backoff(attempt, res.header("retry-after")))
|
|
229
|
+
attempt += 1
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Open a Server-Sent Events stream and yield each StreamEvent; returns an
|
|
234
|
+
# Enumerator when no block is given.
|
|
235
|
+
#
|
|
236
|
+
# Never retried: a stream that fails mid-flight cannot be replayed without
|
|
237
|
+
# duplicating what the caller already consumed. A
|
|
238
|
+
# {type:"error", code:"plan_limit_exceeded", upgrade} frame is raised as a
|
|
239
|
+
# PlanLimitError, exactly as the same refusal on a plain request would be.
|
|
240
|
+
def stream(method, path, query: nil, body: nil, terminator: "[DONE]", headers: {}, &block)
|
|
241
|
+
unless block
|
|
242
|
+
return enum_for(:stream, method, path, query: query, body: body, terminator: terminator, headers: headers)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
req = Request.new(
|
|
246
|
+
method: method.to_s.upcase,
|
|
247
|
+
url: "#{@base_url}#{path}#{self.class.build_query(query)}",
|
|
248
|
+
headers: headers_for("text/event-stream", headers),
|
|
249
|
+
body: encode_body(body),
|
|
250
|
+
timeout: nil # a stream has no fixed duration
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
user_raised = false
|
|
254
|
+
deliver = lambda do |event, res|
|
|
255
|
+
# Other error frames are the stream's own business (the build stream
|
|
256
|
+
# reports a failed step and carries on), so only the plan refusal —
|
|
257
|
+
# which no amount of reading further will fix — is raised.
|
|
258
|
+
raise self.class.error_for(402, res.headers, event.data) if self.class.plan_limit_body?(event.data)
|
|
259
|
+
|
|
260
|
+
begin
|
|
261
|
+
block.call(event)
|
|
262
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
263
|
+
user_raised = true
|
|
264
|
+
raise
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
begin
|
|
269
|
+
catch(:misardev_sse_done) do
|
|
270
|
+
@http.stream(req) do |res|
|
|
271
|
+
unless res.status >= 200 && res.status < 300
|
|
272
|
+
decoded = self.class.decode_body(res.status, res.read_all)
|
|
273
|
+
raise self.class.error_for(res.status, res.headers, decoded, res.reason)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
parser = SSE.new(terminator)
|
|
277
|
+
res.each_chunk do |chunk|
|
|
278
|
+
parser.feed(chunk) { |event| deliver.call(event, res) }
|
|
279
|
+
# Leave the connection rather than reading on past the terminator.
|
|
280
|
+
throw :misardev_sse_done if parser.done?
|
|
281
|
+
end
|
|
282
|
+
parser.finish { |event| deliver.call(event, res) }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
rescue MisarDevError
|
|
286
|
+
raise
|
|
287
|
+
rescue StandardError => e
|
|
288
|
+
raise if user_raised
|
|
289
|
+
|
|
290
|
+
raise NetworkError, e.message.to_s.empty? ? "Network error" : e.message
|
|
291
|
+
end
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private
|
|
296
|
+
|
|
297
|
+
def present(value)
|
|
298
|
+
value.nil? || value.to_s.empty? ? nil : value.to_s
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def encode_body(body)
|
|
302
|
+
body.nil? ? nil : JSON.generate(body)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def headers_for(accept, extra)
|
|
306
|
+
{
|
|
307
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
308
|
+
"Accept" => accept,
|
|
309
|
+
"Content-Type" => "application/json",
|
|
310
|
+
"X-Misar-SDK" => "misardev-ruby/#{VERSION}"
|
|
311
|
+
}.merge(@headers).merge(extra || {})
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MisarDev
|
|
4
|
+
# Base class for every error the SDK raises.
|
|
5
|
+
#
|
|
6
|
+
# Every non-2xx answer becomes a MisarDevError; the subclasses let a caller
|
|
7
|
+
# branch on the kind of failure without parsing strings.
|
|
8
|
+
# `rescue MisarDev::MisarDevError` (or its alias `MisarDev::Error`) catches
|
|
9
|
+
# everything the SDK raises.
|
|
10
|
+
class MisarDevError < StandardError
|
|
11
|
+
# @return [Integer] HTTP status, or 0 when the request never got an answer
|
|
12
|
+
attr_reader :status
|
|
13
|
+
# @return [String, nil] machine-readable error code from the body
|
|
14
|
+
attr_reader :code
|
|
15
|
+
# @return [Object, nil] the decoded error body
|
|
16
|
+
attr_reader :body
|
|
17
|
+
# @return [String, nil] the x-request-id header, for support requests
|
|
18
|
+
attr_reader :request_id
|
|
19
|
+
|
|
20
|
+
def initialize(status, message, code: nil, body: nil, request_id: nil)
|
|
21
|
+
@status = status
|
|
22
|
+
@code = code
|
|
23
|
+
@body = body
|
|
24
|
+
@request_id = request_id
|
|
25
|
+
super(message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Worth retrying as-is: a throttle or a transient server condition.
|
|
29
|
+
def retryable?
|
|
30
|
+
status == 429 || (status >= 500 && status < 600)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Error = MisarDevError
|
|
35
|
+
|
|
36
|
+
# 400 / 422 — the request was malformed or failed validation.
|
|
37
|
+
class ValidationError < MisarDevError; end
|
|
38
|
+
|
|
39
|
+
# 401 — the key is missing, revoked, expired, or not a MisarDev key.
|
|
40
|
+
class AuthenticationError < MisarDevError; end
|
|
41
|
+
|
|
42
|
+
# 403 — authenticated, but not allowed to touch this resource.
|
|
43
|
+
class PermissionDeniedError < MisarDevError; end
|
|
44
|
+
|
|
45
|
+
# 404 — no such resource, or not one this key can see.
|
|
46
|
+
class NotFoundError < MisarDevError; end
|
|
47
|
+
|
|
48
|
+
# 409 — conflicts with current state (e.g. a build already running).
|
|
49
|
+
class ConflictError < MisarDevError; end
|
|
50
|
+
|
|
51
|
+
# 5xx — the API failed. Retried automatically for idempotent requests.
|
|
52
|
+
class ServerError < MisarDevError; end
|
|
53
|
+
|
|
54
|
+
# 429 that is an ordinary throttle. Wait #retry_after seconds and try again —
|
|
55
|
+
# the transport already did so max_retries times before raising this.
|
|
56
|
+
class RateLimitError < MisarDevError
|
|
57
|
+
# @return [Float, nil] seconds the server asked us to wait, when it said
|
|
58
|
+
attr_reader :retry_after
|
|
59
|
+
|
|
60
|
+
def initialize(status, message, retry_after: nil, **kwargs)
|
|
61
|
+
@retry_after = retry_after
|
|
62
|
+
super(status, message, **kwargs)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The request never got an HTTP answer (DNS, TLS, reset, timeout). #status is 0.
|
|
67
|
+
class NetworkError < MisarDevError
|
|
68
|
+
def initialize(message)
|
|
69
|
+
super(0, message, code: "network_error")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The subscription behind the API key blocks this call.
|
|
74
|
+
#
|
|
75
|
+
# Detected by `code: "plan_limit_exceeded"` in the body — NEVER by status,
|
|
76
|
+
# because a spent quota is a 429 that would otherwise look like an ordinary
|
|
77
|
+
# throttle. A feature not on the plan answers 402 (`feature_locked`); a spent
|
|
78
|
+
# allowance answers 429 (`quota_exhausted`). Retrying cannot help until the
|
|
79
|
+
# plan changes or the period resets, so the transport never retries it.
|
|
80
|
+
class PlanLimitError < MisarDevError
|
|
81
|
+
# @return [Hash, nil] the full upgrade offer, every field the server sent.
|
|
82
|
+
# Render it; all copy comes from the server.
|
|
83
|
+
attr_reader :offer
|
|
84
|
+
# @return [Float, nil] seconds until a quota resets (quota_exhausted blocks)
|
|
85
|
+
attr_reader :retry_after
|
|
86
|
+
|
|
87
|
+
def initialize(status, message, offer: nil, retry_after: nil, body: nil, request_id: nil)
|
|
88
|
+
@offer = offer
|
|
89
|
+
@retry_after = retry_after
|
|
90
|
+
super(status, message, code: "plan_limit_exceeded", body: body, request_id: request_id)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Where to send the user to unblock themselves: billing, else pricing.
|
|
94
|
+
def upgrade_url
|
|
95
|
+
urls = offer.is_a?(Hash) ? offer["urls"] : nil
|
|
96
|
+
return nil unless urls.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
urls["billing"] || urls["pricing"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The machine key of the limit that was hit, e.g. "api_access".
|
|
102
|
+
def feature
|
|
103
|
+
offer.is_a?(Hash) ? offer["feature"] : nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The slug of the plan the key is on now, e.g. "starter".
|
|
107
|
+
def current_plan
|
|
108
|
+
plan = offer.is_a?(Hash) ? offer["current_plan"] : nil
|
|
109
|
+
plan.is_a?(Hash) ? plan["slug"] : nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The slug of the plan the server recommends, e.g. "pro".
|
|
113
|
+
def recommended_plan
|
|
114
|
+
offer.is_a?(Hash) ? offer["recommended_plan"] : nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# GENERATED by scripts/generate-sdk-resources.mjs from sdks/manifest.json.
|
|
2
|
+
# Do not edit by hand — change the route, run `pnpm sdk:sync`, and regenerate.
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module MisarDev
|
|
6
|
+
module Generated
|
|
7
|
+
# The `account` resource — 5 operation(s).
|
|
8
|
+
class AccountResource
|
|
9
|
+
def initialize(transport)
|
|
10
|
+
@t = transport
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def inspect
|
|
14
|
+
"#<#{self.class.name}>"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `POST /account/api-keys`
|
|
18
|
+
#
|
|
19
|
+
# misardev:method account.createKey
|
|
20
|
+
#
|
|
21
|
+
# @param body [Hash] JSON request body
|
|
22
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
23
|
+
# @raise [MisarDev::MisarDevError]
|
|
24
|
+
def create_key(body = {})
|
|
25
|
+
@t.request("POST", "/account/api-keys", body: body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `DELETE /account/api-keys/:keyId`
|
|
29
|
+
#
|
|
30
|
+
# misardev:method account.deleteKey
|
|
31
|
+
#
|
|
32
|
+
# @param key_id [String] path parameter `keyId`
|
|
33
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
34
|
+
# @raise [MisarDev::MisarDevError]
|
|
35
|
+
def delete_key(key_id)
|
|
36
|
+
@t.request("DELETE", "/account/api-keys/#{Core::Transport.segment(key_id)}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `GET /account/export`
|
|
40
|
+
#
|
|
41
|
+
# misardev:method account.export
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
44
|
+
# @raise [MisarDev::MisarDevError]
|
|
45
|
+
def export
|
|
46
|
+
@t.request("GET", "/account/export")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `GET /account/api-keys`
|
|
50
|
+
#
|
|
51
|
+
# misardev:method account.listKeys
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
54
|
+
# @raise [MisarDev::MisarDevError]
|
|
55
|
+
def list_keys
|
|
56
|
+
@t.request("GET", "/account/api-keys")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# `PATCH /account/api-keys/:keyId`
|
|
60
|
+
#
|
|
61
|
+
# misardev:method account.updateKey
|
|
62
|
+
#
|
|
63
|
+
# @param key_id [String] path parameter `keyId`
|
|
64
|
+
# @param body [Hash] JSON request body
|
|
65
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
66
|
+
# @raise [MisarDev::MisarDevError]
|
|
67
|
+
def update_key(key_id, body = {})
|
|
68
|
+
@t.request("PATCH", "/account/api-keys/#{Core::Transport.segment(key_id)}", body: body)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# GENERATED by scripts/generate-sdk-resources.mjs from sdks/manifest.json.
|
|
2
|
+
# Do not edit by hand — change the route, run `pnpm sdk:sync`, and regenerate.
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module MisarDev
|
|
6
|
+
module Generated
|
|
7
|
+
# The `apiKeys` resource — 3 operation(s).
|
|
8
|
+
class ApiKeysResource
|
|
9
|
+
def initialize(transport)
|
|
10
|
+
@t = transport
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def inspect
|
|
14
|
+
"#<#{self.class.name}>"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `POST /api-keys`
|
|
18
|
+
#
|
|
19
|
+
# misardev:method apiKeys.create
|
|
20
|
+
#
|
|
21
|
+
# @param body [Hash] JSON request body
|
|
22
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
23
|
+
# @raise [MisarDev::MisarDevError]
|
|
24
|
+
def create(body = {})
|
|
25
|
+
@t.request("POST", "/api-keys", body: body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `DELETE /api-keys`
|
|
29
|
+
#
|
|
30
|
+
# misardev:method apiKeys.delete
|
|
31
|
+
#
|
|
32
|
+
# @param body [Hash] JSON request body
|
|
33
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
34
|
+
# @raise [MisarDev::MisarDevError]
|
|
35
|
+
def delete(body = {})
|
|
36
|
+
@t.request("DELETE", "/api-keys", body: body)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `GET /api-keys`
|
|
40
|
+
#
|
|
41
|
+
# misardev:method apiKeys.list
|
|
42
|
+
#
|
|
43
|
+
# @param query [Hash] optional query parameters (limit, offset); nils are dropped
|
|
44
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
45
|
+
# @raise [MisarDev::MisarDevError]
|
|
46
|
+
def list(query = {})
|
|
47
|
+
@t.request("GET", "/api-keys", query: query)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# GENERATED by scripts/generate-sdk-resources.mjs from sdks/manifest.json.
|
|
2
|
+
# Do not edit by hand — change the route, run `pnpm sdk:sync`, and regenerate.
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module MisarDev
|
|
6
|
+
module Generated
|
|
7
|
+
# The `audit` resource — 1 operation(s).
|
|
8
|
+
class AuditResource
|
|
9
|
+
def initialize(transport)
|
|
10
|
+
@t = transport
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def inspect
|
|
14
|
+
"#<#{self.class.name}>"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `GET /audit`
|
|
18
|
+
#
|
|
19
|
+
# misardev:method audit.list
|
|
20
|
+
#
|
|
21
|
+
# @return [Hash, Array, String, nil] decoded JSON, a raw string, or nil
|
|
22
|
+
# @raise [MisarDev::MisarDevError]
|
|
23
|
+
def list
|
|
24
|
+
@t.request("GET", "/audit")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|