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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ # The API Client Base class
5
+ class APIClientBase
6
+ attr_reader :server, :base_uri
7
+ attr_accessor :username, :password
8
+
9
+ include Validations::APIClient
10
+ include Helpers::APIClient
11
+
12
+ def api_uri
13
+ @api_uri ||= URI.parse(server)
14
+ @api_uri.path = base_uri if base_uri
15
+ @api_uri
16
+ end
17
+
18
+ def authenticated?
19
+ raise Exceptions::APIClientNotConfigured unless configured?
20
+
21
+ @username && @password ? true : false
22
+ end
23
+
24
+ def configured?
25
+ @configured ? true : false
26
+ end
27
+
28
+ def json_headers
29
+ { content_type: :json, accept: :json }
30
+ end
31
+
32
+ def get(uri, data = nil)
33
+ client_action do |client|
34
+ if data
35
+ JSON.parse client[uri].get(json_headers.merge(params: data))
36
+ else
37
+ JSON.parse client[uri].get(json_headers)
38
+ end
39
+ end
40
+ end
41
+
42
+ def post(uri, data = nil)
43
+ client_action do |client|
44
+ if data
45
+ JSON.parse client[uri].post(json_escape(data.to_json), json_headers)
46
+ else
47
+ JSON.parse client[uri].post(nil, json_headers)
48
+ end
49
+ end
50
+ end
51
+
52
+ def patch(uri, data)
53
+ client_action do |client|
54
+ response = client[uri].patch(json_escape(data.to_json), json_headers)
55
+ if response && !response.empty?
56
+ JSON.parse(response)
57
+ else
58
+ true
59
+ end
60
+ end
61
+ end
62
+
63
+ def put(uri, data)
64
+ client_action do |client|
65
+ client[uri].put(json_escape(data.to_json), json_headers)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def client_action
72
+ raise Exceptions::APIClientNotConfigured unless configured?
73
+
74
+ yield connection
75
+ end
76
+
77
+ # Don't bother creating a connection until we need one
78
+ def connection
79
+ @connection ||= if authenticated?
80
+ RestClient::Resource.new(api_uri.to_s, @username, @password)
81
+ else
82
+ RestClient::Resource.new(api_uri.to_s)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ # Generic Exception definition
5
+ class APIException < StandardError
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Exceptions
5
+ # The client must be configured before it can be used
6
+ class APIClientNotConfigured < APIException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Exceptions
5
+ # Provided when an attempt is made to modify an immutable resource
6
+ class ImmutableModification < APIException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Exceptions
5
+ # Missing, incorrect argument(s) were passed
6
+ class InvalidArguments < APIException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Exceptions
5
+ # Provided when a cache size is invalid
6
+ class InvalidCacheSize < APIException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Exceptions
5
+ # Missing, incorrect, or extra options were passed
6
+ class InvalidOptions < APIException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Exceptions
5
+ # Provided when property definitions use inappropriate names
6
+ class InvalidProperty < APIException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Exceptions
5
+ # Provided when a query is constructed but is invalid
6
+ class InvalidWhereQuery < APIException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Exceptions
5
+ # ID can not be manually specified on resources
6
+ class NewInstanceWithID < APIException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Helpers
5
+ # Simple helper methods for the API Client
6
+ module APIClient
7
+ def about_service
8
+ get '/'
9
+ end
10
+
11
+ def server_status
12
+ get '/status'
13
+ end
14
+
15
+ def cache(key)
16
+ symbolized_key = key.to_sym
17
+ if !@cache.has?(symbolized_key) && block_given?
18
+ result = yield(self)
19
+ @cache.store(symbolized_key, result)
20
+ elsif !@cache.has?(symbolized_key)
21
+ return nil
22
+ end
23
+ @cache.retrieve(symbolized_key)
24
+ end
25
+
26
+ def invalidate_cache_for(key)
27
+ symbolized_key = key.to_sym
28
+ @cache.invalidate(symbolized_key)
29
+ end
30
+
31
+ def lru_cache
32
+ @cache
33
+ end
34
+
35
+ # Substitute characters with their JSON-supported versions
36
+ # @return [String]
37
+ def json_escape(string)
38
+ json_escape = {
39
+ '&' => '\u0026',
40
+ '>' => '\u003e',
41
+ '<' => '\u003c',
42
+ '%' => '\u0025',
43
+ "\u2028" => '\u2028',
44
+ "\u2029" => '\u2029'
45
+ }
46
+ json_escape_regex = /[\u2028\u2029&><%]/u
47
+
48
+ string.to_s.gsub(json_escape_regex, json_escape)
49
+ end
50
+
51
+ # Provides access to the "raw" underlying rest-client
52
+ # @return [RestClient::Resource]
53
+ def raw
54
+ connection
55
+ end
56
+
57
+ # The API Client version (uses Semantic Versioning)
58
+ # @return [String]
59
+ def version
60
+ VERSION
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Helpers
5
+ # Simple helper methods for Resources
6
+ module Resource
7
+ def datetime_from_params(params, actual_key)
8
+ DateTime.new(
9
+ params["#{actual_key}(1i)"].to_i,
10
+ params["#{actual_key}(2i)"].to_i,
11
+ params["#{actual_key}(3i)"].to_i,
12
+ params["#{actual_key}(4i)"].to_i,
13
+ params["#{actual_key}(5i)"].to_i
14
+ )
15
+ end
16
+
17
+ def fresh?
18
+ !tainted?
19
+ end
20
+
21
+ def host_regex
22
+ /^(([\w]|[\w][\w\-]*[\w])\.)*([\w]|[\w][\w\-]*[\w])$/
23
+ end
24
+
25
+ def id_property
26
+ self.class.properties.select { |_, opts| opts[:id_property] }.keys.first || 'id'
27
+ end
28
+
29
+ def id
30
+ @entity[id_property.to_s]
31
+ end
32
+
33
+ def immutable?
34
+ self.class.immutable?
35
+ end
36
+
37
+ # ActiveRecord ActiveModel::Name compatibility method
38
+ def model_name
39
+ self.class
40
+ end
41
+
42
+ def new?
43
+ !@entity.key?(id_property.to_s)
44
+ end
45
+
46
+ # ActiveRecord ActiveModel::Model compatibility method
47
+ def persisted?
48
+ !new?
49
+ end
50
+
51
+ def postprocess_created_at(value)
52
+ Time.at(value).utc.to_datetime
53
+ end
54
+
55
+ def postprocess_updated_at(value)
56
+ Time.at(value).utc.to_datetime
57
+ end
58
+
59
+ def properties
60
+ self.class.properties
61
+ end
62
+
63
+ def required_properties
64
+ properties.select { |_key, value| value[:required] }
65
+ end
66
+
67
+ def tainted?
68
+ @tainted ? true : false
69
+ end
70
+
71
+ # ActiveRecord ActiveModel::Conversion compatibility method
72
+ def to_key
73
+ new? ? [] : [id]
74
+ end
75
+
76
+ # ActiveRecord ActiveModel::Conversion compatibility method
77
+ def to_model
78
+ self
79
+ end
80
+
81
+ # ActiveRecord ActiveModel::Conversion compatibility method
82
+ def to_param
83
+ new? ? nil : id.to_s
84
+ end
85
+
86
+ def destroy
87
+ raise Exceptions::ImmutableModification if immutable?
88
+
89
+ unless new?
90
+ @api_client.delete(relative_uri.to_s)
91
+ @api_client.invalidate_cache_for(relative_uri.to_s)
92
+ @lazy = false
93
+ @tainted = true
94
+ @entity.delete('id')
95
+ end
96
+ true
97
+ end
98
+
99
+ def reload
100
+ if new?
101
+ # Can't reload a new resource
102
+ false
103
+ else
104
+ @api_client.invalidate_cache_for(relative_uri.to_s)
105
+ entity_data = @api_client.cache(relative_uri.to_s) do |client|
106
+ client.get(relative_uri.to_s)
107
+ end
108
+ @entity = entity_data
109
+ @lazy = false
110
+ @tainted = false
111
+ true
112
+ end
113
+ end
114
+
115
+ def save
116
+ saveable_data = prune_for_save(@entity)
117
+ validate_required_properties(saveable_data)
118
+
119
+ if new?
120
+ @entity = @api_client.post(save_uri.to_s, saveable_data)
121
+ @lazy = true
122
+ else
123
+ @api_client.invalidate_cache_for(relative_uri.to_s)
124
+ @entity = @api_client.put(relative_uri, saveable_data)
125
+ end
126
+ @tainted = false
127
+ true
128
+ end
129
+
130
+ def save_uri
131
+ self.class.relative_uri
132
+ end
133
+
134
+ # ActiveRecord ActiveModel compatibility method
135
+ def update(params)
136
+ new_params = {}
137
+ # need to convert multi-part datetime params
138
+ params.each do |key, value|
139
+ if /([^(]+)\(1i/.match?(key)
140
+ actual_key = key.match(/([^(]+)\(/)[1]
141
+ new_params[actual_key] = datetime_from_params(params, actual_key)
142
+ else
143
+ new_params[key] = value
144
+ end
145
+ end
146
+
147
+ new_params.each do |key, value|
148
+ setter_key = "#{key}=".to_sym
149
+ raise Exceptions::InvalidProperty unless respond_to?(setter_key)
150
+
151
+ send(setter_key, value)
152
+ end
153
+ save
154
+ end
155
+
156
+ def <=>(other)
157
+ if id < other.id
158
+ -1
159
+ elsif id > other.id
160
+ 1
161
+ elsif id == other.id
162
+ 0
163
+ else
164
+ raise Exceptions::InvalidArguments
165
+ end
166
+ end
167
+
168
+ def prune_for_save(data)
169
+ data.reject do |k, v|
170
+ k.to_sym == id_property ||
171
+ !properties[k.to_sym] ||
172
+ properties[k.to_sym][:read_only] ||
173
+ v.nil?
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ module Helpers
5
+ # Simple helper class methods for Resource
6
+ module ResourceClass
7
+ # Determine a list of names to use to access a resource entity attribute
8
+ # @param original_name [String,Symbol] the name of the underlying attribute
9
+ # @param opts [Hash] property options as defined in a {Resource} subclass
10
+ # @return [Array<Symbol>] the list of names
11
+ def determine_getter_names(original_name, opts)
12
+ names = []
13
+ names << (opts[:type] == :boolean ? "#{original_name}?" : original_name)
14
+ if opts[:as]
15
+ Array(opts[:as]).each do |new_name|
16
+ names << (opts[:type] == :boolean ? "#{new_name}?" : new_name)
17
+ end
18
+ end
19
+ names.map(&:to_sym).uniq
20
+ end
21
+
22
+ # Determine a list of names to use to set a resource entity attribute
23
+ # @param original_name [String,Symbol] the name of the underlying attribute
24
+ # @param opts [Hash] property options as defined in a {Resource} subclass
25
+ # @return [Array<Symbol>] the list of names
26
+ def determine_setter_names(original_name, opts)
27
+ names = ["#{original_name}="]
28
+ names.concat(Array(opts[:as]).map { |new_name| "#{new_name}=" }) if opts[:as]
29
+ names.map(&:to_sym).uniq
30
+ end
31
+
32
+ # Produce a more human-readable representation of {#i18n_key}
33
+ # @note ActiveRecord ActiveModel::Name compatibility method
34
+ # @return [String]
35
+ def human
36
+ i18n_key.humanize
37
+ end
38
+
39
+ # Check if a resource class is immutable
40
+ def immutable?
41
+ @immutable ||= false
42
+ end
43
+
44
+ # A mock internationalization key based on the class name
45
+ # @note ActiveRecord ActiveModel::Name compatibility method
46
+ # @return [String]
47
+ def i18n_key
48
+ name.split('::').last.to_underscore
49
+ end
50
+
51
+ alias singular_route_key i18n_key
52
+
53
+ # A symbolized version of {#i18n_key}
54
+ # @note ActiveRecord ActiveModel::Name compatibility method
55
+ # @return [Symbol]
56
+ def param_key
57
+ i18n_key.to_sym
58
+ end
59
+
60
+ # All the properties defined for this Resource class
61
+ # @return [Hash{Symbol => Hash}]
62
+ def properties
63
+ @properties ||= {}
64
+ end
65
+
66
+ # A route key for building URLs
67
+ # @note ActiveRecord ActiveModel::Name compatibility method
68
+ # @return [String]
69
+ def route_key
70
+ i18n_key.en.plural
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkullIsland
4
+ # A very simple Least Recently Used (LRU) cache implementation. Stores data
5
+ # in a Hash, uses a dedicated Array for storing and sorting keys (and
6
+ # implementing the LRU algorithm), and doesn't bother storing access
7
+ # information for cache data. It stores hit and miss counts for the
8
+ # entire cache (not for individual keys). It also uses three mutexes for
9
+ # thread-safety: a write lock, a read lock, and a metadata lock.
10
+ class LRUCache
11
+ attr_reader :max_size, :keys
12
+
13
+ # @raise [Exceptions::InvalidCacheSize] if the max_size isn't an Integer
14
+ def initialize(max_size = 100)
15
+ raise Exceptions::InvalidCacheSize unless max_size.is_a?(Integer)
16
+
17
+ @max_size = max_size
18
+ @hits = 0
19
+ @misses = 0
20
+ @keys = []
21
+ @data = {}
22
+ @read_mutex = Mutex.new
23
+ @write_mutex = Mutex.new
24
+ @meta_mutex = Mutex.new
25
+ end
26
+
27
+ # Does the cache contain the requested item?
28
+ # This doesn't count against cache misses
29
+ # @param key [Symbol] the index of the potentially cached object
30
+ def has?(key)
31
+ @meta_mutex.synchronize { @keys.include?(key) }
32
+ end
33
+
34
+ alias has_key? has?
35
+ alias include? has?
36
+
37
+ # The number of items in the cache
38
+ # @return [Fixnum] key count
39
+ def size
40
+ @meta_mutex.synchronize { @keys.size }
41
+ end
42
+
43
+ # Convert the contents of the cache to a Hash
44
+ # @return [Hash] the cached data
45
+ def to_hash
46
+ @read_mutex.synchronize { @data.dup }
47
+ end
48
+
49
+ # Return a raw Array of the cache data without its keys.
50
+ # Not particularly useful but it may be useful in the future.
51
+ # @return [Array] just the cached values
52
+ def values
53
+ @read_mutex.synchronize { @data.values }
54
+ end
55
+
56
+ # Allow iterating over the cached items, represented as key+value pairs
57
+ def each(&block)
58
+ to_hash.each(&block)
59
+ end
60
+
61
+ # Invalidate a cached item by its index / key. Returns `nil` if the object
62
+ # doesn't exist.
63
+ # @param key [Symbol] the cached object's index
64
+ def invalidate(key)
65
+ invalidate_key(key)
66
+ @write_mutex.synchronize { @data.delete(key) }
67
+ end
68
+
69
+ alias delete invalidate
70
+
71
+ # Remove all items from the cache without clearing statistics
72
+ # @return [Boolean] was the truncate operation successful?
73
+ def truncate
74
+ @read_mutex.synchronize do
75
+ @write_mutex.synchronize do
76
+ @meta_mutex.synchronize { @keys = [] }
77
+ @data = {}
78
+ end
79
+ @data.empty?
80
+ end
81
+ end
82
+
83
+ # Similar to {#truncate} (in fact, it calls it) but it also clears the
84
+ # statistical metadata.
85
+ # @return [Boolean] was the flush operation successful?
86
+ def flush
87
+ if truncate
88
+ @meta_mutex.synchronize do
89
+ @hits = 0
90
+ @misses = 0
91
+ end
92
+ true
93
+ else
94
+ false
95
+ end
96
+ end
97
+
98
+ # Provides a hash of the current metadata for the cache. It provides the
99
+ # current cache size (`:size`),the number of cache hits (`:hits`), and
100
+ # the number of cache misses (`:misses`).
101
+ # @return [Hash] cache statistics
102
+ def statistics
103
+ {
104
+ size: size,
105
+ hits: @meta_mutex.synchronize { @hits },
106
+ misses: @meta_mutex.synchronize { @misses }
107
+ }
108
+ end
109
+
110
+ # Store some data (`value`) indexed by a `key`. If an object exists with
111
+ # the same key, and the value is different, it will be overwritten.
112
+ # Storing a value causes its key to be moved to the end of the keys array
113
+ # (meaning it is the __most recently used__ item), and this happens on
114
+ # #store regardless of whether or not the key previously existed.
115
+ # This behavior is relied upon by {#retrieve} to allow reorganization of
116
+ # the keys without necessarily modifying the data it indexes.
117
+ # Uses recursion for overwriting existing items.
118
+ #
119
+ # @param key [Symbol] the index to use for referencing this cached item
120
+ # @param value [Object] the data to cache
121
+ def store(key, value)
122
+ if has?(key)
123
+ if @read_mutex.synchronize { @data[key] == value }
124
+ invalidate_key(key)
125
+ @meta_mutex.synchronize { @keys << key }
126
+ value
127
+ else
128
+ invalidate(key)
129
+ store(key, value)
130
+ end
131
+ else
132
+ invalidate(@keys.first) until size < @max_size
133
+
134
+ @write_mutex.synchronize do
135
+ @meta_mutex.synchronize { @keys << key }
136
+ @data[key] = value
137
+ end
138
+ end
139
+ end
140
+
141
+ alias []= store
142
+
143
+ # Retrieve an item from the cache. Returns `nil` if the item does not
144
+ # exist. Relies on {#store} returning the stored value to ensure the LRU
145
+ # algorithm is maintained safely.
146
+ # @param key [Symbol] the index to retrieve
147
+ def retrieve(key)
148
+ if has?(key)
149
+ @meta_mutex.synchronize { @hits += 1 }
150
+ # Looks dumb, but it actually only reorganizes the keys Array
151
+ store(key, @read_mutex.synchronize { @data[key] })
152
+ else
153
+ @meta_mutex.synchronize { @misses += 1 }
154
+ nil
155
+ end
156
+ end
157
+
158
+ alias [] retrieve
159
+
160
+ def marshal_dump
161
+ [@max_size, @hits, @misses, @keys, @data]
162
+ end
163
+
164
+ def marshal_load(array)
165
+ @max_size, @hits, @misses, @keys, @data = array
166
+ end
167
+
168
+ private
169
+
170
+ # Invalidate just the key of a cached item. Dangerous if used incorrectly.
171
+ def invalidate_key(key)
172
+ @meta_mutex.synchronize { @keys.delete(key) }
173
+ end
174
+ end
175
+ end