wcc-api 0.1.0 → 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.
@@ -1,7 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module WCC::API
2
4
  class BaseQuery
3
- attr_reader :scope, :paging
4
- attr_accessor :limit, :offset
5
+ attr_reader :scope, :paging, :limit, :offset
5
6
  attr_accessor :filter
6
7
 
7
8
  MAX_LIMIT = 50
@@ -15,7 +16,7 @@ module WCC::API
15
16
  end
16
17
 
17
18
  def permitted_keys
18
- %i(limit offset filter)
19
+ %i[limit offset filter]
19
20
  end
20
21
 
21
22
  def default_scope
@@ -27,11 +28,11 @@ module WCC::API
27
28
  @paging = paging
28
29
  set_defaults
29
30
  permitted_keys.each do |key|
30
- self.public_send("#{key}=", params[key]) if params.has_key?(key)
31
+ public_send("#{key}=", params[key]) if params.key?(key)
31
32
  end
32
33
  end
33
34
 
34
- def call(scope=self.scope)
35
+ def call(scope = self.scope)
35
36
  scope = scope.dup
36
37
  scope = paged(scope)
37
38
  scope = ordered(scope)
@@ -39,7 +40,7 @@ module WCC::API
39
40
  scope
40
41
  end
41
42
 
42
- def paged(scope=self.scope)
43
+ def paged(scope = self.scope)
43
44
  if paging
44
45
  scope
45
46
  .limit(limit)
@@ -49,11 +50,11 @@ module WCC::API
49
50
  end
50
51
  end
51
52
 
52
- def ordered(scope=self.scope)
53
+ def ordered(scope = self.scope)
53
54
  scope
54
55
  end
55
56
 
56
- def filtered(scope=self.scope)
57
+ def filtered(scope = self.scope)
57
58
  scope
58
59
  end
59
60
 
@@ -65,7 +66,7 @@ module WCC::API
65
66
 
66
67
  def limit=(new_limit)
67
68
  new_limit = new_limit.to_i
68
- @limit = (new_limit > MAX_LIMIT) ? MAX_LIMIT : new_limit
69
+ @limit = new_limit > MAX_LIMIT ? MAX_LIMIT : new_limit
69
70
  end
70
71
 
71
72
  def offset=(new_offset)
@@ -85,4 +86,3 @@ module WCC::API
85
86
  end
86
87
  end
87
88
  end
88
-
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::API
4
+ module ControllerHelpers
5
+ private
6
+
7
+ def set_cache_headers(scope_or_record, options = {})
8
+ options = { public: true, must_revalidate: true }.merge!(options)
9
+
10
+ if expiry = options.delete(:expiry)
11
+ expires_in expiry, options.slice(:public, :must_revalidate)
12
+ end
13
+
14
+ fresh_when scope_or_record, options.slice(:etag, :public, :last_modified)
15
+ end
16
+ end
17
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module WCC::API
2
4
  module JSON
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module WCC::API::JSON
2
4
  class Pagination
3
5
  attr_reader :query, :url_for
@@ -19,12 +21,12 @@ module WCC::API::JSON
19
21
  json.sort query.sort if query.respond_to?(:sort)
20
22
  json.filter query.filter
21
23
  json._links do
22
- json.self url_for.(base_url_params)
24
+ json.self url_for.call(base_url_params)
23
25
  if has_previous_page?
24
- json.previous url_for.(base_url_params.merge(offset: query.offset - query.limit))
26
+ json.previous url_for.call(base_url_params.merge(offset: query.offset - query.limit))
25
27
  end
26
28
  if has_next_page?
27
- json.next url_for.(base_url_params.merge(offset: query.offset + query.limit))
29
+ json.next url_for.call(base_url_params.merge(offset: query.offset + query.limit))
28
30
  end
29
31
  end
30
32
  end
@@ -33,23 +35,26 @@ module WCC::API::JSON
33
35
  private
34
36
 
35
37
  def has_previous_page?
36
- query.offset - query.limit >= 0
38
+ query.paging && query.offset - query.limit >= 0
37
39
  end
38
40
 
39
41
  def has_next_page?
40
- query.offset + query.limit < query.total
42
+ query.paging && query.offset + query.limit < query.total
41
43
  end
42
44
 
43
45
  def base_url_params
44
- @base_url_params ||= {
45
- limit: query.limit,
46
- offset: query.offset,
47
- filter: query.filter,
48
- only_path: false
49
- }.tap do |params|
50
- params[:order_by] = query.order_by if query.respond_to?(:order_by)
51
- params[:sort] = query.sort if query.respond_to?(:sort)
52
- end
46
+ @base_url_params ||=
47
+ {
48
+ filter: query.filter,
49
+ only_path: false
50
+ }.tap do |params|
51
+ if query.paging
52
+ params[:limit] = query.limit
53
+ params[:offset] = query.offset
54
+ end
55
+ params[:order_by] = query.order_by if query.respond_to?(:order_by)
56
+ params[:sort] = query.sort if query.respond_to?(:sort)
57
+ end
53
58
  end
54
59
  end
55
60
  end
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'wcc/api'
2
4
 
3
5
  module WCC
4
6
  module API
5
7
  class Railtie < Rails::Railtie
6
- initializer 'wcc.api railtie initializer', group: :all do |app|
8
+ initializer 'wcc.api railtie initializer', group: :all do |_app|
7
9
  ActiveSupport::Inflector.inflections(:en) do |inflect|
8
10
  inflect.acronym 'API'
9
11
  end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wcc'
4
+ require_relative 'rest_client/response'
5
+ require_relative 'rest_client/builder'
6
+ require_relative 'active_record_shim'
7
+
8
+ module WCC::API
9
+ class RestClient
10
+ attr_reader :api_url
11
+
12
+ class << self
13
+ attr_reader :resources, :params
14
+
15
+ def rest_client(&block)
16
+ builder = Builder.new(self)
17
+ builder.instance_exec(&block)
18
+ builder.apply
19
+ end
20
+ end
21
+
22
+ def initialize(api_url:, headers: nil, **options)
23
+ # normalizes a URL to have a slash on the end
24
+ @api_url = api_url.gsub(/\/+$/, '') + '/'
25
+
26
+ @adapter = RestClient.load_adapter(options[:adapter])
27
+
28
+ @options = options
29
+ @query_defaults = {}
30
+ @headers = {
31
+ 'Accept' => 'application/json'
32
+ }.merge(headers || {}).freeze
33
+ @response_class = options[:response_class] || DefaultResponse
34
+ end
35
+
36
+ # performs an HTTP GET request to the specified path within the configured
37
+ # space and environment. Query parameters are merged with the defaults and
38
+ # appended to the request.
39
+ def get(path, query = {})
40
+ url = URI.join(@api_url, path)
41
+
42
+ @response_class.new(self,
43
+ { url: url, query: query },
44
+ get_http(url, query))
45
+ end
46
+
47
+ def post(path, body = {})
48
+ url = URI.join(@api_url, path)
49
+
50
+ @response_class.new(self,
51
+ { url: url },
52
+ post_http(url,
53
+ body.to_json,
54
+ headers: { 'Content-Type': 'application/json' }))
55
+ end
56
+
57
+ def put(path, body = {})
58
+ url = URI.join(@api_url, path)
59
+
60
+ @response_class.new(self,
61
+ { url: url },
62
+ put_http(url,
63
+ body.to_json,
64
+ headers: { 'Content-Type': 'application/json' }))
65
+ end
66
+
67
+ def delete(path)
68
+ url = URI.join(@api_url, path)
69
+
70
+ @response_class.new(self,
71
+ { url: url },
72
+ delete_http(url))
73
+ end
74
+
75
+ ADAPTERS = {
76
+ faraday: ['faraday', '~> 0.9'],
77
+ typhoeus: ['typhoeus', '~> 1.0']
78
+ }.freeze
79
+
80
+ def self.load_adapter(adapter)
81
+ case adapter
82
+ when nil
83
+ ADAPTERS.each do |a, spec|
84
+ begin
85
+ gem(*spec)
86
+ return load_adapter(a)
87
+ rescue Gem::LoadError
88
+ next
89
+ end
90
+ end
91
+ raise ArgumentError, 'Unable to load adapter! Please install one of '\
92
+ "#{ADAPTERS.values.map(&:join).join(',')}"
93
+ when :faraday
94
+ require 'faraday'
95
+ ::Faraday.new do |faraday|
96
+ faraday.response :logger, (Rails.logger if defined?(Rails)), { headers: false, bodies: false }
97
+ faraday.adapter :net_http
98
+ end
99
+ when :typhoeus
100
+ require_relative 'rest_client/typhoeus_adapter'
101
+ TyphoeusAdapter.new
102
+ else
103
+ unless adapter.respond_to?(:get)
104
+ raise ArgumentError, "Adapter #{adapter} is not invokeable! Please "\
105
+ "pass use one of #{ADAPTERS.keys} or create a Faraday-compatible adapter"
106
+ end
107
+ adapter
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def get_http(url, query, headers = {})
114
+ headers = @headers.merge(headers || {})
115
+
116
+ q = @query_defaults.dup
117
+ q = q.merge(query) if query
118
+
119
+ resp = @adapter.get(url, q, headers)
120
+
121
+ resp = get_http(resp.headers['location'], nil, headers) if [301, 302, 307].include?(resp.status) && !@options[:no_follow_redirects]
122
+ resp
123
+ end
124
+
125
+ def post_http(url, body, headers: {})
126
+ headers = @headers.merge(headers || {})
127
+
128
+ resp = @adapter.post(url, body, headers)
129
+
130
+ resp = get_http(resp.headers['location'], nil, headers) if [301, 302, 307].include?(resp.status) && !@options[:no_follow_redirects]
131
+ resp
132
+ end
133
+
134
+ def put_http(url, body, headers: {})
135
+ headers = @headers.merge(headers || {})
136
+
137
+ resp = @adapter.put(url, body, headers)
138
+
139
+ resp = get_http(resp.headers['location'], nil, headers) if [301, 302, 307].include?(resp.status) && !@options[:no_follow_redirects]
140
+ resp
141
+ end
142
+
143
+ def delete_http(url, headers: {})
144
+ headers = @headers.merge(headers || {})
145
+
146
+ resp = @adapter.delete(url, {}, headers)
147
+
148
+ resp = get_http(resp.headers['location'], nil, headers) if [301, 302, 307].include?(resp.status) && !@options[:no_follow_redirects]
149
+ resp
150
+ end
151
+
152
+ class Resource
153
+ attr_reader :client, :endpoint, :model, :options
154
+
155
+ def initialize(client, endpoint, model, options)
156
+ @client = client
157
+ @endpoint = endpoint
158
+ @model = model
159
+ @options = options
160
+ end
161
+
162
+ def find(id, query = {})
163
+ query = (options[:query] || {}).merge(query)
164
+ resp = client.get("#{endpoint}/#{id}", query)
165
+ resp.assert_ok!
166
+ body = options[:key] ? resp.body[options[:key]] : resp.body
167
+ model.new(body, resp.headers.freeze)
168
+ end
169
+
170
+ def list(**filters)
171
+ query = extract_params(filters)
172
+ query = (options[:query] || {}).merge(query)
173
+ query = query.merge!(apply_filters(filters, options[:filters]))
174
+ resp = client.get(endpoint, query)
175
+ resp.assert_ok!
176
+ resp.items.map { |s| model.new(s) }
177
+ end
178
+
179
+ def create(body)
180
+ resp = client.post(endpoint, body)
181
+ resp.assert_ok!
182
+ maybe_model_from_response(resp)
183
+ end
184
+
185
+ def update(id, body)
186
+ resp = client.put("#{endpoint}/#{id}", body)
187
+ resp.assert_ok!
188
+ maybe_model_from_response(resp)
189
+ end
190
+
191
+ def destroy(id)
192
+ resp = client.delete("#{endpoint}/#{id}")
193
+ resp.assert_ok!
194
+ maybe_model_from_response(resp)
195
+ end
196
+
197
+ protected
198
+
199
+ def maybe_model_from_response(resp)
200
+ return true if resp.status == 204
201
+ return true unless resp.raw_body.present?
202
+
203
+ body = options[:key] ? resp.body[options[:key]] : resp.body
204
+ return true unless body.present?
205
+
206
+ model.new(body, resp.headers.freeze)
207
+ end
208
+
209
+ def extract_params(filters)
210
+ filters.each_with_object({}) do |(k, _v), h|
211
+ k_s = k.to_s
212
+ h[k_s] = filters.delete(k) if client.class.params.include?(k_s)
213
+ end
214
+ end
215
+
216
+ def filter_key(filter_name)
217
+ filter_name
218
+ end
219
+
220
+ def apply_filters(filters, expected_filters)
221
+ defaults = default_filters(expected_filters) || {}
222
+ filters.each_with_object(defaults) do |(k, v), h|
223
+ k = k.to_s
224
+ raise ArgumentError, "Unknown filter '#{k}'" unless expected_filters.include?(k)
225
+
226
+ h[filter_key(k)] = v
227
+ end
228
+ end
229
+
230
+ def default_filters(expected_filters)
231
+ options[:default_filters]&.each_with_object({}) do |(k, v), h|
232
+ k = k.to_s
233
+ h[filter_key(k)] = v if expected_filters.include?(k)
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::API
4
+ class RestClient
5
+ class ApiError < StandardError
6
+ attr_reader :response
7
+
8
+ def self.[](code)
9
+ case code
10
+ when 404
11
+ NotFoundError
12
+ else
13
+ ApiError
14
+ end
15
+ end
16
+
17
+ def initialize(response)
18
+ @response = response
19
+ super(response.error_message)
20
+ end
21
+ end
22
+
23
+ class NotFoundError < ApiError
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,82 @@
1
+ module WCC::API
2
+ class RestClient
3
+ class Builder
4
+ def initialize(klass)
5
+ @klass = klass
6
+ end
7
+
8
+ def params(*params)
9
+ @params = params.map(&:to_s)
10
+ end
11
+
12
+ attr_writer :resource_class
13
+ def resource_class
14
+ @resource_class ||=
15
+ @klass.const_get("Resource") || WCC::API::RestClient::Resource
16
+ end
17
+
18
+ def resource(endpoint, model:, **options, &block)
19
+ @resources ||= {}
20
+
21
+ resource_class = options[:resource_class] || self.resource_class
22
+ if block_given?
23
+ resource_class = Class.new(resource_class, &block)
24
+ end
25
+ @resources[endpoint] = options.merge({
26
+ resource_class: resource_class,
27
+ model: model,
28
+ })
29
+ end
30
+
31
+ def apply
32
+ closed_params = (@params || []).freeze
33
+ resources = @resources
34
+ klass = @klass
35
+
36
+ klass.class_exec do
37
+ define_singleton_method :params do
38
+ closed_params
39
+ end
40
+
41
+ define_singleton_method :default do
42
+ @default ||= new
43
+ end
44
+
45
+ define_singleton_method :default= do |client|
46
+ @default = client
47
+ end
48
+
49
+ resources.each do |(endpoint, options)|
50
+ attr_name = options[:attribute] || endpoint.downcase
51
+ resource_class = options[:resource_class]
52
+
53
+ define_method attr_name do
54
+ instance_variable_get("@#{attr_name}") ||
55
+ instance_variable_set("@#{attr_name}",
56
+ resource_class.new(self, endpoint, options[:model], @options.merge(options))
57
+ )
58
+ end
59
+ end
60
+ end
61
+
62
+ resources.each do |(endpoint, options)|
63
+ options[:model].class_exec do
64
+ define_singleton_method :client do
65
+ klass.default
66
+ end
67
+
68
+ define_singleton_method :endpoint do
69
+ endpoint
70
+ end
71
+
72
+ define_singleton_method :key do
73
+ options[:key]
74
+ end
75
+ end
76
+
77
+ options[:model].send(:include, WCC::API::ActiveRecordShim)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end