api_struct 0.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/.gitignore +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +14 -0
- data/.ruby-version +1 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +105 -0
- data/LICENSE.txt +21 -0
- data/README.md +203 -0
- data/Rakefile +6 -0
- data/api_struct.gemspec +34 -0
- data/api_struct.svg +1 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/circle.yml +3 -0
- data/lib/api_struct.rb +19 -0
- data/lib/api_struct/client.rb +86 -0
- data/lib/api_struct/collection.rb +19 -0
- data/lib/api_struct/concerns/underscore.rb +11 -0
- data/lib/api_struct/entity.rb +84 -0
- data/lib/api_struct/errors/client.rb +41 -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 +18 -0
- data/lib/api_struct/settings.rb +7 -0
- data/lib/api_struct/version.rb +3 -0
- data/spec/api_struct/client_spec.rb +118 -0
- data/spec/api_struct/entity_spec.rb +185 -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_success.yml +73 -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/users/1/posts.yml +1049 -0
- data/spec/fixtures/cassettes/users/1/posts/1.yml +797 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/support/stub.rb +9 -0
- metadata +272 -0
data/bin/setup
ADDED
data/circle.yml
ADDED
data/lib/api_struct.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'http'
|
2
|
+
require 'dry-monads'
|
3
|
+
require 'dry-configurable'
|
4
|
+
require 'json'
|
5
|
+
require 'hashie'
|
6
|
+
|
7
|
+
require_relative 'api_struct/version'
|
8
|
+
require_relative 'api_struct/settings'
|
9
|
+
require_relative 'api_struct/concerns/underscore'
|
10
|
+
require_relative 'api_struct/extensions/api_client'
|
11
|
+
require_relative 'api_struct/extensions/dry_monads'
|
12
|
+
require_relative 'api_struct/errors/client'
|
13
|
+
require_relative 'api_struct/client'
|
14
|
+
require_relative 'api_struct/collection'
|
15
|
+
require_relative 'api_struct/entity'
|
16
|
+
require_relative 'api_struct/errors/entity'
|
17
|
+
|
18
|
+
module ApiStruct
|
19
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module ApiStruct
|
2
|
+
class Client
|
3
|
+
DEFAULT_HEADERS = {
|
4
|
+
'Accept': 'application/json',
|
5
|
+
'Content-Type': 'application/json'
|
6
|
+
}
|
7
|
+
URL_OPTION_REGEXP = /\/:([a-z_]+)/.freeze
|
8
|
+
|
9
|
+
attr_reader :client
|
10
|
+
|
11
|
+
def self.method_missing(method_name, *args, &block)
|
12
|
+
endpoints = Settings.config.endpoints
|
13
|
+
return super unless endpoints.keys.include?(method_name)
|
14
|
+
|
15
|
+
define_method(:api_root) { endpoints[method_name][:root] }
|
16
|
+
define_method(:default_path) { first_arg(args) }
|
17
|
+
|
18
|
+
define_method(:headers) do
|
19
|
+
endpoints[method_name][:headers]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
HTTP_METHODS = %i[get post patch put delete].freeze
|
24
|
+
|
25
|
+
HTTP_METHODS.each do |http_method|
|
26
|
+
define_method http_method do |*args, **options|
|
27
|
+
begin
|
28
|
+
wrap client.send(http_method, build_url(args, options), options)
|
29
|
+
rescue HTTP::ConnectionError => e
|
30
|
+
failure(body: e.message, status: :not_connected)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def initialize
|
36
|
+
api_settings_exist
|
37
|
+
client_headers = headers || DEFAULT_HEADERS
|
38
|
+
@client = HTTP::Client.new(headers: client_headers)
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def wrap(response)
|
44
|
+
response.status < 300 ? success(response) : failure(response)
|
45
|
+
end
|
46
|
+
|
47
|
+
def success(response)
|
48
|
+
body = response.body.to_s
|
49
|
+
result = !body.empty? ? JSON.parse(body, symbolize_names: true) : nil
|
50
|
+
Dry::Monads.Right(result)
|
51
|
+
end
|
52
|
+
|
53
|
+
def failure(response)
|
54
|
+
result = ApiStruct::Errors::Client.new(response)
|
55
|
+
Dry::Monads.Left(result)
|
56
|
+
end
|
57
|
+
|
58
|
+
def first_arg(args)
|
59
|
+
args.first.to_s
|
60
|
+
end
|
61
|
+
|
62
|
+
def build_url(args, options)
|
63
|
+
suffix = to_path(args)
|
64
|
+
prefix = to_path(options.delete(:prefix))
|
65
|
+
path = to_path(options.delete(:path) || default_path)
|
66
|
+
|
67
|
+
replace_optional_params(to_path(api_root, prefix, path, suffix), options)
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_path(*args)
|
71
|
+
Array(args).reject { |o| o.respond_to?(:empty?) ? o.empty? : !o }.join('/')
|
72
|
+
end
|
73
|
+
|
74
|
+
def replace_optional_params(url, options)
|
75
|
+
url.gsub(URL_OPTION_REGEXP) do
|
76
|
+
value = options.delete($1.to_sym)
|
77
|
+
value ? "/#{value}" : ''
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def api_settings_exist
|
82
|
+
return if respond_to?(:api_root)
|
83
|
+
raise RuntimeError, "\nSet api configuration for #{self.class}."
|
84
|
+
end
|
85
|
+
end
|
86
|
+
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,84 @@
|
|
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 has_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
|
+
|
27
|
+
def has_entity(attr, options)
|
28
|
+
entity_attributes << attr.to_sym
|
29
|
+
define_method attr.to_s do
|
30
|
+
return unless entity[attr]
|
31
|
+
self.class.convert_to_entity(entity[attr], options[:as])
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def collection(entities, entity_type = self)
|
36
|
+
Collection.new(entities, entity_type)
|
37
|
+
end
|
38
|
+
|
39
|
+
def convert_to_entity(item, entity_type = self)
|
40
|
+
raise EntityError, "#{entity_type} must be inherited from base_entity" unless entity_type < ApiStruct::Entity
|
41
|
+
entity_type.new(item)
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def define_entity_attribute_getter(attr)
|
47
|
+
define_method attr.to_s do
|
48
|
+
block_given? ? yield(entity[attr]) : entity[attr]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def define_entity_attribute_setter(attr)
|
53
|
+
define_method "#{attr}=" do |value|
|
54
|
+
entity[attr] = value
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
attr_reader :entity, :entity_status
|
60
|
+
|
61
|
+
def initialize(entity, entity_status = true)
|
62
|
+
raise EntityError, "#{entity} must be Hash" unless entity.is_a?(Hash)
|
63
|
+
@entity = Hashie::Mash.new(extract_attributes(entity))
|
64
|
+
@entity_status = entity_status
|
65
|
+
__setobj__(@entity)
|
66
|
+
end
|
67
|
+
|
68
|
+
def success?
|
69
|
+
entity_status == true
|
70
|
+
end
|
71
|
+
|
72
|
+
def failure?
|
73
|
+
entity_status == false
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def extract_attributes(attributes)
|
79
|
+
attributes.select { |key, _value| self.class.entity_attributes.include?(key.to_sym) }
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
class EntityError < StandardError; end
|
84
|
+
end
|
@@ -0,0 +1,41 @@
|
|
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
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
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,18 @@
|
|
1
|
+
module ApiStruct
|
2
|
+
module Extensions
|
3
|
+
module DryMonads
|
4
|
+
def from_monad(monad)
|
5
|
+
monad.fmap { |v| from_right(v) }.or_fmap { |e| from_left(e) }.value
|
6
|
+
end
|
7
|
+
|
8
|
+
def from_right(value)
|
9
|
+
return Dry::Monads::Right(nil) if value.nil?
|
10
|
+
value.is_a?(Array) ? collection(value) : new(value)
|
11
|
+
end
|
12
|
+
|
13
|
+
def from_left(error)
|
14
|
+
ApiStruct::Errors::Entity.new({ status: error.status, body: error.body, error: true }, false)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
describe ApiStruct::Client do
|
2
|
+
extend Support::Stub
|
3
|
+
let(:api_root) { 'https://jsonplaceholder.typicode.com' }
|
4
|
+
stub_api('https://jsonplaceholder.typicode.com')
|
5
|
+
|
6
|
+
class StubClient < ApiStruct::Client
|
7
|
+
stub_api :posts
|
8
|
+
|
9
|
+
def show(id)
|
10
|
+
get(id)
|
11
|
+
end
|
12
|
+
|
13
|
+
def update(id, params)
|
14
|
+
patch(id, json: params)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context 'build url options' do
|
19
|
+
context 'url options' do
|
20
|
+
let(:client) { StubClient.new }
|
21
|
+
it ' should replace /posts/users/:id to /posts/users if URL option didnt provided' do
|
22
|
+
url = client.send(:build_url, 'users/:id', {})
|
23
|
+
expect(url).to eq api_root + '/posts/users'
|
24
|
+
end
|
25
|
+
|
26
|
+
it ' should replace users/:id/comments to users/comments if URL option didnt provided' do
|
27
|
+
url = client.send(:build_url, 'users/:id/comments', {})
|
28
|
+
expect(url).to eq api_root + '/posts/users/comments'
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'should replace /users/:id in prefix to /users/1' do
|
32
|
+
url = client.send(:build_url, [], prefix: 'users/:id', id: 1)
|
33
|
+
expect(url).to eq api_root + '/users/1/posts'
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'should replace /users/:user_id/posts/:id in prefix to /users/1/posts/12' do
|
37
|
+
url = client.send(:build_url, ':id', prefix: 'users/:user_id', user_id: 1, id: 12)
|
38
|
+
expect(url).to eq api_root + '/users/1/posts/12'
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'should replace /users/:id to /users/1' do
|
42
|
+
url = client.send(:build_url, 'users/:id', id: 1)
|
43
|
+
expect(url).to eq api_root + '/posts/users/1'
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'user_posts without post_id' do
|
47
|
+
user_id = 1
|
48
|
+
post_id = nil
|
49
|
+
url = client.send(:build_url, post_id, prefix: [:users, user_id])
|
50
|
+
expect(url).to eq api_root + '/users/1/posts'
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'user_posts with post_id' do
|
54
|
+
user_id = 1
|
55
|
+
post_id = 2
|
56
|
+
url = client.send(:build_url, post_id, prefix: [:users, user_id])
|
57
|
+
expect(url).to eq api_root + '/users/1/posts/2'
|
58
|
+
end
|
59
|
+
end
|
60
|
+
it 'should build url with prefix' do
|
61
|
+
VCR.use_cassette('users/1/posts') do
|
62
|
+
response = StubClient.new.get(prefix: 'users/:id', id: 1)
|
63
|
+
expect(response).to be_success
|
64
|
+
expect(response.value).to be_kind_of Array
|
65
|
+
expect(response.value).not_to be_empty
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'should build url with custom path' do
|
70
|
+
VCR.use_cassette('todos') do
|
71
|
+
response = StubClient.new.get(path: 'todos/1')
|
72
|
+
expect(response).to be_success
|
73
|
+
expect(response.value[:id]).to eq(1)
|
74
|
+
expect(response.value[:title]).not_to be_empty
|
75
|
+
expect(response.value.keys).to include(:completed)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'should build url with prefix as array' do
|
80
|
+
VCR.use_cassette('todos') do
|
81
|
+
response = StubClient.new.get(path: [:todos, 1])
|
82
|
+
expect(response).to be_success
|
83
|
+
expect(response.value[:id]).to eq(1)
|
84
|
+
expect(response.value[:title]).not_to be_empty
|
85
|
+
expect(response.value.keys).to include(:completed)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
context 'GET' do
|
91
|
+
it 'when successful response' do
|
92
|
+
VCR.use_cassette('posts/show_success') do
|
93
|
+
response = StubClient.new.show(1)
|
94
|
+
expect(response).to be_success
|
95
|
+
expect(response.value[:id]).to eq(1)
|
96
|
+
expect(response.value[:title]).not_to be_empty
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
it 'when failed response' do
|
101
|
+
VCR.use_cassette('posts/show_failure') do
|
102
|
+
response = StubClient.new.show(101)
|
103
|
+
expect(response).to be_failure
|
104
|
+
expect(response.value.status).to eq(404)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
context 'PATCH' do
|
110
|
+
it 'when successful response' do
|
111
|
+
VCR.use_cassette('posts/update_success') do
|
112
|
+
response = StubClient.new.update(1, title: FFaker::Name.name)
|
113
|
+
expect(response).to be_success
|
114
|
+
expect(response.value[:id]).to eq(1)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|