api_recipes 2.10.3 → 2.10.4
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 +4 -4
- data/README.md +726 -28
- data/lib/api_recipes/version.rb +1 -1
- data/lib/api_recipes.rb +11 -0
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a4721f0a12f39f1dd0614a8787cbdfdde44efd74efa193d8b914b209492e887
|
|
4
|
+
data.tar.gz: 9d3b3706ac992b5ab5b9bde22f022892746bf0dcb496e7dffe51d39fb9d2028c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a32eec192e29132ecbf39fc92ef5ad1823efb2747f15b53e9d67ad83c0293b4f5fb067b16a9e27001e0693cd26c678330186bed6cc7fbf67ffe372e2ad51fb59
|
|
7
|
+
data.tar.gz: 1b5fbaf21da5d84c5558c4e5c09d5fcfbd024ac206cebc61e4e6c891502f477939ff09a641c26e2d9d81f4f564e98a09cc4f2d7691243a28c9135ba7fee50d90
|
data/README.md
CHANGED
|
@@ -1,57 +1,755 @@
|
|
|
1
|
-
[](https://rubygems.org/gems/api_recipes)
|
|
2
|
+
[](https://github.com/madAle/api_recipes/actions/workflows/ci.yml)
|
|
2
3
|
|
|
3
4
|
# ApiRecipes
|
|
4
5
|
|
|
5
|
-
<p align="center">
|
|
6
|
+
<p align="center">
|
|
6
7
|
<img width="300" height="160" src="api_recipes.png"/>
|
|
7
8
|
</p>
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
Describe an HTTP API once, in YAML, and call it with plain Ruby methods.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
ApiRecipes.github.users.repos('octocat').run.data
|
|
14
|
+
# GET https://api.github.com/users/octocat/repos
|
|
15
|
+
# => [{ "name" => "Hello-World", ... }, ...]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
There are no client classes to write. Hosts, paths, verbs, timeouts and expected
|
|
19
|
+
status codes live in a configuration file, and ApiRecipes turns them into a chain
|
|
20
|
+
of methods. Requests are made with [http.rb](https://github.com/httprb/http).
|
|
21
|
+
|
|
22
|
+
## Contents
|
|
23
|
+
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [Quick start](#quick-start)
|
|
26
|
+
- [Describing an API](#describing-an-api)
|
|
27
|
+
- [Loading the configuration](#loading-the-configuration)
|
|
28
|
+
- [Where an API is available](#where-an-api-is-available)
|
|
29
|
+
- [Making requests](#making-requests)
|
|
30
|
+
- [Reading the response](#reading-the-response)
|
|
31
|
+
- [Deciding what counts as success](#deciding-what-counts-as-success)
|
|
32
|
+
- [Handling errors](#handling-errors)
|
|
33
|
+
- [Authorization](#authorization)
|
|
34
|
+
- [Logging and debugging](#logging-and-debugging)
|
|
35
|
+
- [Recipes](#recipes)
|
|
36
|
+
- [Options reference](#options-reference)
|
|
37
|
+
- [Good to know](#good-to-know)
|
|
38
|
+
- [Runnable examples](#runnable-examples)
|
|
39
|
+
- [Compatibility](#compatibility)
|
|
40
|
+
- [Development](#development)
|
|
10
41
|
|
|
11
|
-
Api recipes lets you easily describe and consume an HTTP API.
|
|
12
|
-
|
|
13
42
|
## Installation
|
|
14
43
|
|
|
15
|
-
Add
|
|
44
|
+
Add the gem to your Gemfile and run `bundle install`:
|
|
16
45
|
|
|
17
46
|
```ruby
|
|
18
47
|
gem 'api_recipes'
|
|
19
48
|
```
|
|
20
49
|
|
|
21
|
-
|
|
50
|
+
Or install it directly:
|
|
22
51
|
|
|
23
|
-
$
|
|
52
|
+
$ gem install api_recipes
|
|
24
53
|
|
|
25
|
-
|
|
54
|
+
## Quick start
|
|
26
55
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
## Compatibility
|
|
56
|
+
**1. Describe the API** in a YAML file, for example `config/apis/github.yml`:
|
|
30
57
|
|
|
31
|
-
|
|
58
|
+
```yaml
|
|
59
|
+
github:
|
|
60
|
+
host: api.github.com
|
|
61
|
+
endpoints:
|
|
62
|
+
users: # GET /users
|
|
63
|
+
endpoints:
|
|
64
|
+
show: # GET /users/:login
|
|
65
|
+
path: /:login
|
|
66
|
+
repos: # GET /users/:login/repos
|
|
67
|
+
path: /:login/repos
|
|
68
|
+
```
|
|
32
69
|
|
|
33
|
-
|
|
70
|
+
**2. Load it** once, when your application starts:
|
|
34
71
|
|
|
35
|
-
|
|
72
|
+
```ruby
|
|
73
|
+
require 'api_recipes'
|
|
36
74
|
|
|
37
|
-
|
|
75
|
+
ApiRecipes.configure do |config|
|
|
76
|
+
config.apis_files_paths = ['config/apis/github.yml']
|
|
77
|
+
end
|
|
78
|
+
```
|
|
38
79
|
|
|
39
|
-
|
|
80
|
+
**3. Call it:**
|
|
40
81
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* Add more examples
|
|
44
|
-
* Add documentation
|
|
45
|
-
* Delete this TODO section
|
|
82
|
+
```ruby
|
|
83
|
+
response = ApiRecipes.github.users.show('octocat').run
|
|
46
84
|
|
|
47
|
-
|
|
85
|
+
response.code # => 200
|
|
86
|
+
response.data['name'] # => "The Octocat"
|
|
87
|
+
```
|
|
48
88
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
the [Contributor Covenant](contributor-covenant.org) code of conduct.
|
|
89
|
+
Each endpoint in the file becomes a method, arguments fill the `:placeholders`
|
|
90
|
+
of the path, and `run` sends the request.
|
|
52
91
|
|
|
92
|
+
## Describing an API
|
|
53
93
|
|
|
54
|
-
|
|
94
|
+
The examples in this guide share one configuration. It is longer than the quick
|
|
95
|
+
start because it shows every way of shaping an endpoint:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
github:
|
|
99
|
+
host: api.github.com
|
|
100
|
+
default_headers:
|
|
101
|
+
Accept: application/vnd.github+json
|
|
102
|
+
endpoints:
|
|
103
|
+
users: # GET /users
|
|
104
|
+
endpoints:
|
|
105
|
+
show: # GET /users/:login
|
|
106
|
+
path: /:login
|
|
107
|
+
repos: # GET /users/:login/repos
|
|
108
|
+
path: /:login/repos
|
|
109
|
+
repos:
|
|
110
|
+
route: no # groups the endpoints below, is not callable itself
|
|
111
|
+
path: /repos/:owner/:repo
|
|
112
|
+
endpoints:
|
|
113
|
+
show: # GET /repos/:owner/:repo
|
|
114
|
+
path: ''
|
|
115
|
+
contributors: # GET /repos/:owner/:repo/contributors
|
|
116
|
+
issues: # GET /repos/:owner/:repo/issues
|
|
117
|
+
endpoints:
|
|
118
|
+
create: # POST /repos/:owner/:repo/issues
|
|
119
|
+
path: ''
|
|
120
|
+
verb: post
|
|
121
|
+
ok_code: 201
|
|
122
|
+
search:
|
|
123
|
+
route: no
|
|
124
|
+
endpoints:
|
|
125
|
+
repositories: # GET /search/repositories
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### How a path is built
|
|
129
|
+
|
|
130
|
+
The path of an endpoint is the path of its parent followed by its own segment:
|
|
131
|
+
|
|
132
|
+
| In the file | Segment added |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| no `path` | the endpoint name: `contributors` adds `/contributors` |
|
|
135
|
+
| `path: /something` | that text: `path: /:login/repos` adds `/:login/repos` |
|
|
136
|
+
| `path: ''` | nothing: the endpoint has the same URL as its parent |
|
|
137
|
+
|
|
138
|
+
`path: ''` is how two endpoints share a URL with different verbs, like `issues`
|
|
139
|
+
(GET) and `issues.create` (POST) above.
|
|
140
|
+
|
|
141
|
+
### Path parameters
|
|
142
|
+
|
|
143
|
+
A `:name` in a path is a placeholder. Values are given, in order, to the method
|
|
144
|
+
of the endpoint whose `path` declares them:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
ApiRecipes.github.users.show('octocat').url.to_s
|
|
148
|
+
# => "https://api.github.com/users/octocat"
|
|
149
|
+
|
|
150
|
+
ApiRecipes.github.repos('rails', 'rails').contributors.url.to_s
|
|
151
|
+
# => "https://api.github.com/repos/rails/rails/contributors"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The wrong number of values raises `ApiRecipes::PathParamsMismatch`.
|
|
155
|
+
|
|
156
|
+
### Endpoints that only group others
|
|
157
|
+
|
|
158
|
+
With `route: no` an endpoint exists only to hold nested endpoints. `search` above
|
|
159
|
+
is not callable, `search.repositories` is. Calling `run` on it raises
|
|
160
|
+
`ApiRecipes::NoRouteExists`.
|
|
161
|
+
|
|
162
|
+
### Settings for the whole API
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
billing:
|
|
166
|
+
protocol: http # default: https
|
|
167
|
+
host: billing_service # underscores are accepted
|
|
168
|
+
port: 3000 # default: the port of the protocol
|
|
169
|
+
base_url: api/v1 # prefix of every path
|
|
170
|
+
timeout: 5 # seconds for the whole request, default: 3
|
|
171
|
+
default_headers:
|
|
172
|
+
Accept: application/json
|
|
173
|
+
endpoints:
|
|
174
|
+
invoices:
|
|
175
|
+
endpoints:
|
|
176
|
+
show:
|
|
177
|
+
path: /:id
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
ApiRecipes.billing.invoices.show(42).url.to_s
|
|
182
|
+
# => "http://billing_service:3000/api/v1/invoices/42"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
All options are listed in the [options reference](#options-reference).
|
|
186
|
+
|
|
187
|
+
## Loading the configuration
|
|
188
|
+
|
|
189
|
+
Configure ApiRecipes once, before the first request.
|
|
190
|
+
|
|
191
|
+
### From YAML files
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
ApiRecipes.configure do |config|
|
|
195
|
+
config.apis_files_paths = ['config/apis/github.yml', 'config/apis/billing.yml']
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
A file can describe one API or several. If two files describe the same API, the
|
|
200
|
+
later one replaces the earlier one entirely.
|
|
201
|
+
|
|
202
|
+
Files are evaluated with ERB before being parsed, so values can come from the
|
|
203
|
+
environment:
|
|
204
|
+
|
|
205
|
+
```yaml
|
|
206
|
+
github:
|
|
207
|
+
host: <%= ENV.fetch('GITHUB_HOST', 'api.github.com') %>
|
|
208
|
+
timeout: <%= ENV.fetch('GITHUB_TIMEOUT', 3) %>
|
|
209
|
+
endpoints:
|
|
210
|
+
users:
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### From a Hash
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
ApiRecipes.configure do |config|
|
|
217
|
+
config.apis_configs = {
|
|
218
|
+
github: {
|
|
219
|
+
host: 'api.github.com',
|
|
220
|
+
endpoints: {
|
|
221
|
+
users: { endpoints: { show: { path: '/:login' } } }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Keys can be strings or symbols.
|
|
229
|
+
|
|
230
|
+
## Where an API is available
|
|
231
|
+
|
|
232
|
+
### On the `ApiRecipes` module
|
|
233
|
+
|
|
234
|
+
Every configured API is a method of the module:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
ApiRecipes.github.users.run
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### In your classes
|
|
55
241
|
|
|
56
|
-
|
|
242
|
+
Include the module and declare the APIs the class uses. They become available to
|
|
243
|
+
the class and to its instances:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
class GithubProfile
|
|
247
|
+
include ApiRecipes
|
|
248
|
+
api :github
|
|
249
|
+
|
|
250
|
+
def self.all
|
|
251
|
+
github.users.run.data
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def initialize(login)
|
|
255
|
+
@login = login
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def repo_names
|
|
259
|
+
github.users.repos(@login).run.data.map { |repo| repo['name'] }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### With settings of its own
|
|
265
|
+
|
|
266
|
+
A class can change the configuration it uses. The Hash is merged over the global
|
|
267
|
+
one, so only the differences are needed:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
class EnterpriseProfile
|
|
271
|
+
include ApiRecipes
|
|
272
|
+
api :github, host: 'github.example.com', timeout: 10
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
EnterpriseProfile.github.users.url.to_s
|
|
276
|
+
# => "https://github.example.com/users"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
A class can also declare an API that is not in the global configuration at all:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
class Forecast
|
|
283
|
+
include ApiRecipes
|
|
284
|
+
api :weather, host: 'api.weather.test',
|
|
285
|
+
endpoints: { forecast: { path: '/forecast/:city' } }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
Forecast.weather.forecast('rome').url.to_s
|
|
289
|
+
# => "https://api.weather.test/forecast/rome"
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Making requests
|
|
293
|
+
|
|
294
|
+
### `run` or a block
|
|
295
|
+
|
|
296
|
+
`run` sends the request and returns the response:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
response = ApiRecipes.github.users.show('octocat').run
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
A block does the same and receives the response:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
ApiRecipes.github.users.show('octocat') do |response|
|
|
306
|
+
puts response.data['name']
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Use `run` when you need the returned value: the block form returns the endpoint,
|
|
311
|
+
not the response.
|
|
312
|
+
|
|
313
|
+
### Query string, body and headers
|
|
314
|
+
|
|
315
|
+
After the path parameters, an endpoint method takes one Hash of request options.
|
|
316
|
+
They are the options of
|
|
317
|
+
[http.rb](https://github.com/httprb/http/wiki/Passing-Parameters):
|
|
318
|
+
|
|
319
|
+
| Option | Sends |
|
|
320
|
+
| --- | --- |
|
|
321
|
+
| `params:` | a query string |
|
|
322
|
+
| `json:` | a JSON body, with `Content-Type: application/json` |
|
|
323
|
+
| `form:` | a form-encoded body |
|
|
324
|
+
| `body:` | a raw body |
|
|
325
|
+
| `headers:` | extra headers for this request only |
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
# GET /search/repositories?q=api%20language:ruby&per_page=5
|
|
329
|
+
ApiRecipes.github.search.repositories(params: { q: 'api language:ruby', per_page: 5 }).run
|
|
330
|
+
|
|
331
|
+
# GET /users/octocat/repos?type=owner
|
|
332
|
+
ApiRecipes.github.users.repos('octocat', params: { type: 'owner' }).run
|
|
333
|
+
|
|
334
|
+
# POST /repos/rails/rails/issues with a JSON body
|
|
335
|
+
ApiRecipes.github.repos('rails', 'rails').issues.create(json: { title: 'Found a bug' }).run
|
|
336
|
+
|
|
337
|
+
# One more header, only for this request
|
|
338
|
+
ApiRecipes.github.users.show('octocat', headers: { 'X-Request-Id' => 'abc123' }).run
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Verbs
|
|
342
|
+
|
|
343
|
+
The verb is part of the endpoint description (`verb: post`, `put`, `patch`,
|
|
344
|
+
`delete`, `head`...). The default is `get`.
|
|
345
|
+
|
|
346
|
+
### Looking at the URL without calling it
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
ApiRecipes.github.users.repos('octocat').url.to_s
|
|
350
|
+
# => "https://api.github.com/users/octocat/repos"
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Reading the response
|
|
354
|
+
|
|
355
|
+
`run` returns an `ApiRecipes::Response`. It adds `data` and forwards everything
|
|
356
|
+
else to the [http.rb response](https://github.com/httprb/http/wiki/Response-Handling):
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
response = ApiRecipes.github.users.show('octocat').run
|
|
360
|
+
|
|
361
|
+
response.data # => { "login" => "octocat", ... } parsed JSON
|
|
362
|
+
response.code # => 200
|
|
363
|
+
response.status.success? # => true
|
|
364
|
+
response.headers['Content-Type'] # => "application/json; charset=utf-8"
|
|
365
|
+
response.body.to_s # => "{\"login\":\"octocat\", ...}"
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
`data` parses the body according to the `Content-Type` of the response. For
|
|
369
|
+
anything that is not JSON, or for an empty body, it raises `HTTP::Error`: read
|
|
370
|
+
`response.body.to_s` instead. If a server sends JSON with the wrong
|
|
371
|
+
`Content-Type`, set `mime_type: application/json` on the endpoint or on the API.
|
|
372
|
+
|
|
373
|
+
## Deciding what counts as success
|
|
374
|
+
|
|
375
|
+
By default any 2xx status is a success and anything else raises
|
|
376
|
+
`ApiRecipes::ResponseCodeNotAsExpected`. Three options change that, for one
|
|
377
|
+
endpoint or for the whole API.
|
|
378
|
+
|
|
379
|
+
**Expect one exact status** with `ok_code`:
|
|
380
|
+
|
|
381
|
+
```yaml
|
|
382
|
+
create:
|
|
383
|
+
path: ''
|
|
384
|
+
verb: post
|
|
385
|
+
ok_code: 201 # a 200 is now an error
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
**Never raise** with `on_bad_code: ignore`, and inspect the response yourself:
|
|
389
|
+
|
|
390
|
+
```yaml
|
|
391
|
+
health:
|
|
392
|
+
on_bad_code: ignore
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
response = ApiRecipes.status.health.run
|
|
397
|
+
response.status.success? # => false, and no exception
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**Decide in Ruby** with `verify_with`, the name of a method of the object that
|
|
401
|
+
makes the call. It receives the response; a false or nil result makes the call
|
|
402
|
+
fail:
|
|
403
|
+
|
|
404
|
+
```yaml
|
|
405
|
+
me:
|
|
406
|
+
path: /user
|
|
407
|
+
verify_with: valid_user?
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
class Account
|
|
412
|
+
include ApiRecipes
|
|
413
|
+
api :github
|
|
414
|
+
|
|
415
|
+
def current
|
|
416
|
+
github.me.run.data
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
private
|
|
420
|
+
|
|
421
|
+
def valid_user?(response)
|
|
422
|
+
response.code == 200 && response.data.key?('login')
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The method can be private. It is looked up on the caller, so `verify_with` is
|
|
428
|
+
meant for APIs used from a class or an instance.
|
|
429
|
+
|
|
430
|
+
## Handling errors
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
begin
|
|
434
|
+
ApiRecipes.github.users.show('nobody-here').run
|
|
435
|
+
rescue ApiRecipes::ResponseCodeNotAsExpected => e
|
|
436
|
+
e.response.code # => 404
|
|
437
|
+
e.response.data # => { "message" => "Not Found", ... }
|
|
438
|
+
e.path # => "/users/nobody-here"
|
|
439
|
+
e.expected_code # => "200 <= CODE < 300"
|
|
440
|
+
rescue HTTP::TimeoutError
|
|
441
|
+
# the request took longer than `timeout`
|
|
442
|
+
rescue HTTP::ConnectionError
|
|
443
|
+
# the server could not be reached
|
|
444
|
+
end
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
> **Rescue ApiRecipes errors by name.** They inherit from `Exception`, not from
|
|
448
|
+
> `StandardError`, so a bare `rescue` or `rescue => e` does not catch them.
|
|
449
|
+
|
|
450
|
+
| Error | Raised when |
|
|
451
|
+
| --- | --- |
|
|
452
|
+
| `ApiRecipes::ResponseCodeNotAsExpected` | the response is not a success; has `response`, `path` and `expected_code` |
|
|
453
|
+
| `ApiRecipes::PathParamsMismatch` | an endpoint gets the wrong number of path parameters |
|
|
454
|
+
| `ApiRecipes::NoRouteExists` | `run` is called on an endpoint with `route: no` |
|
|
455
|
+
| `ApiRecipes::ApiNameClashError` | `api :name` is declared in a class that already has a method `name` |
|
|
456
|
+
| `ApiRecipes::ApiConfigIsNotAnHash` | the second argument of `api` is not a Hash |
|
|
457
|
+
| `ApiRecipes::RouteNameClashWithExistentMethod` | a nested endpoint has the name of an existing method, such as `url`, `run` or `name` |
|
|
458
|
+
| `HTTP::TimeoutError`, `HTTP::ConnectionError` | http.rb network errors; these are `StandardError`s |
|
|
459
|
+
|
|
460
|
+
## Authorization
|
|
461
|
+
|
|
462
|
+
Credentials are set on the API of the `ApiRecipes` module. They apply to every
|
|
463
|
+
request to that API, including those made from classes that declare it.
|
|
464
|
+
|
|
465
|
+
**A token**, sent as the `Authorization` header:
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
ApiRecipes.github.authorization = "Bearer #{ENV.fetch('GITHUB_TOKEN')}"
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
**Basic auth:**
|
|
472
|
+
|
|
473
|
+
```ruby
|
|
474
|
+
ApiRecipes.github.basic_auth = { user: 'me', pass: 'secret' }
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**A different token for one class**, through its own default headers:
|
|
478
|
+
|
|
479
|
+
```ruby
|
|
480
|
+
class AdminClient
|
|
481
|
+
include ApiRecipes
|
|
482
|
+
api :github, default_headers: { 'Authorization' => "Bearer #{ENV.fetch('ADMIN_TOKEN')}" }
|
|
483
|
+
end
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
**A different token for one request**, for example the token of the current user:
|
|
487
|
+
|
|
488
|
+
```ruby
|
|
489
|
+
ApiRecipes.github.users.repos('octocat', headers: { 'Authorization' => "Bearer #{user.token}" }).run
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
> Set credentials on `ApiRecipes.github`, not on `MyClass.github`. The second form
|
|
493
|
+
> returns a new object at every call, so the value is lost.
|
|
494
|
+
|
|
495
|
+
## Logging and debugging
|
|
496
|
+
|
|
497
|
+
```ruby
|
|
498
|
+
ApiRecipes.configure do |config|
|
|
499
|
+
config.logger = Rails.logger # any Logger; set it before the files
|
|
500
|
+
config.print_urls = true # print every URL to STDOUT as it is built
|
|
501
|
+
config.apis_files_paths = ['config/apis/github.yml']
|
|
502
|
+
end
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Without `logger`, ApiRecipes logs to STDOUT at debug level. `config.log_to`
|
|
506
|
+
(a path or an IO) and `config.log_level` (`:debug`, `:info`, `:warn`, `:error`,
|
|
507
|
+
`:fatal`) change that without building a Logger.
|
|
508
|
+
|
|
509
|
+
## Recipes
|
|
510
|
+
|
|
511
|
+
### Rails setup
|
|
512
|
+
|
|
513
|
+
```ruby
|
|
514
|
+
# config/initializers/api_recipes.rb
|
|
515
|
+
ApiRecipes.configure do |config|
|
|
516
|
+
config.logger = Rails.logger
|
|
517
|
+
config.apis_files_paths = Dir[Rails.root.join('config', 'apis', '*.yml').to_s]
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
ApiRecipes.github.authorization = "Bearer #{Rails.application.credentials.github_token}"
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### One host per environment
|
|
524
|
+
|
|
525
|
+
Keep one file and let ERB pick the values:
|
|
526
|
+
|
|
527
|
+
```yaml
|
|
528
|
+
billing:
|
|
529
|
+
protocol: <%= ENV.fetch('BILLING_PROTOCOL', 'https') %>
|
|
530
|
+
host: <%= ENV.fetch('BILLING_HOST', 'billing.example.com') %>
|
|
531
|
+
endpoints:
|
|
532
|
+
invoices:
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### Every page of a list
|
|
536
|
+
|
|
537
|
+
```ruby
|
|
538
|
+
def all_repos(login)
|
|
539
|
+
repos = []
|
|
540
|
+
page = 1
|
|
541
|
+
loop do
|
|
542
|
+
batch = ApiRecipes.github.users.repos(login, params: { per_page: 100, page: page }).run.data
|
|
543
|
+
repos.concat(batch)
|
|
544
|
+
break if batch.size < 100
|
|
545
|
+
page += 1
|
|
546
|
+
end
|
|
547
|
+
repos
|
|
548
|
+
end
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
### Creating a resource
|
|
552
|
+
|
|
553
|
+
```ruby
|
|
554
|
+
response = ApiRecipes.github.repos('rails', 'rails').issues.create(
|
|
555
|
+
json: { title: 'Found a bug', labels: ['bug'] }
|
|
556
|
+
).run
|
|
557
|
+
|
|
558
|
+
response.code # => 201
|
|
559
|
+
response.data['number'] # => 1347
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
### A token per user in a multi-threaded application
|
|
563
|
+
|
|
564
|
+
Credentials set with `authorization=` are shared by the whole process. When each
|
|
565
|
+
user has a token, pass it with the request instead:
|
|
566
|
+
|
|
567
|
+
```ruby
|
|
568
|
+
class UserRepos
|
|
569
|
+
def initialize(user)
|
|
570
|
+
@headers = { 'Authorization' => "Bearer #{user.github_token}" }
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def names
|
|
574
|
+
ApiRecipes.github.users.repos('octocat', headers: @headers).run.data.map { |repo| repo['name'] }
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### A health check that never raises
|
|
580
|
+
|
|
581
|
+
```yaml
|
|
582
|
+
status:
|
|
583
|
+
host: status.example.com
|
|
584
|
+
timeout: 1
|
|
585
|
+
on_bad_code: ignore
|
|
586
|
+
endpoints:
|
|
587
|
+
health:
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
```ruby
|
|
591
|
+
def service_up?
|
|
592
|
+
ApiRecipes.status.health.run.status.success?
|
|
593
|
+
rescue HTTP::Error # timeout, refused connection, DNS failure
|
|
594
|
+
false
|
|
595
|
+
end
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Returning nil when a record does not exist
|
|
599
|
+
|
|
600
|
+
```ruby
|
|
601
|
+
def find_user(login)
|
|
602
|
+
ApiRecipes.github.users.show(login).run.data
|
|
603
|
+
rescue ApiRecipes::ResponseCodeNotAsExpected => e
|
|
604
|
+
raise unless e.response.code == 404
|
|
605
|
+
nil
|
|
606
|
+
end
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
### Testing code that uses ApiRecipes
|
|
610
|
+
|
|
611
|
+
Stub the HTTP layer with [WebMock](https://github.com/bblimke/webmock); nothing in
|
|
612
|
+
ApiRecipes needs to be mocked. Send a `Content-Type`, or `data` cannot parse the
|
|
613
|
+
body:
|
|
614
|
+
|
|
615
|
+
```ruby
|
|
616
|
+
require 'webmock/rspec'
|
|
617
|
+
|
|
618
|
+
RSpec.describe GithubProfile do
|
|
619
|
+
it 'lists the repository names' do
|
|
620
|
+
stub_request(:get, 'https://api.github.com/users/octocat/repos')
|
|
621
|
+
.to_return(status: 200,
|
|
622
|
+
body: [{ name: 'hello-world' }].to_json,
|
|
623
|
+
headers: { 'Content-Type' => 'application/json' })
|
|
624
|
+
|
|
625
|
+
expect(GithubProfile.new('octocat').repo_names).to eq(['hello-world'])
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
If your tests configure ApiRecipes themselves, `ApiRecipes.reset!` removes the
|
|
631
|
+
global configuration and the API methods defined on the module, so each example
|
|
632
|
+
can start clean:
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
RSpec.configure do |config|
|
|
636
|
+
config.before(:each) { ApiRecipes.reset! }
|
|
637
|
+
end
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
## Options reference
|
|
641
|
+
|
|
642
|
+
### `ApiRecipes.configure`
|
|
643
|
+
|
|
644
|
+
| Option | Meaning |
|
|
645
|
+
| --- | --- |
|
|
646
|
+
| `apis_files_paths` | Array of YAML files, evaluated with ERB |
|
|
647
|
+
| `apis_configs` | Hash of API descriptions |
|
|
648
|
+
| `logger` | a Logger to use |
|
|
649
|
+
| `log_to` | path or IO for the default logger (default: STDOUT) |
|
|
650
|
+
| `log_level` | level of the default logger (default: the `LOG_LEVEL` variable, or debug) |
|
|
651
|
+
| `print_urls` | print each URL to STDOUT when it is built (default: false) |
|
|
652
|
+
|
|
653
|
+
### API
|
|
654
|
+
|
|
655
|
+
| Option | Default | Meaning |
|
|
656
|
+
| --- | --- | --- |
|
|
657
|
+
| `protocol` | `https` | `http` or `https` |
|
|
658
|
+
| `host` | `localhost` | host name |
|
|
659
|
+
| `port` | port of the protocol | port number |
|
|
660
|
+
| `base_url` | none | prefix of every path, for example `api/v1` |
|
|
661
|
+
| `timeout` | `3` | seconds allowed for the whole request |
|
|
662
|
+
| `default_headers` | none | headers sent with every request |
|
|
663
|
+
| `on_bad_code` | `raise` | `raise` or `ignore` when the response is not a success |
|
|
664
|
+
| `mime_type` | from the response | content type used by `response.data` |
|
|
665
|
+
| `verify_with` | none | method that decides whether a response is a success |
|
|
666
|
+
| `endpoints` | none | the endpoints of the API |
|
|
667
|
+
|
|
668
|
+
### Endpoint
|
|
669
|
+
|
|
670
|
+
| Option | Default | Meaning |
|
|
671
|
+
| --- | --- | --- |
|
|
672
|
+
| `path` | the endpoint name | segment added to the parent path; `''` adds nothing |
|
|
673
|
+
| `verb` | `get` | HTTP verb |
|
|
674
|
+
| `route` | `yes` | `no` makes the endpoint a group that cannot be called |
|
|
675
|
+
| `ok_code` | any 2xx | the only status accepted as a success |
|
|
676
|
+
| `timeout` | the API value | seconds allowed for the whole request |
|
|
677
|
+
| `on_bad_code` | the API value | `raise` or `ignore` |
|
|
678
|
+
| `mime_type` | the API value | content type used by `response.data` |
|
|
679
|
+
| `verify_with` | the API or parent value | method that decides whether a response is a success |
|
|
680
|
+
| `endpoints` | none | nested endpoints |
|
|
681
|
+
|
|
682
|
+
An endpoint takes `timeout`, `on_bad_code` and `mime_type` from the API, not from
|
|
683
|
+
the endpoint that contains it.
|
|
684
|
+
|
|
685
|
+
## Good to know
|
|
686
|
+
|
|
687
|
+
- **Configure before the first request.** A class keeps the configuration it saw
|
|
688
|
+
the first time it used its API.
|
|
689
|
+
- **Unknown options are ignored without a message.** If an option seems to have
|
|
690
|
+
no effect, check its spelling: the verb is `verb`, not `method`.
|
|
691
|
+
- **Path parameters are not encoded.** A value containing `/` spans two path
|
|
692
|
+
segments, and `nil` or an empty string produce the URL of the parent
|
|
693
|
+
(`/users/`). Check values that come from user input, and encode them with
|
|
694
|
+
`ERB::Util.url_encode` when they can contain reserved characters.
|
|
695
|
+
- **Credentials are shared by the whole process.** For a token per user, see
|
|
696
|
+
[the recipe above](#a-token-per-user-in-a-multi-threaded-application).
|
|
697
|
+
- **Some names are taken.** An endpoint cannot be called like a method its parent
|
|
698
|
+
already has. Directly under the API, such an endpoint (`name`, `configs`,
|
|
699
|
+
`object`) is skipped without a message; a nested one (`url`, `run`, `name`,
|
|
700
|
+
`params`, `route`, `children`) raises `RouteNameClashWithExistentMethod`. An
|
|
701
|
+
API must not be called `configure`, `configuration`, `logger` or `print_urls`:
|
|
702
|
+
it would replace that method of the module.
|
|
703
|
+
- **Core extensions.** The gem adds a few methods to `Hash` and `Array`
|
|
704
|
+
(`deep_symbolize_keys`, `deep_merge`, `extract_options!` and similar, with the
|
|
705
|
+
same behaviour as in ActiveSupport). With the uri gem older than 1.0 it also
|
|
706
|
+
makes `URI` accept underscores in host names.
|
|
707
|
+
|
|
708
|
+
## Runnable examples
|
|
709
|
+
|
|
710
|
+
The [`examples`](examples) folder contains scripts that call public APIs:
|
|
711
|
+
|
|
712
|
+
| Script | Shows |
|
|
713
|
+
| --- | --- |
|
|
714
|
+
| `simple_usage.rb` | configuration from a YAML file; class, instance and module access |
|
|
715
|
+
| `direct_configuration.rb` | an API described directly in a class |
|
|
716
|
+
| `manual_configs.rb` | configuration from a Hash; nested endpoints with path parameters |
|
|
717
|
+
| `multiple_endpoints.rb` | two APIs from two files |
|
|
718
|
+
| `authorization.rb` | token authorization |
|
|
719
|
+
| `basic_auth.rb` | basic auth |
|
|
720
|
+
| `authorization_with_default_headers.rb` | a token for one class, through default headers |
|
|
721
|
+
|
|
722
|
+
Run one from the root of the repository:
|
|
723
|
+
|
|
724
|
+
$ bundle exec ruby examples/simple_usage.rb
|
|
725
|
+
|
|
726
|
+
[`examples/config/github.yml.erb`](examples/config/github.yml.erb) is a
|
|
727
|
+
configuration file with every option commented.
|
|
728
|
+
|
|
729
|
+
## Compatibility
|
|
730
|
+
|
|
731
|
+
- Ruby 2.7 or higher. The test suite runs on the maintained versions: 3.3, 3.4 and 4.0
|
|
732
|
+
- http.rb from 4.4 to 5.x
|
|
733
|
+
- uri 0.x and 1.x
|
|
734
|
+
|
|
735
|
+
## Development
|
|
736
|
+
|
|
737
|
+
$ bundle install
|
|
738
|
+
$ bundle exec rspec
|
|
739
|
+
|
|
740
|
+
The specs never use the network. To run them against a specific version of the
|
|
741
|
+
uri gem:
|
|
742
|
+
|
|
743
|
+
$ URI_VERSION='~> 1.0' bundle exec rspec
|
|
744
|
+
|
|
745
|
+
What is planned for the next releases is in the [roadmap](docs/ROADMAP.md); the
|
|
746
|
+
reasons behind the main design choices are in [docs/decisions](docs/decisions).
|
|
747
|
+
|
|
748
|
+
Bug reports and pull requests are welcome on
|
|
749
|
+
[GitHub](https://github.com/madAle/api_recipes). Contributors are expected to
|
|
750
|
+
follow the [Contributor Covenant](CODE_OF_CONDUCT.md).
|
|
751
|
+
|
|
752
|
+
## License
|
|
57
753
|
|
|
754
|
+
The gem is available as open source under the terms of the
|
|
755
|
+
[MIT License](http://opensource.org/licenses/MIT).
|
data/lib/api_recipes/version.rb
CHANGED
data/lib/api_recipes.rb
CHANGED
|
@@ -114,6 +114,17 @@ module ApiRecipes
|
|
|
114
114
|
end
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# Removes the global api methods, the storage and the configuration.
|
|
118
|
+
def self.reset!
|
|
119
|
+
_aprcps_global_storage.each_key do |api_name|
|
|
120
|
+
singleton_class.send(:remove_method, api_name) if singleton_class.method_defined?(api_name, false)
|
|
121
|
+
end
|
|
122
|
+
@storage = nil
|
|
123
|
+
@configuration = nil
|
|
124
|
+
@print_urls = nil
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
117
128
|
def self._aprcps_global_storage
|
|
118
129
|
unless @storage
|
|
119
130
|
@storage = {}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: api_recipes
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.10.
|
|
4
|
+
version: 2.10.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alessandro Verlato
|
|
@@ -29,6 +29,20 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '6'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: logger
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0'
|
|
32
46
|
email:
|
|
33
47
|
- averlato@gmail.com
|
|
34
48
|
executables: []
|
|
@@ -50,7 +64,9 @@ files:
|
|
|
50
64
|
homepage: https://github.com/madAle/api_recipes
|
|
51
65
|
licenses:
|
|
52
66
|
- MIT
|
|
53
|
-
metadata:
|
|
67
|
+
metadata:
|
|
68
|
+
changelog_uri: https://github.com/madAle/api_recipes/blob/master/CHANGELOG.md
|
|
69
|
+
source_code_uri: https://github.com/madAle/api_recipes
|
|
54
70
|
rdoc_options: []
|
|
55
71
|
require_paths:
|
|
56
72
|
- lib
|