hoalife 0.1.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.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+ require 'hoal_inflector'
5
+
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.inflector = HOALInflector.new
8
+ tests = "#{__dir__}/**/*_test.rb"
9
+ loader.ignore(tests)
10
+ # loader.log!
11
+
12
+ if ENV['DEV']
13
+ require 'listen'
14
+
15
+ loader.enable_reloading
16
+ Listen.to('lib') do
17
+ loader.reload
18
+ end.start
19
+ end
20
+
21
+ loader.setup
22
+
23
+ require 'hoalife/error'
24
+
25
+ # :nodoc
26
+ module HOALife
27
+ @api_base = ENV.fetch('HOALIFE_API_BASE', 'https://api.hoalife.com/api')
28
+ @api_version = ENV.fetch('HOALIFE_API_VERSION', '1').to_i
29
+ @api_key = ENV['HOALIFE_API_KEY']
30
+ @signing_secret = ENV['HOALIFE_SIGNING_SECRET']
31
+ @sleep_when_rate_limited = 10.0
32
+
33
+ class << self
34
+ attr_accessor :api_key, :signing_secret, :api_base, :api_version,
35
+ :sleep_when_rate_limited
36
+
37
+ # Support configuring with a block
38
+ # HOALife.config do |config|
39
+ # config.api_key = "foo"
40
+ # end
41
+ # HOALife.api_key
42
+ # => "foo"
43
+ def config
44
+ yield self
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc
4
+ class HOALife::Account < HOALife::Resource
5
+ include HOALife::Resources::Persistable
6
+
7
+ self.base_path = '/accounts'
8
+
9
+ def as_json
10
+ h = super
11
+
12
+ h.dig('data', 'relationships').merge!(
13
+ 'parent' => { 'data' => { 'id' => parent_id } }
14
+ )
15
+
16
+ h
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc
4
+ class HOALife::CCRArticle < HOALife::Resource
5
+ include HOALife::Resources::Persistable
6
+
7
+ self.base_path = '/ccr_articles'
8
+
9
+ def as_json
10
+ h = super
11
+
12
+ h.dig('data', 'relationships').merge!(
13
+ 'account' => { 'data' => { 'id' => account_id } }
14
+ )
15
+
16
+ h
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc
4
+ class HOALife::CCRViolationType < HOALife::Resource
5
+ include HOALife::Resources::Persistable
6
+
7
+ self.base_path = '/ccr_violation_types'
8
+
9
+ def as_json
10
+ h = super
11
+
12
+ h.dig('data', 'relationships').merge!(
13
+ 'ccr_article' => { 'data' => { 'id' => ccr_article_id } }
14
+ )
15
+
16
+ h
17
+ end
18
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'json'
6
+
7
+ # Base class for all HTTP requests
8
+ # Handles the implementation specific code of the API
9
+ class HOALife::Client::Base
10
+ def initialize(url, body = nil)
11
+ @url = url
12
+ @body = body
13
+ end
14
+
15
+ def status
16
+ @status ||= response.code.to_i
17
+ end
18
+
19
+ def json
20
+ @json ||= JSON.parse(response.body)
21
+ end
22
+
23
+ def response
24
+ @response ||= validate_response!
25
+ end
26
+
27
+ private
28
+
29
+ def request!
30
+ raise 'Not implemented'
31
+ end
32
+
33
+ def uri
34
+ @uri ||= URI(@url)
35
+ end
36
+
37
+ def request_headers
38
+ {
39
+ 'Authorization' => authorization_header,
40
+ 'ACCEPT' => api_version,
41
+ 'Content-Type' => 'application/vnd.api+json'
42
+ }
43
+ end
44
+
45
+ def validate_response!
46
+ response = request!
47
+
48
+ verify_successful_response!(response)
49
+
50
+ verify_signature!(response) unless HOALife.signing_secret.nil?
51
+
52
+ response
53
+ end
54
+
55
+ # rubocop:disable Metrics/MethodLength
56
+ def verify_successful_response!(resp)
57
+ headers = resp.each_header.to_h
58
+ body = resp.body
59
+ code = resp.code.to_i
60
+
61
+ case code
62
+ when 400
63
+ raise HOALife::BadRequestError.new(code, headers, body)
64
+ when 401..403
65
+ auth_error(headers, body, code)
66
+ when 404..429
67
+ http_error(headers, body, code)
68
+ when 400..600
69
+ raise HOALife::HTTPError.new(code, headers, generic_error(resp))
70
+ end
71
+ end
72
+ # rubocop:enable Metrics/MethodLength
73
+
74
+ def auth_error(headers, body, code)
75
+ case code
76
+ when 401
77
+ raise HOALife::UnauthorizedError.new(code, headers, body)
78
+ when 403
79
+ raise HOALife::ForbiddenError.new(code, headers, body)
80
+ end
81
+ end
82
+
83
+ def http_error(headers, body, code)
84
+ case code
85
+ when 404
86
+ raise HOALife::NotFoundError.new(code, headers, body)
87
+ when 429
88
+ raise HOALife::RateLimitError.new(code, headers, body)
89
+ end
90
+ end
91
+
92
+ def verify_signature!(resp)
93
+ digest = OpenSSL::Digest.new('sha256')
94
+ signature = OpenSSL::HMAC.hexdigest(
95
+ digest, HOALife.signing_secret, resp.body
96
+ )
97
+
98
+ raise HOALife::SigningMissmatchError if signature != resp['X-Signature']
99
+ end
100
+
101
+ def api_version
102
+ return nil if HOALife.api_version.nil?
103
+
104
+ "version=#{HOALife.api_version}"
105
+ end
106
+
107
+ def authorization_header
108
+ raise HOALife::Error, 'No API Key specified' if HOALife.api_key.nil?
109
+
110
+ "Token #{HOALife.api_key}"
111
+ end
112
+
113
+ def generic_error(resp)
114
+ JSON.parse(resp.body)
115
+ rescue JSON::ParserError
116
+ { 'data' => {
117
+ 'id' => resp['X-Request-Id'], 'type' => 'error',
118
+ 'attributes' => {
119
+ 'id' => resp['X-Request-Id'], 'title' => 'HTTP Error',
120
+ 'status' => resp.code.to_i, 'detail' => resp.body
121
+ }
122
+ } }
123
+ end
124
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # HTTP DELETE requests
4
+ class HOALife::Client::Delete < HOALife::Client::Base
5
+ private
6
+
7
+ def request!
8
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
9
+ req = Net::HTTP::Delete.new(uri, request_headers)
10
+
11
+ http.request(req)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # HTTP Get requests
4
+ class HOALife::Client::Get < HOALife::Client::Base
5
+ private
6
+
7
+ def request!
8
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
9
+ req = Net::HTTP::Get.new(uri, request_headers)
10
+
11
+ http.request(req)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # HTTP POST requests
4
+ class HOALife::Client::Post < HOALife::Client::Base
5
+ private
6
+
7
+ def request!
8
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
9
+ req = Net::HTTP::Post.new(uri, request_headers)
10
+
11
+ req.body = @body
12
+
13
+ http.request(req)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # HTTP PUT requests
4
+ class HOALife::Client::Put < HOALife::Client::Base
5
+ private
6
+
7
+ def request!
8
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
9
+ req = Net::HTTP::Put.new(uri, request_headers)
10
+
11
+ req.body = @body
12
+
13
+ http.request(req)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails ActiveSupport::Concern
4
+ # https://github.com/rails/rails/blob/66cabeda2c46c582d19738e1318be8d59584cc5b/activesupport/lib/active_support/concern.rb#L163
5
+ module HOALife::Concern
6
+ class MultipleIncludedBlocks < StandardError #:nodoc:
7
+ def initialize
8
+ super "Cannot define multiple 'included' blocks for a Concern"
9
+ end
10
+ end
11
+
12
+ def self.extended(base) #:nodoc:
13
+ base.instance_variable_set(:@_dependencies, [])
14
+ end
15
+
16
+ def append_features(base) #:nodoc:
17
+ if base.instance_variable_defined?(:@_dependencies)
18
+ base.instance_variable_get(:@_dependencies) << self
19
+ false
20
+ else
21
+ return false if base < self
22
+
23
+ @_dependencies.each { |dep| base.include(dep) }
24
+ super
25
+ base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
26
+ base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
27
+ end
28
+ end
29
+
30
+ def included(base = nil, &block)
31
+ if base.nil?
32
+ if instance_variable_defined?(:@_included_block)
33
+ if @_included_block.source_location != block.source_location
34
+ raise MultipleIncludedBlocks
35
+ end
36
+ else
37
+ @_included_block = block
38
+ end
39
+ else
40
+ super
41
+ end
42
+ end
43
+
44
+ def class_methods(&class_methods_module_definition)
45
+ mod = const_defined?(:ClassMethods, false) ?
46
+ const_get(:ClassMethods) :
47
+ const_set(:ClassMethods, Module.new)
48
+
49
+ mod.module_eval(&class_methods_module_definition)
50
+ end
51
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HOALife
4
+ class Error < StandardError; end
5
+
6
+ # HTTP Errors
7
+ class HTTPError < Error
8
+ attr_reader :status, :headers, :details
9
+
10
+ def initialize(status, headers, details)
11
+ @status = status
12
+ @headers = headers
13
+
14
+ begin
15
+ @details = JSON.parse(details)
16
+ rescue JSON::ParserError, TypeError
17
+ @details = details
18
+ end
19
+
20
+ super(status)
21
+ end
22
+ end
23
+
24
+ class BadRequestError < HTTPError; end
25
+ class UnauthorizedError < HTTPError; end
26
+ class ForbiddenError < HTTPError; end
27
+ class NotFoundError < HTTPError; end
28
+ class RateLimitError < HTTPError; end
29
+
30
+ class SigningMissmatchError < Error; end
31
+
32
+ class UndefinedResourceError < Error; end
33
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc
4
+ class HOALife::Escalation < HOALife::Resource
5
+ include HOALife::Resources::HasNestedResources
6
+
7
+ self.base_path = '/escalations'
8
+
9
+ has_nested_resources :violations
10
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc
4
+ class HOALife::Inspection < HOALife::Resource
5
+ self.base_path = '/inspections'
6
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc
4
+ class HOALife::Property < HOALife::Resource
5
+ include HOALife::Resources::Persistable
6
+
7
+ self.base_path = '/properties'
8
+
9
+ def as_json
10
+ h = super
11
+
12
+ h.dig('data', 'relationships').merge!(
13
+ 'account' => { 'data' => { 'id' => account_id } }
14
+ )
15
+
16
+ h
17
+ end
18
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+ require 'json'
5
+ require 'time'
6
+ require 'forwardable'
7
+
8
+ # :nodoc
9
+ class HOALife::Resource < OpenStruct
10
+ class << self
11
+ extend Forwardable
12
+
13
+ attr_accessor :base_path
14
+ def_delegators :resource_collection, :all, :first, :last, :where, :order,
15
+ :total_pages, :current_page, :total, :count, :size, :reload
16
+
17
+ def new(obj = {}, relationships = {})
18
+ return super(obj, relationships) unless obj['type']
19
+
20
+ camelized = HOALInflector.new.camelize(obj['type'], nil)
21
+
22
+ begin
23
+ klass = Object.const_get("HOALife::#{camelized}")
24
+ rescue NameError
25
+ raise HOALife::UndefinedResourceError,
26
+ "HOALife::#{camelized} is not defined"
27
+ end
28
+
29
+ klass.new(obj['attributes'], obj['relationships'])
30
+ end
31
+
32
+ def resource_collection
33
+ @resource_collection ||= HOALife::Resources::Collection.new(
34
+ HOALife.api_base + base_path
35
+ )
36
+ end
37
+ end
38
+
39
+ def initialize(obj = {}, _relationships = {})
40
+ @obj = cast_attrs(obj)
41
+
42
+ super(obj)
43
+ end
44
+
45
+ def as_json
46
+ h = {
47
+ 'data' => {
48
+ 'attributes' => {},
49
+ 'relationships' => {}
50
+ }
51
+ }
52
+
53
+ each_pair do |k, _v|
54
+ h['data']['attributes'][k] = send(k)
55
+ end
56
+
57
+ h
58
+ end
59
+
60
+ def to_json(*_args)
61
+ JSON.generate as_json
62
+ end
63
+
64
+ private
65
+
66
+ # rubocop:disable Style/RescueModifier
67
+ def cast_attrs(obj)
68
+ obj.each do |k, v|
69
+ next unless k.match?(/_at$/)
70
+
71
+ time = Time.parse(v) rescue nil
72
+ obj[k] = time if time
73
+ end
74
+ end
75
+ # rubocop:enable Style/RescueModifier
76
+ end