fixably 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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +18 -0
  3. data/.gitignore +14 -0
  4. data/.mutant.yml +23 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +145 -0
  7. data/CODE_OF_CONDUCT.md +84 -0
  8. data/Gemfile +12 -0
  9. data/Gemfile.lock +119 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +227 -0
  12. data/Rakefile +12 -0
  13. data/bin/console +15 -0
  14. data/bin/mutant +3 -0
  15. data/bin/setup +8 -0
  16. data/docs/customer/child.md +23 -0
  17. data/docs/customer.md +78 -0
  18. data/docs/device.md +56 -0
  19. data/docs/order/line.md +27 -0
  20. data/docs/order/note.md +34 -0
  21. data/docs/order/task.md +27 -0
  22. data/docs/order.md +121 -0
  23. data/docs/queue.md +36 -0
  24. data/docs/status.md +42 -0
  25. data/docs/user.md +23 -0
  26. data/fixably.gemspec +38 -0
  27. data/lib/fixably/action_policy.rb +94 -0
  28. data/lib/fixably/actions.rb +148 -0
  29. data/lib/fixably/active_resource/base.rb +53 -0
  30. data/lib/fixably/active_resource/paginated_collection.rb +91 -0
  31. data/lib/fixably/application_resource.rb +49 -0
  32. data/lib/fixably/argument_parameterisation.rb +77 -0
  33. data/lib/fixably/authorization.rb +17 -0
  34. data/lib/fixably/config.rb +39 -0
  35. data/lib/fixably/create_has_many_record.rb +90 -0
  36. data/lib/fixably/encoding.rb +38 -0
  37. data/lib/fixably/load_from_response.rb +116 -0
  38. data/lib/fixably/logger.rb +33 -0
  39. data/lib/fixably/resource_lazy_loader.rb +76 -0
  40. data/lib/fixably/resources/customer.rb +64 -0
  41. data/lib/fixably/resources/device.rb +27 -0
  42. data/lib/fixably/resources/order.rb +87 -0
  43. data/lib/fixably/resources/queue.rb +9 -0
  44. data/lib/fixably/resources/status.rb +12 -0
  45. data/lib/fixably/resources/user.rb +15 -0
  46. data/lib/fixably/version.rb +5 -0
  47. data/lib/fixably.rb +37 -0
  48. metadata +217 -0
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveResource
4
+ class Base
5
+ class << self
6
+ private
7
+
8
+ alias original_instantiate_record instantiate_record
9
+
10
+ # Fixably uses camel case keys but it's more Ruby-like to use underscores
11
+ def instantiate_record(record, prefix_options = {})
12
+ underscored_record = record.deep_transform_keys(&:underscore)
13
+ original_instantiate_record(underscored_record, prefix_options)
14
+ end
15
+
16
+ alias original_query_string query_string
17
+
18
+ # Fixably expects all searches to be sent under a singular query parameter
19
+ # q=search1,search2,attribute:search3
20
+ def query_string(options)
21
+ opts = {}
22
+
23
+ non_query_parameters.each do
24
+ opts[_1] = options.fetch(_1) if options[_1]
25
+ end
26
+
27
+ f = filters(options)
28
+ opts[:q] = f.join(",") unless f.count.zero?
29
+
30
+ original_query_string(opts)
31
+ end
32
+
33
+ def filters(options)
34
+ options.each_with_object([]) do |(key, value), array|
35
+ next if non_query_parameters.include?(key)
36
+
37
+ array <<
38
+ if key.equal?(:filter)
39
+ value
40
+ else
41
+ camel_key = key.to_s.camelize(:lower)
42
+ "#{camel_key}:#{value}"
43
+ end
44
+ end
45
+ end
46
+
47
+ def non_query_parameters = %i[expand limit offset page]
48
+ end
49
+ end
50
+ end
51
+
52
+ ActiveResource::Base.collection_parser =
53
+ Fixably::ActiveResource::PaginatedCollection
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../create_has_many_record"
4
+
5
+ module Fixably
6
+ module ActiveResource
7
+ class PaginatedCollection < ::ActiveResource::Collection
8
+ class << self
9
+ def paginatable?(value)
10
+ collection = attributes(value)
11
+ return false unless collection.is_a?(Hash)
12
+
13
+ interface = %w[limit offset total_items items]
14
+ (interface - collection.keys).empty?
15
+ end
16
+
17
+ def attributes(collection_wrapper)
18
+ if collection_wrapper.respond_to?(:attributes)
19
+ collection_wrapper.attributes
20
+ else
21
+ collection_wrapper
22
+ end
23
+ end
24
+ end
25
+
26
+ attr_reader :limit
27
+ attr_reader :offset
28
+ attr_reader :total_items
29
+
30
+ attr_accessor :parent_resource
31
+ attr_accessor :parent_association
32
+
33
+ def initialize(collection_wrapper = nil)
34
+ @limit = collection_wrapper&.fetch("limit") || 0
35
+ @offset = collection_wrapper&.fetch("offset") || 0
36
+ @total_items = collection_wrapper&.fetch("totalItems") do
37
+ collection_wrapper.fetch("total_items")
38
+ end || 0
39
+
40
+ collection = collection_wrapper&.fetch("items") || []
41
+ super(collection)
42
+ end
43
+
44
+ def <<(record)
45
+ CreateHasManyRecord.(record: record, collection: self)
46
+ end
47
+
48
+ def paginated_each
49
+ page = self
50
+
51
+ loop do
52
+ page.each { yield(_1) }
53
+ break unless page.next_page?
54
+
55
+ page = page.next_page
56
+ end
57
+ end
58
+
59
+ def paginated_map
60
+ [].tap do |records|
61
+ paginated_each { records << _1 }
62
+ end
63
+ end
64
+
65
+ def next_page
66
+ raise StopIteration, "There are no more pages" unless next_page?
67
+
68
+ where(limit: limit, offset: offset + limit)
69
+ end
70
+
71
+ def next_page?
72
+ (limit + offset) < total_items
73
+ end
74
+
75
+ def previous_page
76
+ raise StopIteration, "There are no more pages" unless previous_page?
77
+
78
+ new_offset = offset - limit
79
+ new_limit = limit
80
+ new_limit += new_offset if new_offset.negative?
81
+ new_offset = 0 if new_offset.negative?
82
+
83
+ where(limit: new_limit, offset: new_offset)
84
+ end
85
+
86
+ def previous_page?
87
+ offset.positive?
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actions"
4
+ require_relative "authorization"
5
+ require_relative "encoding"
6
+ require_relative "load_from_response"
7
+
8
+ module Fixably
9
+ class ApplicationResource < ::ActiveResource::Base
10
+ self.include_format_in_path = false
11
+
12
+ include Actions
13
+ include Encoding
14
+ include LoadFromResponse
15
+ extend Authorization
16
+
17
+ attr_accessor :parent_association
18
+
19
+ class << self
20
+ def site
21
+ self.site = site_url unless _site_defined?
22
+ super()
23
+ end
24
+
25
+ protected
26
+
27
+ def site_url
28
+ subdomain = Fixably.config.require(:subdomain)
29
+ base_url = "https://#{subdomain}.fixably.com/api/#{api_version}"
30
+
31
+ name_parts = name.split("::")
32
+ return base_url if name_parts.length.equal?(2)
33
+
34
+ parent_resource = name_parts.fetch(1).underscore
35
+ "#{base_url}/#{parent_resource.pluralize}/:#{parent_resource}_id"
36
+ end
37
+
38
+ private
39
+
40
+ def api_version = "v3"
41
+ end
42
+
43
+ def initialize(attributes = {}, persisted = false) # rubocop:disable Style/OptionalBooleanParameter
44
+ super(attributes, persisted)
45
+
46
+ self.class.site
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fixably
4
+ module ArgumentParameterisation
5
+ private
6
+
7
+ def parametize_arguments(scope, arguments)
8
+ arguments ||= {}
9
+ arguments.merge!(arguments.delete(:params)) if arguments.key?(:params)
10
+
11
+ associations = expand_associations(scope, arguments)
12
+ arguments[:expand] = associations if associations
13
+
14
+ { params: arguments }
15
+ end
16
+
17
+ def expand_associations(scope, arguments)
18
+ return if arguments[:expand].instance_of?(String)
19
+
20
+ case scope
21
+ when :all, :first, :last
22
+ assoc = associations(arguments)&.join(",")
23
+ assoc ? "items(#{assoc})" : "items"
24
+ when :one, nil, Integer, String
25
+ associations(arguments)&.join(",")
26
+ else
27
+ raise ArgumentError, "Unknown scope: #{scope.inspect}"
28
+ end
29
+ end
30
+
31
+ def associations(arguments)
32
+ arguments[:expand]&.to_set { expand_association(_1) }
33
+ end
34
+
35
+ def expand_association(association)
36
+ relationship = reflections.fetch(association).macro
37
+ case relationship
38
+ when :has_one
39
+ association
40
+ when :has_many
41
+ "#{association}(items)"
42
+ else
43
+ raise ArgumentError, "Unknown relationship, #{relationship}"
44
+ end
45
+ end
46
+
47
+ def stringify_array_values(arguments)
48
+ arguments.tap do |args|
49
+ args.each do |attribute, value|
50
+ next unless value.is_a?(Array)
51
+
52
+ validate_array_value!(attribute, value)
53
+ value << nil if value.length.equal?(1)
54
+ args[attribute] = "[#{value.map { stringify(_1) }.join(",")}]"
55
+ end
56
+ end
57
+ end
58
+
59
+ def validate_array_value!(attribute, value)
60
+ return if value.length.positive? && value.length <= 2
61
+
62
+ raise(
63
+ ArgumentError,
64
+ "Ranged searches should have either 1 or 2 values but " \
65
+ "#{attribute} has #{value.length}"
66
+ )
67
+ end
68
+
69
+ def stringify(value)
70
+ if value.respond_to?(:strftime)
71
+ value.strftime("%F")
72
+ else
73
+ value
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fixably
4
+ module Authorization
5
+ def headers
6
+ result = super()
7
+ result["Authorization"] = api_key
8
+ result
9
+ end
10
+
11
+ private
12
+
13
+ def api_key
14
+ Fixably.config.require(:api_key)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "logger"
4
+
5
+ module Fixably
6
+ class Config
7
+ attr_accessor :api_key
8
+ attr_accessor :subdomain
9
+
10
+ def logger
11
+ Logger.logger
12
+ end
13
+
14
+ def logger=(log)
15
+ Logger.logger = log
16
+ end
17
+
18
+ def require(param)
19
+ value = public_send(param)
20
+ return value unless value.nil? || value.empty?
21
+
22
+ require_error(param)
23
+ end
24
+
25
+ private
26
+
27
+ def require_error(param)
28
+ raise(
29
+ ArgumentError,
30
+ <<~MESSAGE
31
+ #{param} is required but hasn't been set.
32
+ Fixably.configure do |config|
33
+ config.#{param} = "value"
34
+ end
35
+ MESSAGE
36
+ )
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fixably
4
+ class CreateHasManyRecord
5
+ def self.call(record:, collection:)
6
+ new(record: record, collection: collection).call
7
+ end
8
+
9
+ attr_reader :record
10
+ attr_reader :collection
11
+
12
+ def initialize(record:, collection:)
13
+ @record = record
14
+ @collection = collection
15
+ end
16
+
17
+ def call
18
+ can_append!
19
+ save_record
20
+ collection.elements << record
21
+ end
22
+
23
+ private
24
+
25
+ def can_append!
26
+ instance_of_resource_class!
27
+ nested_resource!
28
+ parent_recource_known!
29
+ parent_association_known!
30
+ parent_is_persisted!
31
+ end
32
+
33
+ def instance_of_resource_class!
34
+ return if record.instance_of?(collection.resource_class)
35
+
36
+ raise(
37
+ TypeError,
38
+ "Appended record must be an instance of " \
39
+ "#{collection.resource_class.name}"
40
+ )
41
+ end
42
+
43
+ def nested_resource!
44
+ return if nested_resource?
45
+
46
+ raise(
47
+ ArgumentError,
48
+ "Can only appended resources nested one level deep"
49
+ )
50
+ end
51
+
52
+ def nested_resource?
53
+ name_parts = record.class.name.split("::")
54
+ name_parts.length.equal?(3)
55
+ end
56
+
57
+ def parent_recource_known!
58
+ return if collection.parent_resource
59
+
60
+ raise "A parent resource has not been set"
61
+ end
62
+
63
+ def parent_association_known!
64
+ return if collection.parent_association
65
+
66
+ raise "The association to the parent resource has not been set"
67
+ end
68
+
69
+ def parent_is_persisted!
70
+ if !collection.parent_resource.persisted?
71
+ raise "The parent resource has not been been persisted"
72
+ end
73
+
74
+ if !collection.parent_resource.id?
75
+ raise "Cannot find an ID for the parent resource"
76
+ end
77
+ end
78
+
79
+ def save_record
80
+ record.parent_association = collection.parent_association
81
+ record.prefix_options[parent_id_key] = collection.parent_resource.id
82
+ record.save!
83
+ end
84
+
85
+ def parent_id_key
86
+ "#{collection.parent_resource.class.name.split("::").last.underscore}_id".
87
+ to_sym
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fixably
4
+ module Encoding
5
+ # Since our monkey patch converts the keys to underscore, it is necessary to
6
+ # convert them back to camelcase when performing a create or update
7
+ def encode(_options = nil, attrs: nil)
8
+ attrs ||= attributes
9
+ remove_has_many_associations(attrs)
10
+ remove_unallowed_parameters(attrs)
11
+ nest_for_association(attrs).
12
+ deep_transform_keys { _1.camelize(:lower) }.
13
+ public_send("to_#{self.class.format.extension}")
14
+ end
15
+
16
+ protected
17
+
18
+ def remove_on_encode = %w[created_at href id]
19
+
20
+ private
21
+
22
+ def remove_has_many_associations(attrs)
23
+ reflections.select { _2.macro.equal?(:has_many) }.keys.each do
24
+ attrs.delete(_1)
25
+ end
26
+ end
27
+
28
+ def remove_unallowed_parameters(attrs)
29
+ remove_on_encode.each { attrs.delete(_1) }
30
+ end
31
+
32
+ def nest_for_association(attrs)
33
+ return attrs unless parent_association
34
+
35
+ { parent_association.to_s => [attrs] }
36
+ end
37
+ end
38
+ end