oushodh 0.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: f57782031f48684d8ced235eedfc7d0ef4aa61f4fb564f18a719c9943e87c5f7
4
+ data.tar.gz: a088814a8dd90650093ead0977269665b98fe7f4a0e5d35705d50892524df0ea
5
+ SHA512:
6
+ metadata.gz: f73f76a32be50debafc06cada395d5a81a83ca6e85c106ef27b6419ce7ecc4e82c7313157f77c0408fa98115c76906046f13cfd6cee7ecba339cc51c5dac2fda
7
+ data.tar.gz: bdada82c330195ac1e57cac2e256021074f218d9aa439d1e77bd25d51b84bf7eb137bfe8424b08d3c8d85405d2cd88117bf36058451f075ed9bd484fbc5365d4
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (12 Nov, 2026)
4
+
5
+ - First version of the Ruby SDK for the Oushodh API `/v1`.
6
+ - `Oushodh::Client`: `meta`, `list_medicines`, `medicines` (an `Enumerator` that pages to the end), `search`,
7
+ `get_medicine`, `image_url` and `get_image`.
8
+ - Models for `Medicine`, `MedicineDetail`, `Monograph`, `Page`, `SearchResult` and `Meta`, with a reader per field.
9
+ Unknown fields are kept.
10
+ - Errors by family under `Oushodh::Error`, each with `status`, `code` and `message`.
11
+ - Retries on `429 rate_limited` (honours `Retry-After`), `5xx` and network errors. `429 quota_exceeded` is raised at
12
+ once.
13
+ - No runtime dependencies.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oushodh
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,367 @@
1
+ # Oushodh for Ruby
2
+
3
+ [![CI](https://github.com/oushodh/oushodh-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/oushodh/oushodh-ruby/actions/workflows/ci.yml)
4
+
5
+ The official Ruby SDK for the [Oushodh](https://oushodh.dev) medicine data API. Oushodh gives you medicine data for
6
+ Bangladesh, checked against the DGDA register: brands, generics, strengths, dosage forms, manufacturers, MRPs,
7
+ pictures and medical text.
8
+
9
+ - Ruby 3.3 or newer.
10
+ - No runtime dependencies. It uses `net/http` and `json` from the standard library.
11
+ - One client, `Oushodh::Client`, with typed models, errors by family, retries and an `Enumerator` that pages for you.
12
+
13
+ The full API reference is at [oushodh.dev/docs](https://oushodh.dev/docs).
14
+
15
+ ## Contents
16
+
17
+ - [Install](#install)
18
+ - [Get an API key](#get-an-api-key)
19
+ - [Quick start](#quick-start)
20
+ - [Configuration](#configuration)
21
+ - [Methods](#methods)
22
+ - [Models](#models)
23
+ - [Paging](#paging)
24
+ - [Sync](#sync)
25
+ - [Errors](#errors)
26
+ - [Retries and rate limits](#retries-and-rate-limits)
27
+ - [Plan limits](#plan-limits)
28
+ - [Images](#images)
29
+ - [Develop](#develop)
30
+ - [Release](#release)
31
+ - [License](#license)
32
+
33
+ ## Install
34
+
35
+ Install from GitHub. Add this line to your `Gemfile`:
36
+
37
+ ```ruby
38
+ gem "oushodh", git: "https://github.com/oushodh/oushodh-ruby.git", tag: "v0.1.0"
39
+ ```
40
+
41
+ Then run `bundle install`.
42
+
43
+ To pin a commit, add `ref:`:
44
+
45
+ ```ruby
46
+ gem "oushodh", git: "https://github.com/oushodh/oushodh-ruby.git", ref: "<commit sha>"
47
+ ```
48
+
49
+ RubyGems: coming soon (`gem install oushodh`).
50
+
51
+ ## Get an API key
52
+
53
+ Make a key on the Keys page at [app.oushodh.dev](https://app.oushodh.dev). A key starts with `oush_` and is shown
54
+ once, so copy it then. Keep it on your server: a key inside an app can be copied out of it.
55
+
56
+ The client reads the key from `api_key:`, or from the `OUSHODH_API_KEY` environment variable when you pass none.
57
+
58
+ ## Quick start
59
+
60
+ ```ruby
61
+ require "oushodh"
62
+
63
+ client = Oushodh::Client.new # reads OUSHODH_API_KEY
64
+
65
+ result = client.search("napa 500", limit: 5)
66
+
67
+ result.items.each do |medicine|
68
+ puts [medicine.id, medicine.product_name, medicine.prices.strip&.amount].join(" ")
69
+ end
70
+
71
+ detail = client.get_medicine("MED-012933")
72
+ puts detail.monograph&.indications
73
+ ```
74
+
75
+ ## Configuration
76
+
77
+ ```ruby
78
+ client = Oushodh::Client.new(
79
+ api_key: "oush_...",
80
+ base_url: "https://api.oushodh.dev",
81
+ timeout: 30,
82
+ max_retries: 2
83
+ )
84
+ ```
85
+
86
+ | Option | Default | What it does |
87
+ | -------------- | ------------------------- | ----------------------------------------------------------------------------------- |
88
+ | `api_key:` | `OUSHODH_API_KEY` | Your API key. With no key at all, `Client.new` raises `Oushodh::Error`. |
89
+ | `base_url:` | `https://api.oushodh.dev` | Where the API is. |
90
+ | `timeout:` | `30` | Seconds to wait for each request. |
91
+ | `max_retries:` | `2` | Retries after a `429 rate_limited`, a `5xx` or a network error. `0` turns them off. |
92
+ | `transport:` | `Oushodh::Transport.new` | Sends the requests, on `Net::HTTP`. Pass your own, for example in tests. |
93
+
94
+ A transport is any object with `get(uri, headers:, timeout:)` that returns an `Oushodh::Response` (`status`,
95
+ `headers` with lower-case names, `body`). It raises `Oushodh::ConnectionError` or `Oushodh::TimeoutError` when no
96
+ answer comes back.
97
+
98
+ ```ruby
99
+ class FakeTransport
100
+ def get(uri, headers:, timeout:)
101
+ Oushodh::Response.new(status: 200, headers: {}, body: '{"client":"test"}')
102
+ end
103
+ end
104
+
105
+ client = Oushodh::Client.new(api_key: "oush_test_123", transport: FakeTransport.new)
106
+ client.meta.client # => "test"
107
+ ```
108
+
109
+ The client never prints your key: `inspect` and `to_s` leave it out, and no error message holds it.
110
+
111
+ ## Methods
112
+
113
+ | Method | Route | Returns |
114
+ | ----------------------------------------------------- | ------------------------------ | ----------------------------------------- |
115
+ | `meta` | `GET /v1/meta` | `Meta` |
116
+ | `list_medicines(limit:, cursor:, updated_since:, q:)` | `GET /v1/medicines` | `Page` |
117
+ | `medicines(limit:, updated_since:, q:)` | `GET /v1/medicines`, each page | an `Enumerator` of `Medicine` |
118
+ | `search(q, limit:)` | `GET /v1/medicines/search` | `SearchResult` |
119
+ | `get_medicine(ref)` | `GET /v1/medicines/{ref}` | `MedicineDetail` |
120
+ | `image_url(file)` | none | the picture's address, as a `String` |
121
+ | `get_image(file)` | `GET /v1/images/{file}` | the picture's bytes, as a binary `String` |
122
+
123
+ Every keyword is optional.
124
+
125
+ ### meta
126
+
127
+ The account your key belongs to. Call it to check that the key works.
128
+
129
+ ```ruby
130
+ meta = client.meta
131
+
132
+ meta.client # the account the key belongs to
133
+ meta.id_profile # the ID profile your medicine IDs come from
134
+ meta.medicines # how many medicines your key reads
135
+ meta.last_import # when the data was last imported, such as "2026-11-09T15:27:40Z"
136
+ ```
137
+
138
+ ### list_medicines
139
+
140
+ One page of medicines. `limit:` is 1 to 500 (default 100). `q:` filters by name, and `updated_since:` keeps only
141
+ medicines changed at or after a time.
142
+
143
+ ```ruby
144
+ page = client.list_medicines(limit: 100, q: "paracetamol")
145
+
146
+ page.items.size # up to 100
147
+ page.total # every match, across all pages
148
+ page.next # pass it back as cursor:, or nil on the last page
149
+
150
+ following = client.list_medicines(limit: 100, q: "paracetamol", cursor: page.next)
151
+ ```
152
+
153
+ ### medicines
154
+
155
+ Every medicine, page after page to the end. See [Paging](#paging).
156
+
157
+ ```ruby
158
+ client.medicines(q: "napa").each { |medicine| puts medicine.product_name }
159
+ ```
160
+
161
+ ### search
162
+
163
+ The best matches for what a person typed, best match first. `limit:` is 1 to 50 (default 20). When `q` is an ID or a
164
+ UUID, the answer is that one medicine.
165
+
166
+ ```ruby
167
+ result = client.search("napa", limit: 10)
168
+
169
+ result.items.first&.product_name
170
+ result.total # every match, including those past limit
171
+ ```
172
+
173
+ Search has no cursor. To page every match, use `medicines(q: ...)`.
174
+
175
+ ### get_medicine
176
+
177
+ One medicine with its medical text. `ref` is a medicine's ID in any profile, an old ID, or its UUID. The answer
178
+ always carries the medicine's current ID.
179
+
180
+ ```ruby
181
+ detail = client.get_medicine("01a1ba6f-f4d6-7143-ac84-9376f520eef8")
182
+
183
+ detail.id # its current ID
184
+ detail.monograph&.dosage
185
+ detail.monograph&.side_effects
186
+ ```
187
+
188
+ `monograph` is `nil` when no source gives medical text.
189
+
190
+ ### image_url and get_image
191
+
192
+ `image_url` builds a picture's address. `get_image` fetches its bytes. See [Images](#images).
193
+
194
+ ```ruby
195
+ client.image_url("<file>.jpg") # => "https://api.oushodh.dev/v1/images/<file>.jpg"
196
+ client.get_image("<file>.jpg") # => the picture's bytes
197
+ ```
198
+
199
+ ## Models
200
+
201
+ `Meta`, `Page`, `SearchResult`, `Medicine`, `MedicineDetail`, `Monograph` and `Prices` have a reader for each field
202
+ the API documents, with the API's own names: `medicine.product_name`, `medicine.prices.strip&.amount`, `page.next`.
203
+ A value no source gives is `nil`. Times, such as `last_updated`, stay strings like `2026-11-09T15:27:34Z`. The
204
+ [medicine object](https://oushodh.dev/docs/medicine) page describes every field.
205
+
206
+ Each model keeps the whole JSON object, so fields the API adds later are kept too. Read them with `[]` or `dig`, or
207
+ get the hash with `to_h`:
208
+
209
+ ```ruby
210
+ medicine["a_new_field"]
211
+ medicine.dig("prices", "currency")
212
+ medicine.to_h
213
+ ```
214
+
215
+ ## Paging
216
+
217
+ `list_medicines` gives one page: `items`, `next` and `total`. Pass `next` back as `cursor:` for the next page. `next`
218
+ is `nil` on the last page.
219
+
220
+ `medicines` does this for you, page after page to the end. With a block, it yields each medicine. Without one, it
221
+ returns an `Enumerator` that fetches each page only when it needs it:
222
+
223
+ ```ruby
224
+ client.medicines(limit: 500) do |medicine|
225
+ save(medicine)
226
+ end
227
+
228
+ first_ten = client.medicines(q: "napa").first(10) # fetches one page
229
+ ```
230
+
231
+ ## Sync
232
+
233
+ Copy the whole database once, then fetch only what changed.
234
+
235
+ 1. The first sync pages through `medicines` to the end. Keep the `last_updated` of the newest medicine.
236
+ 2. Each later sync passes that time as `updated_since:` and pages to the end. Medicines come oldest change first.
237
+
238
+ ```ruby
239
+ since = load_last_sync # such as "2026-11-08T09:30:00Z", or nil the first time
240
+
241
+ client.medicines(limit: 500, updated_since: since).each do |medicine|
242
+ upsert(medicine.uuid, medicine.to_h)
243
+ since = [since, medicine.last_updated].compact.max
244
+ end
245
+
246
+ store_last_sync(since)
247
+ ```
248
+
249
+ `updated_since:` takes a string like `2026-11-08T09:30:00Z`, or a `Time` (or anything with `to_time`). The SDK sends
250
+ a `Time` in UTC to the whole second. It means "at or after", so the medicine that set the time comes back once more.
251
+ Store each medicine by its `uuid`: it never changes. Medicines are never deleted and IDs are never reused.
252
+
253
+ ## Errors
254
+
255
+ Every error is an `Oushodh::Error`. It has `status` (the HTTP status), `code` (the API's error code) and `message`.
256
+ Match on `code` or on the class, never on `message`: the message is for people and may change. When an error answer
257
+ is not JSON, `code` is `nil` and `message` is `HTTP <status>`.
258
+
259
+ | Class | Status | `code` | Retried |
260
+ | -------------------------------- | ------ | ---------------------------------- | ------------------- |
261
+ | `Oushodh::BadRequestError` | 400 | `bad_request` | no |
262
+ | `Oushodh::AuthenticationError` | 401 | `unauthorized` | no |
263
+ | `Oushodh::PermissionDeniedError` | 403 | `plan_expired` or `plan_limit` | no |
264
+ | `Oushodh::NotFoundError` | 404 | `not_found` | no |
265
+ | `Oushodh::RateLimitError` | 429 | `rate_limited` or `quota_exceeded` | `rate_limited` only |
266
+ | `Oushodh::InternalServerError` | 5xx | `internal` | yes |
267
+ | `Oushodh::ConnectionError` | none | none: the network failed | yes |
268
+ | `Oushodh::TimeoutError` | none | none: the request timed out | yes |
269
+
270
+ `TimeoutError` is a kind of `ConnectionError`. `RateLimitError#retry_after` holds the `Retry-After` seconds.
271
+
272
+ ```ruby
273
+ begin
274
+ medicine = client.get_medicine("MED-012933")
275
+ rescue Oushodh::NotFoundError
276
+ medicine = nil
277
+ rescue Oushodh::PermissionDeniedError => e
278
+ warn "Upgrade your plan to read this medicine." if e.code == "plan_limit"
279
+ raise
280
+ end
281
+ ```
282
+
283
+ ## Retries and rate limits
284
+
285
+ Every route is a `GET`, so the SDK can safely try again. It retries up to `max_retries` times (default 2):
286
+
287
+ - `429 rate_limited`: it waits the seconds in the `Retry-After` header, then tries again.
288
+ - `5xx` and network errors: it waits a little longer each time, with jitter.
289
+ - Any other `4xx`: it never retries. They give the same answer every time.
290
+
291
+ Your plan sets how many requests each key may make in a minute (60 a minute on Developer). Over that limit, the API
292
+ answers `429 rate_limited` with a `Retry-After` header.
293
+
294
+ Your plan also sets how many requests your account may make in a month. When they are used up, the API answers
295
+ `429 quota_exceeded` until the next month. The SDK raises it at once and never waits. Its `retry_after` holds the
296
+ seconds to the next month.
297
+
298
+ ```ruby
299
+ begin
300
+ client.meta
301
+ rescue Oushodh::RateLimitError => e
302
+ raise unless e.code == "quota_exceeded"
303
+
304
+ warn "Monthly quota used up. It resets in #{e.retry_after.to_i} seconds."
305
+ end
306
+ ```
307
+
308
+ ## Plan limits
309
+
310
+ Some plans read only the first medicines, in the order of their permanent number. Lists and search leave out the rest.
311
+ A lookup by ID of such a medicine raises `Oushodh::PermissionDeniedError` with `code` `plan_limit`. An expired plan
312
+ gives `code` `plan_expired`. `client.meta.medicines` says how many medicines your key reads.
313
+
314
+ ## Images
315
+
316
+ `product_image` is the address of a medicine's picture. When it is under `/v1/images/`, Oushodh keeps the copy and
317
+ serves it with no key, so an `<img>` tag can show it. The file never changes, so it can be cached for a year.
318
+
319
+ `image_url(file)` builds that address. `get_image(file)` fetches the bytes and sends no key. Both take the file name,
320
+ not the whole address, and escape it.
321
+
322
+ ```ruby
323
+ url = medicine.product_image
324
+
325
+ if url&.start_with?(client.image_url(""))
326
+ jpeg = client.get_image(url.split("/").last)
327
+ File.binwrite("#{medicine.uuid}.jpg", jpeg)
328
+ end
329
+ ```
330
+
331
+ Any other address belongs to the source, and it can move or be removed.
332
+
333
+ ## Develop
334
+
335
+ ```sh
336
+ bundle install
337
+ bundle exec rake # RuboCop, then the tests
338
+ ```
339
+
340
+ The tests use a local WEBrick server and fake keys. They never call the API.
341
+
342
+ | Path | What it holds |
343
+ | ----------------------- | ------------------------------------------------------- |
344
+ | `lib/oushodh.rb` | Loads the whole SDK: `require "oushodh"`. |
345
+ | `lib/oushodh/client.rb` | `Oushodh::Client`, one method per route. |
346
+ | `lib/oushodh/models/` | One file per model, all built on `Oushodh::Model`. |
347
+ | `lib/oushodh/errors/` | One file per error class, all under `Oushodh::Error`. |
348
+ | `lib/oushodh/http/` | The transport, the `Response` type and the retry rules. |
349
+
350
+ ## Release
351
+
352
+ For maintainers. The gem is not on RubyGems yet.
353
+
354
+ 1. Set `Oushodh::VERSION` in `lib/oushodh/version.rb` and date the version in `CHANGELOG.md`.
355
+ 2. Merge to `main`, then push a tag `v<version>`, such as `v0.1.0`.
356
+ 3. The Release workflow checks that the tag matches the version, runs the tests and publishes to RubyGems with
357
+ trusted publishing. No secret is stored.
358
+
359
+ ## Links
360
+
361
+ - API docs: [oushodh.dev/docs](https://oushodh.dev/docs)
362
+ - API keys: [app.oushodh.dev](https://app.oushodh.dev)
363
+ - Changes: [CHANGELOG.md](CHANGELOG.md)
364
+
365
+ ## License
366
+
367
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Oushodh
7
+ # The Oushodh API client.
8
+ #
9
+ # `api_key` defaults to the OUSHODH_API_KEY environment variable. Pass `transport:` to send requests your own
10
+ # way, for example a fake one in tests.
11
+ class Client
12
+ DEFAULT_BASE_URL = "https://api.oushodh.dev"
13
+ DEFAULT_TIMEOUT = 30
14
+ DEFAULT_MAX_RETRIES = 2
15
+
16
+ ERRORS = {
17
+ 400 => BadRequestError,
18
+ 401 => AuthenticationError,
19
+ 403 => PermissionDeniedError,
20
+ 404 => NotFoundError
21
+ }.freeze
22
+
23
+ attr_reader :base_url, :timeout, :max_retries
24
+
25
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
26
+ max_retries: DEFAULT_MAX_RETRIES, transport: Transport.new)
27
+ key = api_key || ENV.fetch("OUSHODH_API_KEY", nil)
28
+
29
+ if key.nil? || key.empty?
30
+ raise Error, "No API key: pass api_key: or set the OUSHODH_API_KEY environment variable"
31
+ end
32
+
33
+ @api_key = key
34
+ @base_url = base_url.chomp("/")
35
+ @timeout = timeout
36
+ @max_retries = max_retries
37
+ @transport = transport
38
+ end
39
+
40
+ def inspect
41
+ "#<#{self.class.name} base_url=#{@base_url.inspect}>"
42
+ end
43
+
44
+ alias to_s inspect
45
+
46
+ def meta
47
+ Meta.new(get_json("/v1/meta"))
48
+ end
49
+
50
+ def list_medicines(limit: nil, cursor: nil, updated_since: nil, q: nil)
51
+ params = { limit: limit, cursor: cursor, updated_since: format_since(updated_since), q: q }
52
+
53
+ Page.new(get_json("/v1/medicines", params))
54
+ end
55
+
56
+ # Every medicine, page after page to the end. Without a block it returns an Enumerator that fetches each
57
+ # page only when it is needed.
58
+ def medicines(limit: nil, updated_since: nil, q: nil, &block)
59
+ unless block_given?
60
+ return enum_for(:medicines, limit: limit, updated_since: updated_since, q: q)
61
+ end
62
+
63
+ cursor = nil
64
+
65
+ loop do
66
+ page = list_medicines(limit: limit, cursor: cursor, updated_since: updated_since, q: q)
67
+
68
+ page.items.each(&block)
69
+ cursor = page.next
70
+
71
+ if cursor.nil?
72
+ break
73
+ end
74
+ end
75
+ end
76
+
77
+ def search(query, limit: nil)
78
+ SearchResult.new(get_json("/v1/medicines/search", { q: query, limit: limit }))
79
+ end
80
+
81
+ def get_medicine(ref)
82
+ MedicineDetail.new(get_json("/v1/medicines/#{escape(ref)}"))
83
+ end
84
+
85
+ # The address of a picture, as `product_image` gives it. It needs no key.
86
+ def image_url(file)
87
+ "#{@base_url}/v1/images/#{escape(file)}"
88
+ end
89
+
90
+ # The picture's bytes, as a binary String. It sends no key.
91
+ def get_image(file)
92
+ request("/v1/images/#{escape(file)}", {}, auth: false).body
93
+ end
94
+
95
+ private
96
+
97
+ def get_json(path, params = {})
98
+ JSON.parse(request(path, params, auth: true).body)
99
+ end
100
+
101
+ def request(path, params, auth:)
102
+ uri = URI("#{@base_url}#{path}")
103
+ query = params.compact
104
+
105
+ unless query.empty?
106
+ uri.query = URI.encode_www_form(query)
107
+ end
108
+
109
+ attempt = 0
110
+
111
+ loop do
112
+ error = nil
113
+
114
+ begin
115
+ response = @transport.get(uri, headers: headers(auth), timeout: @timeout)
116
+ rescue ConnectionError => e
117
+ error = e
118
+ else
119
+ if response.status.between?(200, 299)
120
+ return response
121
+ end
122
+
123
+ error = api_error(response)
124
+ end
125
+
126
+ delay = Retry.delay(error, attempt, @max_retries)
127
+
128
+ if delay.nil?
129
+ raise error
130
+ end
131
+
132
+ sleep(delay)
133
+ attempt += 1
134
+ end
135
+ end
136
+
137
+ def headers(auth)
138
+ headers = { "User-Agent" => "oushodh-ruby/#{VERSION}" }
139
+
140
+ if auth
141
+ headers["Authorization"] = "Bearer #{@api_key}"
142
+ headers["Accept"] = "application/json"
143
+ end
144
+
145
+ headers
146
+ end
147
+
148
+ def api_error(response)
149
+ status = response.status
150
+ code, message = error_detail(response.body)
151
+ message ||= "HTTP #{status}"
152
+
153
+ if status == 429
154
+ return RateLimitError.new(message, status: status, code: code, retry_after: retry_after(response))
155
+ end
156
+
157
+ kind = ERRORS.fetch(status) { status >= 500 ? InternalServerError : Error }
158
+
159
+ kind.new(message, status: status, code: code)
160
+ end
161
+
162
+ def error_detail(body)
163
+ detail = JSON.parse(body)["error"]
164
+
165
+ unless detail.is_a?(Hash)
166
+ return [nil, nil]
167
+ end
168
+
169
+ code = detail["code"].is_a?(String) ? detail["code"] : nil
170
+ message = detail["message"].is_a?(String) ? detail["message"] : nil
171
+
172
+ [code, message]
173
+ rescue JSON::ParserError, TypeError, NoMethodError
174
+ [nil, nil]
175
+ end
176
+
177
+ def retry_after(response)
178
+ Float(response.headers["retry-after"], exception: false)
179
+ end
180
+
181
+ def format_since(value)
182
+ if value.nil? || value.is_a?(String)
183
+ return value
184
+ end
185
+
186
+ value.to_time.getutc.strftime("%Y-%m-%dT%H:%M:%SZ")
187
+ end
188
+
189
+ def escape(part)
190
+ part.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |char| format("%%%02X", char.ord) }
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # 401 `unauthorized`: no key, an unknown key or a revoked key.
5
+ class AuthenticationError < Error; end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # 400 `bad_request`.
5
+ class BadRequestError < Error; end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # No answer came back: the network failed.
5
+ class ConnectionError < Error; end
6
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # The base of every error the SDK raises. `status` is the HTTP status and `code` the API's error code.
5
+ # Both are nil when no answer came back. Match on `code` or the class, never on `message`.
6
+ class Error < StandardError
7
+ attr_reader :status, :code
8
+
9
+ def initialize(message, status: nil, code: nil)
10
+ super(message)
11
+ @status = status
12
+ @code = code
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # 5xx, such as `internal`.
5
+ class InternalServerError < Error; end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # 404 `not_found`.
5
+ class NotFoundError < Error; end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # 403 `plan_expired` or `plan_limit`.
5
+ class PermissionDeniedError < Error; end
6
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # 429 `rate_limited` or `quota_exceeded`. `retry_after` is in seconds.
5
+ class RateLimitError < Error
6
+ attr_reader :retry_after
7
+
8
+ def initialize(message, status: nil, code: nil, retry_after: nil)
9
+ super(message, status: status, code: code)
10
+ @retry_after = retry_after
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # No answer came back in time.
5
+ class TimeoutError < ConnectionError; end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # What a transport answers: the HTTP status, the headers with lower-case names, and the body.
5
+ Response = Data.define(:status, :headers, :body)
6
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # When to try a failed request again, and how long to wait first.
5
+ module Retry
6
+ module_function
7
+
8
+ # Seconds to wait before the next try, or nil to raise the error now.
9
+ def delay(error, attempt, max_retries)
10
+ if attempt >= max_retries
11
+ return nil
12
+ end
13
+
14
+ # quota_exceeded waits until next month, so only rate_limited is retried.
15
+ if error.is_a?(RateLimitError)
16
+ return error.code == "rate_limited" ? error.retry_after || backoff(attempt) : nil
17
+ end
18
+
19
+ if error.is_a?(InternalServerError) || error.is_a?(ConnectionError)
20
+ return backoff(attempt)
21
+ end
22
+
23
+ nil
24
+ end
25
+
26
+ def backoff(attempt)
27
+ [0.5 * (2**attempt), 8.0].min * rand(0.5..1.0)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ module Oushodh
7
+ # The default transport, on Net::HTTP. A transport is any object with `get(uri, headers:, timeout:)` that
8
+ # returns a Response, or raises ConnectionError or TimeoutError when no answer comes back.
9
+ class Transport
10
+ def get(uri, headers:, timeout:)
11
+ http = Net::HTTP.new(uri.host, uri.port)
12
+
13
+ http.use_ssl = uri.scheme == "https"
14
+ http.open_timeout = timeout
15
+ http.read_timeout = timeout
16
+ http.write_timeout = timeout
17
+ http.max_retries = 0 # the client does its own retries
18
+
19
+ response = http.start { |connection| connection.get(uri.request_uri, headers) }
20
+
21
+ Response.new(status: response.code.to_i, headers: response.to_hash.transform_values(&:first),
22
+ body: response.body || "")
23
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
24
+ raise TimeoutError, "The request timed out"
25
+ rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
26
+ raise ConnectionError, "Could not reach the API: #{e.class}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # One medicine: one brand in one strength and dosage form.
5
+ class Medicine < Model
6
+ fields :id, :uuid, :brand, :category, :product_name, :product_image, :images, :unit, :size, :price,
7
+ :price_per, :price_source, :description_en, :description_bn, :dosage_form, :strength,
8
+ :generic_name, :manufacturer, :source_url, :last_updated, :dgda_listed, :medicine_type,
9
+ unit_prices: [UnitPrice], prices: Prices
10
+ end
11
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # One medicine, with its medical text.
5
+ class MedicineDetail < Medicine
6
+ fields monograph: Monograph
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # The account the key belongs to.
5
+ class Meta < Model
6
+ fields :client, :id_profile, :medicines, :last_import
7
+ end
8
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # A JSON object from the API. It keeps the whole parsed hash, so fields the API adds later stay readable
5
+ # with `[]` and `to_h`. Each documented field also has a reader.
6
+ class Model
7
+ def self.fields(*names, **nested)
8
+ names.each do |name|
9
+ define_method(name) { @data[name.to_s] }
10
+ end
11
+
12
+ nested.each do |name, type|
13
+ define_method(name) { Model.wrap(@data[name.to_s], type) }
14
+ end
15
+ end
16
+
17
+ def self.wrap(value, type)
18
+ if value.nil?
19
+ return nil
20
+ end
21
+
22
+ if type.is_a?(Array)
23
+ return value.map { |item| type.first.new(item) }
24
+ end
25
+
26
+ type.new(value)
27
+ end
28
+
29
+ def initialize(data)
30
+ @data = data
31
+ end
32
+
33
+ def [](key)
34
+ @data[key.to_s]
35
+ end
36
+
37
+ def dig(key, *rest)
38
+ @data.dig(key.to_s, *rest)
39
+ end
40
+
41
+ def to_h
42
+ @data
43
+ end
44
+
45
+ def ==(other)
46
+ other.is_a?(self.class) && other.to_h == @data
47
+ end
48
+
49
+ alias eql? ==
50
+
51
+ def hash
52
+ [self.class, @data].hash
53
+ end
54
+
55
+ def inspect
56
+ "#<#{self.class.name} #{@data.inspect}>"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # The medical text of a medicine, as its source wrote it.
5
+ class Monograph < Model
6
+ fields :source, :indications, :composition, :pharmacology, :dosage, :interactions, :contraindications,
7
+ :side_effects, :pregnancy_lactation, :precautions, :overdose, :storage
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # One `prices.packs` entry.
5
+ class PackAmount < Model
6
+ fields :amount, :units, :label, :source
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # One page of medicines.
5
+ class Page < Model
6
+ fields :next, :total, items: [Medicine]
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # Every MRP the sources state for a medicine.
5
+ class Prices < Model
6
+ fields :currency, unit: UnitAmount, sizes: [SizeAmount], strip: StripAmount, packs: [PackAmount]
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # The best matches for a search.
5
+ class SearchResult < Model
6
+ fields :total, items: [Medicine]
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # One `prices.sizes` entry.
5
+ class SizeAmount < Model
6
+ fields :size, :amount, :source
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # `prices.strip`.
5
+ class StripAmount < Model
6
+ fields :amount, :units, :source
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # `prices.unit`.
5
+ class UnitAmount < Model
6
+ fields :amount, :source
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ # One `unit_prices` entry.
5
+ class UnitPrice < Model
6
+ fields :size, :price
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oushodh
4
+ VERSION = "0.1.0"
5
+ end
data/lib/oushodh.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "oushodh/version"
4
+
5
+ require_relative "oushodh/errors/error"
6
+ require_relative "oushodh/errors/bad_request_error"
7
+ require_relative "oushodh/errors/authentication_error"
8
+ require_relative "oushodh/errors/permission_denied_error"
9
+ require_relative "oushodh/errors/not_found_error"
10
+ require_relative "oushodh/errors/rate_limit_error"
11
+ require_relative "oushodh/errors/internal_server_error"
12
+ require_relative "oushodh/errors/connection_error"
13
+ require_relative "oushodh/errors/timeout_error"
14
+
15
+ require_relative "oushodh/models/model"
16
+ require_relative "oushodh/models/unit_price"
17
+ require_relative "oushodh/models/unit_amount"
18
+ require_relative "oushodh/models/size_amount"
19
+ require_relative "oushodh/models/strip_amount"
20
+ require_relative "oushodh/models/pack_amount"
21
+ require_relative "oushodh/models/prices"
22
+ require_relative "oushodh/models/medicine"
23
+ require_relative "oushodh/models/monograph"
24
+ require_relative "oushodh/models/medicine_detail"
25
+ require_relative "oushodh/models/page"
26
+ require_relative "oushodh/models/search_result"
27
+ require_relative "oushodh/models/meta"
28
+
29
+ require_relative "oushodh/http/response"
30
+ require_relative "oushodh/http/retry"
31
+ require_relative "oushodh/http/transport"
32
+
33
+ require_relative "oushodh/client"
34
+
35
+ # The Ruby SDK for the Oushodh medicine data API.
36
+ module Oushodh
37
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: oushodh
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Oushodh
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Medicine data for Bangladesh, checked against the DGDA register.
13
+ email:
14
+ - support@oushodh.dev
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE
21
+ - README.md
22
+ - lib/oushodh.rb
23
+ - lib/oushodh/client.rb
24
+ - lib/oushodh/errors/authentication_error.rb
25
+ - lib/oushodh/errors/bad_request_error.rb
26
+ - lib/oushodh/errors/connection_error.rb
27
+ - lib/oushodh/errors/error.rb
28
+ - lib/oushodh/errors/internal_server_error.rb
29
+ - lib/oushodh/errors/not_found_error.rb
30
+ - lib/oushodh/errors/permission_denied_error.rb
31
+ - lib/oushodh/errors/rate_limit_error.rb
32
+ - lib/oushodh/errors/timeout_error.rb
33
+ - lib/oushodh/http/response.rb
34
+ - lib/oushodh/http/retry.rb
35
+ - lib/oushodh/http/transport.rb
36
+ - lib/oushodh/models/medicine.rb
37
+ - lib/oushodh/models/medicine_detail.rb
38
+ - lib/oushodh/models/meta.rb
39
+ - lib/oushodh/models/model.rb
40
+ - lib/oushodh/models/monograph.rb
41
+ - lib/oushodh/models/pack_amount.rb
42
+ - lib/oushodh/models/page.rb
43
+ - lib/oushodh/models/prices.rb
44
+ - lib/oushodh/models/search_result.rb
45
+ - lib/oushodh/models/size_amount.rb
46
+ - lib/oushodh/models/strip_amount.rb
47
+ - lib/oushodh/models/unit_amount.rb
48
+ - lib/oushodh/models/unit_price.rb
49
+ - lib/oushodh/version.rb
50
+ homepage: https://oushodh.dev/docs
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://oushodh.dev/docs
55
+ source_code_uri: https://github.com/oushodh/oushodh-ruby
56
+ changelog_uri: https://github.com/oushodh/oushodh-ruby/blob/main/CHANGELOG.md
57
+ allowed_push_host: https://rubygems.org
58
+ rubygems_mfa_required: 'true'
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.3'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 4.0.20
74
+ specification_version: 4
75
+ summary: Ruby SDK for the Oushodh medicine data API.
76
+ test_files: []