haveapi 0.6.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.editorconfig +15 -0
  3. data/CHANGELOG +15 -0
  4. data/README.md +66 -47
  5. data/doc/create-client.md +14 -5
  6. data/doc/json-schema.erb +16 -2
  7. data/doc/protocol.md +25 -3
  8. data/doc/protocol.plantuml +14 -8
  9. data/haveapi.gemspec +4 -2
  10. data/lib/haveapi.rb +5 -3
  11. data/lib/haveapi/action.rb +34 -6
  12. data/lib/haveapi/action_state.rb +92 -0
  13. data/lib/haveapi/authentication/basic/provider.rb +7 -0
  14. data/lib/haveapi/authentication/token/provider.rb +5 -0
  15. data/lib/haveapi/client_example.rb +83 -0
  16. data/lib/haveapi/client_examples/curl.rb +86 -0
  17. data/lib/haveapi/client_examples/fs_client.rb +116 -0
  18. data/lib/haveapi/client_examples/http.rb +91 -0
  19. data/lib/haveapi/client_examples/js_client.rb +149 -0
  20. data/lib/haveapi/client_examples/php_client.rb +122 -0
  21. data/lib/haveapi/client_examples/ruby_cli.rb +117 -0
  22. data/lib/haveapi/client_examples/ruby_client.rb +106 -0
  23. data/lib/haveapi/context.rb +3 -2
  24. data/lib/haveapi/example.rb +29 -2
  25. data/lib/haveapi/extensions/action_exceptions.rb +2 -2
  26. data/lib/haveapi/extensions/base.rb +1 -1
  27. data/lib/haveapi/extensions/exception_mailer.rb +339 -0
  28. data/lib/haveapi/hooks.rb +1 -1
  29. data/lib/haveapi/parameters/typed.rb +5 -3
  30. data/lib/haveapi/public/css/highlight.css +99 -0
  31. data/lib/haveapi/public/doc/protocol.png +0 -0
  32. data/lib/haveapi/public/js/highlight.pack.js +2 -0
  33. data/lib/haveapi/public/js/highlighter.js +9 -0
  34. data/lib/haveapi/public/js/main.js +32 -0
  35. data/lib/haveapi/public/js/nojs-tabs.js +196 -0
  36. data/lib/haveapi/resources/action_state.rb +196 -0
  37. data/lib/haveapi/server.rb +96 -27
  38. data/lib/haveapi/version.rb +2 -2
  39. data/lib/haveapi/views/main_layout.erb +14 -0
  40. data/lib/haveapi/views/version_page.erb +187 -13
  41. data/lib/haveapi/views/version_sidebar.erb +37 -3
  42. metadata +49 -5
@@ -5,7 +5,7 @@ require 'haveapi/version'
5
5
  Gem::Specification.new do |s|
6
6
  s.name = 'haveapi'
7
7
  s.version = HaveAPI::VERSION
8
- s.date = '2016-10-18'
8
+ s.date = '2016-11-24'
9
9
  s.summary =
10
10
  s.description = 'Framework for creating self-describing APIs'
11
11
  s.authors = 'Jakub Skokan'
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
13
13
  s.files = `git ls-files -z`.split("\x0")
14
14
  s.license = 'MIT'
15
15
 
16
- s.required_ruby_version = '~> 2.0'
16
+ s.required_ruby_version = '>= 2.0.0'
17
17
 
18
18
  s.add_runtime_dependency 'require_all'
19
19
  s.add_runtime_dependency 'json'
@@ -24,4 +24,6 @@ Gem::Specification.new do |s|
24
24
  s.add_runtime_dependency 'rake'
25
25
  s.add_runtime_dependency 'github-markdown', '~> 0.6.9'
26
26
  s.add_runtime_dependency 'nesty', '~> 1.0.2'
27
+ s.add_runtime_dependency 'haveapi-client', '~> 0.6.0'
28
+ s.add_runtime_dependency 'mail'
27
29
  end
@@ -10,18 +10,20 @@ require 'github/markdown'
10
10
  require 'json'
11
11
 
12
12
  module HaveAPI
13
- module Actions
14
- end
13
+ module Resources ; end
14
+ module Actions ; end
15
15
  end
16
16
 
17
17
  require_relative 'haveapi/params'
18
18
  require_rel 'haveapi/parameters/'
19
19
  require_rel 'haveapi/*.rb'
20
+ require_rel 'haveapi/actions/*.rb'
21
+ require_rel 'haveapi/resources/*.rb'
20
22
  require_rel 'haveapi/model_adapters/hash'
21
23
  require_rel 'haveapi/model_adapters/active_record' if ar
22
24
  require_rel 'haveapi/authentication'
23
- require_rel 'haveapi/actions/*.rb'
24
25
  require_rel 'haveapi/output_formatters/base.rb'
25
26
  require_rel 'haveapi/output_formatters/'
26
27
  require_rel 'haveapi/validators/'
28
+ require_rel 'haveapi/client_examples/'
27
29
  require_rel 'haveapi/extensions'
@@ -8,13 +8,14 @@ module HaveAPI
8
8
  has_attr :http_method, :get
9
9
  has_attr :auth, true
10
10
  has_attr :aliases, []
11
+ has_attr :blocking, false
11
12
 
12
13
  include Hookable
13
14
 
14
15
  has_hook :exec_exception,
15
16
  desc: 'Called when unhandled exceptions occurs during Action.exec',
16
17
  args: {
17
- action: 'HaveAPI::Action instance',
18
+ context: 'HaveAPI::Context instance',
18
19
  exception: 'exception instance',
19
20
  },
20
21
  ret: {
@@ -86,6 +87,17 @@ module HaveAPI
86
87
  model_adapter(input.layout).used_by(:input, self)
87
88
  model_adapter(output.layout).used_by(:output, self)
88
89
 
90
+ if blocking
91
+ meta(:global) do
92
+ output do
93
+ integer :action_state_id,
94
+ label: 'Action state ID',
95
+ desc: 'ID of ActionState object for state querying. When null, the action '+
96
+ 'is not blocking for the current invocation.'
97
+ end
98
+ end
99
+ end
100
+
89
101
  if @meta
90
102
  @meta.each_value do |m|
91
103
  next unless m
@@ -193,6 +205,7 @@ module HaveAPI
193
205
  auth: @auth,
194
206
  description: @desc,
195
207
  aliases: @aliases,
208
+ blocking: @blocking ? true : false,
196
209
  input: @input ? @input.describe(context) : {parameters: {}},
197
210
  output: @output ? @output.describe(context) : {parameters: {}},
198
211
  meta: @meta ? @meta.merge(@meta) { |_, v| v && v.describe(context) } : nil,
@@ -319,7 +332,7 @@ module HaveAPI
319
332
  pre_exec
320
333
  exec
321
334
  rescue Exception => e
322
- tmp = call_class_hooks_as_for(Action, :exec_exception, args: [self, e])
335
+ tmp = call_class_hooks_as_for(Action, :exec_exception, args: [@context, e])
323
336
 
324
337
  if tmp.empty?
325
338
  p e.message
@@ -327,7 +340,9 @@ module HaveAPI
327
340
  error('Server error occurred')
328
341
  end
329
342
 
330
- error(tmp[:message]) unless tmp[:status]
343
+ unless tmp[:status]
344
+ error(tmp[:message], {}, http_status: tmp[:http_status] || 500)
345
+ end
331
346
  end
332
347
  end
333
348
 
@@ -392,6 +407,10 @@ module HaveAPI
392
407
  safe_ret = ret
393
408
  end
394
409
 
410
+ if self.class.blocking
411
+ @reply_meta[:global][:action_state_id] = state_id
412
+ end
413
+
395
414
  ns = {output.namespace => safe_ret}
396
415
  ns[Metadata.namespace] = @reply_meta[:global] unless meta[:no]
397
416
 
@@ -402,7 +421,7 @@ module HaveAPI
402
421
  end
403
422
 
404
423
  else
405
- [false, @message, @errors]
424
+ [false, @message, @errors, @http_status]
406
425
  end
407
426
  end
408
427
 
@@ -475,13 +494,22 @@ module HaveAPI
475
494
  ret
476
495
  end
477
496
 
478
- def ok(ret={})
497
+ # @param ret [Hash] response
498
+ # @param opts [Hash] options
499
+ # @option opts [Integer] http_status HTTP status code sent to the client
500
+ def ok(ret = {}, opts = {})
501
+ @http_status = opts[:http_status]
479
502
  throw(:return, ret)
480
503
  end
481
504
 
482
- def error(msg, errs={})
505
+ # @param msg [String] error message sent to the client
506
+ # @param errs [Hash<Array>] parameter errors sent to the client
507
+ # @param opts [Hash] options
508
+ # @option opts [Integer] http_status HTTP status code sent to the client
509
+ def error(msg, errs = {}, opts = {})
483
510
  @message = msg
484
511
  @errors = errs
512
+ @http_status = opts[:http_status]
485
513
  throw(:return, false)
486
514
  end
487
515
 
@@ -0,0 +1,92 @@
1
+ module HaveAPI
2
+ # This class is an interface between APIs and HaveAPI for handling of blocking actions.
3
+ # Blocking actions are not executed immediately, but their execution takes an unspecified
4
+ # amount of time. This interface allows to list actions that are pending completion and view
5
+ # their status.
6
+ #
7
+ # If method `poll` is defined, it is called by action Resources::ActionState::Poll.
8
+ # it can provide a more sophisticated polling implementation than the implicit one, which is
9
+ # to create a new instance of this class every second and check its state. `poll` is passed
10
+ # one argument, a hash of input parameters from Resources::ActionState::Poll.
11
+ class ActionState
12
+ # Return an array of objects representing actions that are pending completion.
13
+ # @param [Object] user
14
+ # @param [Integer] offset
15
+ # @param [Integer] limit
16
+ # @param [Symbol] order (:newest or :oldest)
17
+ # @return [Array<ActionState>]
18
+ def self.list_pending(user, offset, limit, order)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ # The constructor either gets parameter `id` or `state`. If `state` is not provided,
23
+ # the method should find it using the `id`.
24
+ #
25
+ # When the client is asking about the state of a specific action, lookup using `id`
26
+ # is used. When the client is listing pending actions, instances of this class are
27
+ # created in self.list_pending and are passed the `state` parameter to avoid double
28
+ # lookups. `id` should lead to the same object that would be passed as `state`.
29
+ #
30
+ # @param [Object] user
31
+ # @param [Integer] id action state id
32
+ # @param [Object] state
33
+ def initialize(user, id: nil, state: nil)
34
+ raise NotImplementedError
35
+ end
36
+
37
+ # @return [Boolean] true if the action exists
38
+ def valid?
39
+ raise NotImplementedError
40
+ end
41
+
42
+ # @return [Boolean] true of the action is finished
43
+ def finished?
44
+ raise NotImplementedError
45
+ end
46
+
47
+ # @return [Boolean] true if the action was/is going to be successful
48
+ def status
49
+ raise NotImplementedError
50
+ end
51
+
52
+ # @return [Integer] action state id
53
+ def id
54
+ raise NotImplementedError
55
+ end
56
+
57
+ # @return [String] human-readable label of this action state
58
+ def label
59
+ raise NotImplementedError
60
+ end
61
+
62
+ # @return [Hash]
63
+ def progress
64
+ raise NotImplementedError
65
+ end
66
+
67
+ # @return [Time]
68
+ def created_at
69
+
70
+ end
71
+
72
+ # @return [Time]
73
+ def updated_at
74
+
75
+ end
76
+
77
+ # @return [Boolean] true if the action can be cancelled
78
+ def can_cancel?
79
+ false
80
+ end
81
+
82
+ # Stop action execution
83
+ # @raise [RuntimeError] if the cancellation failed
84
+ # @raise [NotImplementedError] if the cancellation is not supported
85
+ # @return [Integer] if the cancellation succeded and is a blocking action
86
+ # @return [truthy] if the cancellation succeeded
87
+ # @return [falsy] if the cancellation failed
88
+ def cancel
89
+ raise NotImplementedError, 'action cancellation is not implemented by this API'
90
+ end
91
+ end
92
+ end
@@ -26,6 +26,13 @@ module HaveAPI::Authentication
26
26
  user
27
27
  end
28
28
 
29
+ def describe
30
+ {
31
+ description: "Authentication using HTTP basic. Username and password is passed "+
32
+ "via HTTP header. Its use is forbidden from web browsers."
33
+ }
34
+ end
35
+
29
36
  protected
30
37
  # Reimplement this method. It has to return an authenticated
31
38
  # user or nil.
@@ -100,6 +100,11 @@ module HaveAPI::Authentication
100
100
  {
101
101
  http_header: http_header,
102
102
  query_parameter: query_parameter,
103
+ description: "The client authenticates with username and password and gets "+
104
+ "a token. From this point, the password can be forgotten and "+
105
+ "the token is used instead. Tokens can have different lifetimes, "+
106
+ "can be renewed and revoked. The token is passed either via HTTP "+
107
+ "header or query parameter."
103
108
  }
104
109
  end
105
110
 
@@ -0,0 +1,83 @@
1
+ module HaveAPI
2
+ module ClientExamples ; end
3
+
4
+ # All client example classes should inherit this class. Depending on the client,
5
+ # the subclass may choose to implement either method `example` or `request` and
6
+ # `response`. `example` should be implemented if the client example shows only
7
+ # the request or the request and response should be coupled together.
8
+ #
9
+ # Methods `example`, `request` and `response` take one argument, the example
10
+ # to describe.
11
+ class ClientExample
12
+ class << self
13
+ # All subclasses have to call this method to set their label and be
14
+ # registered.
15
+ def label(v = nil)
16
+ if v
17
+ @label = v
18
+ HaveAPI::ClientExample.register(self)
19
+ end
20
+
21
+ @label
22
+ end
23
+
24
+ # Code name is passed to the syntax highligher.
25
+ def code(v = nil)
26
+ @code = v if v
27
+ @code
28
+ end
29
+
30
+ # A number used for ordering client examples.
31
+ def order(v = nil)
32
+ @order = v if v
33
+ @order
34
+ end
35
+
36
+ def register(klass)
37
+ @clients ||= []
38
+ @clients << klass
39
+ end
40
+
41
+ # Shortcut to {ClientExample#init}
42
+ def init(*args)
43
+ new(*args).init
44
+ end
45
+
46
+ # Shortcut to {ClientExample#auth}
47
+ def auth(*args)
48
+ new(*args[0..-3]).auth(*args[-2..-1])
49
+ end
50
+
51
+ # Shortcut to {ClientExample#example}
52
+ def example(*args)
53
+ new(*args[0..-2]).example(args.last)
54
+ end
55
+
56
+ # @return [Array<ClientExample>] sorted array of classes
57
+ def clients
58
+ @clients.sort { |a, b| a.order <=> b.order }
59
+ end
60
+ end
61
+
62
+ attr_reader :resource_path, :resource, :action_name, :action, :host, :base_url, :version
63
+
64
+ def initialize(host, base_url, version, *args)
65
+ @host = host
66
+ @base_url = base_url
67
+ @version = version
68
+ @resource_path, @resource, @action_name, @action = args
69
+ end
70
+
71
+ def init
72
+
73
+ end
74
+
75
+ def auth(method, desc)
76
+
77
+ end
78
+
79
+ def version_url
80
+ File.join(base_url, "v#{version}", '/')
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,86 @@
1
+ require 'pp'
2
+
3
+ module HaveAPI::ClientExamples
4
+ class Curl < Http
5
+ label 'curl'
6
+ code :bash
7
+ order 90
8
+
9
+ def init
10
+ "$ curl --request OPTIONS '#{version_url}'"
11
+ end
12
+
13
+ def auth(method, desc)
14
+ login = {login: 'user', password: 'password', lifetime: 'fixed'}
15
+
16
+ case method
17
+ when :basic
18
+ <<END
19
+ # Password is asked on standard input
20
+ $ curl --request OPTIONS \\
21
+ --user username \\
22
+ '#{base_url}'
23
+ Password: secret
24
+
25
+ # Password given on the command line
26
+ $ curl --request OPTIONS \\
27
+ --user username:secret \\
28
+ '#{base_url}'
29
+ END
30
+
31
+ when :token
32
+ <<END
33
+ # Acquire the token
34
+ $ curl --request POST \\
35
+ --header 'Content-Type: application/json' \\
36
+ --data-binary "#{format_data(token: login)}" \\
37
+ '#{File.join(base_url, '_auth', 'token', 'tokens')}'
38
+
39
+ # Use a previously acquired token
40
+ $ curl --request OPTIONS \\
41
+ --header '#{desc[:http_header]}: thetoken' \\
42
+ '#{base_url}'
43
+ END
44
+ end
45
+ end
46
+
47
+ def request(sample)
48
+ url = File.join(
49
+ base_url,
50
+ resolve_path(
51
+ action[:method],
52
+ action[:url],
53
+ sample[:url_params] || [],
54
+ sample[:request]
55
+ )
56
+ )
57
+
58
+ data = format_data({
59
+ action[:input][:namespace] => sample[:request],
60
+ })
61
+
62
+ <<END
63
+ $ curl --request #{action[:method]} \\
64
+ --data-binary "#{data}" \\
65
+ '#{url}'
66
+ END
67
+ end
68
+
69
+ def response(sample)
70
+ JSON.pretty_generate({
71
+ status: sample[:status],
72
+ message: sample[:message],
73
+ response: {action[:output][:namespace] => sample[:response]},
74
+ errors: sample[:errors],
75
+ })
76
+ end
77
+
78
+ def format_data(data)
79
+ json = JSON.pretty_generate(data)
80
+ json.split("\n").map do |line|
81
+ out = ''
82
+ PP.pp(line, out).strip[1..-2]
83
+ end.join("\n")
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,116 @@
1
+ module HaveAPI::ClientExamples
2
+ class FsClient < HaveAPI::ClientExample
3
+ label 'File system'
4
+ code :bash
5
+ order 40
6
+
7
+ def init
8
+ "# Mount the file system\n$ haveapi-fs #{base_url} #{mountpoint} -o version=#{version}"
9
+ end
10
+
11
+ def auth(method, desc)
12
+ case method
13
+ when :basic
14
+ <<END
15
+ # Provide credentials as file system options
16
+ #{init} -o auth_method=basic,user=myuser,password=secret
17
+
18
+ # If username or password isn't provided, the user is asked on stdin
19
+ #{init} -o auth_method=basic,user=myuser
20
+ Password: secret
21
+ END
22
+
23
+ when :token
24
+ <<END
25
+ # Authenticate using username and password
26
+ #{init} -o auth_method=token,user=myuser
27
+ Password: secret
28
+
29
+ # If you have generated a token, you can use it
30
+ #{init} -o auth_method=token,token=yourtoken
31
+
32
+ # Note that the file system can read config file from haveapi-client, so if
33
+ # you set up authentication there, the file system will use it.
34
+ END
35
+ end
36
+ end
37
+
38
+ def example(sample)
39
+ cmd = [init]
40
+
41
+ path = [mountpoint].concat(resource_path)
42
+
43
+ unless class_action?
44
+ if !sample[:url_params] || sample[:url_params].empty?
45
+ fail "example {#{sample}} of action #{resource_path.join('.')}"+
46
+ ".#{action_name} is for an instance action but does not include "+
47
+ "URL parameters"
48
+ end
49
+
50
+ path << sample[:url_params].first.to_s
51
+ end
52
+
53
+ path << 'actions' << action_name
54
+
55
+ cmd << "\n# Change to action directory"
56
+ cmd << "$ cd #{File.join(path)}"
57
+
58
+ if sample[:request] && !sample[:request].empty?
59
+ cmd << "\n# Prepare input parameters"
60
+
61
+ sample[:request].each do |k, v|
62
+ cmd << "$ echo '#{v}' > input/#{k}"
63
+ end
64
+ end
65
+
66
+ cmd << "\n# Execute the action"
67
+ cmd << "$ echo 1 > exec"
68
+
69
+ cmd << "\n# Query the action's result"
70
+ cmd << "$ cat status"
71
+ cmd << (sample[:status] ? '1' : '0')
72
+
73
+ if sample[:status]
74
+ if sample[:response] && !sample[:response].empty? \
75
+ && %i(hash object).include?(action[:output][:layout])
76
+ cmd << "\n# Query the output parameters"
77
+
78
+ sample[:response].each do |k, v|
79
+ cmd << "$ cat output/#{k}"
80
+
81
+ if v === true
82
+ cmd << '1'
83
+
84
+ elsif v === false
85
+ cmd << '0'
86
+
87
+ else
88
+ cmd << "#{v.to_s}"
89
+ end
90
+
91
+ cmd << "\n"
92
+ end
93
+ end
94
+
95
+ else
96
+ cmd << "\n# Get the error message"
97
+ cmd << "$ cat message"
98
+ cmd << sample[:message]
99
+
100
+ cmd << "\n# Parameter errors can be seen in the `errors` directory"
101
+ cmd << "$ ls errors"
102
+ cmd << (sample[:errors] || {}).keys.join("\n")
103
+ end
104
+
105
+ cmd.join("\n")
106
+ end
107
+
108
+ def mountpoint
109
+ "/mnt/#{host}"
110
+ end
111
+
112
+ def class_action?
113
+ action[:url].index(/:[a-zA-Z\-_]+/).nil?
114
+ end
115
+ end
116
+ end