misarblog 1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 668bfc444b17b3b94fcf0cee95e32d9b4666c8e592f2620e4bb1ca84900d96fc
4
+ data.tar.gz: 5eab33effad1e6be69d8c8c6f7585afbc87b0b8031a05f030046256f5b2b74f7
5
+ SHA512:
6
+ metadata.gz: '09713e47a720fb3028f62d13fb14b9650b7f079bac11f90a9576376e7d45454d8f90e68cf04f019f680916c7a8d809589e690adeec32c5f94ef36681a3697221'
7
+ data.tar.gz: e2475931a76cdbbbbd1f388e66ad9197d7f48ffd49924f321ffe5e4d7674e69b0f5732344b0bbe5e386a6a49cb8b9a7257233b2e8661daac61b93aa98b7c75c5
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this SDK are documented here. Versions follow
4
+ [semantic versioning](https://semver.org/).
5
+
6
+ ## 1.1.0 — 2026-08-16
7
+
8
+ ### Added
9
+ - `comments.list(...)` — `GET /comments`, an article's thread with replies
10
+ nested one level deep.
11
+ - `follows.status(...)` — `GET /follows`, follower/following counts plus
12
+ whether the key's owner follows the profile.
13
+ - A dedicated plan-limit error type carrying the plan slug, the pricing URL and
14
+ seconds until the allowance resets. The API answers a spent allowance with
15
+ 429 and a locked feature with 402, both tagged `plan_limit_exceeded`.
16
+
17
+ ### Changed
18
+ - Plan refusals no longer consume the retry budget. A `plan_limit_exceeded`
19
+ response is surfaced immediately instead of being retried three times with
20
+ back-off — retrying cannot help until the allowance resets or the plan
21
+ changes. Plain rate-limit 429s still retry as before.
22
+ - The SDK now covers all 25 key-authenticated operations.
23
+
24
+ ### Removed
25
+ - The `auth` token-refresh helper. It posted to `misar.blog/api/auth/refresh`,
26
+ a route that no longer exists, and did not use API-key authentication.
27
+
28
+ ## 1.0.0
29
+
30
+ - Initial release: 23 developer-API operations, typed models, retry with
31
+ exponential back-off.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Misar AI Technology Pvt Ltd
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,81 @@
1
+ # Misar.Blog Ruby SDK
2
+
3
+ Official Ruby client for the Misar.Blog developer API. Standard library only, retry with back-off.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ gem install misarblog
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ruby
14
+ require "misarblog"
15
+
16
+ blog = MisarBlog.new(api_key: "mbk_...")
17
+
18
+ me = blog.account.profile
19
+ thread = blog.comments.list(article_id: "article-id", limit: 50)
20
+ follows = blog.follows.status(user_id: me["id"])
21
+
22
+ begin
23
+ blog.ai.complete(prompt: "Draft an intro paragraph")
24
+ rescue MisarBlog::PlanLimitError => e
25
+ puts "#{e.plan} plan is out of credits — upgrade at #{e.upgrade_url}"
26
+ end
27
+ ```
28
+
29
+ ## Authentication and plan gating
30
+
31
+ Every call goes through the metered gateway at `https://api.misar.io/blog/v1`
32
+ with your developer key as a Bearer token. Mint a key in the dashboard at
33
+ <https://www.misar.blog/dashboard/settings/api> — key management is a
34
+ cookie-session flow and is deliberately not exposed by this SDK.
35
+
36
+ Feature access and throughput follow the subscription attached to that key:
37
+
38
+ | Signal | Meaning |
39
+ | --- | --- |
40
+ | `401` | Missing, expired or revoked key |
41
+ | `403` | The key is scoped and lacks the scope this route needs |
42
+ | `429` (plain) | Rate limit — 100 requests/minute per key. The SDK retries with back-off |
43
+ | `429` + `plan_limit_exceeded` | A metered allowance is spent. Retrying will not help until it resets |
44
+ | `402` + `plan_limit_exceeded` | The feature is not on this plan |
45
+
46
+ The last two raise ``MisarBlog::PlanLimitError`` rather than a generic error, carrying the
47
+ plan slug, the pricing URL and (when the API supplies it) seconds until reset.
48
+ Show the upgrade URL instead of reporting a bare failure — the SDK does not
49
+ retry these, because retrying cannot change the outcome.
50
+
51
+ ## Covered operations
52
+
53
+ All 25 key-authenticated operations:
54
+
55
+ | Group | Operations |
56
+ | --- | --- |
57
+ | Articles | list, get, create, update, create draft, search, recommendations |
58
+ | Series | list, create, add article |
59
+ | Reactions | get, add, remove |
60
+ | Comments | list |
61
+ | Follows | status |
62
+ | AI | complete, titles |
63
+ | Images | generate, upload |
64
+ | Account | profile, plan, trial status, start trial |
65
+ | Analytics | summary, upsell funnel |
66
+
67
+ The API exposes no SSE or WebSocket endpoint that accepts an API key, so this
68
+ SDK is request/response only. See [`openapi/blog.openapi.json`][spec] for the
69
+ machine-readable contract.
70
+
71
+ [spec]: https://api.misar.io/blog/v1/openapi.json
72
+
73
+ ## Links
74
+
75
+ - API docs — <https://docs.misar.io/blog>
76
+ - OpenAPI spec — <https://api.misar.io/blog/v1/openapi.json>
77
+ - Dashboard — <https://www.misar.blog/dashboard/settings/api>
78
+
79
+ ## License
80
+
81
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,370 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ require_relative "errors"
6
+ require_relative "models"
7
+
8
+ module MisarBlog
9
+ # Base URL for the Misar.Blog dev-API. The gateway strips `/api`, so the
10
+ # public base is `https://api.misar.io/blog/v1` (never add `/api`).
11
+ BASE_URL = "https://api.misar.io/blog/v1".freeze
12
+ RETRYABLE = [429, 500, 502, 503, 504].freeze
13
+ RETRY_BASE_S = 0.3
14
+
15
+ # ── Resource base ────────────────────────────────────────────────────────────
16
+
17
+ class Resource
18
+ def initialize(client)
19
+ @client = client
20
+ end
21
+
22
+ private
23
+
24
+ # Drop nil values so optional params are never serialized.
25
+ def compact(hash)
26
+ hash.reject { |_, v| v.nil? }
27
+ end
28
+
29
+ def query(params)
30
+ compacted = compact(params)
31
+ compacted.empty? ? "" : "?#{URI.encode_www_form(compacted)}"
32
+ end
33
+ end
34
+
35
+ # ── Resource: AI ─────────────────────────────────────────────────────────────
36
+
37
+ class AiResource < Resource
38
+ # POST /ai/complete — free-form completion.
39
+ def complete(prompt:, system: nil, max_tokens: nil)
40
+ body = compact(system: system, prompt: prompt, max_tokens: max_tokens)
41
+ Models::AiText.new(@client.request(:post, "/ai/complete", body))
42
+ end
43
+
44
+ # POST /ai/titles — SEO/AEO/GEO title suggestions.
45
+ # action: "suggest" | "seo"
46
+ def titles(action:, prompt: nil, context: nil)
47
+ body = compact(action: action, prompt: prompt, context: context)
48
+ Models::TitlesResult.new(@client.request(:post, "/ai/titles", body))
49
+ end
50
+ end
51
+
52
+ # ── Resource: Articles ───────────────────────────────────────────────────────
53
+
54
+ class ArticlesResource < Resource
55
+ # GET /articles
56
+ def list(status: nil, visibility: nil, webhook_only: nil, sort: nil, limit: nil)
57
+ qs = query(status: status, visibility: visibility,
58
+ webhook_only: webhook_only, sort: sort, limit: limit)
59
+ Models::ArticleList.new(@client.request(:get, "/articles#{qs}"))
60
+ end
61
+
62
+ # GET /articles/{slug}
63
+ def get(slug)
64
+ Models::Article.new(@client.request(:get, "/articles/#{escape(slug)}"))
65
+ end
66
+
67
+ # POST /articles — publish (or schedule) a new article.
68
+ def publish(title:, body_markdown:, tags: nil, cover_image_url: nil,
69
+ schedule_at: nil, visibility: nil)
70
+ body = compact(title: title, body_markdown: body_markdown, tags: tags,
71
+ cover_image_url: cover_image_url, schedule_at: schedule_at,
72
+ visibility: visibility)
73
+ Models::Article.new(@client.request(:post, "/articles", body))
74
+ end
75
+
76
+ # PATCH /articles/{slug} — update an existing article.
77
+ def update(slug, title: nil, body_markdown: nil, tags: nil, publish: nil)
78
+ body = compact(title: title, body_markdown: body_markdown,
79
+ tags: tags, publish: publish)
80
+ Models::Article.new(@client.request(:patch, "/articles/#{escape(slug)}", body))
81
+ end
82
+
83
+ # POST /drafts — create an unpublished draft.
84
+ def create_draft(title:, body_markdown:, tags: nil)
85
+ body = compact(title: title, body_markdown: body_markdown, tags: tags)
86
+ Models::Article.new(@client.request(:post, "/drafts", body))
87
+ end
88
+
89
+ # GET /search — full-text search across articles/profiles/tags.
90
+ def search(q: nil, type: nil, tag: nil, author: nil, sort: nil,
91
+ from: nil, to: nil, limit: nil)
92
+ qs = query(q: q, type: type, tag: tag, author: author, sort: sort,
93
+ from: from, to: to, limit: limit)
94
+ @client.request(:get, "/search#{qs}")
95
+ end
96
+
97
+ # GET /recommendations — related articles for a given article.
98
+ def recommendations(article_id:, limit: nil)
99
+ qs = query(article_id: article_id, limit: limit)
100
+ @client.request(:get, "/recommendations#{qs}")
101
+ end
102
+
103
+ private
104
+
105
+ def escape(value)
106
+ URI.encode_www_form_component(value.to_s)
107
+ end
108
+ end
109
+
110
+ # ── Resource: Images ─────────────────────────────────────────────────────────
111
+
112
+ class ImagesResource < Resource
113
+ # POST /images/generate — AI cover-image generation.
114
+ def generate(prompt:, size: nil)
115
+ body = compact(prompt: prompt, size: size)
116
+ Models::ImageResult.new(@client.request(:post, "/images/generate", body))
117
+ end
118
+
119
+ # POST /images/upload — register/host an image. Accepts a raw payload Hash.
120
+ def upload(data = {})
121
+ Models::ImageResult.new(@client.request(:post, "/images/upload", data))
122
+ end
123
+ end
124
+
125
+ # ── Resource: Reactions ──────────────────────────────────────────────────────
126
+
127
+ class ReactionsResource < Resource
128
+ # GET /reactions?article_id=
129
+ def get(article_id:)
130
+ qs = query(article_id: article_id)
131
+ Models::ArticleReactions.new(@client.request(:get, "/reactions#{qs}"))
132
+ end
133
+
134
+ # POST /reactions
135
+ def add(article_id:, type:)
136
+ body = compact(article_id: article_id, type: type)
137
+ Models::ReactionResult.new(@client.request(:post, "/reactions", body))
138
+ end
139
+
140
+ # DELETE /reactions?article_id=&type=
141
+ def remove(article_id:, type:)
142
+ qs = query(article_id: article_id, type: type)
143
+ Models::ReactionResult.new(@client.request(:delete, "/reactions#{qs}"))
144
+ end
145
+ end
146
+
147
+ # ── Resource: Series ─────────────────────────────────────────────────────────
148
+
149
+ class SeriesResource < Resource
150
+ # GET /series
151
+ def list
152
+ Models::SeriesList.new(@client.request(:get, "/series"))
153
+ end
154
+
155
+ # POST /series
156
+ def create(title:, description: nil)
157
+ body = compact(title: title, description: description)
158
+ Models::Series.new(@client.request(:post, "/series", body))
159
+ end
160
+
161
+ # POST /series/{slug}/articles — add an article to a series.
162
+ def add_article(slug, article_slug:, position: nil)
163
+ body = compact(article_slug: article_slug, position: position)
164
+ @client.request(:post, "/series/#{escape(slug)}/articles", body)
165
+ end
166
+
167
+ private
168
+
169
+ def escape(value)
170
+ URI.encode_www_form_component(value.to_s)
171
+ end
172
+ end
173
+
174
+ # ── Resource: Account ────────────────────────────────────────────────────────
175
+
176
+ class AccountResource < Resource
177
+ # GET /me
178
+ def profile
179
+ Models::Profile.new(@client.request(:get, "/me"))
180
+ end
181
+
182
+ # GET /plan
183
+ def plan
184
+ Models::Plan.new(@client.request(:get, "/plan"))
185
+ end
186
+
187
+ # GET /trial
188
+ def trial_status
189
+ Models::TrialStatus.new(@client.request(:get, "/trial"))
190
+ end
191
+
192
+ # POST /trial — start a trial for a gated feature.
193
+ def start_trial(feature: nil, ref: nil)
194
+ body = compact(feature: feature, ref: ref)
195
+ @client.request(:post, "/trial", body)
196
+ end
197
+
198
+ # GET /upsell-funnel
199
+ def upsell_funnel(days: nil, feature: nil)
200
+ qs = query(days: days, feature: feature)
201
+ @client.request(:get, "/upsell-funnel#{qs}")
202
+ end
203
+ end
204
+
205
+ # ── Resource: Analytics ──────────────────────────────────────────────────────
206
+
207
+ class AnalyticsResource < Resource
208
+ # GET /analytics
209
+ def get(days: nil)
210
+ qs = query(days: days)
211
+ Models::Analytics.new(@client.request(:get, "/analytics#{qs}"))
212
+ end
213
+ end
214
+
215
+ # ── Main Client ──────────────────────────────────────────────────────────────
216
+
217
+ # GET /comments — an article's comment thread.
218
+ class CommentsResource < Resource
219
+ # List an article's comments, newest first, replies nested one level deep.
220
+ # @param article_id [String]
221
+ # @param limit [Integer, nil] 1..100, defaults to 20 server-side
222
+ # @param offset [Integer, nil] defaults to 0 server-side
223
+ def list(article_id:, limit: nil, offset: nil)
224
+ @client.request(:get, "/comments#{query(compact(
225
+ article_id: article_id, limit: limit, offset: offset
226
+ ))}")
227
+ end
228
+ end
229
+
230
+ # GET /follows — follower counts and the caller's follow state.
231
+ class FollowsResource < Resource
232
+ # @param user_id [String]
233
+ def status(user_id:)
234
+ @client.request(:get, "/follows#{query(compact(user_id: user_id))}")
235
+ end
236
+ end
237
+
238
+ class Client
239
+ attr_reader :ai, :articles, :images, :reactions, :series, :account, :analytics,
240
+ :comments, :follows
241
+
242
+ # @param api_key [String] a `mbk_...` developer key or an OAuth 2.1 access token
243
+ # @param base_url [String] override the API base (default BASE_URL)
244
+ # @param timeout [Integer] per-request read timeout in seconds
245
+ # @param max_retries [Integer] total attempts for retryable failures (>= 1)
246
+ def initialize(api_key:, base_url: BASE_URL, timeout: 30, max_retries: 3)
247
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
248
+
249
+ @api_key = api_key
250
+ @base_url = base_url.chomp("/")
251
+ @timeout = timeout
252
+ @max_retries = [max_retries, 1].max
253
+
254
+ @ai = AiResource.new(self)
255
+ @articles = ArticlesResource.new(self)
256
+ @images = ImagesResource.new(self)
257
+ @reactions = ReactionsResource.new(self)
258
+ @series = SeriesResource.new(self)
259
+ @account = AccountResource.new(self)
260
+ @analytics = AnalyticsResource.new(self)
261
+ @comments = CommentsResource.new(self)
262
+ @follows = FollowsResource.new(self)
263
+ end
264
+
265
+ # @param method [Symbol] :get, :post, :patch, :put, :delete
266
+ # @param path [String] e.g. "/articles"
267
+ # @param data [Hash] request body (post/put/patch only)
268
+ # @return [Hash] decoded JSON body (empty Hash for 204/no-content)
269
+ # @raise [ApiError, NetworkError]
270
+ def request(method, path, data = {})
271
+ url = URI.parse(@base_url + "/" + path.delete_prefix("/"))
272
+ has_body = !data.empty? && %i[post put patch].include?(method)
273
+ json_body = has_body ? JSON.generate(data) : nil
274
+
275
+ last_status = 0
276
+
277
+ @max_retries.times do |attempt|
278
+ final = attempt == @max_retries - 1
279
+ begin
280
+ http = Net::HTTP.new(url.host, url.port)
281
+ http.use_ssl = url.scheme == "https"
282
+ http.open_timeout = 10
283
+ http.read_timeout = @timeout
284
+
285
+ req = build_request(method, url, json_body)
286
+ resp = http.request(req)
287
+ last_status = resp.code.to_i
288
+
289
+ # Retry on transient statuses — but on the FINAL attempt, still
290
+ # return/raise from the real response (never swallow the send).
291
+ # A plan-limit 429 is not "slow down" — retrying cannot help until
292
+ # the allowance resets or the plan changes, so fall straight through
293
+ # to parse_response and raise instead of burning the retry budget.
294
+ if RETRYABLE.include?(last_status) && !final && !plan_limit?(resp)
295
+ sleep(RETRY_BASE_S * (2**attempt))
296
+ next
297
+ end
298
+
299
+ return parse_response(resp, last_status)
300
+ rescue Net::OpenTimeout, Net::ReadTimeout,
301
+ Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
302
+ raise NetworkError.new(e.message, e) if final
303
+
304
+ sleep(RETRY_BASE_S * (2**attempt))
305
+ next
306
+ end
307
+ end
308
+
309
+ raise ApiError.new(last_status, "Max retries exceeded")
310
+ end
311
+
312
+ private
313
+
314
+ def build_request(method, url, json_body)
315
+ klass = case method
316
+ when :get then Net::HTTP::Get
317
+ when :post then Net::HTTP::Post
318
+ when :patch then Net::HTTP::Patch
319
+ when :put then Net::HTTP::Put
320
+ when :delete then Net::HTTP::Delete
321
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
322
+ end
323
+
324
+ req = klass.new(url.request_uri)
325
+ req["Authorization"] = "Bearer #{@api_key}"
326
+ req["Content-Type"] = "application/json"
327
+ req["Accept"] = "application/json"
328
+ req.body = json_body if json_body
329
+ req
330
+ end
331
+
332
+ # True when the response body carries the API's plan-limit code.
333
+ def plan_limit?(resp)
334
+ body = resp.body
335
+ return false if body.nil? || body.empty?
336
+
337
+ decoded = JSON.parse(body)
338
+ decoded.is_a?(Hash) && decoded["code"] == "plan_limit_exceeded"
339
+ rescue JSON::ParserError
340
+ false
341
+ end
342
+
343
+ def response_headers(resp)
344
+ resp.each_header.to_h
345
+ rescue StandardError
346
+ {}
347
+ end
348
+
349
+ def parse_response(resp, status)
350
+ return {} if status == 204 || resp.body.nil? || resp.body.empty?
351
+
352
+ decoded = begin
353
+ JSON.parse(resp.body)
354
+ rescue JSON::ParserError
355
+ nil
356
+ end
357
+
358
+ if status >= 400
359
+ msg = decoded.is_a?(Hash) ? (decoded["error"] || decoded["message"] || resp.body) : resp.body
360
+ if decoded.is_a?(Hash) && decoded["code"] == "plan_limit_exceeded"
361
+ raise PlanLimitError.new(status, msg, decoded, response_headers(resp))
362
+ end
363
+
364
+ raise ApiError.new(status, msg, "api_error", decoded)
365
+ end
366
+
367
+ decoded.is_a?(Hash) ? decoded : { "data" => decoded }
368
+ end
369
+ end
370
+ end
@@ -0,0 +1,11 @@
1
+ module MisarBlog
2
+ EMBED_BASE = "https://misar.blog".freeze
3
+
4
+ def self.embed_url(username:, slug: nil, theme: "auto")
5
+ url = "#{EMBED_BASE}/#{username}"
6
+ url = "#{url}/#{slug}" if slug && !slug.empty?
7
+ url = "#{url}/embed"
8
+ url = "#{url}?theme=#{theme}" if theme != "auto"
9
+ url
10
+ end
11
+ end
@@ -0,0 +1,54 @@
1
+ module MisarBlog
2
+ # Raised when the Misar.Blog dev-API returns a non-2xx response.
3
+ class ApiError < StandardError
4
+ attr_reader :status, :error_type, :body
5
+
6
+ def initialize(status, message, error_type = "api_error", body = nil)
7
+ @status = status
8
+ @error_type = error_type
9
+ @body = body
10
+ super("misar-blog: API error #{status} (#{error_type}): #{message}")
11
+ end
12
+ end
13
+
14
+ # Raised when the subscription attached to the API key blocks the call.
15
+ #
16
+ # The API signals this with `code: "plan_limit_exceeded"` and answers 429 when
17
+ # a metered allowance is exhausted (retryable once the period rolls over) or
18
+ # 402 when the feature is locked outright. It is raised as its own class
19
+ # rather than a generic 429 because retrying cannot help until the allowance
20
+ # resets or the plan changes — the client stops retrying on sight.
21
+ class PlanLimitError < ApiError
22
+ # @return [String, nil] the account's current plan slug
23
+ attr_reader :plan
24
+ # @return [String, nil] pricing page to send the user to
25
+ attr_reader :upgrade_url
26
+ # @return [Integer, nil] seconds until the allowance resets
27
+ attr_reader :retry_after
28
+ # @return [Hash] the full upgrade offer from the response body
29
+ attr_reader :upgrade
30
+
31
+ def initialize(status, message, body = nil, headers = {})
32
+ body ||= {}
33
+ headers = (headers || {}).transform_keys { |k| k.to_s.downcase }
34
+ @upgrade = body["upgrade"].is_a?(Hash) ? body["upgrade"] : {}
35
+ @plan = headers["x-misar-plan"] || @upgrade.dig("current_plan", "slug")
36
+ # Headers are authoritative; fall back to the offer body when a proxy has
37
+ # stripped them.
38
+ @upgrade_url = headers["x-misar-upgrade-url"] || @upgrade.dig("urls", "pricing")
39
+ ra = headers["retry-after"]
40
+ @retry_after = ra&.match?(/\A\d+\z/) ? ra.to_i : nil
41
+ super(status, message, "plan_limit_exceeded", body)
42
+ end
43
+ end
44
+
45
+ # Raised for connectivity failures where no HTTP response was received.
46
+ class NetworkError < ApiError
47
+ attr_reader :cause_error
48
+
49
+ def initialize(message, cause = nil)
50
+ super(0, message, "network_error")
51
+ @cause_error = cause
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,146 @@
1
+ module MisarBlog
2
+ # Typed models for the Misar.Blog dev-API. Each wraps the decoded JSON body
3
+ # and exposes the documented fields, while `#raw` keeps the original Hash for
4
+ # forward-compatibility with fields added after this SDK was published.
5
+ module Models
6
+ # Base class: stores the raw Hash and offers indifferent-ish access.
7
+ class Base
8
+ attr_reader :raw
9
+
10
+ def initialize(raw)
11
+ @raw = raw || {}
12
+ end
13
+
14
+ def [](key)
15
+ @raw[key.to_s]
16
+ end
17
+
18
+ def to_h
19
+ @raw
20
+ end
21
+ end
22
+
23
+ # An article (draft, scheduled, published, ...).
24
+ class Article < Base
25
+ def id; @raw["id"]; end
26
+ def slug; @raw["slug"]; end
27
+ def title; @raw["title"]; end
28
+ def status; @raw["status"]; end
29
+ def url; @raw["url"]; end
30
+ def editor_url; @raw["editor_url"]; end
31
+ def excerpt; @raw["excerpt"]; end
32
+ def tags; @raw["tags"] || []; end
33
+ def visibility; @raw["visibility"]; end
34
+ def published_at; @raw["published_at"]; end
35
+ def created_at; @raw["created_at"]; end
36
+ end
37
+
38
+ # Result of GET /articles.
39
+ class ArticleList < Base
40
+ def articles; (@raw["articles"] || []).map { |a| Article.new(a) }; end
41
+ def total; @raw["total"]; end
42
+ end
43
+
44
+ # A series/collection of articles.
45
+ class Series < Base
46
+ def id; @raw["id"]; end
47
+ def slug; @raw["slug"]; end
48
+ def title; @raw["title"]; end
49
+ def description; @raw["description"]; end
50
+ def article_count; @raw["article_count"]; end
51
+ def created_at; @raw["created_at"]; end
52
+ end
53
+
54
+ # Result of GET /series.
55
+ class SeriesList < Base
56
+ def series; (@raw["series"] || []).map { |s| Series.new(s) }; end
57
+ end
58
+
59
+ # The authenticated developer's public profile (GET /me).
60
+ class Profile < Base
61
+ def id; @raw["id"]; end
62
+ def username; @raw["username"]; end
63
+ def display_name; @raw["display_name"]; end
64
+ def bio; @raw["bio"]; end
65
+ def avatar_url; @raw["avatar_url"]; end
66
+ def stripe_connected; @raw["stripe_connected"]; end
67
+ def profile_url; @raw["profile_url"]; end
68
+ def created_at; @raw["created_at"]; end
69
+ end
70
+
71
+ # A single plan-usage counter row.
72
+ class PlanUsage < Base
73
+ def feature; @raw["feature"]; end
74
+ def feature_label; @raw["feature_label"]; end
75
+ def used; @raw["used"]; end
76
+ def limit; @raw["limit"]; end
77
+ def remaining; @raw["remaining"]; end
78
+ def period; @raw["period"]; end
79
+ def resets_at; @raw["resets_at"]; end
80
+ end
81
+
82
+ # Plan + usage snapshot (GET /plan).
83
+ class Plan < Base
84
+ def plan; @raw["plan"]; end
85
+ def slug; (@raw["plan"] || {})["slug"]; end
86
+ def name; (@raw["plan"] || {})["name"]; end
87
+ def usage; (@raw["usage"] || []).map { |u| PlanUsage.new(u) }; end
88
+ def upgrade; @raw["upgrade"]; end
89
+ end
90
+
91
+ # Account-wide analytics summary (GET /analytics).
92
+ class Analytics < Base
93
+ def period_days; @raw["period_days"]; end
94
+ def views; @raw["views"]; end
95
+ def revenue_cents; @raw["revenue_cents"]; end
96
+ def revenue_net_cents; @raw["revenue_net_cents"]; end
97
+ def active_subscribers; @raw["active_subscribers"]; end
98
+ end
99
+
100
+ # Reaction counts for an article (GET /reactions).
101
+ class ArticleReactions < Base
102
+ def article_id; @raw["article_id"]; end
103
+ def counts; @raw["counts"] || {}; end
104
+ def total; @raw["total"]; end
105
+ def user_reactions; @raw["user_reactions"] || []; end
106
+ end
107
+
108
+ # Result of adding/removing a reaction.
109
+ class ReactionResult < Base
110
+ def success; @raw["success"]; end
111
+ def reacted; @raw["reacted"]; end
112
+ end
113
+
114
+ # Trial eligibility/status (GET /trial).
115
+ class TrialStatus < Base
116
+ def eligible; @raw["eligible"]; end
117
+ def active; @raw["active"]; end
118
+ def ends_at; @raw["ends_at"]; end
119
+ def plan_slug; @raw["plan_slug"]; end
120
+ def days; @raw["days"]; end
121
+ def requires_card; @raw["requires_card"]; end
122
+ def reason; @raw["reason"]; end
123
+ end
124
+
125
+ # A single generated title suggestion.
126
+ class TitleSuggestion < Base
127
+ def title; @raw["title"]; end
128
+ def hint; @raw["hint"]; end
129
+ end
130
+
131
+ # Result of POST /ai/titles.
132
+ class TitlesResult < Base
133
+ def titles; (@raw["titles"] || []).map { |t| TitleSuggestion.new(t) }; end
134
+ end
135
+
136
+ # Result of POST /ai/complete.
137
+ class AiText < Base
138
+ def text; @raw["text"]; end
139
+ end
140
+
141
+ # Result of the image endpoints.
142
+ class ImageResult < Base
143
+ def url; @raw["url"]; end
144
+ end
145
+ end
146
+ end
data/lib/misarblog.rb ADDED
@@ -0,0 +1,11 @@
1
+ require "misarblog/embed"
2
+ require "misarblog/errors"
3
+ require "misarblog/models"
4
+ require "misarblog/client"
5
+
6
+ module MisarBlog
7
+ # Convenience constructor: MisarBlog.new(api_key: "mbk_...")
8
+ def self.new(**kwargs)
9
+ Client.new(**kwargs)
10
+ end
11
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: misarblog
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Misar AI
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: webmock
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.23'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.23'
41
+ description: Full-featured Ruby SDK for the Misar.Blog developer API (api.misar.io/blog/v1).
42
+ Covers all 25 dev-API operations with typed models, mbk_ bearer auth, and retry
43
+ with exponential backoff.
44
+ email:
45
+ - hello@misar.io
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - lib/misarblog.rb
54
+ - lib/misarblog/client.rb
55
+ - lib/misarblog/embed.rb
56
+ - lib/misarblog/errors.rb
57
+ - lib/misarblog/models.rb
58
+ homepage: https://www.misar.blog/docs/sdks/ruby
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ homepage_uri: https://www.misar.blog/docs/sdks/ruby
63
+ source_code_uri: https://github.com/Misar-AI/misarblog-sdks
64
+ changelog_uri: https://github.com/Misar-AI/misarblog-sdks/releases
65
+ post_install_message:
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '2.7'
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 3.5.22
81
+ signing_key:
82
+ specification_version: 4
83
+ summary: Official Ruby SDK for Misar.Blog — articles, series, reactions, AI, analytics
84
+ test_files: []