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.
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