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.
- checksums.yaml +7 -0
- data/.circleci/config.yml +25 -0
- data/.gitignore +18 -0
- data/.rspec +3 -0
- data/.rubocop.yml +15 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +123 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/Rakefile +6 -0
- data/api_struct.gemspec +36 -0
- data/api_struct.svg +1 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/api_struct/client.rb +88 -0
- data/lib/api_struct/collection.rb +19 -0
- data/lib/api_struct/concerns/underscore.rb +7 -0
- data/lib/api_struct/entity.rb +93 -0
- data/lib/api_struct/errors/client.rb +43 -0
- data/lib/api_struct/errors/entity.rb +7 -0
- data/lib/api_struct/extensions/api_client.rb +43 -0
- data/lib/api_struct/extensions/dry_monads.rb +22 -0
- data/lib/api_struct/settings.rb +7 -0
- data/lib/api_struct/version.rb +3 -0
- data/lib/api_struct.rb +20 -0
- data/spec/api_struct/client_spec.rb +156 -0
- data/spec/api_struct/entity_spec.rb +188 -0
- data/spec/fixtures/cassettes/posts/1.yml +73 -0
- data/spec/fixtures/cassettes/posts/index_success.yml +1335 -0
- data/spec/fixtures/cassettes/posts/show_failure.yml +67 -0
- data/spec/fixtures/cassettes/posts/show_failure_html.yml +67 -0
- data/spec/fixtures/cassettes/posts/show_success.yml +74 -0
- data/spec/fixtures/cassettes/posts/suffix.yml +73 -0
- data/spec/fixtures/cassettes/posts/update_success.yml +71 -0
- data/spec/fixtures/cassettes/todos.yml +143 -0
- data/spec/fixtures/cassettes/user_todos.yml +189 -0
- data/spec/fixtures/cassettes/users/1/posts/1.yml +797 -0
- data/spec/fixtures/cassettes/users/1/posts.yml +1049 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/support/stub.rb +9 -0
- 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,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,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
|
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
|