api_struct 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +14 -0
  5. data/.ruby-version +1 -0
  6. data/Gemfile +6 -0
  7. data/Gemfile.lock +105 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +203 -0
  10. data/Rakefile +6 -0
  11. data/api_struct.gemspec +34 -0
  12. data/api_struct.svg +1 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +8 -0
  15. data/circle.yml +3 -0
  16. data/lib/api_struct.rb +19 -0
  17. data/lib/api_struct/client.rb +86 -0
  18. data/lib/api_struct/collection.rb +19 -0
  19. data/lib/api_struct/concerns/underscore.rb +11 -0
  20. data/lib/api_struct/entity.rb +84 -0
  21. data/lib/api_struct/errors/client.rb +41 -0
  22. data/lib/api_struct/errors/entity.rb +7 -0
  23. data/lib/api_struct/extensions/api_client.rb +43 -0
  24. data/lib/api_struct/extensions/dry_monads.rb +18 -0
  25. data/lib/api_struct/settings.rb +7 -0
  26. data/lib/api_struct/version.rb +3 -0
  27. data/spec/api_struct/client_spec.rb +118 -0
  28. data/spec/api_struct/entity_spec.rb +185 -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_success.yml +73 -0
  33. data/spec/fixtures/cassettes/posts/suffix.yml +73 -0
  34. data/spec/fixtures/cassettes/posts/update_success.yml +71 -0
  35. data/spec/fixtures/cassettes/todos.yml +143 -0
  36. data/spec/fixtures/cassettes/users/1/posts.yml +1049 -0
  37. data/spec/fixtures/cassettes/users/1/posts/1.yml +797 -0
  38. data/spec/spec_helper.rb +25 -0
  39. data/spec/support/stub.rb +9 -0
  40. metadata +272 -0
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/circle.yml ADDED
@@ -0,0 +1,3 @@
1
+ machine:
2
+ ruby:
3
+ version: 2.4.2
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,11 @@
1
+ module Concerns
2
+ module Underscore
3
+ def underscore(camel_cased_word)
4
+ camel_cased_word.gsub(/::/, '/')
5
+ .gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
6
+ .gsub(/([a-z\d])([A-Z])/,'\1_\2')
7
+ .tr("-", "_")
8
+ .downcase
9
+ end
10
+ end
11
+ 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,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,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,7 @@
1
+ module ApiStruct
2
+ class Settings
3
+ extend ::Dry::Configurable
4
+
5
+ setting :endpoints, {}
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module ApiStruct
2
+ VERSION = '0.1.0'
3
+ 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