haveapi 0.6.0 → 0.7.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 (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