halchemy 1.0.2

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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.idea/.gitignore +8 -0
  3. data/.idea/halchemy.iml +69 -0
  4. data/.idea/icon.png +0 -0
  5. data/.idea/inspectionProfiles/Project_Default.xml +36 -0
  6. data/.idea/modules.xml +8 -0
  7. data/.idea/vcs.xml +6 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +5 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +43 -0
  12. data/Rakefile +12 -0
  13. data/__tests__/common.rb +97 -0
  14. data/__tests__/configurable/base_url.rb +46 -0
  15. data/__tests__/configurable/error_handling.rb +75 -0
  16. data/__tests__/configurable/headers.rb +92 -0
  17. data/__tests__/make_http_requests/follow_link_relations.rb +86 -0
  18. data/__tests__/make_http_requests/http_response_details.rb +60 -0
  19. data/__tests__/make_http_requests/optimistic_concurrency.rb +38 -0
  20. data/__tests__/make_http_requests/with_headers.rb +32 -0
  21. data/__tests__/make_http_requests/with_parameters.rb +32 -0
  22. data/__tests__/make_http_requests/with_templates.rb +45 -0
  23. data/__tests__/use_resources/iterate_collections.rb +89 -0
  24. data/bdd +1 -0
  25. data/lib/halchemy/api.rb +150 -0
  26. data/lib/halchemy/error_handling.rb +13 -0
  27. data/lib/halchemy/follower.rb +20 -0
  28. data/lib/halchemy/http_model.rb +41 -0
  29. data/lib/halchemy/metadata.rb +20 -0
  30. data/lib/halchemy/requester.rb +181 -0
  31. data/lib/halchemy/resource.rb +68 -0
  32. data/lib/halchemy/status_codes.rb +60 -0
  33. data/lib/halchemy/version.rb +5 -0
  34. data/lib/halchemy.rb +8 -0
  35. data/sig/__tests__/base_url.rbs +1 -0
  36. data/sig/__tests__/halchemy.rbs +4 -0
  37. data/sig/__tests__/headers.rbs +1 -0
  38. data/sig/__tests__/remove_headers.rbs +1 -0
  39. data/sig/halchemy/api.rbs +54 -0
  40. data/sig/halchemy/base_requester.rbs +35 -0
  41. data/sig/halchemy/error_handling.rbs +6 -0
  42. data/sig/halchemy/follower.rbs +9 -0
  43. data/sig/halchemy/hal_resource.rbs +13 -0
  44. data/sig/halchemy/http_model/request.rbs +14 -0
  45. data/sig/halchemy/http_model/response.rbs +15 -0
  46. data/sig/halchemy/metadata.rbs +9 -0
  47. data/sig/halchemy/read_only_requester.rbs +9 -0
  48. data/sig/halchemy/requester.rbs +18 -0
  49. data/sig/halchemy/resource.rbs +5 -0
  50. data/sig/list_style_handlers.rbs +1 -0
  51. data/sig/matchers.rbs +1 -0
  52. data/sig/patterns.rbs +1 -0
  53. metadata +93 -0
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri_template"
4
+
5
+ LIST_STYLE_HANDLERS = {
6
+ "repeat_key" => ->(key, array) { array.map { |item| [key, URI.encode_www_form_component(item.to_s)] } },
7
+ "bracket" => ->(key, array) { array.map { |item| ["#{key}[]", URI.encode_www_form_component(item.to_s)] } },
8
+ "index" => lambda { |key, array|
9
+ array.each_with_index.map do |item, index|
10
+ ["#{key}[#{index}]", URI.encode_www_form_component(item.to_s)]
11
+ end
12
+ },
13
+ "comma" => ->(key, array) { [[key, array.map { |item| URI.encode_www_form_component(item.to_s) }.join(",")]] },
14
+ "pipe" => ->(key, array) { [[key, array.map { |item| URI.encode_www_form_component(item.to_s) }.join("|")]] }
15
+ }.freeze
16
+
17
+ module Halchemy
18
+ # The results of a Follower#to is a Requester. In the case of a GET for the root resource, the Requester is Read Only
19
+ # Otherwise it is a full Requester. Both requester types share much in common. This is defined in BaseRequester
20
+ class BaseRequester
21
+ # @param [Halchemy::Api] api
22
+ # @param [String | Tuple[HalResource, String]] target
23
+ # @return [void]
24
+ def initialize(api, target)
25
+ @api = api
26
+ @_data = nil
27
+ @_headers = CICPHash.new
28
+ @_template_values = {}
29
+ @_parameters = {}
30
+
31
+ process_target(target)
32
+ end
33
+
34
+ def url
35
+ rtn = @_url
36
+
37
+ if @_is_templated
38
+ tpl = URITemplate.new(rtn)
39
+ rtn = tpl.expand(@_template_values)
40
+ end
41
+
42
+ rtn = add_parameters_to_url rtn unless @_parameters.empty?
43
+ rtn
44
+ end
45
+
46
+ # @param [Hash] headers
47
+ def with_headers(headers)
48
+ @_headers.merge! headers
49
+ self
50
+ end
51
+
52
+ # @param [Hash] values
53
+ def with_template_values(values)
54
+ @_template_values.merge! values
55
+ self
56
+ end
57
+
58
+ # @param [Hash] parameters
59
+ def with_parameters(parameters)
60
+ @_parameters.merge! parameters
61
+ self
62
+ end
63
+
64
+ def request(method)
65
+ data = @_data.is_a?(Hash) ? @_data.to_json : @_data
66
+ @api.request(method, url, @_headers, data)
67
+ end
68
+
69
+ private
70
+
71
+ # @param [String | Tuple[HalResource, String]] target
72
+ # @return [void]
73
+ def process_target(target)
74
+ if target.is_a?(String)
75
+ @_url = target
76
+ else
77
+ resource, rel = target
78
+ @_url = resource["_links"][rel]["href"]
79
+ @_is_templated = resource["_links"][rel].fetch("templated", false)
80
+ @resource = resource
81
+ end
82
+ end
83
+
84
+ # Handle list-style parameters based on the list style configuration
85
+ def handle_list(key, array)
86
+ handler = LIST_STYLE_HANDLERS[@api.parameters_list_style]
87
+ raise ArgumentError, "Unsupported parameters list style: #{@api.parameters_list_style}" unless handler
88
+
89
+ handler.call(key, array)
90
+ end
91
+
92
+ # Recursively flatten parameters into a list of key-value pairs
93
+ def flatten_parameters(prefix, parameters)
94
+ flattened = []
95
+
96
+ parameters.each do |key, value|
97
+ full_key = prefix.nil? || prefix.empty? ? key.to_s : "#{prefix}.#{key}"
98
+
99
+ case value
100
+ when nil
101
+ flattened << [full_key, nil]
102
+ when Array
103
+ flattened.concat(handle_list(full_key, value))
104
+ when Hash
105
+ flattened.concat(flatten_parameters(full_key, value))
106
+ when TrueClass, FalseClass
107
+ flattened << [full_key, value.to_s]
108
+ else
109
+ flattened << [full_key, URI.encode_www_form_component(value.to_s)]
110
+ end
111
+ end
112
+
113
+ flattened
114
+ end
115
+
116
+ # Add the flattened parameters to the URL as a query string
117
+ def add_parameters_to_url(url)
118
+ query_params = flatten_parameters(nil, @_parameters)
119
+ query_string = query_params.map { |key, value| value.nil? ? key : "#{key}=#{value}" }.join("&")
120
+
121
+ if url.include?("?")
122
+ "#{url}&#{query_string}"
123
+ else
124
+ "#{url}?#{query_string}"
125
+ end
126
+ end
127
+ end
128
+
129
+ # The result of GET on the root URL is a ReadOnlyRequester, i.e. only
130
+ # GET, HEAD, and OPTIONS are permitted
131
+ class ReadOnlyRequester < BaseRequester
132
+ def get
133
+ request :get
134
+ end
135
+
136
+ def head
137
+ request :head
138
+ end
139
+
140
+ def options
141
+ request :options
142
+ end
143
+ end
144
+
145
+ # This provides a full-suite of HTTP methods, with handling of payload conversion and
146
+ # optimistic concurrency.
147
+ class Requester < ReadOnlyRequester
148
+ def post(data = nil, content_type = nil)
149
+ prepare_payload(content_type, data)
150
+ request :post
151
+ end
152
+
153
+ def put(data = nil, content_type = nil)
154
+ prepare_payload(content_type, data)
155
+ prepare_modify_header
156
+ request :put
157
+ end
158
+
159
+ def patch(data = nil, content_type = nil)
160
+ prepare_payload(content_type, data)
161
+ prepare_modify_header
162
+ request :patch
163
+ end
164
+
165
+ def delete
166
+ prepare_modify_header
167
+ request :delete
168
+ end
169
+
170
+ private
171
+
172
+ def prepare_payload(content_type, data)
173
+ @_data = data
174
+ @_headers["Content-Type"] = content_type unless content_type.nil?
175
+ end
176
+
177
+ def prepare_modify_header
178
+ @_headers.merge!(@api.optimistic_concurrency_header(@resource)) if @resource.is_a?(HalResource)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halchemy
4
+ # The Resource class extends a Hash to include a metadata object containing details
5
+ # about the HTTP request and response. This lets the metadata stay "out of the way" allowing
6
+ # the client code to use the result of a request directly as a resource without losing access
7
+ # to the request details, response details, and any error details.
8
+ class Resource < Hash
9
+ attr_accessor :_halchemy
10
+
11
+ def to_s
12
+ if !keys.empty?
13
+ to_json
14
+ else
15
+ _halchemy&.response&.body.to_s
16
+ end
17
+ end
18
+ end
19
+
20
+ # The HalResource, like Resource, is also a Hash that adds functionality to work with the
21
+ # link relations in a HAL Resource.
22
+ class HalResource < Resource
23
+ # @param [Hash] hash
24
+ # @return [boolean]
25
+ def self.hal?(hash)
26
+ return false unless hash.is_a?(Hash)
27
+
28
+ links = hash["_links"]
29
+ embedded = hash["_embedded"]
30
+
31
+ return false unless links.is_a?(Hash)
32
+ return false unless links["self"].is_a?(Hash)
33
+ return false unless links["self"]["href"].is_a?(String)
34
+ return false if embedded && !embedded.is_a?(Hash)
35
+
36
+ true
37
+ end
38
+
39
+ def rel?(rel_name)
40
+ self["_links"].key?(rel_name)
41
+ end
42
+
43
+ def links
44
+ self["_links"].keys ||= []
45
+ end
46
+
47
+ def raise_for_syntax_error(field)
48
+ unless keys.include?(field)
49
+ raise KeyError, "Field '#{field}' does not exist, so cannot be iterated as a collection"
50
+ end
51
+ raise TypeError, "Field '#{field}' is not a collection" unless self[field].is_a?(Array)
52
+ end
53
+
54
+ def collection(field)
55
+ raise_for_syntax_error(field)
56
+
57
+ Enumerator.new do |y|
58
+ self[field].each do |item|
59
+ unless Halchemy::HalResource.hal?(item)
60
+ raise TypeError, "The '#{field}' collection contains non-HAL formatted objects"
61
+ end
62
+
63
+ y.yield Halchemy::HalResource.new.merge!(item)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ private
4
+
5
+ PATTERNS = [
6
+ [/^(\d+)-(\d+)$/, ->(m) { { type: :range, start: m[1].to_i, end: m[2].to_i } }],
7
+ [/^>=(\d+)$/, ->(m) { { type: :gte, value: m[1].to_i } }],
8
+ [/^<=(\d+)$/, ->(m) { { type: :lte, value: m[1].to_i } }],
9
+ [/^>(\d+)$/, ->(m) { { type: :gt, value: m[1].to_i } }],
10
+ [/^<(\d+)$/, ->(m) { { type: :lt, value: m[1].to_i } }],
11
+ [/^(\d+)$/, ->(m) { { type: :eq, value: m[1].to_i } }]
12
+ ].freeze
13
+
14
+ MATCHERS = {
15
+ range: ->(condition, status_code) { (condition[:start]..condition[:end]).include?(status_code) },
16
+ gt: ->(condition, status_code) { status_code > condition[:value] },
17
+ lt: ->(condition, status_code) { status_code < condition[:value] },
18
+ gte: ->(condition, status_code) { status_code >= condition[:value] },
19
+ lte: ->(condition, status_code) { status_code <= condition[:value] },
20
+ eq: ->(condition, status_code) { status_code == condition[:value] }
21
+ }.freeze
22
+
23
+ def settings_include_status_code?(settings, status_code)
24
+ return false if settings.nil? || settings.strip.empty?
25
+
26
+ parse_status_code_settings(settings).any? do |condition|
27
+ match_condition?(condition, status_code)
28
+ end
29
+ end
30
+
31
+ # @return [Array[String]]
32
+ # @param [String] settings
33
+ def parse_status_code_settings(settings)
34
+ settings.split(/,|\s+/).each_with_object([]) do |part, conditions|
35
+ part.strip!
36
+ conditions << parse_condition(part)
37
+ end.compact
38
+ end
39
+
40
+ # Parses an individual condition string into a hash.
41
+ # @param [String] part The individual condition string.
42
+ # @return [Hash, nil] The parsed condition hash or nil for blank parts.
43
+ def parse_condition(part)
44
+ return nil if part.match?(/^\s*$/)
45
+
46
+ PATTERNS.each do |pattern, handler|
47
+ if (match = part.match(pattern))
48
+ return handler.call(match)
49
+ end
50
+ end
51
+
52
+ raise SyntaxError, "Invalid status code settings string: '#{part}'"
53
+ end
54
+
55
+ # @param [Array[Hash]] condition
56
+ # @param [Integer] status_code
57
+ # @return [bool]
58
+ def match_condition?(condition, status_code)
59
+ MATCHERS.fetch(condition[:type], ->(_, _) { false }).call(condition, status_code)
60
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halchemy
4
+ VERSION = "1.0.2"
5
+ end
data/lib/halchemy.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "halchemy/version"
4
+ require_relative "halchemy/api"
5
+
6
+ module Halchemy
7
+ class HttpError < StandardError; end
8
+ end
@@ -0,0 +1 @@
1
+ BASE_URL: String
@@ -0,0 +1,4 @@
1
+ module Halchemy
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1 @@
1
+ HEADERS: hash[String, String]
@@ -0,0 +1 @@
1
+ REMOVE_HEADERS: array[String]
@@ -0,0 +1,54 @@
1
+ module Halchemy
2
+ class Api
3
+ @error_handling: ErrorHandling
4
+
5
+ @etag_field: String
6
+ @parameters_list_style: String
7
+
8
+ def self.build_response: -> HttpModel::Response
9
+
10
+ attr_accessor base_url: String
11
+ attr_accessor error_handling: ErrorHandling
12
+ attr_accessor headers: hash[String, String]
13
+
14
+ attr_accessor parameters_list_style: String
15
+
16
+ def add_headers: -> void
17
+
18
+ def follow: -> Follower
19
+
20
+ def optimistic_concurrency_header: -> Hash[String, String]
21
+
22
+ def remove_headers: -> void
23
+
24
+ def request: -> (Resource | HalResource)
25
+
26
+ def root: -> BaseRequester
27
+
28
+ def using_endpoint: -> BaseRequester
29
+
30
+ private
31
+
32
+ def self.build_resource: -> (Resource | HalResource)
33
+
34
+ def build_resource: -> (Resource | HalResource)
35
+
36
+ def build_response: -> HttpModel::Response
37
+
38
+ def build_url: -> String
39
+
40
+ def configure: -> void
41
+
42
+ def do_settings_include_status_code: -> bool
43
+
44
+ def extract_body: -> (String | Hash[String, Object] | nil)
45
+
46
+ def match_condition?: -> bool
47
+
48
+ def parse_body: -> (Hash[String, Object] | nil)
49
+
50
+ def parse_status_code_setting: -> Array[String]
51
+
52
+ def raise_for_errors: -> void
53
+ end
54
+ end
@@ -0,0 +1,35 @@
1
+ module Halchemy
2
+ class BaseRequester
3
+ @_data: Object | nil
4
+ @_headers: CICPHash
5
+ @_is_templated: bool
6
+ @_parameters: Hash[String, Object]
7
+ @_template_values: Hash[String, Object]
8
+ @_url: String
9
+ @api: Api
10
+ @resource: Resource | HalResource
11
+
12
+ def initialize: -> void
13
+
14
+ def request: -> (Resource | HalResource)
15
+
16
+ def url: -> String
17
+
18
+ def with_headers: -> (BaseRequester | ReadOnlyRequester | Requester)
19
+
20
+ def with_parameters: -> (BaseRequester | ReadOnlyRequester | Requester)
21
+
22
+ def with_template_values: -> (BaseRequester | ReadOnlyRequester | Requester)
23
+
24
+ private
25
+
26
+ def add_parameters_to_url: -> String
27
+
28
+ def flatten_parameters: -> Array[String]
29
+
30
+ def handle_list: -> Array[Object]
31
+
32
+ def process_target: -> void
33
+
34
+ end
35
+ end
@@ -0,0 +1,6 @@
1
+ module Halchemy
2
+ class ErrorHandling
3
+ attr_accessor raise_for_network_errors: bool
4
+ attr_accessor raise_for_status_codes: String | nil
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module Halchemy
2
+ class Follower
3
+ @api: Api
4
+
5
+ @resource: Resource
6
+
7
+ def to: -> Requester
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ module Halchemy
2
+ class HalResource
3
+ def self.hal?: -> bool
4
+
5
+ def collection: -> Enumerator[HalResource]
6
+
7
+ def links: -> Array[String]
8
+
9
+ def raise_for_syntax_error: -> void
10
+
11
+ def rel?: -> bool
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ module Halchemy
2
+ module HttpModel
3
+ class Request
4
+ @headers: CICPHash
5
+ @method: String
6
+ @url: String
7
+
8
+ attr_accessor body: Object | nil
9
+ attr_accessor headers: CICPHash
10
+ attr_accessor method: Symbol
11
+ attr_accessor url: String
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ module Halchemy
2
+ module HttpModel
3
+ class Response
4
+ @headers: CICPHash
5
+ @reason: String
6
+ @status_code: int
7
+
8
+ attr_accessor body: Object | nil
9
+ attr_accessor error: Object | nil
10
+ attr_accessor headers: CICPHash
11
+ attr_accessor reason: String
12
+ attr_accessor status_code: Integer
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ module Halchemy
2
+ class Metadata
3
+ attr_reader error: Object
4
+ attr_reader request: HttpModel::Request
5
+ attr_reader response: HttpModel::Response
6
+
7
+ def raise_for_status_codes: -> void
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Halchemy
2
+ class ReadOnlyRequester < BaseRequester
3
+ def get: -> (Resource | HalResource)
4
+
5
+ def head: -> (Resource | HalResource)
6
+
7
+ def options: -> (Resource | HalResource)
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ module Halchemy
2
+ class Requester < ReadOnlyRequester
3
+
4
+ def delete: -> (Resource | HalResource)
5
+
6
+ def patch: -> (Resource | HalResource)
7
+
8
+ def post: -> (Resource | HalResource)
9
+
10
+ def put: -> (Resource | HalResource)
11
+
12
+ private
13
+
14
+ def prepare_modify_header: -> void
15
+
16
+ def prepare_payload: -> void
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ module Halchemy
2
+ class Resource
3
+ attr_accessor _halchemy: Metadata | nil
4
+ end
5
+ end
@@ -0,0 +1 @@
1
+ LIST_STYLE_HANDLERS: Hash[String, Object]
data/sig/matchers.rbs ADDED
@@ -0,0 +1 @@
1
+ MATCHERS: Hash[Symbol, Object]
data/sig/patterns.rbs ADDED
@@ -0,0 +1 @@
1
+ PATTERNS: Array[Tuple[Regexp, Object]]
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: halchemy
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Michael Ottoson
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-01-26 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Do you have an API that serves data following the HAL specification? The
13
+ **halchemy** library makes it easy for your client to make the most of that API.
14
+ email:
15
+ - michael@pointw.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".idea/.gitignore"
21
+ - ".idea/halchemy.iml"
22
+ - ".idea/icon.png"
23
+ - ".idea/inspectionProfiles/Project_Default.xml"
24
+ - ".idea/modules.xml"
25
+ - ".idea/vcs.xml"
26
+ - ".rubocop.yml"
27
+ - CHANGELOG.md
28
+ - LICENSE.txt
29
+ - README.md
30
+ - Rakefile
31
+ - __tests__/common.rb
32
+ - __tests__/configurable/base_url.rb
33
+ - __tests__/configurable/error_handling.rb
34
+ - __tests__/configurable/headers.rb
35
+ - __tests__/make_http_requests/follow_link_relations.rb
36
+ - __tests__/make_http_requests/http_response_details.rb
37
+ - __tests__/make_http_requests/optimistic_concurrency.rb
38
+ - __tests__/make_http_requests/with_headers.rb
39
+ - __tests__/make_http_requests/with_parameters.rb
40
+ - __tests__/make_http_requests/with_templates.rb
41
+ - __tests__/use_resources/iterate_collections.rb
42
+ - bdd
43
+ - lib/halchemy.rb
44
+ - lib/halchemy/api.rb
45
+ - lib/halchemy/error_handling.rb
46
+ - lib/halchemy/follower.rb
47
+ - lib/halchemy/http_model.rb
48
+ - lib/halchemy/metadata.rb
49
+ - lib/halchemy/requester.rb
50
+ - lib/halchemy/resource.rb
51
+ - lib/halchemy/status_codes.rb
52
+ - lib/halchemy/version.rb
53
+ - sig/__tests__/base_url.rbs
54
+ - sig/__tests__/halchemy.rbs
55
+ - sig/__tests__/headers.rbs
56
+ - sig/__tests__/remove_headers.rbs
57
+ - sig/halchemy/api.rbs
58
+ - sig/halchemy/base_requester.rbs
59
+ - sig/halchemy/error_handling.rbs
60
+ - sig/halchemy/follower.rbs
61
+ - sig/halchemy/hal_resource.rbs
62
+ - sig/halchemy/http_model/request.rbs
63
+ - sig/halchemy/http_model/response.rbs
64
+ - sig/halchemy/metadata.rbs
65
+ - sig/halchemy/read_only_requester.rbs
66
+ - sig/halchemy/requester.rbs
67
+ - sig/halchemy/resource.rbs
68
+ - sig/list_style_handlers.rbs
69
+ - sig/matchers.rbs
70
+ - sig/patterns.rbs
71
+ homepage: https://github.com/pointw-dev/halchemy
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ homepage_uri: https://github.com/pointw-dev/halchemy
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 3.1.0
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.6.2
91
+ specification_version: 4
92
+ summary: HAL for humans
93
+ test_files: []