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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +81 -0
- data/lib/misarblog/client.rb +370 -0
- data/lib/misarblog/embed.rb +11 -0
- data/lib/misarblog/errors.rb +54 -0
- data/lib/misarblog/models.rb +146 -0
- data/lib/misarblog.rb +11 -0
- metadata +84 -0
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
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: []
|