jeckle 0.4.0.beta3 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: daea79214eac076d0f45217ccad97e299f0b8ed4
4
- data.tar.gz: 815eb79a5058729ef025476a438cff1a5fc358c4
2
+ SHA256:
3
+ metadata.gz: 187c3bfe2db7cab76746126db6fbf418de6068d087adeb0c817d2ded6f1d4462
4
+ data.tar.gz: e2aa04caf4838dd135c1c4cb9a70db7429975466fa3db37cf4745a6febba541d
5
5
  SHA512:
6
- metadata.gz: d7299c4462181501c834c7b33cfde8d06beca7ad21611c87a2b173070e2e9f864b3f01977045ee914cb54efe71c8c913e6fdce6021669c37735303144688ef17
7
- data.tar.gz: 83388f41b76f585a801c13d7f4742237448f6982c44a5210abc5c0e3f37ba1a6172d74aa65dc1eefb82f0a7e3002337536c89961c309f0a0b78134efd31aff44
6
+ metadata.gz: 5ce3a1792ce54a89573a6a0be1a72d85f4381f7542883847ff0b7e5200309ec9388b2dfb45923e5133aa0c3c1c83b81e2df78030a276bbc83d07869699a9ff57
7
+ data.tar.gz: 3f181d2afe18db62be11d4d5faffa47c1e1f4532029d7704d46cf2b29969e1b15bd63ce18563ccad8c5c3796fab2cd9a504a5c77478917c24a2337a55661614f
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Jeckle
2
2
 
3
- [![Build Status](https://travis-ci.org/tomas-stefano/jeckle.svg?branch=master)](https://travis-ci.org/tomas-stefano/jeckle)
4
- [![Code Climate](https://codeclimate.com/github/tomas-stefano/jeckle.png)](https://codeclimate.com/github/tomas-stefano/jeckle)
5
- [![Test Coverage](https://codeclimate.com/github/tomas-stefano/jeckle/coverage.png)](https://codeclimate.com/github/tomas-stefano/jeckle)
3
+ [![CI](https://github.com/tomas-stefano/jeckle/actions/workflows/ci.yml/badge.svg)](https://github.com/tomas-stefano/jeckle/actions/workflows/ci.yml)
6
4
 
7
5
  Wrap APIs with easiness and flexibility.
8
6
 
@@ -20,53 +18,148 @@ Let third party APIs be Heckle for your app's Jeckle.
20
18
 
21
19
  Add this line to your application's Gemfile:
22
20
 
23
- gem 'jeckle'
21
+ ```ruby
22
+ gem 'jeckle'
23
+ ```
24
24
 
25
25
  And then execute:
26
26
 
27
- $ bundle
27
+ ```sh
28
+ $ bundle
29
+ ```
28
30
 
29
- ### For Rails applications
31
+ ## Usage
30
32
 
31
- We recommend to create a initializer:
33
+ ### Configuring an API
32
34
 
33
- ```ruby
34
- # config/initializers/jeckle.rb
35
+ Let's say you'd like to connect your app to Dribbble.com - a community of designers sharing screenshots of their work, process, and projects.
35
36
 
37
+ First, you would need to configure the API:
38
+
39
+ ```ruby
36
40
  Jeckle.configure do |config|
37
- config.register :some_service do |api|
38
- api.base_uri = 'http://api.someservice.com'
39
- api.headers = {
40
- 'Accept' => 'application/json'
41
- }
42
- api.namespaces = { prefix: 'api', version: 'v1' }
43
- api.logger = Rails.logger
44
- api.read_timeout = 5
41
+ config.register :dribbble do |api|
42
+ api.base_uri = 'http://api.dribbble.com'
43
+ api.middlewares do
44
+ response :json
45
+ end
45
46
  end
46
47
  end
47
48
  ```
48
49
 
49
- And then put your API stuff scoped inside a `services` folder:
50
+ ### Mapping resources
51
+
52
+ Following the previous example, Dribbble.com consists of pieces of web designers work called "Shots". Each shot has the attributes `id`, `name`, `url` and `image_url`. A Jeckle resource representing Dribbble's shots would be something like this:
50
53
 
51
54
  ```ruby
52
- # app/services/some_service/models/my_resource.rb
55
+ class Shot
56
+ include Jeckle::Resource
57
+
58
+ api :dribbble
59
+
60
+ attribute :id, Integer
61
+ attribute :name, String
62
+ attribute :url, String
63
+ attribute :image_url, String
64
+ end
65
+ ```
66
+
67
+ ### Fetching data
53
68
 
54
- module SomeService
55
- module Models
56
- class MyResource
57
- include Jeckle::Resource
69
+ The resource class allows us to search shots through HTTP requests to the API, based on the provided information. For example, we can find a specific shot by providing its id to the `find` method:
58
70
 
59
- api :some_service
71
+ ```ruby
72
+ # GET http://api.dribbble.com/shots/1600459
73
+ shot = Shot.find 1600459
74
+ ```
75
+
76
+ That will return a `Shot` instance, containing the shot info:
77
+
78
+ ```ruby
79
+ shot.id
80
+ => 1600459
81
+
82
+ shot.name
83
+ => "Daryl Heckle And Jeckle Oates"
84
+
85
+ shot.image_url
86
+ => "https://d13yacurqjgara.cloudfront.net/users/85699/screenshots/1600459/daryl_heckle_and_jeckle_oates-dribble.jpg"
87
+ ```
88
+
89
+ You can also look for many shots matching one or more attributes, by using the `search` method:
90
+
91
+ ```ruby
92
+ # GET http://api.dribbble.com/shots?name=avengers
93
+ shots = Shot.search name: 'avengers'
94
+ ```
60
95
 
61
- attribute :id
96
+ ### Attribute Aliasing
97
+
98
+ Sometimes you want to call the API's attributes something else, either because their names aren't very concise or because they're out of you app's convention. If that's the case, you can add an `as` option:
99
+
100
+ ```ruby
101
+ attribute :thumbnailSize, String, as: :thumbnail_size
102
+ ```
103
+
104
+ Both mapping will work:
105
+
106
+ ```ruby
107
+ shot.thumbnailSize
108
+ => "50x50"
109
+
110
+ shot.thumbnail_size
111
+ => "50x50"
112
+ ```
113
+
114
+ We're all set! Now we can expand the mapping of our API, e.g to add ability to search Dribbble Designer directory by adding Designer class, or we can expand the original mapping of Shot class to include more attributes, such as tags or comments.
115
+
116
+ ### Error Handling
117
+
118
+ Jeckle provides a built-in Faraday middleware that automatically raises typed errors for HTTP error responses. Enable it in your API configuration:
119
+
120
+ ```ruby
121
+ Jeckle.configure do |config|
122
+ config.register :dribbble do |api|
123
+ api.base_uri = 'http://api.dribbble.com'
124
+ api.middlewares do
125
+ response :json
126
+ response :jeckle_raise_error
62
127
  end
63
128
  end
64
129
  end
65
130
  ```
66
131
 
132
+ Then rescue specific errors in your code:
133
+
134
+ ```ruby
135
+ begin
136
+ Shot.find 999
137
+ rescue Jeckle::NotFoundError => e
138
+ puts "Not found: #{e.message} (status: #{e.status})"
139
+ rescue Jeckle::ClientError => e
140
+ puts "Client error: #{e.status}"
141
+ rescue Jeckle::ServerError => e
142
+ puts "Server error: #{e.status}"
143
+ rescue Jeckle::HTTPError => e
144
+ puts "HTTP error: #{e.status}"
145
+ end
146
+ ```
147
+
148
+ The error hierarchy:
149
+
150
+ - `Jeckle::Error` — base error
151
+ - `Jeckle::ConnectionError` — network connectivity errors
152
+ - `Jeckle::TimeoutError` — request timeout errors
153
+ - `Jeckle::HTTPError` — HTTP errors (has `status` and `body` attributes)
154
+ - `Jeckle::ClientError` — 4xx errors
155
+ - `BadRequestError` (400), `UnauthorizedError` (401), `ForbiddenError` (403), `NotFoundError` (404), `UnprocessableEntityError` (422), `TooManyRequestsError` (429)
156
+ - `Jeckle::ServerError` — 5xx errors
157
+ - `InternalServerError` (500), `ServiceUnavailableError` (503)
158
+
159
+ ## Examples
160
+
161
+ You can see more examples here: [https://github.com/tomas-stefano/jeckle/tree/master/examples](https://github.com/tomas-stefano/jeckle/tree/master/examples)
162
+
67
163
  ## Roadmap
68
164
 
69
- - Faraday middleware abstraction
70
- - Per action API
71
- - Comprehensive restful actions
72
- - Testability
165
+ Follow [GitHub's milestones](https://github.com/tomas-stefano/jeckle/milestones)
data/lib/jeckle/api.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Jeckle
2
4
  class API
3
5
  attr_accessor :logger
@@ -10,14 +12,14 @@ module Jeckle
10
12
  conn.params = params
11
13
  conn.response :logger, logger
12
14
 
13
- conn.basic_auth basic_auth[:username], basic_auth[:password] if basic_auth
14
- conn.instance_exec &@middlewares_block if @middlewares_block
15
+ conn.request :authorization, :basic, basic_auth[:username], basic_auth[:password] if basic_auth
16
+ conn.instance_exec(&@middlewares_block) if @middlewares_block
15
17
  end
16
18
  end
17
19
 
18
20
  def basic_auth=(credential_params)
19
- [:username, :password].all? do |key|
20
- credential_params.has_key? key
21
+ %i[username password].all? do |key|
22
+ credential_params.key? key
21
23
  end or raise Jeckle::NoUsernameOrPasswordError, credential_params
22
24
 
23
25
  @basic_auth = credential_params
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jeckle
4
+ module AttributeAliasing
5
+ def attribute(name, coercion, options = {})
6
+ if (custom_name = options.delete(:as))
7
+ super(custom_name, coercion, options)
8
+
9
+ alias_method name, custom_name
10
+ alias_method :"#{name}=", :"#{custom_name}="
11
+ else
12
+ super
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/jeckle/errors.rb CHANGED
@@ -1,29 +1,115 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Jeckle
4
+ class Error < StandardError; end
5
+
6
+ class ConnectionError < Error; end
7
+ class TimeoutError < Error; end
8
+
9
+ class HTTPError < Error
10
+ attr_reader :status, :body
11
+
12
+ def initialize(message = nil, status: nil, body: nil)
13
+ @status = status
14
+ @body = body
15
+ super(message)
16
+ end
17
+ end
18
+
19
+ class ClientError < HTTPError; end
20
+
21
+ class BadRequestError < ClientError
22
+ DEFAULT_STATUS = 400
23
+
24
+ def initialize(message = 'Bad Request', status: DEFAULT_STATUS, body: nil)
25
+ super
26
+ end
27
+ end
28
+
29
+ class UnauthorizedError < ClientError
30
+ DEFAULT_STATUS = 401
31
+
32
+ def initialize(message = 'Unauthorized', status: DEFAULT_STATUS, body: nil)
33
+ super
34
+ end
35
+ end
36
+
37
+ class ForbiddenError < ClientError
38
+ DEFAULT_STATUS = 403
39
+
40
+ def initialize(message = 'Forbidden', status: DEFAULT_STATUS, body: nil)
41
+ super
42
+ end
43
+ end
44
+
45
+ class NotFoundError < ClientError
46
+ DEFAULT_STATUS = 404
47
+
48
+ def initialize(message = 'Not Found', status: DEFAULT_STATUS, body: nil)
49
+ super
50
+ end
51
+ end
52
+
53
+ class UnprocessableEntityError < ClientError
54
+ DEFAULT_STATUS = 422
55
+
56
+ def initialize(message = 'Unprocessable Entity', status: DEFAULT_STATUS, body: nil)
57
+ super
58
+ end
59
+ end
60
+
61
+ class TooManyRequestsError < ClientError
62
+ DEFAULT_STATUS = 429
63
+
64
+ def initialize(message = 'Too Many Requests', status: DEFAULT_STATUS, body: nil)
65
+ super
66
+ end
67
+ end
68
+
69
+ class ServerError < HTTPError; end
70
+
71
+ class InternalServerError < ServerError
72
+ DEFAULT_STATUS = 500
73
+
74
+ def initialize(message = 'Internal Server Error', status: DEFAULT_STATUS, body: nil)
75
+ super
76
+ end
77
+ end
78
+
79
+ class ServiceUnavailableError < ServerError
80
+ DEFAULT_STATUS = 503
81
+
82
+ def initialize(message = 'Service Unavailable', status: DEFAULT_STATUS, body: nil)
83
+ super
84
+ end
85
+ end
86
+
87
+ # Legacy errors (kept for backwards compatibility)
2
88
  class ArgumentError < ::ArgumentError; end
3
89
 
4
90
  class NoSuchAPIError < ArgumentError
5
91
  def initialize(api)
6
- message = %{The API name '#{api}' doesn't exist in Jeckle definitions.
92
+ message = %(The API name '#{api}' doesn't exist in Jeckle definitions.
7
93
 
8
94
  Heckle: - Hey chum, what we can do now?
9
95
  Jeckle: - Old chap, you need to put the right API name!
10
96
  Heckle: - Hey pal, tell me the APIs then!
11
97
  Jeckle: - Deal the trays, old thing: #{Jeckle::Setup.registered_apis.keys}.
12
- }
98
+ )
13
99
 
14
- super message
100
+ super(message)
15
101
  end
16
102
  end
17
103
 
18
104
  class NoUsernameOrPasswordError < ArgumentError
19
- def initialize(credentials)
20
- message = %{No such keys "username" and "password" on `basic_auth` definition"
105
+ def initialize(_credentials)
106
+ message = %(No such keys "username" and "password" on `basic_auth` definition"
21
107
 
22
108
  Heckle: - Hey chum, what we can do now?
23
109
  Jeckle: - Old chap, you need to define a username and a password for basic auth!
24
- }
110
+ )
25
111
 
26
- super message
112
+ super(message)
27
113
  end
28
114
  end
29
115
  end
data/lib/jeckle/http.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Jeckle
2
4
  module HTTP
3
5
  def self.included(base)
@@ -11,8 +13,6 @@ module Jeckle
11
13
  end
12
14
  end
13
15
 
14
- # @public
15
- #
16
16
  # The name of the resource that Jeckle uses to make the request
17
17
  #
18
18
  # @example
@@ -41,23 +41,6 @@ module Jeckle
41
41
  @resource_name ||= model_name.element.pluralize
42
42
  end
43
43
 
44
- # @public
45
- #
46
- # Overwritten the resource name without write the resource name method.
47
- #
48
- # @example
49
- #
50
- # module OtherApi
51
- # class Project
52
- # include Jeckle::Resource
53
- # resource 'projects.json'
54
- # end
55
- # end
56
- #
57
- def resource(jeckle_resource_name)
58
- @resource_name = jeckle_resource_name
59
- end
60
-
61
44
  # The API name that Jeckle uses to find all the api settings like domain, headers, etc.
62
45
  #
63
46
  # @example
@@ -75,14 +58,14 @@ module Jeckle
75
58
  #
76
59
  def api(registered_api_name)
77
60
  api_mapping[:default_api] = Jeckle::Setup.registered_apis.fetch(registered_api_name)
78
- rescue KeyError => e
61
+ rescue KeyError
79
62
  raise Jeckle::NoSuchAPIError, registered_api_name
80
63
  end
81
64
 
82
65
  # @deprecated Please use {#api} instead
83
66
  #
84
67
  def default_api(registered_api_name)
85
- warn "[DEPRECATION] `default_api` is deprecated. Please use `api` instead."
68
+ warn '[DEPRECATION] `default_api` is deprecated. Please use `api` instead.'
86
69
  api(registered_api_name)
87
70
  end
88
71
 
@@ -91,14 +74,8 @@ module Jeckle
91
74
  end
92
75
 
93
76
  def run_request(endpoint, options = {})
94
- request = Jeckle::Request.run api_mapping[:default_api], endpoint, options
95
-
96
- if logger = api_mapping[:default_api].logger
97
- logger.debug("#{self} Response: #{request.response.body}")
98
- end
99
-
100
- request
77
+ Jeckle::Request.run api_mapping[:default_api], endpoint, options
101
78
  end
102
79
  end
103
80
  end
104
- end
81
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jeckle
4
+ module Middleware
5
+ class RaiseError < Faraday::Middleware
6
+ STATUS_MAP = {
7
+ 400 => Jeckle::BadRequestError,
8
+ 401 => Jeckle::UnauthorizedError,
9
+ 403 => Jeckle::ForbiddenError,
10
+ 404 => Jeckle::NotFoundError,
11
+ 422 => Jeckle::UnprocessableEntityError,
12
+ 429 => Jeckle::TooManyRequestsError,
13
+ 500 => Jeckle::InternalServerError,
14
+ 503 => Jeckle::ServiceUnavailableError
15
+ }.freeze
16
+
17
+ def on_complete(env)
18
+ status = env.status
19
+
20
+ return if status < 400
21
+
22
+ error_class = STATUS_MAP.fetch(status) do
23
+ status < 500 ? Jeckle::ClientError : Jeckle::ServerError
24
+ end
25
+
26
+ raise error_class.new(env.reason_phrase, status: status, body: env.body)
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ Faraday::Response.register_middleware(jeckle_raise_error: Jeckle::Middleware::RaiseError)
data/lib/jeckle/model.rb CHANGED
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Jeckle
2
4
  module Model
3
5
  def self.included(base)
4
- base.send :include, ActiveModel::Validations
5
- base.send :include, Virtus.model
6
+ base.include ActiveModel::Validations
7
+ base.include Virtus.model
6
8
  end
7
9
  end
8
10
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Jeckle
2
4
  class Request
3
5
  attr_reader :api, :body, :headers, :method, :params, :response, :endpoint
@@ -6,17 +8,28 @@ module Jeckle
6
8
  @api = api
7
9
 
8
10
  @method = options.delete(:method) || :get
9
- @body = options.delete(:body) if %w(post put).include?(method.to_s)
11
+ @body = options.delete(:body) if %w[post put patch].include?(method.to_s)
10
12
  @headers = options.delete(:headers)
11
13
 
14
+ if options[:params].nil? && options.size.positive?
15
+ warn %([DEPRECATION] Sending URL params mixed with options hash is deprecated.
16
+ Instead of doing this:
17
+ run_request 'cars/search', id: id, method: :get
18
+ Do this:
19
+ run_request 'cars/search', params: { id: id }, method: :get)
20
+
21
+ @params = options
22
+ else
23
+ @params = options.delete(:params) || {}
24
+ end
25
+
12
26
  @endpoint = endpoint
13
- @params = options
14
27
 
15
28
  @response = perform_api_request
16
29
  end
17
30
 
18
- def self.run(*args)
19
- new *args
31
+ def self.run(api, endpoint, options = {})
32
+ new(api, endpoint, options)
20
33
  end
21
34
 
22
35
  private
@@ -1,11 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Jeckle
2
4
  module Resource
3
5
  def self.included(base)
4
- base.send :include, ActiveModel::Naming
6
+ base.include ActiveModel::Naming
7
+
8
+ base.include Jeckle::Model
9
+ base.include Jeckle::HTTP
10
+ base.include Jeckle::RESTActions
5
11
 
6
- base.send :include, Jeckle::Model
7
- base.send :include, Jeckle::HTTP
8
- base.send :include, Jeckle::RESTActions
12
+ base.extend Jeckle::AttributeAliasing
9
13
  end
10
14
  end
11
15
  end
@@ -1,106 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Jeckle
2
4
  module RESTActions
3
5
  def self.included(base)
4
- base.send :extend, Jeckle::RESTActions::Collection
6
+ base.extend Jeckle::RESTActions::Collection
5
7
  end
6
8
 
7
9
  module Collection
8
- # @public
9
- #
10
- # The root name that Jeckle will parse the <b>response</b>. Default is <b>false</b>.
11
- #
12
- # @example
13
- #
14
- # module Dribble
15
- # class Shot
16
- # include Jeckle::Resource
17
- # root collection: true, member: true
18
- # end
19
- # end
20
- #
21
- # Shot.collection_root_name # => Will parse the root node as 'shots'
22
- # Shot.member_root_name # => Will parse the root node as 'shot'
23
- #
24
- # Sometimes that are APIs you need to fetch /projects, BUT the root node is extremely different from the resource name.
25
- #
26
- # module OtherApi
27
- # class Project
28
- # include Jeckle::Resource
29
- # api :my_api
30
- # root collection: 'awesome-projects', member: 'awesome-project'
31
- # end
32
- # end
33
- #
34
- def root(options={})
35
- @collection_root_name = find_root_name(options[:collection], :pluralize)
36
- @member_root_name = find_root_name(options[:member], :singularize)
37
- end
38
-
39
- # @public
40
- #
41
- # Member action that requests for the resource using the resource name
42
- #
43
- # @example
44
- #
45
- # Post.find(1) # => posts/1
46
- #
47
10
  def find(id)
48
- endpoint = "#{resource_name}/#{id}"
49
- response = run_request(endpoint).response.body
50
- attributes = parse_response(response, member_root_name)
11
+ endpoint = "#{resource_name}/#{id}"
12
+ attributes = run_request(endpoint).response.body
51
13
 
52
- new(attributes)
14
+ new attributes
53
15
  end
54
16
 
55
- # @public
56
- #
57
- # Collection action that requests for the resource using the resource name
58
- #
59
- # @example
60
- #
61
- # Post.search({ status: 'published' }) # => posts/?status=published
62
- # Post.where({ status: 'published' }) # => posts/?status=published
63
- #
64
17
  def search(params = {})
65
- request = run_request(resource_name, params)
66
- response = request.response
67
- collection = parse_response(response.body, collection_root_name)
18
+ custom_resource_name = params.delete(:resource_name) if params.is_a?(Hash)
68
19
 
69
- CollectionResponse.new(collection, context: self, response: response)
70
- end
71
- alias :where :search
72
-
73
- # @private
74
- #
75
- def parse_response(response, root_name)
76
- if root_name
77
- response[root_name]
78
- else
79
- response
80
- end
81
- end
82
-
83
- # @private
84
- #
85
- def find_root_name(root_name, root_method)
86
- return root_name if root_name.is_a?(String)
87
-
88
- if root_name
89
- model_name.element.send(root_method)
90
- end
91
- end
92
-
93
- # @private
94
- #
95
- def collection_root_name
96
- @collection_root_name
97
- end
20
+ response = run_request(custom_resource_name || resource_name, params: params).response.body || []
21
+ collection = response.is_a?(Array) ? response : response[resource_name]
98
22
 
99
- # @private
100
- #
101
- def member_root_name
102
- @member_root_name
23
+ Array(collection).collect { |attrs| new attrs }
103
24
  end
104
25
  end
105
26
  end
106
- end
27
+ end
data/lib/jeckle/setup.rb CHANGED
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Jeckle
2
4
  class Setup
3
- # Register apis, providing all the configurations to it.
5
+ # Register APIs, providing all the configurations to it.
4
6
  #
5
7
  # @example
6
8
  #
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Jeckle
2
- VERSION = '0.4.0.beta3'
4
+ VERSION = '0.5.0'
3
5
  end