acfs 1.3.3 → 1.6.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +372 -0
  3. data/LICENSE +22 -0
  4. data/README.md +321 -0
  5. data/acfs.gemspec +38 -0
  6. data/lib/acfs.rb +51 -0
  7. data/lib/acfs/adapter/base.rb +26 -0
  8. data/lib/acfs/adapter/typhoeus.rb +82 -0
  9. data/lib/acfs/collection.rb +28 -0
  10. data/lib/acfs/collections/paginatable.rb +76 -0
  11. data/lib/acfs/configuration.rb +120 -0
  12. data/lib/acfs/errors.rb +147 -0
  13. data/lib/acfs/global.rb +101 -0
  14. data/lib/acfs/location.rb +76 -0
  15. data/lib/acfs/middleware/base.rb +24 -0
  16. data/lib/acfs/middleware/json.rb +31 -0
  17. data/lib/acfs/middleware/logger.rb +23 -0
  18. data/lib/acfs/middleware/msgpack.rb +32 -0
  19. data/lib/acfs/middleware/print.rb +23 -0
  20. data/lib/acfs/middleware/serializer.rb +41 -0
  21. data/lib/acfs/operation.rb +96 -0
  22. data/lib/acfs/request.rb +32 -0
  23. data/lib/acfs/request/callbacks.rb +54 -0
  24. data/lib/acfs/resource.rb +39 -0
  25. data/lib/acfs/resource/attributes.rb +270 -0
  26. data/lib/acfs/resource/attributes/base.rb +29 -0
  27. data/lib/acfs/resource/attributes/boolean.rb +39 -0
  28. data/lib/acfs/resource/attributes/date_time.rb +32 -0
  29. data/lib/acfs/resource/attributes/dict.rb +39 -0
  30. data/lib/acfs/resource/attributes/float.rb +33 -0
  31. data/lib/acfs/resource/attributes/integer.rb +29 -0
  32. data/lib/acfs/resource/attributes/list.rb +36 -0
  33. data/lib/acfs/resource/attributes/string.rb +26 -0
  34. data/lib/acfs/resource/attributes/uuid.rb +48 -0
  35. data/lib/acfs/resource/dirty.rb +37 -0
  36. data/lib/acfs/resource/initialization.rb +31 -0
  37. data/lib/acfs/resource/loadable.rb +35 -0
  38. data/lib/acfs/resource/locatable.rb +135 -0
  39. data/lib/acfs/resource/operational.rb +26 -0
  40. data/lib/acfs/resource/persistence.rb +258 -0
  41. data/lib/acfs/resource/query_methods.rb +266 -0
  42. data/lib/acfs/resource/service.rb +44 -0
  43. data/lib/acfs/resource/validation.rb +49 -0
  44. data/lib/acfs/response.rb +30 -0
  45. data/lib/acfs/response/formats.rb +27 -0
  46. data/lib/acfs/response/status.rb +33 -0
  47. data/lib/acfs/rspec.rb +13 -0
  48. data/lib/acfs/runner.rb +102 -0
  49. data/lib/acfs/service.rb +94 -0
  50. data/lib/acfs/service/middleware.rb +58 -0
  51. data/lib/acfs/service/middleware/stack.rb +65 -0
  52. data/lib/acfs/singleton_resource.rb +85 -0
  53. data/lib/acfs/stub.rb +199 -0
  54. data/lib/acfs/util.rb +22 -0
  55. data/lib/acfs/version.rb +16 -0
  56. data/lib/acfs/yard.rb +6 -0
  57. data/spec/acfs/adapter/typhoeus_spec.rb +55 -0
  58. data/spec/acfs/collection_spec.rb +157 -0
  59. data/spec/acfs/configuration_spec.rb +53 -0
  60. data/spec/acfs/global_spec.rb +140 -0
  61. data/spec/acfs/location_spec.rb +25 -0
  62. data/spec/acfs/middleware/json_spec.rb +79 -0
  63. data/spec/acfs/middleware/msgpack_spec.rb +62 -0
  64. data/spec/acfs/operation_spec.rb +12 -0
  65. data/spec/acfs/request/callbacks_spec.rb +48 -0
  66. data/spec/acfs/request_spec.rb +79 -0
  67. data/spec/acfs/resource/attributes/boolean_spec.rb +58 -0
  68. data/spec/acfs/resource/attributes/date_time_spec.rb +51 -0
  69. data/spec/acfs/resource/attributes/dict_spec.rb +77 -0
  70. data/spec/acfs/resource/attributes/float_spec.rb +61 -0
  71. data/spec/acfs/resource/attributes/integer_spec.rb +36 -0
  72. data/spec/acfs/resource/attributes/list_spec.rb +60 -0
  73. data/spec/acfs/resource/attributes/uuid_spec.rb +42 -0
  74. data/spec/acfs/resource/attributes_spec.rb +179 -0
  75. data/spec/acfs/resource/dirty_spec.rb +49 -0
  76. data/spec/acfs/resource/initialization_spec.rb +36 -0
  77. data/spec/acfs/resource/loadable_spec.rb +22 -0
  78. data/spec/acfs/resource/locatable_spec.rb +118 -0
  79. data/spec/acfs/resource/persistance_spec.rb +322 -0
  80. data/spec/acfs/resource/query_methods_spec.rb +548 -0
  81. data/spec/acfs/resource/validation_spec.rb +129 -0
  82. data/spec/acfs/response/formats_spec.rb +52 -0
  83. data/spec/acfs/response/status_spec.rb +71 -0
  84. data/spec/acfs/runner_spec.rb +95 -0
  85. data/spec/acfs/service/middleware_spec.rb +35 -0
  86. data/spec/acfs/service_spec.rb +48 -0
  87. data/spec/acfs/singleton_resource_spec.rb +17 -0
  88. data/spec/acfs/stub_spec.rb +345 -0
  89. data/spec/acfs_spec.rb +205 -0
  90. data/spec/fixtures/config.yml +14 -0
  91. data/spec/spec_helper.rb +42 -0
  92. data/spec/support/hash.rb +11 -0
  93. data/spec/support/response.rb +12 -0
  94. data/spec/support/service.rb +92 -0
  95. data/spec/support/shared/find_callbacks.rb +50 -0
  96. metadata +159 -26
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'acfs/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'acfs'
9
+ spec.version = Acfs::VERSION
10
+ spec.authors = ['Jan Graichen']
11
+ spec.email = %w[jgraichen@altimos.de]
12
+ spec.homepage = 'https://github.com/jgraichen/acfs'
13
+ spec.license = 'MIT'
14
+ spec.description = 'API Client For Services'
15
+ spec.summary = <<~SUMMARY.strip
16
+ An abstract API base client for service oriented application.
17
+ SUMMARY
18
+
19
+ spec.files = Dir['**/*'].grep(%r{
20
+ ^((bin|lib|test|spec|features)/|
21
+ .*\.gemspec|.*LICENSE.*|.*README.*|.*CHANGELOG.*)
22
+ }xi)
23
+
24
+ spec.executables = spec.files.grep(%r{^bin/}) {|f| File.basename(f) }
25
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
26
+ spec.require_paths = %w[lib]
27
+
28
+ spec.required_ruby_version = '>= 2.5.0'
29
+
30
+ spec.add_runtime_dependency 'actionpack', '>= 5.2'
31
+ spec.add_runtime_dependency 'activemodel', '>= 5.2'
32
+ spec.add_runtime_dependency 'activesupport', '>= 5.2'
33
+ spec.add_runtime_dependency 'multi_json', '~> 1.0'
34
+ spec.add_runtime_dependency 'rack'
35
+ spec.add_runtime_dependency 'typhoeus', '~> 1.0'
36
+
37
+ spec.add_development_dependency 'bundler'
38
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/hash'
5
+ require 'active_support/core_ext/class'
6
+ require 'active_support/core_ext/string'
7
+ require 'active_support/core_ext/module'
8
+ require 'active_support/notifications'
9
+
10
+ module Acfs
11
+ extend ActiveSupport::Autoload
12
+ require 'acfs/version'
13
+ require 'acfs/errors'
14
+ require 'acfs/global'
15
+ require 'acfs/util'
16
+
17
+ require 'acfs/collection'
18
+ require 'acfs/configuration'
19
+ require 'acfs/location'
20
+ require 'acfs/operation'
21
+ require 'acfs/request'
22
+ require 'acfs/resource'
23
+ require 'acfs/response'
24
+ require 'acfs/runner'
25
+ require 'acfs/service'
26
+ require 'acfs/singleton_resource'
27
+
28
+ extend Global
29
+
30
+ autoload :Stub
31
+
32
+ module Middleware
33
+ extend ActiveSupport::Autoload
34
+ require 'acfs/middleware/base'
35
+ require 'acfs/middleware/serializer'
36
+
37
+ autoload :Print
38
+ autoload :Logger
39
+ autoload :JSON
40
+ autoload :JsonDecoder, 'acfs/middleware/json'
41
+ autoload :JsonEncoder, 'acfs/middleware/json'
42
+ autoload :MessagePack, 'acfs/middleware/msgpack'
43
+ autoload :MessagePackDecoder, 'acfs/middleware/msgpack'
44
+ autoload :MessagePackEncoder, 'acfs/middleware/msgpack'
45
+ end
46
+
47
+ module Adapter
48
+ require 'acfs/adapter/base'
49
+ require 'acfs/adapter/typhoeus'
50
+ end
51
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acfs::Adapter
4
+ # Base adapter handling operation queuing
5
+ # and processing.
6
+ #
7
+ class Base
8
+ def initialize(*); end
9
+
10
+ # Start processing queued requests.
11
+ #
12
+ def start; end
13
+
14
+ # Abort running and queued requests.
15
+ #
16
+ def abort; end
17
+
18
+ # Run request right now skipping queue.
19
+ #
20
+ def run(_); end
21
+
22
+ # Enqueue request to be run later.
23
+ #
24
+ def queue(_); end
25
+ end
26
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typhoeus'
4
+
5
+ module Acfs
6
+ module Adapter
7
+ DEFAULT_OPTIONS = {
8
+ tcp_keepalive: true,
9
+ tcp_keepidle: 5,
10
+ tcp_keepintvl: 5
11
+ }.freeze
12
+
13
+ # Adapter for Typhoeus.
14
+ #
15
+ class Typhoeus < Base
16
+ def initialize(**kwargs)
17
+ super
18
+
19
+ @opts = DEFAULT_OPTIONS
20
+ @opts = @opts.merge(opts) if (opts = kwargs.delete(:opts))
21
+ @kwargs = kwargs
22
+ end
23
+
24
+ def start
25
+ hydra.run
26
+ rescue StandardError
27
+ @hydra = nil
28
+ raise
29
+ end
30
+
31
+ delegate :abort, to: :hydra
32
+
33
+ def run(request)
34
+ convert_request(request).run
35
+ end
36
+
37
+ def queue(request)
38
+ hydra.queue convert_request request
39
+ end
40
+
41
+ protected
42
+
43
+ def hydra
44
+ @hydra ||= ::Typhoeus::Hydra.new(**@kwargs)
45
+ end
46
+
47
+ def convert_request(req)
48
+ opts = {
49
+ method: req.method,
50
+ params: req.params,
51
+ headers: req.headers.merge(
52
+ 'Expect' => '',
53
+ 'Transfer-Encoding' => ''
54
+ ),
55
+ body: req.body
56
+ }
57
+
58
+ request = ::Typhoeus::Request.new(req.url, **@opts, **opts)
59
+
60
+ request.on_complete do |response|
61
+ raise ::Acfs::TimeoutError.new(req) if response.timed_out?
62
+
63
+ if response.code.zero?
64
+ # Failed to get HTTP response
65
+ raise ::Acfs::RequestError.new(req, response.return_message)
66
+ end
67
+
68
+ req.complete! convert_response(req, response)
69
+ end
70
+
71
+ request
72
+ end
73
+
74
+ def convert_response(request, response)
75
+ Acfs::Response.new request,
76
+ status: response.code,
77
+ headers: response.headers,
78
+ body: response.body
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ require 'acfs/resource/loadable'
6
+ require 'acfs/collections/paginatable'
7
+
8
+ module Acfs
9
+ class Collection < ::Delegator
10
+ include Resource::Loadable
11
+ include Acfs::Util::Callbacks
12
+ include Collections::Paginatable
13
+
14
+ def initialize(resource_class)
15
+ super([])
16
+
17
+ @resource_class = resource_class
18
+ end
19
+
20
+ def __getobj__
21
+ @models
22
+ end
23
+
24
+ def __setobj__(obj)
25
+ @models = obj
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acfs::Collections
4
+ module Paginatable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ def self.operation(_action, **opts, &_block)
9
+ opts[:url]
10
+ end
11
+
12
+ attr_reader :total_pages, :current_page, :total_count
13
+ end
14
+
15
+ def process_response(response)
16
+ setup_params response.request.params if response.request
17
+ setup_headers response.headers
18
+ end
19
+
20
+ def next_page(&block)
21
+ page 'next', &block
22
+ end
23
+
24
+ def prev_page(&block)
25
+ page 'prev', &block
26
+ end
27
+
28
+ def first_page(&block)
29
+ page 'first', &block
30
+ end
31
+
32
+ def last_page(&block)
33
+ page 'last', &block
34
+ end
35
+
36
+ def page(rel, &block)
37
+ return unless relations[rel]
38
+
39
+ @resource_class.all nil, url: relations[rel], &block
40
+ end
41
+
42
+ private
43
+
44
+ def relations
45
+ @relations ||= {}
46
+ end
47
+
48
+ def setup_headers(headers)
49
+ if headers['X-Total-Pages']
50
+ @total_pages = Integer(headers['X-Total-Pages'])
51
+ end
52
+
53
+ if headers['X-Total-Count']
54
+ @total_count = Integer(headers['X-Total-Count'])
55
+ end
56
+
57
+ setup_links headers['Link'] if headers['Link']
58
+ end
59
+
60
+ def setup_links(links)
61
+ links.split(/,\s+/).each do |link|
62
+ if link =~ /^\s*<([^>]+)>.*\s+rel="([\w_-]+)".*$/
63
+ relations[Regexp.last_match[2]] = Regexp.last_match[1]
64
+ end
65
+ end
66
+ end
67
+
68
+ def setup_params(params)
69
+ @current_page = begin
70
+ Integer params.fetch(:page, 1)
71
+ rescue ArgumentError
72
+ params[:page]
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'yaml'
5
+
6
+ module Acfs
7
+ # Acfs configuration is used to locate services and get their base URLs.
8
+ #
9
+ class Configuration
10
+ attr_reader :locations
11
+ attr_accessor :adapter
12
+
13
+ # @api private
14
+ def initialize
15
+ @locations = {}
16
+ end
17
+
18
+ # @api public
19
+ #
20
+ # Configure using given block. If block accepts zero arguments
21
+ # bock will be evaluated in context of the configuration instance
22
+ # otherwise the configuration instance will be given as first arguments.
23
+ #
24
+ # @yield [configuration] Give configuration as arguments or evaluate block
25
+ # in context of configuration object.
26
+ # @yieldparam configuration [Configuration] Configuration object.
27
+ # @return [undefined]
28
+ #
29
+ def configure(&block)
30
+ if block.arity.positive?
31
+ block.call self
32
+ else
33
+ instance_eval(&block)
34
+ end
35
+ end
36
+
37
+ # @api public
38
+ #
39
+ # @overload locate(service, uri)
40
+ # Configures URL where a service can be reached.
41
+ #
42
+ # @param [Symbol] service
43
+ # Service identity key for service that is reachable under given URL.
44
+ #
45
+ # @param [String] uri
46
+ # URL where service is reachable. Will be passed to {URI.parse}.
47
+ #
48
+ # @return [undefined]
49
+ #
50
+ # @overload locate(service)
51
+ # Return configured base URL for given service identity key.
52
+ #
53
+ # @param [Symbol] service Service identity key to lookup.
54
+ # @return [URI, NilClass] Configured base URL or nil.
55
+ #
56
+ def locate(service, uri = nil)
57
+ service = service.to_s.underscore.to_sym
58
+ if uri.nil?
59
+ locations[service]
60
+ else
61
+ locations[service] = URI.parse uri
62
+ end
63
+ end
64
+
65
+ # @api public
66
+ #
67
+ # Load configuration from given YAML file.
68
+ #
69
+ # @param [String] filename Path to YAML configuration file.
70
+ # @return [undefined]
71
+ #
72
+ def load(filename)
73
+ config = YAML.safe_load(File.read(filename), [], [], true)
74
+ env = ENV['RACK_ENV'] || ENV['RAILS_ENV'] || 'development'
75
+
76
+ config = config[env] if config.key? env
77
+ config.each do |key, value|
78
+ case key
79
+ when 'services' then load_services value
80
+ end
81
+ end
82
+ end
83
+
84
+ # @api private
85
+ #
86
+ # Load services from configuration YAML.
87
+ #
88
+ def load_services(services)
89
+ services.each do |service, data|
90
+ if (val = data).is_a?(String) || (val = data['locate'])
91
+ locate service.to_sym, val
92
+ end
93
+ end
94
+ end
95
+
96
+ class << self
97
+ # @api private
98
+ #
99
+ # Return current configuration object.
100
+ #
101
+ # @return [Configuration]
102
+ #
103
+ def current
104
+ @current ||= new
105
+ end
106
+
107
+ # @api private
108
+ #
109
+ # Swap configuration object with given new one. Must be
110
+ # a {Configuration} object.
111
+ #
112
+ # @param [Configuration] configuration
113
+ # @return [undefined]
114
+ #
115
+ def set(configuration)
116
+ @current = configuration if configuration.is_a? Configuration
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acfs
4
+ # Acfs base error.
5
+ #
6
+ class Error < StandardError
7
+ def initialize(opts = {}, message = nil)
8
+ opts.merge! message: message if message
9
+ super opts[:message]
10
+ end
11
+ end
12
+
13
+ class UnsupportedOperation < StandardError; end
14
+
15
+ class RequestError < Error
16
+ attr_reader :request
17
+
18
+ def initialize(request, message)
19
+ @request = request
20
+
21
+ message = "#{message}: #{request.method.upcase} #{request.url}"
22
+
23
+ super message: message
24
+ end
25
+ end
26
+
27
+ class TimeoutError < RequestError
28
+ def initialize(request)
29
+ super(request, 'Timeout reached')
30
+ end
31
+ end
32
+
33
+ # Response error containing the responsible response object.
34
+ #
35
+ class ErroneousResponse < Error
36
+ attr_reader :response
37
+
38
+ def initialize(opts = {})
39
+ @response = opts[:response]
40
+
41
+ message = if response
42
+ (opts[:message] ? "#{opts[:message]}:" : 'Received') +
43
+ " #{response.code} for #{response.request.method.upcase}" \
44
+ " #{response.request.url} #{response.request.format}"
45
+ else
46
+ opts[:message] || 'Received erroneous response'
47
+ end
48
+
49
+ super opts, message
50
+ end
51
+ end
52
+
53
+ class AmbiguousStubError < Error
54
+ attr_reader :stubs, :operation
55
+
56
+ def initialize(opts = {})
57
+ require 'pp'
58
+
59
+ @stubs = opts.delete :stubs
60
+ @operation = opts.delete :operation
61
+
62
+ message = "Ambiguous stubs for #{operation.action} " \
63
+ "on #{operation.resource}.\n" +
64
+ stubs.map {|s| " #{s.opts.pretty_inspect}" }.join
65
+
66
+ super opts, message
67
+ end
68
+ end
69
+
70
+ # 400
71
+ class BadRequest < ErroneousResponse; end
72
+
73
+ # 401
74
+ class Unauthorized < ErroneousResponse; end
75
+
76
+ # 403
77
+ class Forbidden < ErroneousResponse; end
78
+
79
+ # 404
80
+ class ResourceNotFound < ErroneousResponse; end
81
+
82
+ # 422
83
+ class InvalidResource < ErroneousResponse
84
+ attr_reader :errors, :resource
85
+
86
+ def initialize(opts = {})
87
+ @errors = opts.delete :errors
88
+ @resource = opts.delete :resource
89
+
90
+ case @errors
91
+ when Hash
92
+ opts[:message] ||= @errors.each_pair.map do |k, v|
93
+ @errors.is_a?(Array) ? "#{k}: #{v.join(', ')}" : "#{k}: #{v}"
94
+ end.join ', '
95
+ when Array
96
+ opts[:message] ||= @errors.join ', '
97
+ end
98
+
99
+ super
100
+ end
101
+ end
102
+
103
+ # 500
104
+ class ServerError < ErroneousResponse; end
105
+
106
+ # 502
107
+ class BadGateway < ErroneousResponse; end
108
+
109
+ # 503
110
+ class ServiceUnavailable < ErroneousResponse; end
111
+
112
+ # 504
113
+ class GatewayTimeout < ErroneousResponse; end
114
+
115
+ # A ResourceNotLoaded error will be thrown when calling some
116
+ # modifing methods on not loaded resources as it is usally
117
+ # unwanted to call e.g. `update_attributes` on a not loaded
118
+ # resource.
119
+ # Correct solution is to first run `Acfs.run` to fetch the
120
+ # resource and then update the resource.
121
+ #
122
+ class ResourceNotLoaded < Error
123
+ attr_reader :resource
124
+
125
+ def initialize(opts = {})
126
+ @resource = opts.delete :resource
127
+ super
128
+ end
129
+ end
130
+
131
+ # Gets raised if ressource type is no valid subclass of
132
+ # parent resource. Check if the type is set to the correct
133
+ # Acfs::Resource Name
134
+ class ResourceTypeError < Error
135
+ attr_reader :base_class, :type_name
136
+
137
+ def initialize(opts = {})
138
+ @base_class = opts.delete :base_class
139
+ @type_name = opts.delete :type_name
140
+ opts[:message] = "Received resource type `#{type_name}` " \
141
+ "is no subclass of #{base_class}"
142
+ super
143
+ end
144
+ end
145
+
146
+ class RealRequestsNotAllowedError < StandardError; end
147
+ end