http_resource 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae3a2c83649ab50e44f3b85b70e223903788c31b8c1d3e738d87ae4154f1e716
4
- data.tar.gz: 4807d9724ace0ad3b1cddedaa74c693454c4f1d0b29591a7f655228ea8a09a71
3
+ metadata.gz: 6d9fe8b0aeb930b24273f354fa958c855392cbc7169178aff012fc55c9349bc0
4
+ data.tar.gz: c59a5d2b61d6b759f494fc15619b14a26f1040425c6d1d9eb43c90a67218e2d8
5
5
  SHA512:
6
- metadata.gz: 5b6bffc9f723e5ad0e7c2c15917dd7a18b9064719c5b1c128f3aebe6f1d601390db7b83ed1c7abffe1e047e2293d3ee7f9524c85c0501d3e08ea2d67f05f7e72
7
- data.tar.gz: bed8b7f03efd23d9f52a543bc07e6b0cd16b1429ce5df9f6373f29ab51fbdc100684720e47fb16fcc4c10d7489db70177a471a0cca67b97ae9f2c1e1147a5e78
6
+ metadata.gz: 882a09948d72787115e9dde41bf305269355f16bc431e929e2b13d7d28372a48fbaa695682b0e73be030412d1e402cde6fe339d946db1991317e3e19be4e16d7
7
+ data.tar.gz: e12dcb9fbb52ea78a319a1fa19c27f906a88c66079e03f0ec3f97dc358e7dafc48772a329509934c24cc9475809d58915d81614c8c06a10e75af886b9a672868
data/README.md CHANGED
@@ -54,6 +54,7 @@ client = HttpResource::Client.new(
54
54
 
55
55
  client.get(["api", "contacts", id]) # GET, id escaped as ONE path segment
56
56
  client.post(["api", "actions"], { foo: 1 }) # POST a JSON body
57
+ client.put(["api", "contacts", id], { name: "Anna" })
57
58
  client.patch(["api", "contacts", email], { name: "Anna" })
58
59
  client.delete(["api", "contacts", id])
59
60
  ```
@@ -65,6 +66,37 @@ Reads return parsed JSON (a `Hash`/`Array`, or `nil` on an empty body). Every
65
66
  call raises an `HttpResource::ApiError` subclass on a non-2xx response or a
66
67
  transport failure.
67
68
 
69
+ ### Form-encoded bodies (OAuth)
70
+
71
+ `post`/`put`/`patch` take a `form:` keyword to send an
72
+ `application/x-www-form-urlencoded` body instead of JSON — for the form-encoded
73
+ endpoints OAuth consumers hit (RFC 6749 token, RFC 7662 introspection, …):
74
+
75
+ ```ruby
76
+ token = client.post(["oauth", "token"], form: {
77
+ grant_type: "client_credentials",
78
+ scope: "read write"
79
+ })
80
+ token["access_token"] # a 2xx still returns parsed JSON
81
+ ```
82
+
83
+ The response side is identical to a JSON call — parsed JSON on a 2xx, a typed
84
+ `ApiError` on a non-2xx — so an OAuth failure is just a rescue-able error whose
85
+ `#body` carries the payload:
86
+
87
+ ```ruby
88
+ begin
89
+ client.post(["oauth", "token"], form: { grant_type: "authorization_code", code: bad })
90
+ rescue HttpResource::ClientError => e
91
+ e.status # => 400
92
+ JSON.parse(e.body)["error"] # => "invalid_grant"
93
+ end
94
+ ```
95
+
96
+ Pass **either** a JSON `payload` **or** `form:`, never both (it raises
97
+ `ArgumentError`). Form keys/values are percent-encoded, so untrusted input can't
98
+ inject a header or an extra field.
99
+
68
100
  ### A process-wide default client
69
101
 
70
102
  ```ruby
@@ -213,6 +245,22 @@ A **`String`** path is the trusted escape hatch and is sent **verbatim** — so
213
245
  let the framework encode it. The guarantee is covered by a dedicated,
214
246
  adversarial spec (`spec/escape_safety_spec.rb`).
215
247
 
248
+ ## Changelog
249
+
250
+ ### 0.2.0
251
+
252
+ - Add a `form:` keyword to `post`/`put`/`patch` for `application/x-www-form-urlencoded`
253
+ bodies (OAuth token/introspection and other form-encoded endpoints). Responses
254
+ stay resty: parsed JSON on a 2xx, a typed `ApiError` (with `#body`) on a non-2xx.
255
+ - Add a first-class `put` verb (JSON or `form:` body).
256
+ - Passing both a JSON `payload` and `form:` raises `ArgumentError`; the bodyless
257
+ verbs (`get`/`delete`) reject `form:`.
258
+
259
+ ### 0.1.0
260
+
261
+ - Initial release: Net::HTTP transport, typed `ApiError` hierarchy, bang/non-bang
262
+ resources, pluggable auth, per-call timeouts, escape-safe URL building.
263
+
216
264
  ## Development
217
265
 
218
266
  ```sh
@@ -8,13 +8,14 @@ require "openssl"
8
8
 
9
9
  module HttpResource
10
10
  # Net::HTTP transport for a single REST host. Resource-oriented: the verbs
11
- # (get/post/patch/delete) are the primitives a Resource is built on, and also
12
- # an escape hatch for endpoints not yet modelled.
11
+ # (get/post/put/patch/delete) are the primitives a Resource is built on, and
12
+ # also an escape hatch for endpoints not yet modelled.
13
13
  #
14
14
  # client = HttpResource::Client.new(base_url: "https://api.example.org",
15
15
  # auth: HttpResource::Auth.bearer(token))
16
- # client.get(["api", "contacts", id]) # GET, id escaped as one segment
17
- # client.post(["api", "actions"], { ... }) # POST a JSON body
16
+ # client.get(["api", "contacts", id]) # GET, id escaped as one segment
17
+ # client.post(["api", "actions"], { ... }) # POST a JSON body
18
+ # client.post(["oauth", "token"], form: { ... }) # POST a form body (OAuth, RFC 6749)
18
19
  #
19
20
  # Reads return parsed JSON (a Hash/Array, or nil on an empty body). Every call
20
21
  # raises an HttpResource::ApiError subclass on a non-2xx response or a transport
@@ -45,31 +46,45 @@ module HttpResource
45
46
  # an Array of segments (["api", "contacts", email]) each individually escaped.
46
47
  # Each verb accepts open_timeout:/read_timeout: to override the client's
47
48
  # budget for that one call (e.g. a short read_timeout on a synchronous read).
48
- def get(path, params: nil, **timeouts)
49
- request(:get, path, params:, **timeouts)
49
+ #
50
+ # The body-bearing verbs (post/put/patch) send EITHER a JSON body — the
51
+ # positional `payload` — or a form body — `form: {...}`, encoded as
52
+ # application/x-www-form-urlencoded (for the form-encoded endpoints OAuth
53
+ # consumers hit: RFC 6749 token, RFC 7662 introspection, …). Passing both is
54
+ # a caller bug and raises ArgumentError. The response side is identical either
55
+ # way: parsed JSON on a 2xx, a typed ApiError (with #status + #body) on a
56
+ # non-2xx — so a "400 invalid_grant" is a rescue-able ClientError whose #body
57
+ # carries the error payload.
58
+ def get(path, params: nil, open_timeout: nil, read_timeout: nil)
59
+ request(:get, path, params:, open_timeout:, read_timeout:)
50
60
  end
51
61
 
52
- def post(path, payload = nil, **timeouts)
53
- request(:post, path, body: payload, **timeouts)
62
+ def post(path, payload = nil, form: nil, open_timeout: nil, read_timeout: nil)
63
+ request(:post, path, body: payload, form:, open_timeout:, read_timeout:)
54
64
  end
55
65
 
56
- def patch(path, payload = nil, **timeouts)
57
- request(:patch, path, body: payload, **timeouts)
66
+ def put(path, payload = nil, form: nil, open_timeout: nil, read_timeout: nil)
67
+ request(:put, path, body: payload, form:, open_timeout:, read_timeout:)
58
68
  end
59
69
 
60
- def delete(path, **timeouts)
61
- request(:delete, path, **timeouts)
70
+ def patch(path, payload = nil, form: nil, open_timeout: nil, read_timeout: nil)
71
+ request(:patch, path, body: payload, form:, open_timeout:, read_timeout:)
72
+ end
73
+
74
+ def delete(path, open_timeout: nil, read_timeout: nil)
75
+ request(:delete, path, open_timeout:, read_timeout:)
62
76
  end
63
77
 
64
78
  private
65
79
 
66
- def request(method, path, body: nil, params: nil, open_timeout: nil, read_timeout: nil)
80
+ def request(method, path, body: nil, form: nil, params: nil, open_timeout: nil, read_timeout: nil)
67
81
  # Build the URI + request OUTSIDE the network rescue: a URI::InvalidURIError
68
- # (bad path) or JSON::GeneratorError (un-serializable payload, e.g. a NaN
69
- # amount) is a deterministic caller bug, and must NOT be masked as a
70
- # retryable TransportError that would have a worker retry it forever.
82
+ # (bad path), JSON::GeneratorError (un-serializable payload, e.g. a NaN
83
+ # amount) or an ArgumentError (both a JSON and a form body) is a
84
+ # deterministic caller bug, and must NOT be masked as a retryable
85
+ # TransportError — that would have a worker retry it forever.
71
86
  uri = build_uri(path, params)
72
- req = build_request(method, uri, body)
87
+ req = build_request(method, uri, body, form)
73
88
  connection = http(uri, open_timeout:, read_timeout:)
74
89
  begin
75
90
  handle(connection.request(req))
@@ -116,19 +131,32 @@ module HttpResource
116
131
  ERB::Util.url_encode(str)
117
132
  end
118
133
 
119
- def build_request(method, uri, body)
134
+ def build_request(method, uri, body, form = nil)
120
135
  klass = {
121
- get: Net::HTTP::Get, post: Net::HTTP::Post,
136
+ get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put,
122
137
  patch: Net::HTTP::Patch, delete: Net::HTTP::Delete
123
138
  }.fetch(method)
124
139
  request = klass.new(uri)
125
140
  @auth&.apply(request)
126
141
  request["Accept"] = "application/json"
127
- if body
142
+ apply_body(request, body, form)
143
+ request
144
+ end
145
+
146
+ # A request carries EITHER a JSON body (`payload`) or a form body (`form:`),
147
+ # never both — passing both is a caller bug. Form values are percent-encoded
148
+ # by URI.encode_www_form (the same encoder used for query params), so an
149
+ # untrusted key/value can't inject a header, a second field, or CRLF.
150
+ def apply_body(request, body, form)
151
+ raise ArgumentError, "pass either a JSON payload or form:, not both" if body && form
152
+
153
+ if form
154
+ request["Content-Type"] = "application/x-www-form-urlencoded"
155
+ request.body = URI.encode_www_form(form)
156
+ elsif body
128
157
  request["Content-Type"] = "application/json"
129
158
  request.body = JSON.generate(body)
130
159
  end
131
- request
132
160
  end
133
161
 
134
162
  def http(uri, open_timeout: nil, read_timeout: nil)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HttpResource
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: http_resource
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Skiftet