skull_island 0.1.0 → 0.1.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +39 -0
  4. data/.travis.yml +9 -2
  5. data/Gemfile +3 -1
  6. data/Gemfile.lock +127 -0
  7. data/README.md +348 -2
  8. data/Rakefile +13 -3
  9. data/bin/console +4 -3
  10. data/lib/core_extensions/string/transformations.rb +30 -0
  11. data/lib/skull_island/api_client.rb +36 -0
  12. data/lib/skull_island/api_client_base.rb +86 -0
  13. data/lib/skull_island/api_exception.rb +7 -0
  14. data/lib/skull_island/exceptions/api_client_not_configured.rb +9 -0
  15. data/lib/skull_island/exceptions/immutable_modification.rb +9 -0
  16. data/lib/skull_island/exceptions/invalid_arguments.rb +9 -0
  17. data/lib/skull_island/exceptions/invalid_cache_size.rb +9 -0
  18. data/lib/skull_island/exceptions/invalid_options.rb +9 -0
  19. data/lib/skull_island/exceptions/invalid_property.rb +9 -0
  20. data/lib/skull_island/exceptions/invalid_where_query.rb +9 -0
  21. data/lib/skull_island/exceptions/new_instance_with_id.rb +9 -0
  22. data/lib/skull_island/helpers/api_client.rb +64 -0
  23. data/lib/skull_island/helpers/resource.rb +178 -0
  24. data/lib/skull_island/helpers/resource_class.rb +74 -0
  25. data/lib/skull_island/lru_cache.rb +175 -0
  26. data/lib/skull_island/resource.rb +198 -0
  27. data/lib/skull_island/resource_collection.rb +193 -0
  28. data/lib/skull_island/resources/certificate.rb +36 -0
  29. data/lib/skull_island/resources/consumer.rb +20 -0
  30. data/lib/skull_island/resources/plugin.rb +144 -0
  31. data/lib/skull_island/resources/route.rb +83 -0
  32. data/lib/skull_island/resources/service.rb +94 -0
  33. data/lib/skull_island/resources/upstream.rb +129 -0
  34. data/lib/skull_island/resources/upstream_target.rb +86 -0
  35. data/lib/skull_island/rspec/fake_api_client.rb +63 -0
  36. data/lib/skull_island/rspec.rb +3 -0
  37. data/lib/skull_island/simple_api_client.rb +18 -0
  38. data/lib/skull_island/validations/api_client.rb +45 -0
  39. data/lib/skull_island/validations/resource.rb +24 -0
  40. data/lib/skull_island/version.rb +3 -1
  41. data/lib/skull_island.rb +47 -1
  42. data/skull_island.gemspec +16 -13
  43. metadata +66 -7
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ # A generic API resource
5
+ # TODO: Thread safety
6
+ class Resource
7
+ attr_accessor :api_client
8
+ attr_reader :errors
9
+
10
+ include Comparable
11
+ include Validations::Resource
12
+ include Helpers::Resource
13
+ extend Helpers::ResourceClass
14
+
15
+ # Can this type of resource be changed client-side?
16
+ def self.immutable(status)
17
+ raise Exceptions::InvalidArguments unless status.is_a?(TrueClass) || status.is_a?(FalseClass)
18
+
19
+ @immutable = status
20
+ end
21
+
22
+ # Define a property for a model
23
+ # @!macro [attach] property
24
+ # The $1 property
25
+ # @todo add more validations on options and names
26
+ def self.property(name, options = {})
27
+ @properties ||= {}
28
+
29
+ invalid_prop_names = %i[
30
+ > < = class def
31
+ % ! / . ? * {}
32
+ \[\]
33
+ ]
34
+
35
+ raise(Exceptions::InvalidProperty) if invalid_prop_names.include?(name.to_sym)
36
+
37
+ @properties[name.to_sym] = options
38
+ end
39
+
40
+ def self.gen_getter_method(name, opts)
41
+ determine_getter_names(name, opts).each do |method_name|
42
+ define_method(method_name) do
43
+ name_as_string = name.to_s
44
+ reload if @lazy && !@entity.key?(name_as_string)
45
+
46
+ if opts[:postprocess]
47
+ send("postprocess_#{name}".to_sym, @entity[name_as_string])
48
+ else
49
+ @entity[name_as_string]
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def self.gen_setter_method(name, opts)
56
+ determine_setter_names(name, opts).each do |method_name|
57
+ define_method(method_name) do |value|
58
+ raise Exceptions::ImmutableModification if immutable?
59
+
60
+ if opts[:validate]
61
+ raise Exceptions::InvalidArguments unless send("validate_#{name}".to_sym, value)
62
+ end
63
+ @entity[name.to_s] = if opts[:preprocess]
64
+ send("preprocess_#{name}".to_sym, value)
65
+ else
66
+ value
67
+ end
68
+ @tainted = true
69
+ @modified_properties << name.to_sym
70
+ end
71
+ end
72
+ end
73
+
74
+ def self.gen_property_methods
75
+ properties.each do |prop, opts|
76
+ # Getter methods
77
+ next if opts[:id_property]
78
+
79
+ gen_getter_method(prop, opts) unless opts[:write_only]
80
+
81
+ # Setter methods (don't make one for obviously read-only properties)
82
+ gen_setter_method(prop, opts) unless opts[:read_only]
83
+ end
84
+ end
85
+
86
+ # The URI (relative to the API base) for this object (or its index/list)
87
+ def self.relative_uri
88
+ route_key
89
+ end
90
+
91
+ def self.all(options = {})
92
+ # TODO: Add validations for options
93
+ # TODO: add validation checks for the required pieces
94
+
95
+ api_client = options[:api_client] || APIClient.instance
96
+
97
+ root = 'data' # root for API JSON response data
98
+ # TODO: do something with lazy requests...
99
+
100
+ ResourceCollection.new(
101
+ api_client.get(relative_uri)[root].collect do |record|
102
+ unless options[:lazy]
103
+ api_client.invalidate_cache_for "#{relative_uri}/#{record['id']}"
104
+ api_client.cache("#{relative_uri}/#{record['id']}") do
105
+ record
106
+ end
107
+ end
108
+ new(
109
+ entity: record,
110
+ lazy: (options[:lazy] ? true : false),
111
+ tainted: false,
112
+ api_client: api_client
113
+ )
114
+ end,
115
+ type: self,
116
+ api_client: api_client
117
+ )
118
+ end
119
+
120
+ def self.from_hash(hash)
121
+ # TODO: better options validations
122
+ raise Exceptions::InvalidOptions unless options.is_a?(Hash)
123
+
124
+ api_client = options[:api_client] || APIClient.instance
125
+
126
+ new(
127
+ entity: hash,
128
+ lazy: true,
129
+ tainted: true,
130
+ api_client: api_client
131
+ )
132
+ end
133
+
134
+ def self.get(id, options = {})
135
+ # TODO: Add validations for options
136
+
137
+ api_client = options[:api_client] || APIClient.instance
138
+
139
+ if options[:lazy]
140
+ new(
141
+ entity: { 'id' => id },
142
+ lazy: true,
143
+ tainted: false,
144
+ api_client: api_client
145
+ )
146
+ else
147
+ entity_data = api_client.cache("#{relative_uri}/#{id}") do |client|
148
+ client.get("#{relative_uri}/#{id}")
149
+ end
150
+
151
+ new(
152
+ entity: entity_data,
153
+ lazy: false,
154
+ tainted: false,
155
+ api_client: api_client
156
+ )
157
+ end
158
+ end
159
+
160
+ def self.where(attribute, value, options = {})
161
+ # TODO: validate incoming options
162
+ options[:comparison] ||= value.is_a?(Regexp) ? :match : '=='
163
+ api_client = options[:api_client] || APIClient.instance
164
+ all(lazy: (options[:lazy] ? true : false), api_client: api_client).where(
165
+ attribute, value, comparison: options[:comparison]
166
+ )
167
+ end
168
+
169
+ def initialize(options = {})
170
+ # TODO: better options validations
171
+ raise Exceptions::InvalidOptions unless options.is_a?(Hash)
172
+
173
+ @entity = options[:entity] || {}
174
+
175
+ # Allows lazy-loading if we're told this is a lazy instance
176
+ # This means only the minimal attributes were fetched.
177
+ # This shouldn't be set by end-users.
178
+ @lazy = options.key?(:lazy) ? options[:lazy] : false
179
+ # This allows local, user-created instances to be differentiated from fetched
180
+ # instances from the backend API. This shouldn't be set by end-users.
181
+ @tainted = options.key?(:tainted) ? options[:tainted] : true
182
+ # This is the API Client used to get data for this resource
183
+ @api_client = options[:api_client] || APIClient.instance
184
+ @errors = {}
185
+ # A place to store which properties have been modified
186
+ @modified_properties = []
187
+
188
+ validate_mutability
189
+ validate_id
190
+
191
+ self.class.class_eval { gen_property_methods }
192
+ end
193
+
194
+ def relative_uri
195
+ "#{self.class.relative_uri}/#{id}"
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ # The ResourceCollection class
5
+ # Should not allow or use mixed types
6
+ class ResourceCollection
7
+ include Enumerable
8
+ include Comparable
9
+
10
+ # @return [Class] a collection of this {Resource} subclass
11
+ attr_reader :type
12
+
13
+ def initialize(list, options = {})
14
+ # TODO: better options validations
15
+ raise Exceptions::InvalidOptions unless options.is_a?(Hash)
16
+ raise Exceptions::InvalidArguments if list.empty? && options[:type].nil?
17
+
18
+ @api_client = options[:api_client] || APIClient.instance
19
+ @list = list
20
+ @type = options[:type] || list.first.class
21
+ end
22
+
23
+ def each(&block)
24
+ @list.each(&block)
25
+ end
26
+
27
+ # Does the collection contain anything?
28
+ # @return [Boolean]
29
+ def empty?
30
+ @list.empty?
31
+ end
32
+
33
+ # Provide the first (or first `number`) entries
34
+ # @param number [Fixnum] How many to provide
35
+ # @return [ResourceCollection,Resource]
36
+ def first(number = nil)
37
+ if number
38
+ self.class.new(@list.first(number), type: @type, api_client: @api_client)
39
+ else
40
+ @list.first
41
+ end
42
+ end
43
+
44
+ # Provide the last (or last `number`) entries
45
+ # @param number [Fixnum] How many to provide
46
+ # @return [ResourceCollection,Resource]
47
+ def last(number = nil)
48
+ if number
49
+ self.class.new(@list.last(number), type: @type, api_client: @api_client)
50
+ else
51
+ @list.last
52
+ end
53
+ end
54
+
55
+ # Merge two collections
56
+ # @param other [ResourceCollection]
57
+ # @return [ResourceCollection]
58
+ def merge(other)
59
+ raise Exceptions::InvalidArguments unless other.is_a?(self.class)
60
+
61
+ self + (other - self)
62
+ end
63
+
64
+ # An alias for {#type}
65
+ def model
66
+ type
67
+ end
68
+
69
+ # Hacked together #or() method in the same spirit as #where().
70
+ # This method can be chained for multiple / more specific queries.
71
+ #
72
+ # @param attribute [Symbol] the attribute to query
73
+ # @param value [Object] the value to compare against
74
+ # - allowed options are "'==', '!=', '>', '>=', '<', '<=', and 'match'"
75
+ # @raise [Exceptions::InvalidWhereQuery] if not the right kind of comparison
76
+ # @return [ResourceCollection]
77
+ def or(attribute, value, options = {})
78
+ options[:comparison] ||= value.is_a?(Regexp) ? :match : '=='
79
+ if empty?
80
+ @type.where(attribute, value, comparison: options[:comparison], api_client: @api_client)
81
+ else
82
+ merge first.class.where(
83
+ attribute, value,
84
+ comparison: options[:comparison],
85
+ api_client: @api_client
86
+ )
87
+ end
88
+ end
89
+
90
+ # Pass pagination through to the Array (which passes to will_paginate)
91
+ def paginate(*args)
92
+ @list.paginate(*args)
93
+ end
94
+
95
+ # Returns the number of Resource instances in the collection
96
+ # @return [Fixnum]
97
+ def size
98
+ @list.size
99
+ end
100
+
101
+ # Allow complex sorting like an Array
102
+ # @return [ResourceCollection] sorted collection
103
+ def sort(&block)
104
+ self.class.new(super(&block), type: @type, api_client: @api_client)
105
+ end
106
+
107
+ # Horribly inefficient way to allow querying Resources by their attributes.
108
+ # This method can be chained for multiple / more specific queries.
109
+ #
110
+ # @param attribute [Symbol] the attribute to query
111
+ # @param value [Object] the value to compare against
112
+ # - allowed options are "'==', '!=', '>', '>=', '<', '<=', and 'match'"
113
+ # @raise [Exceptions::InvalidWhereQuery] if not the right kind of comparison
114
+ # @return [ResourceCollection]
115
+ def where(attribute, value, options = {})
116
+ valid_comparisons = %i[== != > >= < <= match]
117
+ options[:comparison] ||= value.is_a?(Regexp) ? :match : '=='
118
+ unless valid_comparisons.include?(options[:comparison].to_sym)
119
+ raise Exceptions::InvalidWhereQuery
120
+ end
121
+
122
+ self.class.new(
123
+ @list.collect do |item|
124
+ if item.send(attribute).nil?
125
+ nil
126
+ elsif item.send(attribute).send(options[:comparison].to_sym, value)
127
+ item
128
+ end
129
+ end.compact,
130
+ type: @type,
131
+ api_client: @api_client
132
+ )
133
+ end
134
+
135
+ alias and where
136
+
137
+ # Return the collection item at the specified index
138
+ # @return [Resource,ResourceCollection] the item at the requested index
139
+ def [](index)
140
+ if index.is_a?(Range)
141
+ self.class.new(@list[index], type: @type, api_client: @api_client)
142
+ else
143
+ @list[index]
144
+ end
145
+ end
146
+
147
+ # Return a collection after subtracting from the original
148
+ # @return [ResourceCollection]
149
+ def -(other)
150
+ if other.respond_to?(:to_a)
151
+ self.class.new(@list - other.to_a, type: @type, api_client: @api_client)
152
+ elsif other.is_a?(Resource)
153
+ self.class.new(@list - Array(other), type: @type, api_client: @api_client)
154
+ else
155
+ raise Exceptions::InvalidArguments
156
+ end
157
+ end
158
+
159
+ # Return a collection after adding to the original
160
+ # Warning: this may cause duplicates or mixed type joins! For safety,
161
+ # use #merge
162
+ # @return [ResourceCollection]
163
+ def +(other)
164
+ if other.is_a?(self.class)
165
+ self.class.new(@list + other.to_a, type: @type, api_client: @api_client)
166
+ elsif other.is_a?(@type)
167
+ self.class.new(@list + [other], type: @type, api_client: @api_client)
168
+ else
169
+ raise Exceptions::InvalidArguments
170
+ end
171
+ end
172
+
173
+ def <<(other)
174
+ raise Exceptions::InvalidArguments, 'Resource Type Mismatch' unless other.class == @type
175
+
176
+ @list << other
177
+ end
178
+
179
+ def <=>(other)
180
+ collect(&:id).sort <=> other.collect(&:id).sort
181
+ end
182
+
183
+ # Allow comparison of collection
184
+ # @return [Boolean] do the collections contain the same resource ids?
185
+ def ==(other)
186
+ if other.is_a? self.class
187
+ collect(&:id).sort == other.collect(&:id).sort
188
+ else
189
+ false
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ # Resource classes go here...
5
+ module Resources
6
+ # The Certificate resource class
7
+ #
8
+ # @see https://docs.konghq.com/0.14.x/admin-api/#certificate-object Certificate API definition
9
+ class Certificate < Resource
10
+ property :cert, required: true, validate: true
11
+ property :key, required: true, validate: true
12
+ property :snis, validate: true
13
+ property :created_at, read_only: true, postprocess: true
14
+
15
+ private
16
+
17
+ # Used to validate {#cert} on set
18
+ def validate_cert(value)
19
+ # only String is allowed
20
+ value.is_a?(String)
21
+ end
22
+
23
+ # Used to validate {#key} on set
24
+ def validate_key(value)
25
+ # only String is allowed
26
+ value.is_a?(String)
27
+ end
28
+
29
+ # Used to validate {#snis} on set
30
+ def validate_snis(value)
31
+ # only Array is allowed
32
+ value.is_a?(Array)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ # Resource classes go here...
5
+ module Resources
6
+ # The Consumer resource class
7
+ #
8
+ # @see https://docs.konghq.com/0.14.x/admin-api/#consumer-object Consumer API definition
9
+ class Consumer < Resource
10
+ property :username
11
+ property :custom_id
12
+ property :created_at, read_only: true, postprocess: true
13
+
14
+ # Provides a collection of related {Plugin} instances
15
+ def plugins
16
+ Plugin.where(:consumer, self, api_client: api_client)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ # Resource classes go here...
5
+ module Resources
6
+ # The Plugin resource class
7
+ #
8
+ # @see https://docs.konghq.com/0.14.x/admin-api/#plugin-object Plugin API definition
9
+ class Plugin < Resource
10
+ property :name
11
+ property :enabled, type: :boolean
12
+ # property :run_on # 1.0.x only
13
+ property :config, validate: true
14
+ property :consumer_id, validate: true, preprocess: true, postprocess: true, as: :consumer
15
+ property :route_id, validate: true, preprocess: true, postprocess: true, as: :route
16
+ property :service_id, validate: true, preprocess: true, postprocess: true, as: :service
17
+ property :created_at, read_only: true, postprocess: true
18
+
19
+ def self.enabled_names(api_client: APIClient.instance)
20
+ api_client.get("#{relative_uri}/enabled")['enabled_plugins']
21
+ end
22
+
23
+ def self.schema(name, api_client: APIClient.instance)
24
+ api_client.get("#{relative_uri}/schema/#{name}")
25
+ end
26
+
27
+ private
28
+
29
+ # TODO: 1.0.x requires refactoring as `consumer_id` becomes `consumer`
30
+ def postprocess_consumer_id(value)
31
+ if value.is_a?(Hash)
32
+ Consumer.new(
33
+ entity: value,
34
+ lazy: true,
35
+ tainted: false
36
+ )
37
+ elsif value.is_a?(String)
38
+ Consumer.new(
39
+ entity: { 'id' => value },
40
+ lazy: true,
41
+ tainted: false
42
+ )
43
+ else
44
+ value
45
+ end
46
+ end
47
+
48
+ # TODO: 1.0.x requires refactoring as `consumer_id` becomes `consumer`
49
+ def preprocess_consumer_id(input)
50
+ if input.is_a?(Hash)
51
+ input['id']
52
+ elsif input.is_a?(Consumer)
53
+ input.id
54
+ else
55
+ input
56
+ end
57
+ end
58
+
59
+ # TODO: 1.0.x requires refactoring as `route_id` becomes `route`
60
+ def postprocess_route_id(value)
61
+ if value.is_a?(Hash)
62
+ Route.new(
63
+ entity: value,
64
+ lazy: true,
65
+ tainted: false
66
+ )
67
+ elsif value.is_a?(String)
68
+ Route.new(
69
+ entity: { 'id' => value },
70
+ lazy: true,
71
+ tainted: false
72
+ )
73
+ else
74
+ value
75
+ end
76
+ end
77
+
78
+ # TODO: 1.0.x requires refactoring as `route_id` becomes `route`
79
+ def preprocess_route_id(input)
80
+ if input.is_a?(Hash)
81
+ input['id']
82
+ elsif input.is_a?(Route)
83
+ input.id
84
+ else
85
+ input
86
+ end
87
+ end
88
+
89
+ # TODO: 1.0.x requires refactoring as `service_id` becomes `service`
90
+ def postprocess_service_id(value)
91
+ if value.is_a?(Hash)
92
+ Service.new(
93
+ entity: value,
94
+ lazy: true,
95
+ tainted: false
96
+ )
97
+ elsif value.is_a?(String)
98
+ Service.new(
99
+ entity: { 'id' => value },
100
+ lazy: true,
101
+ tainted: false
102
+ )
103
+ else
104
+ value
105
+ end
106
+ end
107
+
108
+ # TODO: 1.0.x requires refactoring as `service_id` becomes `service`
109
+ def preprocess_service_id(input)
110
+ if input.is_a?(Hash)
111
+ input['id']
112
+ elsif input.is_a?(Service)
113
+ input.id
114
+ else
115
+ input
116
+ end
117
+ end
118
+
119
+ # Used to validate {#config} on set
120
+ def validate_config(value)
121
+ # only Hashes are allowed
122
+ value.is_a?(Hash)
123
+ end
124
+
125
+ # Used to validate {#consumer} on set
126
+ def validate_consumer_id(value)
127
+ # allow either a Consumer object or a Hash of a specific structure
128
+ value.is_a?(Consumer) || (value.is_a?(Hash) && value['id'].is_a?(String))
129
+ end
130
+
131
+ # Used to validate {#route} on set
132
+ def validate_route_id(value)
133
+ # allow either a Route object or a Hash of a specific structure
134
+ value.is_a?(Route) || (value.is_a?(Hash) && value['id'].is_a?(String))
135
+ end
136
+
137
+ # Used to validate {#service} on set
138
+ def validate_service_id(value)
139
+ # allow either a Service object or a Hash of a specific structure
140
+ value.is_a?(Service) || (value.is_a?(Hash) && value['id'].is_a?(String))
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ # Resource classes go here...
5
+ module Resources
6
+ # The Route resource class
7
+ #
8
+ # @see https://docs.konghq.com/0.14.x/admin-api/#route-object Route API definition
9
+ class Route < Resource
10
+ property :name
11
+ property :methods
12
+ property :paths
13
+ property :protocols, validate: true
14
+ property :hosts, validate: true
15
+ property :regex_priority, validate: true
16
+ property :strip_path, type: :boolean
17
+ property :preserve_host, type: :boolean
18
+ # The following are 1.0.x only
19
+ # property :snis
20
+ # property :sources
21
+ # property :destinations
22
+ property :service, validate: true, preprocess: true, postprocess: true
23
+ property :created_at, read_only: true, postprocess: true
24
+ property :updated_at, read_only: true, postprocess: true
25
+
26
+ # Provides a collection of related {Plugin} instances
27
+ def plugins
28
+ Plugin.where(:route, self, api_client: api_client)
29
+ end
30
+
31
+ private
32
+
33
+ def postprocess_service(value)
34
+ if value.is_a?(Hash)
35
+ Service.new(
36
+ entity: value,
37
+ lazy: true,
38
+ tainted: false
39
+ )
40
+ else
41
+ value
42
+ end
43
+ end
44
+
45
+ def preprocess_service(input)
46
+ if input.is_a?(Hash)
47
+ input
48
+ else
49
+ { 'id' => input.id }
50
+ end
51
+ end
52
+
53
+ # Used to validate {#protocols} on set
54
+ def validate_protocols(value)
55
+ value.is_a?(Array) && # Must be an array
56
+ (1..4).cover?(value.size) && # Must be exactly 1..4 in size
57
+ value.uniq == value && # Must not have duplicate values
58
+ (value - %w[http https tls tcp]).empty? # Must only contain appropriate protocols
59
+ end
60
+
61
+ # Used to validate {#hosts} on set
62
+ def validate_hosts(value)
63
+ # allow only valid hostnames
64
+ value.each do |host|
65
+ return false unless host.match?(host_regex) && !host.match?(/_/)
66
+ end
67
+ true
68
+ end
69
+
70
+ # Used to validate {#regex_priority} on set
71
+ def validate_regex_priority(value)
72
+ # only positive Integers are allowed
73
+ value.is_a?(Integer) && (value.positive? || value.zero?)
74
+ end
75
+
76
+ # Used to validate {#service} on set
77
+ def validate_service(value)
78
+ # allow either a Service object or a Hash of a specific structure
79
+ value.is_a?(Service) || (value.is_a?(Hash) && value['id'].is_a?(String))
80
+ end
81
+ end
82
+ end
83
+ end