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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 42f3b715025a1ca699b15276a2b2101298200008a0a8a5917b139a96c6b49680
4
+ data.tar.gz: 905bb383b0beeea71f4ecb209ce3619b36bfb770405dad7c0bd10ac4998b055b
5
+ SHA512:
6
+ metadata.gz: e7911203f2f36edb6f9c7dd510a24121eda9460f63da4ceb6fbaef34e53fd86875e8505ffd983a3f22f35db8df7829f80ee7dc042d2b71420fb4acb5371e830b
7
+ data.tar.gz: 95f819173a4603bd6f52d215405a9f9c1607c94ff1c1102bdc7e91f927e8587cb9e1a94ff294c15188645a0dabc54ce9f62825f1da692b74cd1f944dc107ecb5
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this SDK are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.0.0] — 2026-10-16
8
+
9
+ First release as the **`misardev`** gem, the package name every Misar.Dev SDK now follows.
10
+ It replaces the previous unpublished hand-written `misar_dev` client (`require "misar_dev"`,
11
+ Faraday-based), whose methods do not carry over — see below.
12
+
13
+ ### Added
14
+
15
+ - Every route in `sdks/manifest.json` — 21 resources, 106 methods, 3 streams (109
16
+ operations) — generated from the API's route tree, so the client cannot drift from what
17
+ the server serves.
18
+ - `PlanLimitError` carrying the full upgrade offer (`offer`, `upgrade_url`, `feature`,
19
+ `current_plan`, `recommended_plan`, `retry_after`), detected by
20
+ `code: "plan_limit_exceeded"` rather than by status.
21
+ - Typed errors per status: `ValidationError`, `AuthenticationError`,
22
+ `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `RateLimitError`,
23
+ `ServerError`, `NetworkError`, all subclassing `MisarDevError`.
24
+ - Server-Sent Events, block-yielding or as an `Enumerator`: `chat.completions_stream`,
25
+ `chat.studio_stream`, `projects.build_stream`.
26
+ - `paginate` for offset-paginated lists.
27
+ - `MISARDEV_API_KEY` / `MISARDEV_BASE_URL` environment fallbacks; injectable `http_client:`.
28
+
29
+ ### Changed
30
+
31
+ - Default base URL is `https://api.misar.io/dev`.
32
+ - Built on `net/http` alone; the Faraday dependency is gone. Requires Ruby 3.0+.
33
+ - Retries honour `Retry-After`; `POST`/`PATCH` are not retried after a server error, and a
34
+ plan refusal is never retried.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Misar.AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # Misar.Dev Ruby SDK
2
+
3
+ > Create, build and ship apps on Misar.Dev from Ruby — one client, no runtime dependencies.
4
+
5
+ [![gem](https://img.shields.io/gem/v/misardev)](https://rubygems.org/gems/misardev)
6
+ [![ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-CC342D)](https://rubygems.org/gems/misardev)
7
+ [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
8
+
9
+ **21 resource groups · 106 methods · 3 streams · typed plan-limit errors**
10
+
11
+ Misar.Dev turns a description into a working app and puts it online. This SDK is the same API the studio uses: create a project, write its files, deploy it to a subdomain or your own domain, start from a template, ask the AI through an OpenAI-compatible chat endpoint, and search across your code.
12
+
13
+ Every resource and method is generated from the API's own route tree (`sdks/manifest.json`), so the client never drifts from what the server actually serves.
14
+
15
+ Built on `net/http` with nothing outside the standard library, for Ruby 3.0+. Every method returns the decoded JSON — a `Hash` with **string** keys, or an `Array` — so nothing breaks when the API adds a field.
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ### RubyGems
22
+
23
+ ```bash
24
+ gem install misardev
25
+ ```
26
+
27
+ ### Bundler
28
+
29
+ ```ruby
30
+ gem "misardev", "~> 1.0"
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Authentication
36
+
37
+ Create an API key at https://www.misar.dev/settings/api-keys. It starts with `mdk_` and is
38
+ sent as `Authorization: Bearer mdk_…`. Pass it to the constructor, or set
39
+ `MISARDEV_API_KEY` and call `MisarDev.new` with no arguments.
40
+
41
+ Every call is checked against the plan behind that key. There is no client-side limit
42
+ checking — the server decides, and the SDK surfaces its answer. A plan refusal carries
43
+ `code: "plan_limit_exceeded"` and an upgrade offer, raises `PlanLimitError`, and is never
44
+ retried.
45
+
46
+ ```ruby
47
+ require "misardev"
48
+
49
+ dev = MisarDev.new(ENV["MISARDEV_API_KEY"])
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Resources
55
+
56
+ Every group the client exposes, and every public method on it. Each method's comment names
57
+ the route it calls.
58
+
59
+ ### Build and ship
60
+
61
+ | Resource | Methods | What it covers |
62
+ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
63
+ | `dev.projects` | `add_collaborator`, `archive`, `build_status`, `cancel_build`, `check_subdomain`, `create`, `create_conversation`, `delete`, `delete_env`, `delete_skills`, `deploy`, `duplicate`, `export`, `get`, `get_backend`, `get_deployment`, `get_domain`, `get_env`, `get_knowledge`, `get_skills`, `get_tools`, `import_artifact`, `list`, `list_checkpoints`, `list_collaborators`, `list_conversations`, `list_files`, `provision_backend`, `remove_collaborator`, `remove_domain`, `restore`, `set_domain`, `undeploy`, `update`, `update_env`, `update_knowledge`, `update_skills`, `update_subdomain`, `update_tools`, `write_files`, `build_stream` | Projects end to end: create, edit, files, builds, deploys, domains, env vars, collaborators, knowledge, skills, tools, export. |
64
+ | `dev.templates` | `create`, `delete`, `get`, `list`, `remix`, `update`, `use` | Starter templates: browse, create, remix, and start a project from one. |
65
+ | `dev.checkpoints` | `delete`, `restore`, `update` | Saved project snapshots: rename, restore, delete. |
66
+ | `dev.builds` | `list` | Recent builds across your projects. |
67
+
68
+ ### AI
69
+
70
+ | Resource | Methods | What it covers |
71
+ | ------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
72
+ | `dev.chat` | `completions`, `studio_stream`, `completions_stream` | OpenAI-compatible chat completions (plain and streamed) and the studio build conversation stream. |
73
+ | `dev.search` | `code`, `web` | Web-grounded answers with citations, and search across your own code. |
74
+ | `dev.rag` | `clear`, `index`, `search` | Index a project's code for semantic search, query it, clear it. |
75
+
76
+ ### Connect
77
+
78
+ | Resource | Methods | What it covers |
79
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
80
+ | `dev.connectors` | `available`, `create_connection`, `delete_connection`, `list`, `list_connections`, `test_connection`, `toggle`, `update_connection` | Connector catalogue, per-account enable state, and per-project connections. |
81
+ | `dev.github` | `disconnect`, `get_connection` | The linked GitHub account: read it or disconnect it. |
82
+ | `dev.skills` | `get`, `list` | Your Misar.AI skills library, read-only. |
83
+
84
+ ### Teams and community
85
+
86
+ | Resource | Methods | What it covers |
87
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
88
+ | `dev.organizations` | `accept_invitation`, `create`, `create_invitation`, `list`, `list_invitations`, `list_members`, `remove_member`, `revoke_invitation`, `update_member` | Organizations, members and invitations. |
89
+ | `dev.gallery` | `like`, `list`, `unlike` | The public community gallery, and likes. |
90
+ | `dev.profile` | `follow`, `get`, `unfollow` | Public builder profiles, follow and unfollow. |
91
+ | `dev.notifications` | `list`, `mark_all_read`, `mark_read` | In-app notifications and read state. |
92
+ | `dev.reports` | `create` | Report abusive content. |
93
+
94
+ ### Account, plan and billing
95
+
96
+ | Resource | Methods | What it covers |
97
+ | -------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------- |
98
+ | `dev.account` | `create_key`, `delete_key`, `export`, `list_keys`, `update_key` | Account API keys (with expiry and IP allowlists) and your data export. |
99
+ | `dev.api_keys` | `create`, `delete`, `list` | API keys — create, list, revoke. |
100
+ | `dev.usage` | `list`, `plan`, `track` | Usage for a period, your plan's allowances, and usage events. |
101
+ | `dev.billing` | `checkout`, `comparison`, `plans`, `subscription`, `switch_plan` | Plans, plan comparison, subscription, checkout, plan switch. |
102
+ | `dev.audit` | `list` | Your account's audit log. |
103
+ | `dev.domains` | `lookup` | Custom-domain availability lookup. |
104
+
105
+ ---
106
+
107
+ ## Client
108
+
109
+ | Thing | Detail |
110
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
111
+ | Entry point | `MisarDev::Client.new(api_key = nil, base_url:, max_retries:, timeout:, http_client:, headers:)`. `MisarDev.new(...)` is a shorthand. Every resource is a reader. |
112
+ | `api_key` | Falls back to `MISARDEV_API_KEY`; raises `MisarDev::MisarDevError` when neither is set. |
113
+ | `base_url:` | `https://api.misar.io/dev` (or `MISARDEV_BASE_URL`). |
114
+ | `max_retries:` | `3` total attempts. |
115
+ | `timeout:` | `60` seconds per attempt. Streams have no overall timeout. |
116
+ | `http_client:` | Any object with `call(request)` and `stream(request) { \|response\| }` — inject one for tests or proxies. Defaults to `net/http` (10-second open timeout). |
117
+ | `headers:` | Extra headers on every request. |
118
+ | Call signature | `dev.resource.method(path_params…, required_query…, body = {}, query = {})` — keyword-style hashes work: `dev.templates.list(limit: 5)`. |
119
+ | Results | Decoded JSON (`Hash` with string keys, or `Array`). `204`/empty is `nil`; a non-JSON body comes back as a `String`. |
120
+ | Retried | `429` (any method), and `500`/`502`/`503`/`504`/network failures for `GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`; exponential backoff from 250 ms, honouring `Retry-After` (capped at 60 s). |
121
+ | Never retried | Plan refusals, `POST`/`PATCH` after a server error or dropped connection, and streams. |
122
+ | Escape hatches | `dev.request(method, path, query:, body:)` and `dev.stream(method, path, ...)` for any route. |
123
+ | Errors | `MisarDev::MisarDevError` (alias `MisarDev::Error`) and nine subclasses. |
124
+
125
+ ---
126
+
127
+ ## Quick start
128
+
129
+ ```ruby
130
+ require "misardev"
131
+
132
+ dev = MisarDev.new(ENV["MISARDEV_API_KEY"])
133
+
134
+ created = dev.projects.create(
135
+ name: "Coffee shop site",
136
+ description: "A one-page site for a neighbourhood coffee shop"
137
+ )
138
+
139
+ puts created["project"]["id"]
140
+ ```
141
+
142
+ ## Primary functions
143
+
144
+ ### Create a project and write its files
145
+
146
+ ```ruby
147
+ project_id = dev.projects.create(name: "Landing page")["project"]["id"]
148
+
149
+ dev.projects.write_files(project_id, files: [
150
+ { path: "index.html", content: "<h1>Hello</h1>" },
151
+ { path: "styles.css", content: "h1 { color: #22c55e; }" }
152
+ ])
153
+
154
+ files = dev.projects.list_files(project_id)
155
+ ```
156
+
157
+ ### Deploy
158
+
159
+ ```ruby
160
+ # Check the subdomain first — required query params are positional.
161
+ check = dev.projects.check_subdomain(project_id, "coffee-shop")
162
+
163
+ deployment = dev.projects.deploy(project_id, subdomain: "coffee-shop")
164
+ status = dev.projects.get_deployment(project_id)
165
+ ```
166
+
167
+ ### Start from a template
168
+
169
+ ```ruby
170
+ templates = dev.templates.list(featured: true, limit: 10)["templates"]
171
+
172
+ created = dev.templates.use(templates.first["id"], projectName: "My portfolio")
173
+ ```
174
+
175
+ ### Ask the AI (OpenAI-compatible)
176
+
177
+ ```ruby
178
+ completion = dev.chat.completions(
179
+ model: "misar-online",
180
+ messages: [{ role: "user", content: "What's new in CSS this year?" }]
181
+ )
182
+
183
+ puts completion["choices"][0]["message"]["content"]
184
+ ```
185
+
186
+ ### Stream a completion
187
+
188
+ ```ruby
189
+ dev.chat.completions_stream(
190
+ model: "misar-online",
191
+ messages: [{ role: "user", content: "Write a haiku" }]
192
+ ) do |chunk|
193
+ print chunk.data["choices"][0]["delta"]["content"]
194
+ end
195
+ ```
196
+
197
+ ### Search your code
198
+
199
+ ```ruby
200
+ hits = dev.search.code(q: "useEffect", types: "files", limit: 20)
201
+ semantic = dev.rag.search(project_id, query: "where do we send email?")
202
+ ```
203
+
204
+ ### Read usage and plan
205
+
206
+ ```ruby
207
+ plan = dev.usage.plan
208
+ usage = dev.usage.list(days: 30)
209
+ ```
210
+
211
+ ### Manage API keys
212
+
213
+ ```ruby
214
+ fresh = dev.account.create_key(name: "CI")
215
+ puts fresh["key"] # shown once and never again
216
+ ```
217
+
218
+ ## Errors
219
+
220
+ Every non-2xx answer raises a typed error, all under `MisarDev`. Each carries `status`,
221
+ `message`, `code`, the decoded `body` and the `request_id` (from `x-request-id`).
222
+ `rescue MisarDev::MisarDevError` catches everything the SDK raises.
223
+
224
+ | Class | When |
225
+ | ----------------------- | -------------------------------------------------------------------------------- |
226
+ | `ValidationError` | `400` / `422` — the request was malformed. |
227
+ | `AuthenticationError` | `401` — the key is missing, revoked, expired or not a Misar.Dev key. |
228
+ | `PermissionDeniedError` | `403` — authenticated, but not allowed to touch this resource. |
229
+ | `NotFoundError` | `404` — no such resource, or not one this key can see. |
230
+ | `ConflictError` | `409` — conflicts with the current state (for example, a build already running). |
231
+ | `RateLimitError` | `429` throttle, after retries. Carries `retry_after` (seconds). |
232
+ | `PlanLimitError` | The plan behind the key does not cover the call. See below. |
233
+ | `ServerError` | `5xx`, after retries where retrying is safe. |
234
+ | `NetworkError` | The request never got an answer (DNS, socket, timeout). `status` is `0`. |
235
+ | `MisarDevError` | The base class, and anything else. Subclasses `StandardError`. |
236
+
237
+ ### Plan limits
238
+
239
+ A feature that is not on the plan answers **`402`** (`reason: "feature_locked"`); a spent
240
+ allowance answers **`429`** (`reason: "quota_exhausted"`, with `Retry-After`). Both carry
241
+ `code: "plan_limit_exceeded"` and an `upgrade` offer. The SDK keys on that code — never on
242
+ the status — so a spent quota is never mistaken for a throttle and never retried.
243
+
244
+ `PlanLimitError#offer` is the full offer as a `Hash`, every field the server sent: a
245
+ headline, the plans that would unblock you with their prices and what each one unlocks, and
246
+ the URLs. All of that copy comes from the server, so render it rather than writing your own.
247
+ Convenience readers: `upgrade_url` (billing, else pricing), `feature`, `current_plan`,
248
+ `recommended_plan`, `retry_after` (seconds until a quota resets).
249
+
250
+ ```ruby
251
+ begin
252
+ dev.projects.list
253
+ rescue MisarDev::PlanLimitError => e
254
+ warn e.offer&.fetch("headline", nil) || e.message
255
+ (e.offer&.fetch("plans", nil) || []).each { |plan| warn " #{plan['name']} — #{plan['price']}" }
256
+ warn "Upgrade: #{e.upgrade_url}"
257
+ # e.feature, e.current_plan, e.recommended_plan, e.retry_after
258
+ end
259
+ ```
260
+
261
+ ## Streaming
262
+
263
+ Three endpoints stream Server-Sent Events. Pass a block to receive each
264
+ `MisarDev::StreamEvent` (`event`, `data`, `id`) as it arrives; without a block you get a
265
+ lazy `Enumerator` — nothing is sent until you iterate it. `data` is the decoded JSON, or the
266
+ raw string when the payload is not JSON.
267
+
268
+ | Method | Route |
269
+ | ------------------------- | ---------------------------------------------------------------------------- |
270
+ | `chat.completions_stream` | `POST /v1/chat/completions` — sets `stream: true` for you, ends at `[DONE]`. |
271
+ | `chat.studio_stream` | `POST /chat` — the studio's build conversation. |
272
+ | `projects.build_stream` | `GET /projects/{id}/build-stream` — live build progress. |
273
+
274
+ A stream is never retried: replaying one that failed mid-flight would duplicate whatever you
275
+ had already read. A plan refusal — before the stream opens or as an error frame inside it —
276
+ raises `PlanLimitError`. `break` out of the block to stop early; the connection is closed.
277
+
278
+ ```ruby
279
+ dev.chat.completions_stream(
280
+ model: "misar-online",
281
+ messages: [{ role: "user", content: "Write a haiku" }]
282
+ ) do |chunk|
283
+ print chunk.data["choices"][0]["delta"]["content"]
284
+ end
285
+
286
+ dev.projects.build_stream(project_id).each do |event|
287
+ puts "#{event.event} #{event.data}"
288
+ end
289
+ ```
290
+
291
+ ## Pagination
292
+
293
+ List endpoints that take `limit` / `offset` return one page at a time. `dev.paginate` walks
294
+ every page and stops on a short one, returning an `Enumerator` over the items. Items come from
295
+ the page itself when it is an `Array`, otherwise from its first `Array`-valued field
296
+ (`templates`, `items`, …).
297
+
298
+ ```ruby
299
+ dev.paginate { |offset, limit| dev.templates.list(offset: offset, limit: limit) }.each do |template|
300
+ puts template["name"]
301
+ end
302
+
303
+ # page_size: and max_items: are optional
304
+ dev.paginate(page_size: 50, max_items: 200) { |offset, limit| dev.gallery.list(offset: offset, limit: limit) }.to_a
305
+ ```
306
+
307
+ ---
308
+
309
+ ## Links
310
+
311
+ - Website — https://www.misar.dev
312
+ - Parent — https://misar.io
313
+ - Documentation — https://docs.misar.io/dev
314
+ - Source — https://github.com/Misar-AI/misardev-sdks
315
+ - RubyGems — https://rubygems.org/gems/misardev
316
+
317
+ MIT © [Misar.AI](https://misar.io)
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core/transport"
4
+ require_relative "core/pagination"
5
+ require_relative "generated/resources"
6
+
7
+ module MisarDev
8
+ # The MisarDev API client.
9
+ #
10
+ # Every resource (projects, templates, chat, …) is generated from the API's
11
+ # route tree, so it always matches what the server exposes. The client
12
+ # authenticates with your account API key (mdk_…) and nothing else.
13
+ #
14
+ # dev = MisarDev::Client.new(ENV["MISARDEV_API_KEY"])
15
+ # dev.projects.list
16
+ class Client
17
+ include Generated::Resources
18
+
19
+ # The underlying transport, for routes the SDK does not wrap yet.
20
+ attr_reader :transport
21
+
22
+ # @param api_key [String, nil] falls back to MISARDEV_API_KEY
23
+ # @param base_url [String, nil] defaults to MISARDEV_BASE_URL, else https://api.misar.io/dev
24
+ # @param max_retries [Integer] total attempts for a retryable failure
25
+ # @param timeout [Numeric] per-attempt timeout in seconds; streams have none
26
+ # @param http_client [#call, #stream] inject the HTTP layer (tests, proxies)
27
+ # @param headers [Hash] extra headers sent with every request
28
+ def initialize(api_key = nil, base_url: nil, max_retries: 3, timeout: 60, http_client: nil, headers: {})
29
+ @transport = Core::Transport.new(
30
+ api_key,
31
+ base_url: base_url, max_retries: max_retries, timeout: timeout,
32
+ http_client: http_client, headers: headers
33
+ )
34
+ init_resources(@transport)
35
+ end
36
+
37
+ def base_url
38
+ @transport.base_url
39
+ end
40
+
41
+ def inspect
42
+ "#<#{self.class.name} base_url=#{base_url.inspect}>"
43
+ end
44
+
45
+ # Call any route directly: dev.request("GET", "/projects", query: { limit: 5 })
46
+ def request(method, path, query: nil, body: nil, headers: {})
47
+ @transport.request(method, path, query: query, body: body, headers: headers)
48
+ end
49
+
50
+ # Open any SSE route directly. Yields StreamEvents, or returns an Enumerator.
51
+ def stream(method, path, query: nil, body: nil, terminator: "[DONE]", headers: {}, &block)
52
+ @transport.stream(method, path, query: query, body: body, terminator: terminator, headers: headers, &block)
53
+ end
54
+
55
+ # Walk every page of an offset-paginated list. The block fetches one page
56
+ # given (offset, limit); returns an Enumerator over the items.
57
+ #
58
+ # dev.paginate { |offset, limit| dev.templates.list(offset: offset, limit: limit) }.each { |t| … }
59
+ def paginate(page_size: 50, max_items: nil, &fetch_page)
60
+ Core::Pagination.paginate(page_size: page_size, max_items: max_items, fetch_page: fetch_page)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module MisarDev
7
+ module Core
8
+ # One outgoing request, as handed to an HTTP client. +timeout+ is seconds
9
+ # per attempt, or nil for a stream (no overall limit).
10
+ Request = Struct.new(:method, :url, :headers, :body, :timeout, keyword_init: true)
11
+
12
+ # A complete response. Header names are lower-cased.
13
+ Response = Struct.new(:status, :headers, :body, :reason, keyword_init: true) do
14
+ def header(name)
15
+ (headers || {})[name.to_s.downcase]
16
+ end
17
+ end
18
+
19
+ # A response whose body arrives in pieces: status and headers up front,
20
+ # #each_chunk yields the body as it is read off the wire.
21
+ class StreamResponse
22
+ attr_reader :status, :headers, :reason
23
+
24
+ def initialize(status:, headers:, reason: "", &chunks)
25
+ @status = status
26
+ @headers = headers
27
+ @reason = reason
28
+ @chunks = chunks
29
+ end
30
+
31
+ def header(name)
32
+ (headers || {})[name.to_s.downcase]
33
+ end
34
+
35
+ def each_chunk(&block)
36
+ @chunks.call(&block)
37
+ end
38
+
39
+ def read_all
40
+ out = +""
41
+ each_chunk { |c| out << c }
42
+ out
43
+ end
44
+ end
45
+
46
+ # The default HTTP client: stdlib net/http, nothing else.
47
+ #
48
+ # Pass any object with the same two methods as +http_client:+ to the client
49
+ # (tests, proxies). Raise any StandardError when no HTTP answer was received;
50
+ # the transport turns it into a NetworkError. A non-2xx status is not an
51
+ # exception at this layer — return it.
52
+ class NetHttpClient
53
+ # Idle time allowed between two reads of a stream.
54
+ STREAM_READ_TIMEOUT = 300
55
+
56
+ def initialize(open_timeout: 10)
57
+ @open_timeout = open_timeout
58
+ end
59
+
60
+ # @return [Response]
61
+ def call(request)
62
+ uri = URI.parse(request.url)
63
+ start(uri, request.timeout) do |http|
64
+ res = http.request(build(uri, request))
65
+ Response.new(status: res.code.to_i, headers: headers_of(res), body: res.body.to_s, reason: res.message.to_s)
66
+ end
67
+ end
68
+
69
+ # Yields one StreamResponse; the connection is closed when the block returns.
70
+ def stream(request)
71
+ uri = URI.parse(request.url)
72
+ start(uri, request.timeout || STREAM_READ_TIMEOUT) do |http|
73
+ http.request(build(uri, request)) do |res|
74
+ yield(StreamResponse.new(status: res.code.to_i, headers: headers_of(res), reason: res.message.to_s) do |&blk|
75
+ res.read_body(&blk)
76
+ end)
77
+ end
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def start(uri, timeout, &block)
84
+ Net::HTTP.start(
85
+ uri.host, uri.port,
86
+ use_ssl: uri.scheme == "https",
87
+ open_timeout: @open_timeout,
88
+ read_timeout: timeout,
89
+ write_timeout: timeout,
90
+ &block
91
+ )
92
+ end
93
+
94
+ def build(uri, request)
95
+ req = Net::HTTPGenericRequest.new(
96
+ request.method, !request.body.nil?, request.method != "HEAD", uri.request_uri, request.headers
97
+ )
98
+ req.body = request.body unless request.body.nil?
99
+ req
100
+ end
101
+
102
+ def headers_of(res)
103
+ out = {}
104
+ res.each_header { |k, v| out[k.downcase] = v }
105
+ out
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MisarDev
4
+ module Core
5
+ # Offset pagination.
6
+ #
7
+ # List endpoints that take limit/offset (templates, gallery, API keys…)
8
+ # return one page at a time. Pagination.paginate walks every page: it calls
9
+ # the block with (offset, limit), yields each item, and stops on a short
10
+ # page. Items are read from the response itself when it is an Array,
11
+ # otherwise from its first Array-valued field (templates, items, keys, …).
12
+ #
13
+ # dev.paginate { |offset, limit| dev.templates.list(offset: offset, limit: limit) }.each { |t| … }
14
+ module Pagination
15
+ module_function
16
+
17
+ def items_of(page)
18
+ return page if page.is_a?(Array)
19
+ return page.values.find { |v| v.is_a?(Array) } || [] if page.is_a?(Hash)
20
+
21
+ []
22
+ end
23
+
24
+ # @return [Enumerator] when no consumer block is given
25
+ def paginate(page_size: 50, max_items: nil, fetch_page: nil, &consumer)
26
+ raise ArgumentError, "fetch_page is required" if fetch_page.nil?
27
+ return enum_for(:paginate, page_size: page_size, max_items: max_items, fetch_page: fetch_page) unless consumer
28
+
29
+ offset = 0
30
+ yielded = 0
31
+ loop do
32
+ break if max_items && yielded >= max_items
33
+
34
+ items = items_of(fetch_page.call(offset, page_size))
35
+ items.each do |item|
36
+ return nil if max_items && yielded >= max_items
37
+
38
+ consumer.call(item)
39
+ yielded += 1
40
+ end
41
+ break if items.length < page_size
42
+
43
+ offset += items.length
44
+ end
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end