hoalife 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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