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 +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +367 -0
- data/lib/oushodh/client.rb +193 -0
- data/lib/oushodh/errors/authentication_error.rb +6 -0
- data/lib/oushodh/errors/bad_request_error.rb +6 -0
- data/lib/oushodh/errors/connection_error.rb +6 -0
- data/lib/oushodh/errors/error.rb +15 -0
- data/lib/oushodh/errors/internal_server_error.rb +6 -0
- data/lib/oushodh/errors/not_found_error.rb +6 -0
- data/lib/oushodh/errors/permission_denied_error.rb +6 -0
- data/lib/oushodh/errors/rate_limit_error.rb +13 -0
- data/lib/oushodh/errors/timeout_error.rb +6 -0
- data/lib/oushodh/http/response.rb +6 -0
- data/lib/oushodh/http/retry.rb +30 -0
- data/lib/oushodh/http/transport.rb +29 -0
- data/lib/oushodh/models/medicine.rb +11 -0
- data/lib/oushodh/models/medicine_detail.rb +8 -0
- data/lib/oushodh/models/meta.rb +8 -0
- data/lib/oushodh/models/model.rb +59 -0
- data/lib/oushodh/models/monograph.rb +9 -0
- data/lib/oushodh/models/pack_amount.rb +8 -0
- data/lib/oushodh/models/page.rb +8 -0
- data/lib/oushodh/models/prices.rb +8 -0
- data/lib/oushodh/models/search_result.rb +8 -0
- data/lib/oushodh/models/size_amount.rb +8 -0
- data/lib/oushodh/models/strip_amount.rb +8 -0
- data/lib/oushodh/models/unit_amount.rb +8 -0
- data/lib/oushodh/models/unit_price.rb +8 -0
- data/lib/oushodh/version.rb +5 -0
- data/lib/oushodh.rb +37 -0
- metadata +76 -0
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
|
+
[](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,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,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,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,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
|
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: []
|