webfunction 1.0.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/.rubocop.yml +42 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +598 -0
- data/Rakefile +12 -0
- data/lib/web_function.rb +1 -0
- data/lib/webfunction/argument.rb +123 -0
- data/lib/webfunction/attribute.rb +113 -0
- data/lib/webfunction/client.rb +155 -0
- data/lib/webfunction/documented_error.rb +67 -0
- data/lib/webfunction/endpoint.rb +243 -0
- data/lib/webfunction/flaggable.rb +40 -0
- data/lib/webfunction/object_schema.rb +135 -0
- data/lib/webfunction/package.rb +196 -0
- data/lib/webfunction/page.rb +134 -0
- data/lib/webfunction/pipeline.rb +91 -0
- data/lib/webfunction/promise.rb +150 -0
- data/lib/webfunction/request.rb +177 -0
- data/lib/webfunction/type/any.rb +48 -0
- data/lib/webfunction/type/array_of.rb +58 -0
- data/lib/webfunction/type/base.rb +89 -0
- data/lib/webfunction/type/union.rb +54 -0
- data/lib/webfunction/type.rb +140 -0
- data/lib/webfunction/utils.rb +67 -0
- data/lib/webfunction/version.rb +5 -0
- data/lib/webfunction.rb +52 -0
- data/sig/webfunction.rbs +4 -0
- metadata +131 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 20a2d512457397579e72ac5b87ade364333ebd28fb2da453f9a50cc4fb11318f
|
|
4
|
+
data.tar.gz: 53deaf1574b81239251933221f8f1153682e6c95fe51cd0553fac1349e4be376
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6b2ca218fc3d8500a3c4cf81c88080549b8e37776b2a7fc231e53be70ca31a7c0081fd89485cafc7226887a26ad4fc0206c6203fb4ab98a7d62fe28f0feb60b1
|
|
7
|
+
data.tar.gz: da4255e1d7cde67079bbd05fc7bd76a7ce42bc36daf471600cf9e8a0d917b852af0373ba890f1ab0721c5c33b1e7d77b8f6bb82641cdfab1d670b3ae8235ca84
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
SuggestExtensions: false
|
|
4
|
+
|
|
5
|
+
Metrics:
|
|
6
|
+
Enabled: false
|
|
7
|
+
|
|
8
|
+
Style/Documentation:
|
|
9
|
+
Enabled: false
|
|
10
|
+
|
|
11
|
+
Style/StringLiterals:
|
|
12
|
+
EnforcedStyle: double_quotes
|
|
13
|
+
|
|
14
|
+
Style/StringLiteralsInInterpolation:
|
|
15
|
+
EnforcedStyle: double_quotes
|
|
16
|
+
|
|
17
|
+
Style/TrailingCommaInArguments:
|
|
18
|
+
EnforcedStyleForMultiline: consistent_comma
|
|
19
|
+
|
|
20
|
+
Style/TrailingCommaInArrayLiteral:
|
|
21
|
+
EnforcedStyleForMultiline: consistent_comma
|
|
22
|
+
|
|
23
|
+
Style/TrailingCommaInHashLiteral:
|
|
24
|
+
EnforcedStyleForMultiline: consistent_comma
|
|
25
|
+
|
|
26
|
+
Style/RaiseArgs:
|
|
27
|
+
EnforcedStyle: compact
|
|
28
|
+
|
|
29
|
+
Layout/MultilineMethodCallBraceLayout:
|
|
30
|
+
EnforcedStyle: new_line
|
|
31
|
+
|
|
32
|
+
Style/IfUnlessModifier:
|
|
33
|
+
Enabled: false
|
|
34
|
+
|
|
35
|
+
Lint/SymbolConversion:
|
|
36
|
+
Enabled: false
|
|
37
|
+
|
|
38
|
+
Layout/ArgumentAlignment:
|
|
39
|
+
EnforcedStyle: with_fixed_indentation
|
|
40
|
+
|
|
41
|
+
Style/ConditionalAssignment:
|
|
42
|
+
EnforcedStyle: assign_inside_condition
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Robin Clart
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
# webfunction
|
|
2
|
+
|
|
3
|
+
A [Web Function](https://webfunction.org) client for Ruby.
|
|
4
|
+
|
|
5
|
+
Web Function is a way to design APIs. There are no verbs and no nested URLs. You
|
|
6
|
+
call an endpoint with a POST request, the path names the action, and the JSON
|
|
7
|
+
body carries the data. This gem lets you call those endpoints from Ruby.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
client = WebFunction::Client.from_package_endpoint("https://api.example.com/package")
|
|
11
|
+
|
|
12
|
+
client.find_user(id: "123")
|
|
13
|
+
# => { "id" => "123", "name" => "Ada" }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Table of contents
|
|
17
|
+
|
|
18
|
+
- [Why Web Function](#why-web-function)
|
|
19
|
+
- [Installation](#installation)
|
|
20
|
+
- [Quick start](#quick-start)
|
|
21
|
+
- [Clients](#clients)
|
|
22
|
+
- [Calling endpoints](#calling-endpoints)
|
|
23
|
+
- [Pagination](#pagination)
|
|
24
|
+
- [Authentication](#authentication)
|
|
25
|
+
- [Versioning](#versioning)
|
|
26
|
+
- [Inspecting a package](#inspecting-a-package)
|
|
27
|
+
- [Types](#types)
|
|
28
|
+
- [Object schemas](#object-schemas)
|
|
29
|
+
- [Error handling](#error-handling)
|
|
30
|
+
- [Pipelining](#pipelining)
|
|
31
|
+
- [Custom HTTP client](#custom-http-client)
|
|
32
|
+
- [Low-level requests](#low-level-requests)
|
|
33
|
+
- [Command line tool](#command-line-tool)
|
|
34
|
+
- [Development](#development)
|
|
35
|
+
- [Contributing](#contributing)
|
|
36
|
+
- [License](#license)
|
|
37
|
+
|
|
38
|
+
## Why Web Function
|
|
39
|
+
|
|
40
|
+
A Web Function API has a few simple rules:
|
|
41
|
+
|
|
42
|
+
- Every call is an HTTP POST.
|
|
43
|
+
- The request body is a JSON object.
|
|
44
|
+
- The response body is any JSON value.
|
|
45
|
+
- A `200` status means success and the body is the return value.
|
|
46
|
+
- A `400` status means the request was bad and the body explains why.
|
|
47
|
+
- Any other status is an error you handle yourself.
|
|
48
|
+
|
|
49
|
+
On top of that, an API can publish a **package**. A package is a JSON document
|
|
50
|
+
that lists the endpoints, their arguments, their return types, and their docs.
|
|
51
|
+
This gem reads a package and gives you a client that calls those endpoints as if
|
|
52
|
+
they were Ruby methods.
|
|
53
|
+
|
|
54
|
+
You can read the full specification at [webfunction.org](https://webfunction.org).
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
Add the gem to your project:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bundle add webfunction
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Or install it on its own:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
gem install webfunction
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The gem needs Ruby 3.1 or newer.
|
|
71
|
+
|
|
72
|
+
## Quick start
|
|
73
|
+
|
|
74
|
+
Point a client at a package URL and start calling endpoints:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
require "webfunction"
|
|
78
|
+
|
|
79
|
+
client = WebFunction::Client.from_package_endpoint("https://api.example.com/package")
|
|
80
|
+
|
|
81
|
+
# An endpoint named "list-items" becomes the method "list_items".
|
|
82
|
+
items = client.list_items(limit: 10)
|
|
83
|
+
# => [{ "id" => 1 }, { "id" => 2 }]
|
|
84
|
+
|
|
85
|
+
# Pass a bearer token for endpoints that need authentication.
|
|
86
|
+
secure = WebFunction::Client.from_package_endpoint(
|
|
87
|
+
"https://api.example.com/package",
|
|
88
|
+
bearer_auth: "my-token",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
secure.create_item(name: "Notebook")
|
|
92
|
+
# => { "id" => 3, "name" => "Notebook" }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Clients
|
|
96
|
+
|
|
97
|
+
A `WebFunction::Client` wraps a package and turns each endpoint into a method.
|
|
98
|
+
|
|
99
|
+
You usually build a client from a package URL. The gem fetches the package,
|
|
100
|
+
reads its endpoints, and returns a ready client:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
client = WebFunction::Client.from_package_endpoint("https://api.example.com/package")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`from_package_endpoint` fetches the package by calling the URL as a Web
|
|
107
|
+
Function endpoint, that is, with a POST request. If your package document is
|
|
108
|
+
served as plain JSON over a regular `GET` request instead, use `from_url`:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
client = WebFunction::Client.from_url("https://api.example.com/package.json")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`from_url` accepts the same options as `from_package_endpoint`. When you pass a
|
|
115
|
+
`version`, it is added to the request as an `api_version` query parameter rather
|
|
116
|
+
than an `Api-Version` header.
|
|
117
|
+
|
|
118
|
+
If you already have a package in memory, build the client from that instead.
|
|
119
|
+
This avoids the extra request:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
package = WebFunction::Package.from_hash(
|
|
123
|
+
"base_url" => "https://api.example.com/",
|
|
124
|
+
"endpoints" => [
|
|
125
|
+
{ "name" => "list-items", "returns" => [["object"]] },
|
|
126
|
+
{ "name" => "create-item", "returns" => ["object"] },
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
client = WebFunction::Client.from_package(package)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
All three builders accept the same options:
|
|
134
|
+
|
|
135
|
+
| Option | Description |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `bearer_auth` | A bearer token sent with every call. |
|
|
138
|
+
| `version` | A version string sent in the `Api-Version` header. |
|
|
139
|
+
| `pipelined` | When `true`, calls are batched into one request. See [Pipelining](#pipelining). |
|
|
140
|
+
|
|
141
|
+
You can also overwrite these using the `Client#bearer_auth=`, `Client#version=`
|
|
142
|
+
and `Client#pipeline=` attribute writers after having instantiated a client.
|
|
143
|
+
|
|
144
|
+
## Calling endpoints
|
|
145
|
+
|
|
146
|
+
Endpoint names use dashes, like `list-items`. The client exposes them as Ruby
|
|
147
|
+
methods with underscores, like `list_items`. You pass arguments as keywords:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
client.list_items(limit: 10, offset: 20)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The return value is the parsed JSON response. It can be a hash, an array, a
|
|
154
|
+
string, a number, a boolean, or `nil`. When the response matches the pagination
|
|
155
|
+
contract, it is wrapped in a `WebFunction::Page` instead — see
|
|
156
|
+
[Pagination](#pagination).
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
client.get_count # => 42
|
|
160
|
+
client.list_items # => [{ "id" => 1 }]
|
|
161
|
+
client.find_user(id: "1") # => { "id" => "1", "name" => "Ada" }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
If you prefer to call an endpoint by its name, use `call`:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
client.call("list-items", limit: 10)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Calling an endpoint that the package does not define raises `NoMethodError`:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
client.does_not_exist
|
|
174
|
+
# => NoMethodError
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Pagination
|
|
178
|
+
|
|
179
|
+
Some endpoints return results in pages. A paginated response is a JSON object
|
|
180
|
+
with three keys: `page` (the items), `next`, and `previous`. The gem detects
|
|
181
|
+
this shape automatically and returns a `WebFunction::Page` instead of a bare
|
|
182
|
+
hash.
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
page = client.list_people(filters: { first_name: "Joe" })
|
|
186
|
+
# => #<WebFunction::Page>
|
|
187
|
+
|
|
188
|
+
page.page
|
|
189
|
+
# => [{ "person_id" => "person_1", "first_name" => "Joe" }, ...]
|
|
190
|
+
|
|
191
|
+
page.next? # => true
|
|
192
|
+
page.previous? # => false
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`Page` is Enumerable over the current page's items:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
page.map { |person| person["person_id"] }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
To move between pages, call `next_page` or `previous_page`. Each call posts the
|
|
202
|
+
opaque `next` or `previous` body from the last response to the same endpoint —
|
|
203
|
+
you never build or change those bodies yourself:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
next_page = page.next_page
|
|
207
|
+
# => #<WebFunction::Page>
|
|
208
|
+
|
|
209
|
+
next_page.previous?
|
|
210
|
+
# => true
|
|
211
|
+
|
|
212
|
+
next_page.previous_page
|
|
213
|
+
# => back to the earlier page
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
When there is no adjacent page, `next?` / `previous?` are `false` and
|
|
217
|
+
`next_page` / `previous_page` return `nil`.
|
|
218
|
+
|
|
219
|
+
You can ask an endpoint whether it declares the `paginated` flag:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
endpoint = client.package.endpoint("list-people")
|
|
223
|
+
endpoint.paginated? # => true
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The full contract is at [webfunction.org/pagination](https://webfunction.org/pagination).
|
|
227
|
+
|
|
228
|
+
## Authentication
|
|
229
|
+
|
|
230
|
+
Some endpoints need a bearer token. Pass it when you build the client and the
|
|
231
|
+
gem adds an `Authorization: Bearer <token>` header to every call:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
client = WebFunction::Client.from_package_endpoint(
|
|
235
|
+
"https://api.example.com/package",
|
|
236
|
+
bearer_auth: "my-token",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
client.list_orders
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The gem does not handle login. How you obtain the token is up to you. To find
|
|
243
|
+
out whether an endpoint needs a token, check its `bearer_auth?` flag:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
endpoint = client.package.endpoint("list-orders")
|
|
247
|
+
endpoint.bearer_auth? # => true
|
|
248
|
+
endpoint.capture_bearer? # => false
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Versioning
|
|
252
|
+
|
|
253
|
+
A versioned package selects its version through the `Api-Version` header. Pass a
|
|
254
|
+
version string when you build the client:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
client = WebFunction::Client.from_package_endpoint(
|
|
258
|
+
"https://api.example.com/package",
|
|
259
|
+
version: "2024-01-01",
|
|
260
|
+
)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
You can ask a package whether it is versioned and which versions it offers:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
package = client.package
|
|
267
|
+
package.versioned? # => true
|
|
268
|
+
package.version # => "2024-01-01"
|
|
269
|
+
package.versions # => ["2023-06-01", "2024-01-01"]
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Inspecting a package
|
|
273
|
+
|
|
274
|
+
A package describes itself. You can read its metadata, walk its endpoints, and
|
|
275
|
+
look at the arguments and outputs of each one. This is useful for building docs
|
|
276
|
+
or for checking a call before you make it.
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
package = client.package
|
|
280
|
+
|
|
281
|
+
package.name # => "Example API"
|
|
282
|
+
package.base_url # => "https://api.example.com/"
|
|
283
|
+
package.docs # => "Markdown documentation for the package."
|
|
284
|
+
package.endpoints # => [#<WebFunction::Endpoint ...>, ...]
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Look up a single endpoint by name. Underscores and dashes both work:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
endpoint = package.endpoint("find-user")
|
|
291
|
+
# Same as:
|
|
292
|
+
endpoint = package.endpoint(:find_user)
|
|
293
|
+
|
|
294
|
+
endpoint.name # => "find-user"
|
|
295
|
+
endpoint.docs # => "Retrieves user data."
|
|
296
|
+
endpoint.returns # => a Type, see below
|
|
297
|
+
endpoint.returns.to_s # => "object"
|
|
298
|
+
endpoint.group # => "Users"
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Each endpoint lists the arguments it takes:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
endpoint.arguments
|
|
305
|
+
# => [#<WebFunction::Argument ...>]
|
|
306
|
+
|
|
307
|
+
id = endpoint.argument("id")
|
|
308
|
+
id.name # => "id"
|
|
309
|
+
id.type # => a Type, see below
|
|
310
|
+
id.type.to_s # => "string"
|
|
311
|
+
id.required? # => true
|
|
312
|
+
id.optional? # => false
|
|
313
|
+
id.choices # => []
|
|
314
|
+
id.docs # => "Identifier of the user."
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
It also lists the attributes it returns when the return type is an object:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
name = endpoint.attribute("name")
|
|
321
|
+
name.name # => "name"
|
|
322
|
+
name.type # => a Type, see below
|
|
323
|
+
name.type.to_s # => "string"
|
|
324
|
+
name.nullable? # => false
|
|
325
|
+
name.values # => []
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
You can call an endpoint object directly once it belongs to a client:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
endpoint = client.package.endpoint("find-user")
|
|
332
|
+
endpoint.call(id: "123")
|
|
333
|
+
# => { "id" => "123", "name" => "Ada" }
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Types
|
|
337
|
+
|
|
338
|
+
An endpoint's `returns`, an argument's `type`, and an attribute's `type` are all
|
|
339
|
+
`WebFunction::Type` objects rather than plain strings. A type is parsed from the
|
|
340
|
+
package once and gives you a richer view of what a value may look like.
|
|
341
|
+
|
|
342
|
+
Every type responds to `to_s` and `format`, so you can render it however your
|
|
343
|
+
docs need:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
type = endpoint.argument("email").type
|
|
347
|
+
|
|
348
|
+
type.to_s # => "string.email"
|
|
349
|
+
type.format(:compact) # => "email"
|
|
350
|
+
type.format(:base) # => "string"
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
A type also knows how to validate a value against itself. This checks both the
|
|
354
|
+
base type and any refinement, like `email` or `uuid`:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
type.valid?("ada@example.com") # => true
|
|
358
|
+
type.valid?("not-an-email") # => false
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The base types are `string`, `number`, `object`, `boolean`, and `null`.
|
|
362
|
+
`string` and `number` may carry a refinement that narrows the value further:
|
|
363
|
+
|
|
364
|
+
- `string`: `date`, `time`, `datetime`, `uuid`, `base64`, `email`, `phone`,
|
|
365
|
+
`url`, `uri`, `ipv4`, `ipv6`, `hostname`.
|
|
366
|
+
- `number`: `u32`, `u64`, `i32`, `i64`, `f32`, `f64`, `timestamp`.
|
|
367
|
+
|
|
368
|
+
Types compose. A package can declare an array of a type, a union of several
|
|
369
|
+
types, or an open `any` type. A top-level array of type strings is read as a
|
|
370
|
+
union of those types, while a nested array denotes an array whose elements have
|
|
371
|
+
the inner type:
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
WebFunction::Type.parse(["object", "null"]).to_s # => "object | null"
|
|
375
|
+
WebFunction::Type.parse([["string"]]).to_s # => "array<string>"
|
|
376
|
+
WebFunction::Type.parse("array").to_s # => "array<any>"
|
|
377
|
+
WebFunction::Type.parse(nil).to_s # => "any"
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
When a type refers to a named object definition (see below), `objects` lists the
|
|
381
|
+
names it references:
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
WebFunction::Type.parse("object.user").objects # => ["user"]
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Object schemas
|
|
388
|
+
|
|
389
|
+
A package can declare named object definitions under its `objects` key. Any type
|
|
390
|
+
can then refer to one as `object.<name>`. This lets several endpoints share the
|
|
391
|
+
same object shape instead of repeating its fields.
|
|
392
|
+
|
|
393
|
+
List the objects a package defines, or look one up by name:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
package.objects
|
|
397
|
+
# => [#<WebFunction::ObjectSchema ...>]
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
An object may be referenced in two different contexts, and each context uses a
|
|
401
|
+
different set of members:
|
|
402
|
+
|
|
403
|
+
- In an **argument** context (an argument's `type`), its `arguments` describe
|
|
404
|
+
its fields.
|
|
405
|
+
- In an **attribute** context (an endpoint's `returns` or an attribute's
|
|
406
|
+
`type`), its `attributes` describe its fields.
|
|
407
|
+
|
|
408
|
+
Because the same object may appear in both contexts, `object` takes a `context:`
|
|
409
|
+
so you get back the right member set:
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
user = package.object("user", context: :attributes)
|
|
413
|
+
|
|
414
|
+
user.name # => "user"
|
|
415
|
+
user.attributes # => [#<WebFunction::Attribute ...>]
|
|
416
|
+
user.attribute("email").type.to_s # => "string.email"
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
If the object is not defined, or defines no members for the requested context,
|
|
420
|
+
`object` returns `nil`.
|
|
421
|
+
|
|
422
|
+
## Error handling
|
|
423
|
+
|
|
424
|
+
Every error this gem raises inherits from `WebFunction::Error`. Each error
|
|
425
|
+
carries a `code` and optional `details`.
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
begin
|
|
429
|
+
client.find_user(id: "missing")
|
|
430
|
+
rescue WebFunction::Error => e
|
|
431
|
+
e.code # => "USER_NOT_FOUND"
|
|
432
|
+
e.message # => "No user with that id."
|
|
433
|
+
e.details # => { "id" => "missing" }
|
|
434
|
+
end
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
These are the error classes:
|
|
438
|
+
|
|
439
|
+
| Class | Raised when |
|
|
440
|
+
| --- | --- |
|
|
441
|
+
| `WebFunction::BadRequestError` | The server replied with status `400`. |
|
|
442
|
+
| `WebFunction::UnexpectedStatusCodeError` | The server replied with a status other than `200` or `400`. |
|
|
443
|
+
| `WebFunction::JsonParseError` | The response body was not valid JSON. |
|
|
444
|
+
| `WebFunction::UnresolvedPromiseError` | A pipeline promise was read before it resolved. |
|
|
445
|
+
|
|
446
|
+
When the server returns a `400`, the body is an error triple. A triple is a
|
|
447
|
+
JSON array with three parts: a code, a message, and details. The gem reads the
|
|
448
|
+
triple and fills in the error:
|
|
449
|
+
|
|
450
|
+
```json
|
|
451
|
+
["USER_NOT_FOUND", "No user with that id.", { "id": "missing" }]
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
```ruby
|
|
455
|
+
rescue WebFunction::BadRequestError => e
|
|
456
|
+
e.code # => "USER_NOT_FOUND"
|
|
457
|
+
e.message # => "No user with that id."
|
|
458
|
+
e.details # => { "id" => "missing" }
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
If the body is not a triple, the gem still raises `BadRequestError` with the
|
|
462
|
+
code `WFN_BAD_REQUEST_ERROR` and puts the raw body in `details`.
|
|
463
|
+
|
|
464
|
+
An endpoint can document the errors it may return. Read them when the endpoint
|
|
465
|
+
uses the `error_triple` flag:
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
endpoint.errors
|
|
469
|
+
# => [#<WebFunction::DocumentedError code="USER_NOT_FOUND" ...>]
|
|
470
|
+
|
|
471
|
+
error = endpoint.error("USER_NOT_FOUND")
|
|
472
|
+
error.code # => "USER_NOT_FOUND"
|
|
473
|
+
error.docs # => "Returned when no user matches the id."
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
The package can document shared errors too:
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
package.errors
|
|
480
|
+
package.error("RATE_LIMITED")
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
## Pipelining
|
|
484
|
+
|
|
485
|
+
Pipelining sends several calls in one HTTP request. The server runs them in
|
|
486
|
+
order and you can feed the output of one call into the next. This cuts the
|
|
487
|
+
number of round trips.
|
|
488
|
+
|
|
489
|
+
Build a pipelined client and each call returns a `WebFunction::Promise` instead
|
|
490
|
+
of a value. A promise stands in for a result that does not exist yet:
|
|
491
|
+
|
|
492
|
+
```ruby
|
|
493
|
+
client = WebFunction::Client.from_package_endpoint(
|
|
494
|
+
"https://api.example.com/package",
|
|
495
|
+
pipelined: true,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
user = client.find_user(id: "123") # => a Promise
|
|
499
|
+
order = client.create_order(user_id: user["id"]) # uses the first result
|
|
500
|
+
|
|
501
|
+
order.resolve
|
|
502
|
+
# => { "id" => "order-1", "user_id" => "123" }
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Reading `user["id"]` before the call runs does not return a value. It returns a
|
|
506
|
+
path into the future result. The gem sends that path to the server, and the
|
|
507
|
+
server fills it in when it runs the second call. Calling `resolve` runs the
|
|
508
|
+
whole pipeline and returns the value.
|
|
509
|
+
|
|
510
|
+
Once a pipeline runs, every promise from that batch holds its value:
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
user.resolve # runs the pipeline
|
|
514
|
+
order.value # already available, no extra request
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
You can also drive a pipeline by hand with `WebFunction::Pipeline`. Each step is
|
|
518
|
+
a hash with a `url`, `headers`, and `body`:
|
|
519
|
+
|
|
520
|
+
```ruby
|
|
521
|
+
pipeline = WebFunction::Pipeline.new("https://api.example.com/run-pipeline")
|
|
522
|
+
|
|
523
|
+
pipeline.add_step(url: "https://api.example.com/a", headers: {}, body: {})
|
|
524
|
+
pipeline.add_step(url: "https://api.example.com/b", headers: {}, body: {})
|
|
525
|
+
|
|
526
|
+
pipeline.execute(returns: :all)
|
|
527
|
+
# => [{ "a" => 1 }, { "b" => 2 }]
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
The `returns` option controls what comes back:
|
|
531
|
+
|
|
532
|
+
- `:all` returns every step result as an array. This is the default.
|
|
533
|
+
- `:last` returns only the last step result.
|
|
534
|
+
- A JSONPath string returns the value at that path, for example `"$[0].id"`.
|
|
535
|
+
|
|
536
|
+
## Custom HTTP client
|
|
537
|
+
|
|
538
|
+
By default the gem makes requests with [Excon](https://github.com/excon/excon).
|
|
539
|
+
You can swap in any HTTP client by setting `WebFunction::Request.http_client` to
|
|
540
|
+
an object that responds to `call`.
|
|
541
|
+
|
|
542
|
+
The object receives the URL, the headers, and the JSON body. It must return a
|
|
543
|
+
two-element array of the status code and the raw response body:
|
|
544
|
+
|
|
545
|
+
```ruby
|
|
546
|
+
WebFunction::Request.http_client = ->(url, headers, body) do
|
|
547
|
+
response = MyHttp.post(url, headers: headers, body: body)
|
|
548
|
+
[response.status, response.body]
|
|
549
|
+
end
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
This is also handy in tests, where you can return a canned response without
|
|
553
|
+
making a real request:
|
|
554
|
+
|
|
555
|
+
```ruby
|
|
556
|
+
WebFunction::Request.http_client = ->(_url, _headers, _body) do
|
|
557
|
+
[200, JSON.generate({ "id" => "123" })]
|
|
558
|
+
end
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
## Low-level requests
|
|
562
|
+
|
|
563
|
+
If you do not need a package, you can call a single endpoint URL directly with
|
|
564
|
+
`WebFunction::Request`:
|
|
565
|
+
|
|
566
|
+
```ruby
|
|
567
|
+
WebFunction::Request.execute(
|
|
568
|
+
"https://api.example.com/find-user",
|
|
569
|
+
bearer_auth: "my-token",
|
|
570
|
+
version: "2024-01-01",
|
|
571
|
+
args: { id: "123" },
|
|
572
|
+
)
|
|
573
|
+
# => { "id" => "123", "name" => "Ada" }
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
The request adds the standard headers, posts the JSON body, and parses the
|
|
577
|
+
response. It raises the same errors described in [Error handling](#error-handling).
|
|
578
|
+
|
|
579
|
+
## Development
|
|
580
|
+
|
|
581
|
+
After you check out the repo, run `bin/setup` to install dependencies. Then run
|
|
582
|
+
`rake test` to run the tests. Run `bin/console` for a prompt where you can
|
|
583
|
+
experiment with the code.
|
|
584
|
+
|
|
585
|
+
To install the gem on your machine, run `bundle exec rake install`. To release a
|
|
586
|
+
new version, update the version in `lib/webfunction/version.rb`, then run
|
|
587
|
+
`bundle exec rake release`. This creates a git tag, pushes the commits and the
|
|
588
|
+
tag, and pushes the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
589
|
+
|
|
590
|
+
## Contributing
|
|
591
|
+
|
|
592
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
593
|
+
[github.com/webfunction-protocol/webfunction-ruby](https://github.com/webfunction-protocol/webfunction-ruby).
|
|
594
|
+
|
|
595
|
+
## License
|
|
596
|
+
|
|
597
|
+
This gem is open source under the terms of the
|
|
598
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/lib/web_function.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require_relative "webfunction"
|