haveapi 0.12.1 → 0.13.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/doc/protocol.md +27 -10
  3. data/haveapi.gemspec +1 -2
  4. data/lib/haveapi/action.rb +23 -7
  5. data/lib/haveapi/actions/default.rb +3 -3
  6. data/lib/haveapi/authentication/base.rb +19 -1
  7. data/lib/haveapi/authentication/basic/provider.rb +7 -1
  8. data/lib/haveapi/authentication/chain.rb +10 -12
  9. data/lib/haveapi/authentication/token/action_config.rb +53 -0
  10. data/lib/haveapi/authentication/token/action_request.rb +23 -0
  11. data/lib/haveapi/authentication/token/action_result.rb +42 -0
  12. data/lib/haveapi/authentication/token/config.rb +115 -0
  13. data/lib/haveapi/authentication/token/provider.rb +259 -81
  14. data/lib/haveapi/authentication/token.rb +9 -0
  15. data/lib/haveapi/client_examples/curl.rb +3 -3
  16. data/lib/haveapi/client_examples/fs_client.rb +3 -3
  17. data/lib/haveapi/client_examples/http.rb +7 -7
  18. data/lib/haveapi/client_examples/js_client.rb +1 -1
  19. data/lib/haveapi/client_examples/php_client.rb +1 -1
  20. data/lib/haveapi/client_examples/ruby_cli.rb +1 -1
  21. data/lib/haveapi/client_examples/ruby_client.rb +1 -1
  22. data/lib/haveapi/context.rb +13 -13
  23. data/lib/haveapi/example.rb +3 -3
  24. data/lib/haveapi/exceptions.rb +2 -0
  25. data/lib/haveapi/extensions/exception_mailer.rb +8 -1
  26. data/lib/haveapi/model_adapters/active_record.rb +6 -6
  27. data/lib/haveapi/parameters/resource.rb +10 -10
  28. data/lib/haveapi/params.rb +1 -1
  29. data/lib/haveapi/resource.rb +18 -10
  30. data/lib/haveapi/resources/action_state.rb +2 -2
  31. data/lib/haveapi/route.rb +4 -3
  32. data/lib/haveapi/server.rb +7 -7
  33. data/lib/haveapi/spec/mock_action.rb +3 -3
  34. data/lib/haveapi/spec/spec_methods.rb +8 -8
  35. data/lib/haveapi/version.rb +2 -2
  36. data/lib/haveapi/views/version_page.erb +2 -2
  37. data/shell.nix +1 -1
  38. metadata +9 -5
  39. data/lib/haveapi/authentication/token/resources.rb +0 -110
@@ -1,4 +1,6 @@
1
1
  require 'haveapi/authentication/base'
2
+ require 'haveapi/resource'
3
+ require 'haveapi/action'
2
4
 
3
5
  module HaveAPI::Authentication
4
6
  module Token
@@ -8,15 +10,18 @@ module HaveAPI::Authentication
8
10
 
9
11
  end
10
12
 
11
- # Provider for token authentication. This class has to be subclassed
12
- # and implemented.
13
+ # Provider for token authentication.
13
14
  #
14
- # Token auth contains resource token. User can request a token by calling
15
- # action Resources::Token::Request. Returned token is then used for
16
- # authenticating the user. Client sends the token with each request
17
- # in configured #http_header or #query_parameter.
15
+ # This provider has to be configured using
16
+ # {HaveAPI::Authentication::Token::Config}.
18
17
  #
19
- # Token can be revoked by calling Resources::Token::Revoke.
18
+ # Token auth contains API resource `token`. User can request a token by
19
+ # calling action `Request`. The returned token is then used for
20
+ # authenticating the user. Client sends the token with each request in
21
+ # configured {HaveAPI::Authentication::Token::Config#http_header} or
22
+ # {HaveAPI::Authentication::Token::Config#query_parameter}.
23
+ #
24
+ # Token can be revoked by calling action `Revoke` and renewed with `Renew`.
20
25
  #
21
26
  # === \Example usage:
22
27
  #
@@ -34,31 +39,60 @@ module HaveAPI::Authentication
34
39
  # end
35
40
  # end
36
41
  #
37
- # Authentication provider:
38
- # class MyTokenAuth < HaveAPI::Authentication::Token::Provider
39
- # protected
40
- # def save_token(request, user, token, lifetime, interval)
41
- # user.tokens << ::Token.new(token: token, lifetime: lifetime,
42
- # valid_to: (lifetime != 'permanent' ? Time.now + interval : nil),
43
- # interval: interval, label: request.user_agent)
44
- # end
42
+ # Authentication provider configuration:
43
+ # class MyTokenAuthConfig < HaveAPI::Authentication::Token::Config
44
+ # request do
45
+ # handle do |req, res|
46
+ # user = ::User.find_by(login: input[:user], password: input[:password])
47
+ #
48
+ # if user.nil?
49
+ # res.error = 'invalid user or password'
50
+ # next res
51
+ # end
52
+ #
53
+ # token = SecureRandom.hex(50)
54
+ # valid_to =
55
+ # if req.input[:lifetime] == 'permanent'
56
+ # nil
57
+ # else
58
+ # Time.now + req.input[:interval]
59
+ #
60
+ # user.tokens << ::Token.new(
61
+ # token: token,
62
+ # lifetime: req.input[:lifetime],
63
+ # valid_to: valid_to,
64
+ # interval: req.input[:interval],
65
+ # label: req.request.user_agent,
66
+ # )
45
67
  #
46
- # def revoke_token(request, user, token)
47
- # user.tokens.delete(token: token)
68
+ # res.token = token
69
+ # res.valid_to = valid_to
70
+ # res.complete = true
71
+ # res.ok
72
+ # end
48
73
  # end
49
74
  #
50
- # def renew_token(request, user, token)
51
- # t = ::Token.find_by(user: user, token: token)
75
+ # renew do
76
+ # handle do |req, res|
77
+ # t = ::Token.find_by(user: req.user, token: req.token)
52
78
  #
53
- # if t.lifetime.start_with('renewable')
54
- # t.renew
55
- # t.save
56
- # t.valid_to
79
+ # if t && t.lifetime.start_with('renewable')
80
+ # t.renew
81
+ # t.save
82
+ # res.valid_to = t.valid_to
83
+ # res.ok
84
+ # else
85
+ # res.error = 'unable to renew token'
86
+ # res
87
+ # end
57
88
  # end
58
89
  # end
59
90
  #
60
- # def find_user_by_credentials(request, username, password)
61
- # ::User.find_by(login: username, password: password)
91
+ # revoke do
92
+ # handle do |req, res|
93
+ # req.user.tokens.delete(token: req.token)
94
+ # res.ok
95
+ # end
62
96
  # end
63
97
  #
64
98
  # def find_user_by_token(request, token)
@@ -79,94 +113,238 @@ module HaveAPI::Authentication
79
113
  # Finally put the provider in the authentication chain:
80
114
  # api = HaveAPI.new(...)
81
115
  # ...
82
- # api.auth_chain << MyTokenAuth
116
+ # api.auth_chain << HaveAPI::Authentication::Token.with_config(MyTokenAuthConfig)
83
117
  class Provider < Base
118
+ auth_method :token
119
+
120
+ # Configure the token provider
121
+ # @param cfg [Config]
122
+ def self.with_config(cfg)
123
+ Module.new do
124
+ define_singleton_method(:new) do |*args|
125
+ Provider.new(*args, cfg)
126
+ end
127
+ end
128
+ end
129
+
130
+ attr_reader :config
131
+
132
+ def initialize(server, v, cfg)
133
+ @config = cfg.new(server, v)
134
+ super(server, v)
135
+ end
136
+
84
137
  def setup
85
- Resources::Token.token_instance ||= {}
86
- Resources::Token.token_instance[@version] = self
138
+ @server.allow_header(config.class.http_header)
139
+ end
87
140
 
88
- @server.allow_header(http_header)
141
+ def resource_module
142
+ return @module if @module
143
+ provider = self
144
+
145
+ @module = Module.new do
146
+ const_set(:Token, provider.send(:token_resource))
147
+ end
89
148
  end
90
149
 
150
+ # Authenticate request
151
+ # @param request [Sinatra::Request]
91
152
  def authenticate(request)
92
153
  t = token(request)
93
154
 
94
- t && find_user_by_token(request, t)
155
+ t && config.find_user_by_token(request, t)
95
156
  end
96
157
 
158
+ # Extract token from HTTP request
159
+ # @param request [Sinatra::Request]
160
+ # @return [String]
97
161
  def token(request)
98
- request[query_parameter] || request.env[header_to_env]
162
+ request[config.class.query_parameter] || request.env[header_to_env]
99
163
  end
100
164
 
101
165
  def describe
102
166
  {
103
- http_header: http_header,
104
- query_parameter: query_parameter,
105
- description: "The client authenticates with username and password and gets "+
106
- "a token. From this point, the password can be forgotten and "+
107
- "the token is used instead. Tokens can have different lifetimes, "+
108
- "can be renewed and revoked. The token is passed either via HTTP "+
109
- "header or query parameter."
167
+ http_header: config.class.http_header,
168
+ query_parameter: config.class.query_parameter,
169
+ description: "The client authenticates with credentials, usually "+
170
+ "username and password, and gets a token. "+
171
+ "From this point, the credentials can be forgotten and "+
172
+ "the token is used instead. Tokens can have different lifetimes, "+
173
+ "can be renewed and revoked. The token is passed either via HTTP "+
174
+ "header or query parameter."
110
175
  }
111
176
  end
112
177
 
113
- protected
114
- # HTTP header that is searched for token.
115
- def http_header
116
- 'X-HaveAPI-Auth-Token'
178
+ private
179
+ def header_to_env
180
+ "HTTP_#{config.class.http_header.upcase.gsub(/\-/, '_')}"
117
181
  end
118
182
 
119
- # Query parameter searched for token.
120
- def query_parameter
121
- :_auth_token
122
- end
183
+ def token_resource
184
+ provider = self
123
185
 
124
- # Generate token. Implicit implementation returns token of 100 chars.
125
- def generate_token
126
- SecureRandom.hex(50)
127
- end
186
+ HaveAPI::Resource.define_resource(:Token) do
187
+ define_singleton_method(:token_instance) { provider }
128
188
 
129
- # Save generated +token+ for +user+. Token has given +lifetime+
130
- # and when not permanent, also a +interval+ of validity.
131
- # Returns a date time which is token expiration.
132
- # It is up to the implementation of this method to remember
133
- # token lifetime and interval.
134
- # Must be implemented.
135
- def save_token(request, user, token, lifetime, interval)
189
+ auth false
190
+ version :all
136
191
 
137
- end
192
+ define_action(:Request) do
193
+ route ''
194
+ http_method :post
138
195
 
139
- # Revoke existing +token+ for +user+.
140
- # Must be implemented.
141
- def revoke_token(request, user, token)
196
+ input(:hash) do
197
+ if block = provider.config.class.request.input
198
+ instance_exec(&block)
199
+ end
142
200
 
143
- end
201
+ string :lifetime, label: 'Lifetime', required: true,
202
+ choices: %i(fixed renewable_manual renewable_auto permanent),
203
+ desc: <<END
204
+ fixed - the token has a fixed validity period, it cannot be renewed
205
+ renewable_manual - the token can be renewed, but it must be done manually via renew action
206
+ renewable_auto - the token is renewed automatically to now+interval every time it is used
207
+ permanent - the token will be valid forever, unless deleted
208
+ END
209
+ integer :interval, label: 'Interval',
210
+ desc: 'How long will requested token be valid, in seconds.',
211
+ default: 60*5, fill: true
212
+ end
144
213
 
145
- # Renew existing +token+ for +user+.
146
- # Returns a date time which is token expiration.
147
- # Must be implemented.
148
- def renew_token(request, user, token)
214
+ output(:hash) do
215
+ string :token
216
+ datetime :valid_to
217
+ bool :complete
218
+ string :next_action
219
+ end
149
220
 
150
- end
221
+ authorize do
222
+ allow
223
+ end
151
224
 
152
- # Used by action Resources::Token::Request when the user is requesting
153
- # a token. This method returns user object or nil.
154
- # Must be implemented.
155
- def find_user_by_credentials(request, username, password)
225
+ def exec
226
+ config = self.class.resource.token_instance.config
156
227
 
157
- end
228
+ begin
229
+ result = config.class.request.handle.call(ActionRequest.new(
230
+ request: request,
231
+ input: input,
232
+ ), ActionResult.new)
233
+ rescue HaveAPI::AuthenticationError => e
234
+ error(e.message)
235
+ end
158
236
 
159
- # Authenticate user by +token+. Return user object or nil.
160
- # If the token was created as auto-renewable, this method
161
- # is responsible for its renewal.
162
- # Must be implemented.
163
- def find_user_by_token(request, token)
237
+ unless result.ok?
238
+ error(result.error || 'invalid authentication credentials')
239
+ end
164
240
 
165
- end
241
+ {
242
+ token: result.token,
243
+ valid_to: result.valid_to,
244
+ complete: result.complete?,
245
+ next_action: result.next_action,
246
+ }
247
+ end
248
+ end
166
249
 
167
- private
168
- def header_to_env
169
- "HTTP_#{http_header.upcase.gsub(/\-/, '_')}"
250
+ define_action(:Revoke) do
251
+ http_method :post
252
+ auth true
253
+
254
+ authorize do
255
+ allow
256
+ end
257
+
258
+ def exec
259
+ provider = self.class.resource.token_instance
260
+ result = provider.config.class.revoke.handle.call(ActionRequest.new(
261
+ request: request,
262
+ user: current_user,
263
+ token: provider.token(request),
264
+ ), ActionResult.new)
265
+
266
+ if result.ok?
267
+ ok
268
+ else
269
+ error(result.error || 'revoke failed')
270
+ end
271
+ end
272
+ end
273
+
274
+ define_action(:Renew) do
275
+ http_method :post
276
+ auth true
277
+
278
+ output(:hash) do
279
+ datetime :valid_to
280
+ end
281
+
282
+ authorize do
283
+ allow
284
+ end
285
+
286
+ def exec
287
+ provider = self.class.resource.token_instance
288
+ result = provider.config.renew_token(ActionRequest.new(
289
+ request: request,
290
+ user: current_user,
291
+ token: provider.token(request),
292
+ ), ActionResult.new)
293
+
294
+ if result.ok?
295
+ {valid_to: result.valid_to}
296
+ else
297
+ error(result.error || 'renew failed')
298
+ end
299
+ end
300
+ end
301
+
302
+ provider.config.class.actions.each do |name, config|
303
+ define_action(:"#{name.to_s.capitalize}") do
304
+ http_method :post
305
+ auth false
306
+
307
+ input(:hash) do
308
+ string :token, required: true
309
+ instance_exec(&config.input) if config.input
310
+ end
311
+
312
+ output(:hash) do
313
+ string :token
314
+ datetime :valid_to
315
+ bool :complete
316
+ string :next_action
317
+ end
318
+
319
+ authorize do
320
+ allow
321
+ end
322
+
323
+ define_method(:exec) do
324
+ begin
325
+ result = config.handle.call(ActionRequest.new(
326
+ request: request,
327
+ input: input,
328
+ token: input[:token],
329
+ ), ActionResult.new)
330
+ rescue HaveAPI::AuthenticationError => e
331
+ error(e.message)
332
+ end
333
+
334
+ unless result.ok?
335
+ error(result.error || 'authentication failed')
336
+ end
337
+
338
+ {
339
+ token: result.token,
340
+ valid_to: result.valid_to,
341
+ complete: result.complete?,
342
+ next_action: result.next_action,
343
+ }
344
+ end
345
+ end
346
+ end
347
+ end
170
348
  end
171
349
  end
172
350
  end
@@ -0,0 +1,9 @@
1
+ module HaveAPI::Authentication
2
+ module Token
3
+ # Configure the token provider
4
+ # @param cfg [Config]
5
+ def self.with_config(cfg)
6
+ Provider.with_config(cfg)
7
+ end
8
+ end
9
+ end
@@ -12,7 +12,7 @@ module HaveAPI::ClientExamples
12
12
  end
13
13
 
14
14
  def auth(method, desc)
15
- login = {login: 'user', password: 'password', lifetime: 'fixed'}
15
+ login = {user: 'user', password: 'password', lifetime: 'fixed'}
16
16
 
17
17
  case method
18
18
  when :basic
@@ -50,8 +50,8 @@ END
50
50
  base_url,
51
51
  resolve_path(
52
52
  action[:method],
53
- action[:url],
54
- sample[:url_params] || [],
53
+ action[:path],
54
+ sample[:path_params] || [],
55
55
  sample[:request]
56
56
  )
57
57
  )
@@ -43,13 +43,13 @@ END
43
43
  path = [mountpoint].concat(resource_path)
44
44
 
45
45
  unless class_action?
46
- if !sample[:url_params] || sample[:url_params].empty?
46
+ if !sample[:path_params] || sample[:path_params].empty?
47
47
  fail "example {#{sample}} of action #{resource_path.join('.')}"+
48
48
  ".#{action_name} is for an instance action but does not include "+
49
49
  "URL parameters"
50
50
  end
51
51
 
52
- path << sample[:url_params].first.to_s
52
+ path << sample[:path_params].first.to_s
53
53
  end
54
54
 
55
55
  path << 'actions' << action_name
@@ -112,7 +112,7 @@ END
112
112
  end
113
113
 
114
114
  def class_action?
115
- action[:url].index(/:[a-zA-Z\-_]+/).nil?
115
+ action[:path].index(/:[a-zA-Z\-_]+/).nil?
116
116
  end
117
117
  end
118
118
  end
@@ -33,7 +33,7 @@ POST /_auth/token/tokens HTTP/1.1
33
33
  Host: #{host}
34
34
  Content-Type: application/json
35
35
 
36
- #{JSON.pretty_generate({token: {login: 'user', password: 'secret', lifetime: 'fixed'}})}
36
+ #{JSON.pretty_generate({token: {user: 'user', password: 'secret', lifetime: 'fixed'}})}
37
37
  END
38
38
  end
39
39
  end
@@ -41,8 +41,8 @@ END
41
41
  def request(sample)
42
42
  path = resolve_path(
43
43
  action[:method],
44
- action[:url],
45
- sample[:url_params] || [],
44
+ action[:path],
45
+ sample[:path_params] || [],
46
46
  sample[:request]
47
47
  )
48
48
 
@@ -74,11 +74,11 @@ END
74
74
  res
75
75
  end
76
76
 
77
- def resolve_path(method, url, url_params, input_params)
78
- ret = url.clone
77
+ def resolve_path(method, path, path_params, input_params)
78
+ ret = path.clone
79
79
 
80
- url_params.each do |v|
81
- ret.sub!(/:[a-zA-Z\-_]+/, v.to_s)
80
+ path_params.each do |v|
81
+ ret.sub!(/\{[a-zA-Z\-_]+\}/, v.to_s)
82
82
  end
83
83
 
84
84
  return ret if method != 'GET' || !input_params || input_params.empty?
@@ -56,7 +56,7 @@ END
56
56
  def example(sample)
57
57
  args = []
58
58
 
59
- args.concat(sample[:url_params]) if sample[:url_params]
59
+ args.concat(sample[:path_params]) if sample[:path_params]
60
60
 
61
61
  if sample[:request] && !sample[:request].empty?
62
62
  args << JSON.pretty_generate(sample[:request])
@@ -39,7 +39,7 @@ END
39
39
  def example(sample)
40
40
  args = []
41
41
 
42
- args.concat(sample[:url_params]) if sample[:url_params]
42
+ args.concat(sample[:path_params]) if sample[:path_params]
43
43
 
44
44
  if sample[:request] && !sample[:request].empty?
45
45
  args << format_parameters(:input, sample[:request])
@@ -47,7 +47,7 @@ END
47
47
  cmd = [init]
48
48
  cmd << resource_path.join('.')
49
49
  cmd << action_name
50
- cmd.concat(sample[:url_params]) if sample[:url_params]
50
+ cmd.concat(sample[:path_params]) if sample[:path_params]
51
51
 
52
52
  if sample[:request] && !sample[:request].empty?
53
53
  cmd << "-- \\\n"
@@ -42,7 +42,7 @@ END
42
42
  def example(sample)
43
43
  args = []
44
44
 
45
- args.concat(sample[:url_params]) if sample[:url_params]
45
+ args.concat(sample[:path_params]) if sample[:path_params]
46
46
 
47
47
  if sample[:request] && !sample[:request].empty?
48
48
  args << PP.pp(sample[:request], '').strip
@@ -1,18 +1,18 @@
1
1
  module HaveAPI
2
2
  class Context
3
- attr_accessor :server, :version, :request, :resource, :action, :url, :args,
3
+ attr_accessor :server, :version, :request, :resource, :action, :path, :args,
4
4
  :params, :current_user, :authorization, :endpoint,
5
5
  :action_instance, :action_prepare, :layout
6
6
 
7
7
  def initialize(server, version: nil, request: nil, resource: [], action: nil,
8
- url: nil, args: nil, params: nil, user: nil,
8
+ path: nil, args: nil, params: nil, user: nil,
9
9
  authorization: nil, endpoint: nil)
10
10
  @server = server
11
11
  @version = version
12
12
  @request = request
13
13
  @resource = resource
14
14
  @action = action
15
- @url = url
15
+ @path = path
16
16
  @args = args
17
17
  @params = params
18
18
  @current_user = user
@@ -20,10 +20,10 @@ module HaveAPI
20
20
  @endpoint = endpoint
21
21
  end
22
22
 
23
- def resolved_url
24
- return @url unless @args
23
+ def resolved_path
24
+ return @path unless @args
25
25
 
26
- ret = @url.dup
26
+ ret = @path.dup
27
27
 
28
28
  @args.each do |arg|
29
29
  resolve_arg!(ret, arg)
@@ -32,7 +32,7 @@ module HaveAPI
32
32
  ret
33
33
  end
34
34
 
35
- def url_for(action, args=nil)
35
+ def path_for(action, args=nil)
36
36
  top_module = Kernel
37
37
  top_route = @server.routes[@version]
38
38
 
@@ -60,20 +60,20 @@ module HaveAPI
60
60
  ret
61
61
  end
62
62
 
63
- def call_url_params(action, obj)
64
- ret = params && action.resolve_url_params(obj)
63
+ def call_path_params(action, obj)
64
+ ret = params && action.resolve_path_params(obj)
65
65
 
66
66
  return [ret] if ret && !ret.is_a?(Array)
67
67
  ret
68
68
  end
69
69
 
70
- def url_with_params(action, obj)
71
- url_for(action, call_url_params(action, obj))
70
+ def path_with_params(action, obj)
71
+ path_for(action, call_path_params(action, obj))
72
72
  end
73
73
 
74
74
  private
75
- def resolve_arg!(url, arg)
76
- url.sub!(/:[a-zA-Z\-_]+/, arg.to_s)
75
+ def resolve_arg!(path, arg)
76
+ path.sub!(/\{[a-zA-Z\-_]+\}/, arg.to_s)
77
77
  end
78
78
  end
79
79
  end
@@ -8,8 +8,8 @@ module HaveAPI
8
8
  @authorization = block
9
9
  end
10
10
 
11
- def url_params(*params)
12
- @url_params = params
11
+ def path_params(*params)
12
+ @path_params = params
13
13
  end
14
14
 
15
15
  def request(f)
@@ -60,7 +60,7 @@ module HaveAPI
60
60
  {
61
61
  title: @title,
62
62
  comment: @comment,
63
- url_params: @url_params,
63
+ path_params: @path_params,
64
64
  request: filter_input_params(context, @request),
65
65
  response: filter_output_params(context, @response),
66
66
  status: @status.nil? ? true : @status,
@@ -4,4 +4,6 @@ module HaveAPI
4
4
  class BuildError < StandardError
5
5
  include Nesty::NestedError
6
6
  end
7
+
8
+ class AuthenticationError < StandardError ; end
7
9
  end
@@ -58,6 +58,13 @@ module HaveAPI::Extensions
58
58
 
59
59
  env = context.request.env
60
60
 
61
+ user =
62
+ if context.current_user.respond_to?(:id)
63
+ context.current_user.id
64
+ else
65
+ context.current_user
66
+ end
67
+
61
68
  mail(context, exception, TEMPLATE.result(binding))
62
69
  end
63
70
 
@@ -222,7 +229,7 @@ module HaveAPI::Extensions
222
229
  </tr>
223
230
  <tr>
224
231
  <th>User</th>
225
- <td><%=h context.request.current_user %></td>
232
+ <td><%=h user %></td>
226
233
  </tr>
227
234
  </table>
228
235
  <div class="clear"></div>