uploadcare-api_struct 1.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 (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