uploadcare-api_struct 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +25 -0
  3. data/.gitignore +18 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +15 -0
  6. data/CHANGELOG.md +15 -0
  7. data/Gemfile +6 -0
  8. data/Gemfile.lock +123 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +211 -0
  11. data/Rakefile +6 -0
  12. data/api_struct.gemspec +36 -0
  13. data/api_struct.svg +1 -0
  14. data/bin/console +14 -0
  15. data/bin/setup +8 -0
  16. data/lib/api_struct/client.rb +88 -0
  17. data/lib/api_struct/collection.rb +19 -0
  18. data/lib/api_struct/concerns/underscore.rb +7 -0
  19. data/lib/api_struct/entity.rb +93 -0
  20. data/lib/api_struct/errors/client.rb +43 -0
  21. data/lib/api_struct/errors/entity.rb +7 -0
  22. data/lib/api_struct/extensions/api_client.rb +43 -0
  23. data/lib/api_struct/extensions/dry_monads.rb +22 -0
  24. data/lib/api_struct/settings.rb +7 -0
  25. data/lib/api_struct/version.rb +3 -0
  26. data/lib/api_struct.rb +20 -0
  27. data/spec/api_struct/client_spec.rb +156 -0
  28. data/spec/api_struct/entity_spec.rb +188 -0
  29. data/spec/fixtures/cassettes/posts/1.yml +73 -0
  30. data/spec/fixtures/cassettes/posts/index_success.yml +1335 -0
  31. data/spec/fixtures/cassettes/posts/show_failure.yml +67 -0
  32. data/spec/fixtures/cassettes/posts/show_failure_html.yml +67 -0
  33. data/spec/fixtures/cassettes/posts/show_success.yml +74 -0
  34. data/spec/fixtures/cassettes/posts/suffix.yml +73 -0
  35. data/spec/fixtures/cassettes/posts/update_success.yml +71 -0
  36. data/spec/fixtures/cassettes/todos.yml +143 -0
  37. data/spec/fixtures/cassettes/user_todos.yml +189 -0
  38. data/spec/fixtures/cassettes/users/1/posts/1.yml +797 -0
  39. data/spec/fixtures/cassettes/users/1/posts.yml +1049 -0
  40. data/spec/spec_helper.rb +26 -0
  41. data/spec/support/stub.rb +9 -0
  42. metadata +283 -0
@@ -0,0 +1,88 @@
1
+ module ApiStruct
2
+ class Client
3
+ DEFAULT_HEADERS = {
4
+ 'Accept': 'application/json',
5
+ 'Content-Type': 'application/json'
6
+ }
7
+ URL_OPTION_REGEXP = %r{/:([a-z_]+)}.freeze
8
+
9
+ attr_reader :client
10
+
11
+ # rubocop:disable Style/MissingRespondToMissing
12
+ def self.method_missing(method_name, *args, &block)
13
+ endpoints = Settings.config.endpoints
14
+ return super unless endpoints.keys.include?(method_name)
15
+
16
+ define_method(:api_root) { endpoints[method_name][:root] }
17
+ define_method(:default_params) { endpoints[method_name][:params] || {} }
18
+ define_method(:default_path) { first_arg(args) }
19
+
20
+ define_method(:headers) do
21
+ endpoints[method_name][:headers]
22
+ end
23
+ end
24
+ # rubocop:enable Style/MissingRespondToMissing
25
+
26
+ HTTP_METHODS = %i[get post patch put delete].freeze
27
+
28
+ HTTP_METHODS.each do |http_method|
29
+ define_method http_method do |*args, **options|
30
+ options[:params] = default_params.merge(options[:params] || {})
31
+ wrap client.send(http_method, build_url(args, options), options)
32
+ rescue HTTP::ConnectionError => e
33
+ failure(body: e.message, status: :not_connected)
34
+ end
35
+ end
36
+
37
+ def initialize
38
+ api_settings_exist
39
+ client_headers = headers || DEFAULT_HEADERS
40
+ @client = HTTP::Client.new(headers: client_headers)
41
+ end
42
+
43
+ private
44
+
45
+ def wrap(response)
46
+ response.status < 300 ? success(response) : failure(response)
47
+ end
48
+
49
+ def success(response)
50
+ body = response.body.to_s
51
+ result = !body.empty? ? JSON.parse(body, symbolize_names: true) : nil
52
+ Dry::Monads::Result::Success.call(result)
53
+ end
54
+
55
+ def failure(response)
56
+ result = ApiStruct::Errors::Client.new(response)
57
+ Dry::Monads::Result::Failure.call(result)
58
+ end
59
+
60
+ def first_arg(args)
61
+ args.first.to_s
62
+ end
63
+
64
+ def build_url(args, options)
65
+ suffix = to_path(args)
66
+ prefix = to_path(options.delete(:prefix))
67
+ path = to_path(options.delete(:path) || default_path)
68
+
69
+ replace_optional_params(to_path(api_root, prefix, path, suffix), options)
70
+ end
71
+
72
+ def to_path(*args)
73
+ Array(args).reject { |o| o.respond_to?(:empty?) ? o.empty? : !o }.join('/')
74
+ end
75
+
76
+ def replace_optional_params(url, options)
77
+ url.gsub(URL_OPTION_REGEXP) do
78
+ value = options.delete(::Regexp.last_match(1).to_sym)
79
+ value ? "/#{value}" : ''
80
+ end
81
+ end
82
+
83
+ def api_settings_exist
84
+ return if respond_to?(:api_root)
85
+ raise "\nSet api configuration for #{self.class}."
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,19 @@
1
+ module ApiStruct
2
+ class Collection < SimpleDelegator
3
+ attr_reader :collection
4
+
5
+ def initialize(collection, entity_klass)
6
+ raise EntityError, 'Collection must be a Array' unless collection.is_a? Array
7
+ @collection = collection.map { |item| entity_klass.convert_to_entity(item) }
8
+ __setobj__(@collection)
9
+ end
10
+
11
+ def success?
12
+ true
13
+ end
14
+
15
+ def failure?
16
+ false
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ module Concerns
2
+ module Underscore
3
+ def underscore(camel_cased_word)
4
+ Dry::Inflector.new.underscore(camel_cased_word)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,93 @@
1
+ module ApiStruct
2
+ class Entity < SimpleDelegator
3
+ extend Extensions::DryMonads
4
+ extend Extensions::ApiClient
5
+
6
+ class << self
7
+ def entity_attributes
8
+ @entity_attributes ||= []
9
+ end
10
+
11
+ def attr_entity(*attrs, &block)
12
+ entity_attributes.concat attrs
13
+
14
+ attrs.each do |attr|
15
+ define_entity_attribute_getter(attr, &block)
16
+ define_entity_attribute_setter(attr)
17
+ end
18
+ end
19
+
20
+ def entities?(attr, options)
21
+ entity_attributes << attr.to_sym
22
+ define_method attr.to_s do
23
+ self.class.collection(entity[attr], options[:as])
24
+ end
25
+ end
26
+ alias has_entities entities?
27
+
28
+ def entity?(attr, options)
29
+ entity_attributes << attr.to_sym
30
+ define_method attr.to_s do
31
+ return unless entity[attr]
32
+ self.class.convert_to_entity(entity[attr], options[:as])
33
+ end
34
+ end
35
+ alias has_entity entity?
36
+
37
+ def collection(entities, entity_type = self)
38
+ Collection.new(entities, entity_type)
39
+ end
40
+
41
+ def convert_to_entity(item, entity_type = self)
42
+ raise EntityError, "#{entity_type} must be inherited from base_entity" unless entity_type < ApiStruct::Entity
43
+ entity_type.new(item)
44
+ end
45
+
46
+ private
47
+
48
+ def define_entity_attribute_getter(attr)
49
+ define_method attr.to_s do
50
+ block_given? ? yield(entity[attr]) : entity[attr]
51
+ end
52
+ end
53
+
54
+ def define_entity_attribute_setter(attr)
55
+ define_method "#{attr}=" do |value|
56
+ entity[attr] = value
57
+ end
58
+ end
59
+ end
60
+
61
+ attr_reader :entity, :entity_status
62
+
63
+ # rubocop:disable Style/OptionalBooleanParameter
64
+ def initialize(entity, entity_status = true)
65
+ raise EntityError, "#{entity} must be Hash" unless entity.is_a?(Hash)
66
+ @entity = Hashie::Mash.new(extract_attributes(entity))
67
+ @entity_status = entity_status
68
+ __setobj__(@entity)
69
+ end
70
+ # rubocop:enable Style/OptionalBooleanParameter
71
+
72
+ def success?
73
+ entity_status == true
74
+ end
75
+
76
+ def failure?
77
+ entity_status == false
78
+ end
79
+
80
+ private
81
+
82
+ def extract_attributes(attributes)
83
+ formatted_attributes = attributes.map { |name, value| [format_name(name), value] }.to_h
84
+ formatted_attributes.select { |key, _value| self.class.entity_attributes.include?(key.to_sym) }
85
+ end
86
+
87
+ def format_name(name)
88
+ Dry::Inflector.new.underscore(name).to_sym
89
+ end
90
+ end
91
+
92
+ class EntityError < StandardError; end
93
+ end
@@ -0,0 +1,43 @@
1
+ module ApiStruct
2
+ module Errors
3
+ class Client
4
+ attr_reader :status, :body, :error
5
+
6
+ def initialize(response)
7
+ if response.is_a?(Hash)
8
+ @status = response[:status]
9
+ @body = response[:body]
10
+ else
11
+ @status = response.status
12
+ @body = parse_body(response.body.to_s)
13
+ end
14
+ end
15
+
16
+ def to_s
17
+ error
18
+ end
19
+
20
+ private
21
+
22
+ def error
23
+ return @status unless @body
24
+
25
+ if @body['errors']
26
+ @body['errors'].map do |k, v|
27
+ v.map { |e| "#{k} #{e}".strip }.join("\n")
28
+ end.join("\n")
29
+ elsif @body['error']
30
+ @body['error']
31
+ else
32
+ @status
33
+ end
34
+ end
35
+
36
+ def parse_body(b)
37
+ !b.empty? ? JSON.parse(b, symbolize_names: true) : nil
38
+ rescue JSON::ParserError
39
+ b
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ module ApiStruct
2
+ module Errors
3
+ class Entity < ApiStruct::Entity
4
+ attr_entity :body, :status, :error
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,43 @@
1
+ module ApiStruct
2
+ module Extensions
3
+ module ApiClient
4
+ include Concerns::Underscore
5
+
6
+ REJECTED_METHODS = %i[api_root default_path headers]
7
+
8
+ attr_reader :clients
9
+
10
+ def client_service(*services, **options)
11
+ @clients ||= {}
12
+ services.each { |service| register_service(service, options) }
13
+ end
14
+
15
+ private
16
+
17
+ def register_service(service, options)
18
+ options[:prefix] = prefix_from_class(service) if options[:prefix] == true
19
+ options[:client_key] = options[:prefix] || :base
20
+
21
+ @clients[options[:client_key]] = service
22
+ allowed_methods(service, options).each { |method| define_client_method(method, options) }
23
+ end
24
+
25
+ def define_client_method(method, options)
26
+ method_name = options[:prefix] ? [options[:prefix], method].join('_') : method
27
+ define_singleton_method method_name do |*args|
28
+ from_monad(clients[options[:client_key]].new.send(method, *args))
29
+ end
30
+ end
31
+
32
+ def prefix_from_class(klass)
33
+ underscore(klass.name.split('::').last).to_sym
34
+ end
35
+
36
+ def allowed_methods(service, options)
37
+ return Array(options[:only]) if options[:only]
38
+ rejected = REJECTED_METHODS.concat(Array(options[:except]))
39
+ service.instance_methods(false).reject { |method| rejected.include?(method) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ module ApiStruct
2
+ module Extensions
3
+ module DryMonads
4
+ def from_monad(monad)
5
+ monad
6
+ .fmap { |v| from_success(v) }.or_fmap { |e| from_failure(e) }.value!
7
+ end
8
+
9
+ def from_success(value)
10
+ return Dry::Monads::Result::Success.call(nil) if value.nil?
11
+
12
+ value.is_a?(Array) ? collection(value) : new(value)
13
+ end
14
+
15
+ def from_failure(error)
16
+ ApiStruct::Errors::Entity.new(
17
+ { status: error.status, body: error.body, error: true }, false
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,7 @@
1
+ module ApiStruct
2
+ class Settings
3
+ extend ::Dry::Configurable
4
+
5
+ setting :endpoints, default: {}
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module ApiStruct
2
+ VERSION = '1.1.0'
3
+ end
data/lib/api_struct.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'http'
2
+ require 'dry-monads'
3
+ require 'dry-configurable'
4
+ require 'dry/inflector'
5
+ require 'json'
6
+ require 'hashie'
7
+
8
+ require_relative 'api_struct/version'
9
+ require_relative 'api_struct/settings'
10
+ require_relative 'api_struct/concerns/underscore'
11
+ require_relative 'api_struct/extensions/api_client'
12
+ require_relative 'api_struct/extensions/dry_monads'
13
+ require_relative 'api_struct/errors/client'
14
+ require_relative 'api_struct/client'
15
+ require_relative 'api_struct/collection'
16
+ require_relative 'api_struct/entity'
17
+ require_relative 'api_struct/errors/entity'
18
+
19
+ module ApiStruct
20
+ end
@@ -0,0 +1,156 @@
1
+ describe ApiStruct::Client do
2
+ extend Support::Stub
3
+
4
+ # rubocop:disable Lint/ConstantDefinitionInBlock
5
+ API_ROOT = 'https://jsonplaceholder.typicode.com'
6
+ # rubocop:enable Lint/ConstantDefinitionInBlock
7
+
8
+ let(:api_root) { API_ROOT }
9
+ stub_api(API_ROOT)
10
+ let(:client) { StubClient.new }
11
+
12
+ # rubocop:disable Lint/ConstantDefinitionInBlock
13
+ class StubClient < ApiStruct::Client
14
+ stub_api :posts
15
+
16
+ def show(id)
17
+ get(id)
18
+ end
19
+
20
+ def update(id, params)
21
+ patch(id, json: params)
22
+ end
23
+ end
24
+ # rubocop:enable Lint/ConstantDefinitionInBlock
25
+
26
+ context 'build url options' do
27
+ context 'url options' do
28
+ it ' should replace /posts/users/:id to /posts/users if URL option didnt provided' do
29
+ url = client.send(:build_url, 'users/:id', {})
30
+ expect(url).to eq "#{api_root}/posts/users"
31
+ end
32
+
33
+ it ' should replace users/:id/comments to users/comments if URL option didnt provided' do
34
+ url = client.send(:build_url, 'users/:id/comments', {})
35
+ expect(url).to eq "#{api_root}/posts/users/comments"
36
+ end
37
+
38
+ it 'should replace /users/:id in prefix to /users/1' do
39
+ url = client.send(:build_url, [], prefix: 'users/:id', id: 1)
40
+ expect(url).to eq "#{api_root}/users/1/posts"
41
+ end
42
+
43
+ it 'should replace /users/:user_id/posts/:id in prefix to /users/1/posts/12' do
44
+ url = client.send(:build_url, ':id', prefix: 'users/:user_id', user_id: 1, id: 12)
45
+ expect(url).to eq "#{api_root}/users/1/posts/12"
46
+ end
47
+
48
+ it 'should replace /users/:id to /users/1' do
49
+ url = client.send(:build_url, 'users/:id', id: 1)
50
+ expect(url).to eq "#{api_root}/posts/users/1"
51
+ end
52
+
53
+ it 'user_posts without post_id' do
54
+ user_id = 1
55
+ post_id = nil
56
+ url = client.send(:build_url, post_id, prefix: [:users, user_id])
57
+ expect(url).to eq "#{api_root}/users/1/posts"
58
+ end
59
+
60
+ it 'user_posts with post_id' do
61
+ user_id = 1
62
+ post_id = 2
63
+ url = client.send(:build_url, post_id, prefix: [:users, user_id])
64
+ expect(url).to eq "#{api_root}/users/1/posts/2"
65
+ end
66
+ end
67
+
68
+ it 'should build url with prefix' do
69
+ VCR.use_cassette('users/1/posts') do
70
+ response = client.get(prefix: 'users/:id', id: 1)
71
+ expect(response).to be_success
72
+ expect(response.value!).to be_kind_of Array
73
+ expect(response.value!).not_to be_empty
74
+ end
75
+ end
76
+
77
+ it 'should build url with custom path' do
78
+ VCR.use_cassette('todos') do
79
+ response = client.get(path: 'todos/1')
80
+ expect(response).to be_success
81
+ expect(response.value![:id]).to eq(1)
82
+ expect(response.value![:title]).not_to be_empty
83
+ expect(response.value!.keys).to include(:completed)
84
+ end
85
+ end
86
+
87
+ context 'Default params' do
88
+ let(:user_id) { 2 }
89
+
90
+ before do
91
+ allow(client).to receive(:default_params).and_return(userId: user_id)
92
+ end
93
+
94
+ it 'should build url with default params' do
95
+ VCR.use_cassette('user_todos') do
96
+ response = client.get(path: 'todos')
97
+
98
+ expect(response).to be_success
99
+ response.value!.each do |response|
100
+ expect(response[:userId]).to eq(user_id)
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ it 'should build url with prefix as array' do
107
+ VCR.use_cassette('todos') do
108
+ response = client.get(path: [:todos, 1])
109
+ expect(response).to be_success
110
+ expect(response.value![:id]).to eq(1)
111
+ expect(response.value![:title]).not_to be_empty
112
+ expect(response.value!.keys).to include(:completed)
113
+ end
114
+ end
115
+ end
116
+
117
+ context 'GET' do
118
+ it 'when successful response' do
119
+ VCR.use_cassette('posts/show_success') do
120
+ response = client.show(1)
121
+ expect(response).to be_success
122
+ expect(response.value![:id]).to eq(1)
123
+ expect(response.value![:title]).not_to be_empty
124
+ end
125
+ end
126
+
127
+ it 'when failed response' do
128
+ VCR.use_cassette('posts/show_failure') do
129
+ response = client.show(101)
130
+ expect(response).to be_failure
131
+ expect(response.failure.status).to eq(404)
132
+ end
133
+ end
134
+
135
+ it 'when failed response with html response' do
136
+ VCR.use_cassette('posts/show_failure_html') do
137
+ response = client.show(101)
138
+ body = response.failure.body
139
+ expect(response).to be_failure
140
+ expect(response.failure.status).to eq(404)
141
+ expect(body).to be_kind_of(String)
142
+ expect(body).to match(%r{<body>.+</body>})
143
+ end
144
+ end
145
+ end
146
+
147
+ context 'PATCH' do
148
+ it 'when successful response' do
149
+ VCR.use_cassette('posts/update_success') do
150
+ response = client.update(1, title: FFaker::Name.name)
151
+ expect(response).to be_success
152
+ expect(response.value![:id]).to eq(1)
153
+ end
154
+ end
155
+ end
156
+ end