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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ When(/^I make a request$/) do
4
+ @resource = {}
5
+ ALL_METHODS.each do |method|
6
+ @resource[method] = @api.follow(@root_resource).to("resource1").public_send(method)
7
+ end
8
+ end
9
+
10
+ Then(/^the HTTP request and response details are available to me$/) do
11
+ ALL_METHODS.each do |method|
12
+ resource = @resource[method]
13
+ expect(resource).not_to be_nil
14
+ request = resource._halchemy.request
15
+ response = resource._halchemy.response
16
+ error = resource._halchemy.error
17
+
18
+ expect(request).not_to be_nil
19
+ expect(response).not_to be_nil
20
+
21
+ expect(request.method).to eq(method)
22
+ expect(request.url).to end_with("/path/to/resource1")
23
+
24
+ expect(response.status_code).to eq(200)
25
+ expect(response.reason).to eq("OK")
26
+
27
+ expect(error).to be_nil
28
+ end
29
+ end
30
+
31
+ When(/^the request I made fails: (.*)$/) do |failure|
32
+ @api.error_handling.raise_for_network_errors = false
33
+
34
+ server = stub_request(:any, %r{\A#{BASE_URL}/path(/.*)?\z})
35
+ if failure.start_with?("status_code:")
36
+ server.to_return(status: failure.sub("status_code:", "").to_i, body: { error: "error" }.to_json)
37
+ else
38
+ server.to_raise(failure)
39
+ end
40
+ @expected_failure = failure
41
+
42
+ @resource = {}
43
+ ALL_METHODS.each do |method|
44
+ @resource[method] = @api.follow(@root_resource).to("resource1").public_send(method)
45
+ end
46
+ end
47
+
48
+ Then(/^I can access the error details$/) do
49
+ ALL_METHODS.each do |method|
50
+ response = @resource[method]._halchemy.response
51
+ error = @resource[method]._halchemy.error
52
+ expect(error).not_to be_nil
53
+
54
+ if @expected_failure.start_with?("status_code:")
55
+ expect(response.status_code).to eq(@expected_failure.sub("status_code:", "").to_i)
56
+ else
57
+ expect(error.message).to be @expected_failure
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ Given(/^a modifiable HAL resource$/) do
4
+ stub_for_hal_resource_scenarios
5
+ @api = Halchemy::Api.new BASE_URL
6
+ root_resource = @api.root.get
7
+ @resource = @api.follow(root_resource).to("resource1").get
8
+ end
9
+
10
+ When(/^I request a change to the resource$/) do
11
+ @requests = make_requests(MODIFY_METHODS, @api.follow(@resource).to("self"))
12
+ end
13
+
14
+ Then(/^the If-Match header uses the resource's Etag header$/) do
15
+ MODIFY_METHODS.each do |method|
16
+ expect(@requests[method].headers["If-Match"]).to eq("from header")
17
+ end
18
+ end
19
+
20
+ And(/^the response does not have an Etag header$/) do
21
+ @resource._halchemy&.response&.headers&.delete("Etag")
22
+ end
23
+
24
+ Then(/^the If-Match header uses the resource's _etag field$/) do
25
+ MODIFY_METHODS.each do |method|
26
+ expect(@requests[method].headers["If-Match"]).to eq("from field")
27
+ end
28
+ end
29
+
30
+ And(/^the resource does not have an _etag field$/) do
31
+ @resource.delete("_etag")
32
+ end
33
+
34
+ Then(/^the request is made without an If-Match header$/) do
35
+ MODIFY_METHODS.each do |method|
36
+ expect(@requests[method].headers.keys).not_to include("If-Match")
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ When(/^I specify additional headers for a request$/) do
4
+ requester = @api.follow(@root_resource).to("resource1").with_headers({ "X-CustomHeader" => "custom value" })
5
+ @requests = make_requests(ALL_METHODS, requester)
6
+ end
7
+
8
+ Then(/^the request is made with those headers$/) do
9
+ ALL_METHODS.each do |method|
10
+ headers = @requests[method].headers
11
+ expect(headers.keys.map(&:downcase)).to include("x-customheader")
12
+ expect(headers["X-Customheader"]).to eq("custom value") # this case sensitivity is from WebMock, not halchemy
13
+ end
14
+ end
15
+
16
+ Given(/^I have made a request with additional headers$/) do
17
+ stub_for_hal_resource_scenarios
18
+ @api = Halchemy::Api.new BASE_URL
19
+ @root_resource = @api.root.get
20
+ @api.follow(@root_resource).to("resource1").with_headers({ "X-CustomHeader" => "custom value" }).get
21
+ end
22
+
23
+ When(/^I make a new request without headers$/) do
24
+ @requests = make_requests(ALL_METHODS, @api.follow(@root_resource).to("resource1"))
25
+ end
26
+
27
+ Then(/^the previous request's headers are not included$/) do
28
+ ALL_METHODS.each do |method|
29
+ headers = @requests[method].headers
30
+ expect(headers.keys.map(&:downcase)).not_to include("x-customheader")
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ When(/^I supply (.*)$/) do |parameters|
4
+ requester = @api.follow(@root_resource).to("resource1").with_parameters(JSON.parse(parameters))
5
+ @requests = make_requests(ALL_METHODS, requester)
6
+ end
7
+
8
+ Then(/^the parameters are added to the URL as a RFC 3986 compliant (.*)$/) do |query_string|
9
+ ALL_METHODS.each do |method|
10
+ request_path = normalize_path(@requests[method].uri.to_s)
11
+ correct_query_string = normalize_path("/path?#{query_string}")[5..]
12
+ expect(request_path).to end_with(correct_query_string)
13
+ end
14
+ end
15
+
16
+ Given(/^an endpoint at (.*)$/) do |url|
17
+ @api = Halchemy::Api.new(BASE_URL)
18
+ @endpoint = @api.using_endpoint(url)
19
+ end
20
+
21
+ When(/^I provide (.*)$/) do |parameters|
22
+ parameters = JSON.parse(parameters)
23
+ @url = @endpoint.with_parameters(parameters).url
24
+ end
25
+
26
+ Then(/^the result is a (.*)$/) do |correct_url|
27
+ expect(@url).to end_with(correct_url)
28
+ end
29
+
30
+ And(/^I choose a parameters (.*)$/) do |list_style|
31
+ @api.parameters_list_style = list_style
32
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ Given(/^a HAL resource with a link that is an RFC 6570 compliant (.*)$/) do |templated_href|
4
+ resource1_json = {
5
+ data: "some resource",
6
+ _links: {
7
+ self: { href: "/path/to/resource1" },
8
+ next: {
9
+ href: templated_href,
10
+ templated: true
11
+ }
12
+ }
13
+ }.to_json
14
+ stub_request(:any, /.*/).to_return(body: resource1_json, status: 200)
15
+ @api = Halchemy::Api.new BASE_URL
16
+ # @hal_resource = Halchemy::HalResource.new.merge! JSON.parse(resource1_json)
17
+ @hal_resource = @api.using_endpoint("/resource1").get
18
+ end
19
+
20
+ When(/^I follow that link and provide (.*)$/) do |template_values|
21
+ requester = @api.follow(@hal_resource).to("next").with_template_values(JSON.parse(template_values))
22
+ @requests = make_requests(ALL_METHODS, requester)
23
+ end
24
+
25
+ Then(/^the requested URL ends with the (.*)$/) do |correct_path|
26
+ ALL_METHODS.each do |method|
27
+ request_path = normalize_path(@requests[method].uri.to_s)
28
+ correct_path = normalize_path(correct_path)
29
+ expect(request_path).to eq(correct_path)
30
+ end
31
+ end
32
+
33
+ When(/^the (.*) provided are missing one or more values$/) do |template_values|
34
+ requester = @api.follow(@hal_resource).to("next")
35
+ requester = requester.with_template_values(JSON.parse(template_values)) unless template_values == "-omit-"
36
+ @requests = make_requests(ALL_METHODS, requester)
37
+ end
38
+
39
+ Then(/^the constructed URL ends with the (.*)$/) do |correct_path|
40
+ ALL_METHODS.each do |method|
41
+ request_path = normalize_path(@requests[method].uri.to_s)
42
+ correct_path = normalize_path(correct_path)
43
+ expect(request_path).to eq(correct_path)
44
+ end
45
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ Given(/^a HAL resource that has a field which is a collection of objects in HAL format$/) do
4
+ @resource = Halchemy::HalResource.new.merge!(JSON.parse({
5
+ _items: [
6
+ {
7
+ field: "alpha",
8
+ _links: { self: { href: "/resource/alpha" } }
9
+ },
10
+ {
11
+ field: "beta",
12
+ _links: { self: { href: "/resource/beta" } }
13
+ },
14
+ {
15
+ field: "delta",
16
+ _links: { self: { href: "/resource/delta" } }
17
+ }
18
+ ],
19
+ _links: { self: { href: "/resource" } }
20
+ }.to_json))
21
+ end
22
+
23
+ When(/^I iterate the items in that field's collection$/) do
24
+ @iterator = @resource.collection("_items")
25
+ @all_hal = @resource.collection("_items").reduce(false) do |all_hal, item|
26
+ all_hal || item.is_a?(Halchemy::HalResource)
27
+ end
28
+ end
29
+
30
+ Then(/^each item is a HAL resource$/) do
31
+ expect(@all_hal).to be true
32
+ end
33
+
34
+ When(/^I try to iterate as a collection a field in the resource that does not exist$/) do
35
+ @root_resource.collection("_items").reduce(false) { |all_hal, item| all_hal || item.is_a?(Halchemy::HalResource) }
36
+ rescue KeyError => e
37
+ @error = e
38
+ end
39
+
40
+ Then(/^it throws an exception telling me that the field does not exist$/) do
41
+ expect(@error).to be_a(KeyError)
42
+ expect(@error.message).to include("does not exist")
43
+ end
44
+
45
+ Given(/^a HAL resource with a non-collection field$/) do
46
+ @resource = Halchemy::HalResource.new.merge!(JSON.parse({
47
+ _id: "3a834-34f9f03-39b843",
48
+ _links: { self: { href: "/resource" } }
49
+ }.to_json))
50
+ end
51
+
52
+ When(/^I try to iterate as a collection a field that is not a collection$/) do
53
+ @resource.collection("_id").reduce(false) { |all_hal, item| all_hal || item.is_a?(Halchemy::HalResource) }
54
+ rescue TypeError => e
55
+ @error = e
56
+ end
57
+
58
+ Then(/^it throws an exception telling me that the field is not a collection$/) do
59
+ expect(@error).to be_a(TypeError)
60
+ expect(@error.message).to include("is not a collection")
61
+ end
62
+
63
+ Given(/^a HAL resource that has a field which is a collection, but not of HAL formatted objects$/) do
64
+ @resource = Halchemy::HalResource.new.merge!(JSON.parse({
65
+ _items: [
66
+ {
67
+ make: "Ford Mustang"
68
+ },
69
+ {
70
+ make: "Chrysler 300"
71
+ },
72
+ {
73
+ make: "RAM 1500"
74
+ }
75
+ ],
76
+ _links: { self: { href: "/resource" } }
77
+ }.to_json))
78
+ end
79
+
80
+ When(/^I try to iterate the items in that field's collection$/) do
81
+ @resource.collection("_items").reduce(false) { |all_hal, item| all_hal || item.is_a?(Halchemy::HalResource) }
82
+ rescue TypeError => e
83
+ @error = e
84
+ end
85
+
86
+ Then(/^it throws an exception telling me collection contains non-HAL formatted objects$/) do
87
+ expect(@error).to be_a(TypeError)
88
+ expect(@error.message).to include("non-HAL")
89
+ end
data/bdd ADDED
@@ -0,0 +1 @@
1
+ cucumber ../../features/configurable/configure_base_url.feature:24 --publish-quiet
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cicphash"
4
+ require "httpx"
5
+ require "http_status_codes"
6
+
7
+ require_relative "requester"
8
+ require_relative "follower"
9
+ require_relative "error_handling"
10
+ require_relative "status_codes"
11
+ require_relative "resource"
12
+ require_relative "http_model"
13
+ require_relative "metadata"
14
+
15
+ module Halchemy
16
+ # This is the Halchemy::Api class, that is the main class for interacting with HAL-based APIs
17
+ class Api
18
+ attr_accessor :base_url, :headers, :error_handling, :parameters_list_style
19
+
20
+ def initialize(base_url = "http://localhost:2112", headers: {})
21
+ @base_url = base_url
22
+ configure
23
+
24
+ @headers.merge!(headers)
25
+ end
26
+
27
+ def root
28
+ using_endpoint("/", is_root: true)
29
+ end
30
+
31
+ def using_endpoint(target, is_root: false)
32
+ if is_root
33
+ ReadOnlyRequester.new(self, target)
34
+ else
35
+ Requester.new(self, target)
36
+ end
37
+ end
38
+
39
+ # @param [Hash[String, string]] headers
40
+ def add_headers(headers)
41
+ @headers.merge!(headers)
42
+ end
43
+
44
+ # @param [Array[String] headers
45
+ def remove_headers(header_keys)
46
+ header_keys.map { |key| @headers.delete(key) }
47
+ end
48
+
49
+ def request(method, target, headers = nil, data = nil)
50
+ url = build_url(target)
51
+
52
+ request_headers = headers.nil? ? @headers : @headers.merge(headers)
53
+ request = HttpModel::Request.new(method, url, data, request_headers)
54
+
55
+ http = HTTPX.with(headers: request_headers)
56
+ result = http.public_send(method, url, body: data)
57
+
58
+ raise_for_errors(result)
59
+ build_resource(request, result)
60
+ end
61
+
62
+ def follow(resource)
63
+ Follower.new(self, resource)
64
+ end
65
+
66
+ def optimistic_concurrency_header(resource)
67
+ etag = resource._halchemy.response.headers["Etag"] || resource[@etag_field]
68
+ etag.nil? ? {} : { "If-Match" => etag }
69
+ end
70
+
71
+ private
72
+
73
+ def configure
74
+ @headers = CICPHash.new.merge!({
75
+ "Authorization" => "Basic cm9vdDpwYXNzd29yZA==",
76
+ "Content-type" => "application/json",
77
+ "Accept" => "application/hal+json, application/json;q=0.9, */*;q=0.8"
78
+ })
79
+ @error_handling = ErrorHandling.new
80
+ @parameters_list_style = "repeat_key"
81
+ @etag_field = "_etag"
82
+ end
83
+
84
+ def build_url(target)
85
+ return target if target.start_with?("http")
86
+
87
+ [@base_url.chomp("/"), target.sub(%r{\A/+}, "")].join("/")
88
+ end
89
+
90
+ def raise_for_errors(result)
91
+ if @error_handling.raise_for_network_errors && result.error.instance_of?(StandardError)
92
+ raise HttpError, result.error.message
93
+ end
94
+
95
+ return unless result.respond_to?(:status)
96
+ return unless settings_include_status_code?(@error_handling.raise_for_status_codes, result.status)
97
+
98
+ raise HttpError, "Status code #{result.status} matches \"#{@error_handling.raise_for_status_codes}\""
99
+ end
100
+
101
+ # @param [HttpModel::Request] request
102
+ # @param [Object] result
103
+ # @return [Halchemy::Resource | Halchemy::HalResource]
104
+ def build_resource(request, result)
105
+ # result.request.url, result.request.headers, result.request.body
106
+ response = build_response(result)
107
+ json = parse_body(result)
108
+
109
+ resource = if json.nil?
110
+ Resource.new
111
+ elsif HalResource.hal?(json)
112
+ HalResource.new.merge! json
113
+ else
114
+ Resource.new.merge! json
115
+ end
116
+
117
+ resource.tap { |r| r._halchemy = Metadata.new(request, response, result.error) }
118
+ end
119
+
120
+ def parse_body(result)
121
+ body = extract_body(result)
122
+ return body if body.is_a?(Hash)
123
+
124
+ begin
125
+ json = JSON.parse(body.to_s) unless body.is_a?(Hash) || body.nil?
126
+ rescue JSON::ParserError
127
+ json = nil
128
+ end
129
+ json
130
+ end
131
+
132
+ def extract_body(result)
133
+ begin
134
+ body = result.json if result.respond_to?("json")
135
+ rescue HTTPX::Error
136
+ body = result.to_s # Return raw text if it fails
137
+ end
138
+ body
139
+ end
140
+
141
+ def build_response(result)
142
+ status = result.respond_to?(:status) ? result.status : 0
143
+ reason = status.positive? ? HTTPStatusCodes::MAP[status] : "Did not receive a response from the server"
144
+ headers = result.headers if result.respond_to?(:headers)
145
+ body = result.body if result.respond_to?(:body)
146
+
147
+ HttpModel::Response.new(status, reason, headers, body)
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halchemy
4
+ # A container for the settings which govern how errors are handled.
5
+ class ErrorHandling
6
+ attr_accessor :raise_for_network_errors, :raise_for_status_codes
7
+
8
+ def initialize
9
+ @raise_for_network_errors = true
10
+ @raise_for_status_codes = nil
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halchemy
4
+ # Provides the way to navigate from a resource by following to a link relation
5
+ class Follower
6
+ def initialize(api, resource)
7
+ @api = api
8
+ @resource = resource
9
+ end
10
+
11
+ # @param [String] rel
12
+ def to(rel)
13
+ unless @resource["_links"].key?(rel)
14
+ raise KeyError, "#{@resource.class} does not have a link relation named #{rel}"
15
+ end
16
+
17
+ Requester.new(@api, [@resource, rel])
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halchemy
4
+ module HttpModel
5
+ # frozen_string_literal: true
6
+
7
+ # Provides essential details about the request that was made which resulted in the Resource
8
+ # this is attached to
9
+ class Request
10
+ attr_accessor :method, :url, :headers, :body
11
+
12
+ def initialize(method, url, headers, body = nil)
13
+ @method = method
14
+ @url = url
15
+ @headers = headers
16
+ @body = body
17
+ end
18
+
19
+ def to_s
20
+ "#{@method.to_s.upcase} #{@url}"
21
+ end
22
+ end
23
+
24
+ # Provides HTTP metadata that came along with the response resulting in the Resource
25
+ # this is attached to
26
+ class Response
27
+ attr_accessor :status_code, :reason, :headers, :body, :error
28
+
29
+ def initialize(status_code, reason, headers, body = nil)
30
+ @status_code = status_code
31
+ @reason = reason
32
+ @headers = headers
33
+ @body = body
34
+ end
35
+
36
+ def to_s
37
+ "#{@status_code} #{@reason}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halchemy
4
+ # Allows a Resource to act first as a Hash, by keeping all HTTP related info in resource._halchemy
5
+ class Metadata
6
+ attr_reader :request, :response, :error
7
+
8
+ def initialize(request, response, error)
9
+ @request = request
10
+ @response = response
11
+ @error = error
12
+ end
13
+
14
+ def raise_for_status_codes(settings = ">399")
15
+ return unless settings_include_status_code?(settings, @response.status_code)
16
+
17
+ raise HttpError, "Status code #{@response.status_code} matches \"#{settings}\""
18
+ end
19
+ end
20
+ end