petail 0.3.0 → 0.5.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: e974c64e0259b3561130ac6d4ea4a7bae80fa381e9ab7ee7cb3ab0338d9fe2c4
4
- data.tar.gz: 6e99b146485fd8da57711ed81512ee599b10797fbc5cfb2072941927f78bd640
3
+ metadata.gz: 417bd45430e7d40be79470dcd1a09f6fbab6ca729a893c02568f944b67592cd5
4
+ data.tar.gz: 328809222adc5db346e50b7b144a98c3fd4c0f6e32fef701ee03f7d65a33b210
5
5
  SHA512:
6
- metadata.gz: 4f6aa6a580106976c75f5f7db20362ffad7be1c6d25c1c55ed19dafe8c7ea7b50387213dcac51395d8cb9f6fe9aa5160bd1879fb20b9cbde9b48dedfdbe63822
7
- data.tar.gz: a51c4609448bc36218c226c4f71a1b3fd99d46d4d16c666c4b009d76f1020ec2ee2634f156fbbaecd6012c9f4149409d45d44d00e378e5ecbc1eef1037db9bb9
6
+ metadata.gz: c01be4f0f48a6b5f9b4622b8f133102a8cc0abe073c9176827748b8848f1208c7682590fd52ae0734a696e6cd2fa3d5beb7fc338fc520bc7afaef9cb55eb9a9c
7
+ data.tar.gz: 1c684b3c8e69e3a9c54b627226e2c3ebdecafe1cf013b70f69ac0c6582a5ff4394e201ece321eee3e1115b7fa8573e7d234edc97e20c5b609a770b05cdcc7fad
checksums.yaml.gz.sig CHANGED
Binary file
data/README.adoc CHANGED
@@ -86,6 +86,17 @@ payload.to_xml
86
86
 
87
87
  💡 You can also use `Petail.new` to create instances if you don't like `Petail.[]`, as shown above, but `.[]` is preferred.
88
88
 
89
+ === Members
90
+
91
+ As briefly shown above, the minimum members (attributes) that make up problem details are:
92
+
93
+ * `type` (optional): The full (or relative) URI that links to additional documentation. Default: `"about:blank"`.
94
+ * `status` (optional): The link:https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status[HTTP status code] (or symbol) that must match your HTTP status code. Default: `nil`.
95
+ * `title` (optional): The HTTP status label that must match your HTTP status code label. Default: HTTP status label (dynamically computed based on code unless overwritten).
96
+ * `detail` (optional): The human readable reason for the error (should not include debugging information). Default: `nil`.
97
+ * `instance` (optional): The full (or relative) URI that represents the cause of the error. Default: `nil`.
98
+ * `extensions` (optional): A free form hash of additional details. Default: `{}`.
99
+
89
100
  === Media Types
90
101
 
91
102
  For convenience, you can obtain the necessary media types for your HTTP headers as follows:
@@ -170,7 +181,7 @@ Both serialization and deserialization of JSON is supported. For example, given
170
181
  [source,ruby]
171
182
  ----
172
183
  payload = Petail[
173
- type: "https://test.io/problem_details/out_of_credit",
184
+ type: "https://demo.io/problem_details/out_of_credit",
174
185
  title: "You do not have enough credit.",
175
186
  status: 403,
176
187
  detail: "Your current balance is 30, but that costs 50.",
@@ -187,22 +198,20 @@ This means you can serialize as follows:
187
198
  [source,ruby]
188
199
  ----
189
200
  payload.to_json
190
- # {"type":"https://test.io/problem_details/out_of_credit","title":"You do not have enough credit.","status":403,"detail":"Your current balance is 30, but that costs 50.","instance":"/accounts/1","extensions":{"balance":30,"accounts":["/accounts/1","/accounts/10"]}}
201
+ # "{\"type\":\"https://demo.io/problem_details/out_of_credit\",\"title\":\"You do not have enough credit.\",\"status\":403,\"detail\":\"Your current balance is 30, but that costs 50.\",\"instance\":\"/accounts/1\",\"balance\":30,\"accounts\":[\"/accounts/1\",\"/accounts/10\"]}"
191
202
 
192
203
  payload.to_json indent: " ", space: " ", object_nl: "\n", array_nl: "\n"
193
204
  # {
194
- # "type": "https://test.io/problem_details/out_of_credit",
205
+ # "type": "https://demo.io/problem_details/out_of_credit",
195
206
  # "title": "You do not have enough credit.",
196
207
  # "status": 403,
197
208
  # "detail": "Your current balance is 30, but that costs 50.",
198
209
  # "instance": "/accounts/1",
199
- # "extensions": {
200
- # "balance": 30,
201
- # "accounts": [
202
- # "/accounts/1",
203
- # "/accounts/10"
204
- # ]
205
- # }
210
+ # "balance": 30,
211
+ # "accounts": [
212
+ # "/accounts/1",
213
+ # "/accounts/10"
214
+ # ]
206
215
  # }
207
216
  ----
208
217
 
@@ -212,7 +221,7 @@ You can also deserialize by taking the result of the above and turning the raw J
212
221
 
213
222
  [source,ruby]
214
223
  ----
215
- Petail.from_json "{\"type\":\"https://test.io/problem_details/out_of_credit\",\"title\":\"You do not have enough credit.\",\"status\":403,\"detail\":\"Your current balance is 30, but that costs 50.\",\"instance\":\"/accounts/1\",\"extensions\":{\"balance\":30,\"accounts\":[\"/accounts/1\",\"/accounts/10\"]}}"
224
+ Petail.from_json "{\"type\":\"https://demo.io/problem_details/out_of_credit\",\"title\":\"You do not have enough credit.\",\"status\":403,\"detail\":\"Your current balance is 30, but that costs 50.\",\"instance\":\"/accounts/1\",\"balance\":30,\"accounts\":[\"/accounts/1\",\"/accounts/10\"]}"
216
225
 
217
226
  # #<Struct:Petail::Payload:0x00007670
218
227
  # detail = "Your current balance is 30, but that costs 50.",
@@ -226,7 +235,7 @@ Petail.from_json "{\"type\":\"https://test.io/problem_details/out_of_credit\",\"
226
235
  # instance = "/accounts/1",
227
236
  # status = 403,
228
237
  # title = "You do not have enough credit.",
229
- # type = "https://test.io/problem_details/out_of_credit"
238
+ # type = "https://demo.io/problem_details/out_of_credit"
230
239
  # >
231
240
  ----
232
241
 
@@ -237,7 +246,7 @@ XML is supported too but isn't as robust as JSON support, at the moment. This is
237
246
  [source,ruby]
238
247
  ----
239
248
  payload = Petail[
240
- type: "https://test.io/problem_details/out_of_credit",
249
+ type: "https://demo.io/problem_details/out_of_credit",
241
250
  title: "You do not have enough credit.",
242
251
  status: 403,
243
252
  detail: "Your current balance is 30, but that costs 50.",
@@ -254,13 +263,13 @@ This means you can serialize as follows:
254
263
  [source,ruby]
255
264
  ----
256
265
  payload.to_xml
257
- # "<?xml version='1.0' encoding='UTF-8'?><problem xmlns='urn:ietf:rfc:7807'><type>https://test.io/problem_details/out_of_credit</type><title>You do not have enough credit.</title><status>403</status><detail>Your current balance is 30, but that costs 50.</detail><instance>/accounts/1</instance><balance>30</balance><accounts><i>/accounts/1</i><i>/accounts/10</i></accounts></problem>"
266
+ # "<?xml version='1.0' encoding='UTF-8'?><problem xmlns='urn:ietf:rfc:7807'><type>https://demo.io/problem_details/out_of_credit</type><title>You do not have enough credit.</title><status>403</status><detail>Your current balance is 30, but that costs 50.</detail><instance>/accounts/1</instance><balance>30</balance><accounts><i>/accounts/1</i><i>/accounts/10</i></accounts></problem>"
258
267
 
259
268
  payload.to_xml indent: 2
260
269
  # <?xml version='1.0' encoding='UTF-8'?>
261
270
  # <problem xmlns='urn:ietf:rfc:7807'>
262
271
  # <type>
263
- # https://test.io/problem_details/out_of_credit
272
+ # https://demo.io/problem_details/out_of_credit
264
273
  # </type>
265
274
  # <title>
266
275
  # You do not have enough credit.
@@ -297,7 +306,7 @@ You can also deserialize by taking the result of the above and turning the raw J
297
306
  payload = Petail.from_xml <<~XML
298
307
  <?xml version='1.0' encoding='UTF-8'?>
299
308
  <problem xmlns='urn:ietf:rfc:7807'>
300
- <type>https://test.io/problem_details/out_of_credit</type>
309
+ <type>https://demo.io/problem_details/out_of_credit</type>
301
310
  <title>You do not have enough credit.</title>
302
311
  <status>403</status>
303
312
  <detail>Your current balance is 30, but that costs 50.</detail>
@@ -322,10 +331,75 @@ XML
322
331
  # instance = "/accounts/1",
323
332
  # status = 403,
324
333
  # title = "You do not have enough credit.",
325
- # type = "https://test.io/problem_details/out_of_credit"
334
+ # type = "https://demo.io/problem_details/out_of_credit"
326
335
  # >
327
336
  ----
328
337
 
338
+ === Examples
339
+
340
+ There is a lot of useful information you can provide in your problem details depending on the context you are working in. Some have been shown above but here's a few more that might be of interest.
341
+
342
+ ==== HATEOAS
343
+
344
+ With link:https://nordicapis.com/tools-to-make-hateoas-compliance-easier[HATEOAS], you can provide additional information and links for which the client can understand what next actions are available. The below example shows how you can provide additional resources for clients to adjust accordingly:
345
+
346
+ [source,ruby]
347
+ ----
348
+ Petail[
349
+ type: "https://demo.io/problem_details/rate_limit",
350
+ title: "Rate limit exceeded",
351
+ status: 429,
352
+ detail: "You have exceeded your rate limit of 150 requests per minute",
353
+ instance: "/articles",
354
+ extensions: {
355
+ retry_after: 5,
356
+ links: [
357
+ {
358
+ ref: "self",
359
+ href: "/articles"
360
+ },
361
+ {
362
+ rel: "retry",
363
+ href: "/articles",
364
+ title: "Retry after five minutes"
365
+ },
366
+ {
367
+ rel: "status",
368
+ href: "/statuses/rate_limit",
369
+ title: "Check current rate limit usage"
370
+ }
371
+ ]
372
+ }
373
+ ]
374
+ ----
375
+
376
+ ==== Semantic Structure
377
+
378
+ In other situations, you might need a different structure in order to aid clients that might be AI driven which needs a semantically structured response in order to course correct. Example:
379
+
380
+ [source,ruby]
381
+ ----
382
+ Petail[
383
+ type: "https://demo.io/problem_details/invalid_field",
384
+ title: "Invalid field value",
385
+ status: 400,
386
+ detail: "The category requested doesn't exist",
387
+ instance: "/categories",
388
+ extensions: {
389
+ parameters: {
390
+ category_id: 123
391
+ },
392
+ suggestions: [
393
+ "ruby",
394
+ "git",
395
+ "htmx"
396
+ ]
397
+ }
398
+ ]
399
+ ----
400
+
401
+ With the above, the client now knows what parameters where invalid along with relevant suggestions for proceeding. Even better, the suggestions implicitly show the types of IDs that are required.
402
+
329
403
  == Development
330
404
 
331
405
  To contribute, run:
@@ -353,6 +427,13 @@ To test, run:
353
427
  bin/rake
354
428
  ----
355
429
 
430
+ == Resources
431
+
432
+ You can find additional resources here:
433
+
434
+ * link:https://www.iana.org/assignments/http-problem-types/http-problem-types.xhtml[IANA Hypertext Transfer Protocol (HTTP) Problem Types]: A registered list of problem types you can use.
435
+ * link:https://github.com/protocol-registries/http-problem-types[HTTP Problem Type Registration Requests]: Where you can register new problem types.
436
+
356
437
  == link:https://alchemists.io/policies/license[License]
357
438
 
358
439
  == link:https://alchemists.io/policies/security[Security]
@@ -5,8 +5,10 @@ require "rack/utils"
5
5
  require "rexml"
6
6
 
7
7
  module Petail
8
+ PRIMARY_KEYS = %i[type title status detail instance].freeze
9
+
8
10
  # Models the problem details response payload.
9
- Payload = Struct.new :type, :title, :status, :detail, :instance, :extensions do
11
+ Payload = Struct.new(*PRIMARY_KEYS, :extensions) do
10
12
  def self.for(**attributes)
11
13
  status = attributes.delete(:status).then { Rack::Utils.status_code it if it }
12
14
  title = attributes.delete(:title).then { it || Rack::Utils::HTTP_STATUS_CODES[status] }
@@ -14,18 +16,23 @@ module Petail
14
16
  new title:, status:, **attributes
15
17
  end
16
18
 
17
- def self.from_json(body) = self.for(**JSON(body, symbolize_names: true))
19
+ def self.from_json body
20
+ attributes = JSON body, symbolize_names: true
21
+ extensions = attributes.reject { |key| PRIMARY_KEYS.include? key }
22
+
23
+ self.for(**attributes.slice(*PRIMARY_KEYS), extensions:)
24
+ end
18
25
 
19
26
  # :reek:TooManyStatements
20
27
  def self.from_xml body, deserializer: XML::Deserializer
21
28
  elements = REXML::Document.new(body).root.elements
22
29
 
23
30
  attributes = elements.each_with_object({extensions: {}}) do |element, collection|
24
- name = element.name
31
+ name = element.name.to_sym
25
32
  text = element.text
26
33
 
27
34
  case name
28
- when "type", "title", "detail", "instance", "status" then collection[name.to_sym] = text
35
+ when *PRIMARY_KEYS then collection[name] = text
29
36
  else collection[:extensions].merge! deserializer.call(element)
30
37
  end
31
38
  end
@@ -48,11 +55,12 @@ module Petail
48
55
 
49
56
  def extension?(name) = extensions.key? name
50
57
 
51
- def to_h = super.compact.tap { it.delete :extensions if extensions.empty? }
58
+ def to_h = {type:, title:, status:, detail:, instance:, **extensions}.compact
52
59
 
53
60
  def to_json(*) = to_h.to_json(*)
54
61
 
55
62
  # :reek:TooManyStatements
63
+ # :reek:FeatureEnvy
56
64
  def to_xml(serializer: XML::Serializer, **options)
57
65
  document = REXML::Document.new
58
66
  document.add REXML::XMLDecl.new("1.0", "UTF-8")
@@ -60,9 +68,7 @@ module Petail
60
68
  problem = REXML::Element.new("problem").add_namespace("urn:ietf:rfc:7807")
61
69
  document.add problem
62
70
 
63
- attributes = to_h
64
- attributes.merge! attributes.delete :extensions if extensions.any?
65
- attributes.each { |name, value| serializer.call name, value, problem }
71
+ to_h.each { |name, value| serializer.call name, value, problem }
66
72
 
67
73
  "".dup.tap { document.write(**options, output: it) }
68
74
  end
data/petail.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "petail"
5
- spec.version = "0.3.0"
5
+ spec.version = "0.5.0"
6
6
  spec.authors = ["Brooke Kuhlmann"]
7
7
  spec.email = ["brooke@alchemists.io"]
8
8
  spec.homepage = "https://alchemists.io/projects/petail"
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: petail
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brooke Kuhlmann
@@ -109,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
109
  - !ruby/object:Gem::Version
110
110
  version: '0'
111
111
  requirements: []
112
- rubygems_version: 3.6.9
112
+ rubygems_version: 3.7.1
113
113
  specification_version: 4
114
114
  summary: A RFC 9457 Problem Details for HTTP APIs implementation.
115
115
  test_files: []
metadata.gz.sig CHANGED
Binary file