haveapi 0.3.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/lib/haveapi/action.rb +521 -0
  3. data/lib/haveapi/actions/default.rb +55 -0
  4. data/lib/haveapi/actions/paginable.rb +12 -0
  5. data/lib/haveapi/api.rb +66 -0
  6. data/lib/haveapi/authentication/base.rb +37 -0
  7. data/lib/haveapi/authentication/basic/provider.rb +37 -0
  8. data/lib/haveapi/authentication/chain.rb +110 -0
  9. data/lib/haveapi/authentication/token/provider.rb +166 -0
  10. data/lib/haveapi/authentication/token/resources.rb +107 -0
  11. data/lib/haveapi/authorization.rb +108 -0
  12. data/lib/haveapi/common.rb +38 -0
  13. data/lib/haveapi/context.rb +78 -0
  14. data/lib/haveapi/example.rb +36 -0
  15. data/lib/haveapi/extensions/action_exceptions.rb +25 -0
  16. data/lib/haveapi/extensions/base.rb +9 -0
  17. data/lib/haveapi/extensions/resource_prefetch.rb +7 -0
  18. data/lib/haveapi/hooks.rb +190 -0
  19. data/lib/haveapi/metadata.rb +56 -0
  20. data/lib/haveapi/model_adapter.rb +119 -0
  21. data/lib/haveapi/model_adapters/active_record.rb +352 -0
  22. data/lib/haveapi/model_adapters/hash.rb +27 -0
  23. data/lib/haveapi/output_formatter.rb +57 -0
  24. data/lib/haveapi/output_formatters/base.rb +29 -0
  25. data/lib/haveapi/output_formatters/json.rb +9 -0
  26. data/lib/haveapi/params/param.rb +114 -0
  27. data/lib/haveapi/params/resource.rb +109 -0
  28. data/lib/haveapi/params.rb +314 -0
  29. data/lib/haveapi/public/css/bootstrap-theme.min.css +7 -0
  30. data/lib/haveapi/public/css/bootstrap.min.css +7 -0
  31. data/lib/haveapi/public/js/bootstrap.min.js +6 -0
  32. data/lib/haveapi/public/js/jquery-1.11.1.min.js +4 -0
  33. data/lib/haveapi/resource.rb +120 -0
  34. data/lib/haveapi/route.rb +22 -0
  35. data/lib/haveapi/server.rb +440 -0
  36. data/lib/haveapi/spec/helpers.rb +103 -0
  37. data/lib/haveapi/types.rb +24 -0
  38. data/lib/haveapi/version.rb +3 -0
  39. data/lib/haveapi/views/doc_layout.erb +27 -0
  40. data/lib/haveapi/views/doc_sidebars/create-client.erb +20 -0
  41. data/lib/haveapi/views/doc_sidebars/protocol.erb +42 -0
  42. data/lib/haveapi/views/index.erb +12 -0
  43. data/lib/haveapi/views/main_layout.erb +50 -0
  44. data/lib/haveapi/views/version_page.erb +195 -0
  45. data/lib/haveapi/views/version_sidebar.erb +42 -0
  46. data/lib/haveapi.rb +22 -0
  47. metadata +242 -0
@@ -0,0 +1,440 @@
1
+ module HaveAPI
2
+ class Server
3
+ attr_reader :root, :routes, :module_name, :auth_chain, :versions, :default_version,
4
+ :extensions
5
+
6
+ include Hookable
7
+
8
+ # Called after the user was authenticated (or not). The block is passed
9
+ # current user object or nil as an argument.
10
+ has_hook :post_authenticated
11
+
12
+ module ServerHelpers
13
+ def authenticate!(v)
14
+ require_auth! unless authenticated?(v)
15
+ end
16
+
17
+ def authenticated?(v)
18
+ return @current_user if @current_user
19
+
20
+ @current_user = settings.api_server.send(:do_authenticate, v, request)
21
+ settings.api_server.call_hooks_for(:post_authenticated, args: @current_user)
22
+ @current_user
23
+ end
24
+
25
+ def access_control
26
+ if request.env['HTTP_ORIGIN'] && request.env['HTTP_ACCESS_CONTROL_REQUEST_METHOD']
27
+ halt 200, {
28
+ 'Access-Control-Allow-Origin' => '*',
29
+ 'Access-Control-Allow-Methods' => 'GET,POST,OPTIONS,PATCH,PUT,DELETE',
30
+ 'Access-Control-Allow-Credentials' => 'false',
31
+ 'Access-Control-Allow-Headers' => settings.api_server.allowed_headers,
32
+ 'Access-Control-Max-Age' => (60*60).to_s
33
+ }, ''
34
+ end
35
+ end
36
+
37
+ def current_user
38
+ @current_user
39
+ end
40
+
41
+ def pretty_format(obj)
42
+ ret = ''
43
+ PP.pp(obj, ret)
44
+ end
45
+
46
+ def require_auth!
47
+ report_error(401, {'WWW-Authenticate' => 'Basic realm="Restricted Area"'},
48
+ 'Action requires user to authenticate')
49
+ end
50
+
51
+ def report_error(code, headers, msg)
52
+ @halted = true
53
+ content_type @formatter.content_type, charset: 'utf-8'
54
+ halt code, headers, @formatter.format(false, nil, msg)
55
+ end
56
+
57
+ def root
58
+ settings.api_server.root
59
+ end
60
+
61
+ def logout_url
62
+ ret = url("#{root}_logout")
63
+ ret.insert(ret.index('//') + 2, '_log:out@')
64
+ end
65
+
66
+ def doc(file)
67
+ markdown :"../../../doc/#{file}"
68
+ end
69
+
70
+ def version
71
+ HaveAPI::VERSION
72
+ end
73
+ end
74
+
75
+ def initialize(module_name = HaveAPI.module_name)
76
+ @module_name = module_name
77
+ @allowed_headers = ['Content-Type']
78
+ @auth_chain = HaveAPI::Authentication::Chain.new(self)
79
+ @extensions = []
80
+ end
81
+
82
+ # Include specific version +v+ of API.
83
+ # +v+ can be one of:
84
+ # [:all] use all available versions
85
+ # [Array] use all versions in +Array+
86
+ # [version] include only concrete version
87
+ # +default+ is set only when including concrete version. Use
88
+ # set_default_version otherwise.
89
+ def use_version(v, default: false)
90
+ @versions ||= []
91
+
92
+ if v == :all
93
+ @versions = HaveAPI.get_versions(@module_name)
94
+ elsif v.is_a?(Array)
95
+ @versions += v
96
+ @versions.uniq!
97
+ else
98
+ @versions << v
99
+ @default_version = v if default
100
+ end
101
+ end
102
+
103
+ # Set default version of API.
104
+ def set_default_version(v)
105
+ @default_version = v
106
+ end
107
+
108
+ # Load routes for all resource from included API versions.
109
+ # All routes are mounted under prefix +path+.
110
+ # If no default version is set, the last included version is used.
111
+ def mount(prefix='/')
112
+ @root = prefix
113
+
114
+ @sinatra = Sinatra.new do
115
+ set :views, settings.root + '/views'
116
+ set :public_folder, settings.root + '/public'
117
+ set :bind, '0.0.0.0'
118
+
119
+ helpers ServerHelpers
120
+
121
+ before do
122
+ @formatter = OutputFormatter.new
123
+
124
+ unless @formatter.supports?(request.accept)
125
+ @halted = true
126
+ halt 406, "Not Acceptable\n"
127
+ end
128
+
129
+ content_type @formatter.content_type, charset: 'utf-8'
130
+
131
+ if request.env['HTTP_ORIGIN']
132
+ headers 'Access-Control-Allow-Origin' => '*',
133
+ 'Access-Control-Allow-Credentials' => 'false'
134
+ end
135
+ end
136
+
137
+ not_found do
138
+ report_error(404, {}, 'Action not found') unless @halted
139
+ end
140
+
141
+ after do
142
+ ActiveRecord::Base.clear_active_connections!
143
+ end
144
+ end
145
+
146
+ @sinatra.set(:api_server, self)
147
+
148
+ @routes = {}
149
+ @default_version ||= @versions.last
150
+
151
+ # Mount root
152
+ @sinatra.get @root do
153
+ authenticated?(settings.api_server.default_version)
154
+
155
+ @api = settings.api_server.describe(Context.new(settings.api_server, user: current_user,
156
+ params: params))
157
+
158
+ content_type 'text/html'
159
+ erb :index, layout: :main_layout
160
+ end
161
+
162
+ @sinatra.options @root do
163
+ access_control
164
+ authenticated?(settings.api_server.default_version)
165
+ ret = nil
166
+
167
+ case params[:describe]
168
+ when 'versions'
169
+ ret = {versions: settings.api_server.versions,
170
+ default: settings.api_server.default_version}
171
+
172
+ when 'default'
173
+ ret = settings.api_server.describe_version(Context.new(settings.api_server, version: settings.api_server.default_version,
174
+ user: current_user, params: params))
175
+
176
+ else
177
+ ret = settings.api_server.describe(Context.new(settings.api_server, user: current_user,
178
+ params: params))
179
+ end
180
+
181
+ @formatter.format(true, ret)
182
+ end
183
+
184
+ # Doc
185
+ @sinatra.get "#{@root}doc" do
186
+ content_type 'text/html'
187
+ erb :main_layout do
188
+ doc(:index)
189
+ end
190
+ end
191
+
192
+ @sinatra.get "#{@root}doc/readme" do
193
+ content_type 'text/html'
194
+ erb :main_layout do
195
+ GitHub::Markdown.render(File.new(settings.views + '/../../../README.md').read)
196
+ end
197
+ end
198
+
199
+ @sinatra.get %r{#{@root}doc/([^\.]+)[\.md]?} do |f|
200
+ content_type 'text/html'
201
+ erb :doc_layout, layout: :main_layout do
202
+ begin
203
+ @content = doc(f)
204
+
205
+ rescue Errno::ENOENT
206
+ halt 404
207
+ end
208
+
209
+ @sidebar = erb :"doc_sidebars/#{f}"
210
+ end
211
+ end
212
+
213
+ # Login/logout links
214
+ @sinatra.get "#{root}_login" do
215
+ if current_user
216
+ redirect back
217
+ else
218
+ authenticate!(settings.api_server.default_version) # FIXME
219
+ end
220
+ end
221
+
222
+ @sinatra.get "#{root}_logout" do
223
+ require_auth!
224
+ end
225
+
226
+ @auth_chain << HaveAPI.default_authenticate if @auth_chain.empty?
227
+ @auth_chain.setup(@versions)
228
+
229
+ @extensions.each { |e| e.enabled }
230
+
231
+ # Mount default version first
232
+ mount_version(@root, @default_version)
233
+
234
+ @versions.each do |v|
235
+ mount_version(version_prefix(v), v)
236
+ end
237
+ end
238
+
239
+ def mount_version(prefix, v)
240
+ @routes[v] ||= {}
241
+ @routes[v][:resources] = {}
242
+
243
+ @sinatra.get prefix do
244
+ authenticated?(v)
245
+
246
+ @v = v
247
+ @help = settings.api_server.describe_version(Context.new(settings.api_server, version: v,
248
+ user: current_user, params: params))
249
+ content_type 'text/html'
250
+ erb :doc_layout, layout: :main_layout do
251
+ @content = erb :version_page
252
+ @sidebar = erb :version_sidebar
253
+ end
254
+ end
255
+
256
+ @sinatra.options prefix do
257
+ access_control
258
+ authenticated?(v)
259
+
260
+ @formatter.format(true, settings.api_server.describe_version(Context.new(settings.api_server, version: v,
261
+ user: current_user, params: params)))
262
+ end
263
+
264
+ HaveAPI.get_version_resources(@module_name, v).each do |resource|
265
+ mount_resource(prefix, v, resource, @routes[v][:resources])
266
+ end
267
+ end
268
+
269
+ def mount_resource(prefix, v, resource, hash)
270
+ hash[resource] = {resources: {}, actions: {}}
271
+
272
+ resource.routes(prefix).each do |route|
273
+ if route.is_a?(Hash)
274
+ hash[resource][:resources][route.keys.first] = mount_nested_resource(v, route.values.first)
275
+
276
+ else
277
+ hash[resource][:actions][route.action] = route.url
278
+ mount_action(v, route)
279
+ end
280
+ end
281
+ end
282
+
283
+ def mount_nested_resource(v, routes)
284
+ ret = {resources: {}, actions: {}}
285
+
286
+ routes.each do |route|
287
+ if route.is_a?(Hash)
288
+ ret[:resources][route.keys.first] = mount_nested_resource(v, route.values.first)
289
+
290
+ else
291
+ ret[:actions][route.action] = route.url
292
+ mount_action(v, route)
293
+ end
294
+ end
295
+
296
+ ret
297
+ end
298
+
299
+ def mount_action(v, route)
300
+ @sinatra.method(route.http_method).call(route.url) do
301
+ authenticate!(v) if route.action.auth
302
+
303
+ request.body.rewind
304
+
305
+ begin
306
+ body = request.body.read
307
+
308
+ if body.empty?
309
+ body = nil
310
+ else
311
+ body = JSON.parse(body, symbolize_names: true)
312
+ end
313
+
314
+ rescue => e
315
+ report_error(400, {}, 'Bad JSON syntax')
316
+ end
317
+
318
+ action = route.action.new(request, v, params, body, Context.new(settings.api_server, version: v,
319
+ action: route.action, url: route.url,
320
+ params: params,
321
+ user: current_user, endpoint: true))
322
+
323
+ unless action.authorized?(current_user)
324
+ report_error(403, {}, 'Access denied. Insufficient permissions.')
325
+ end
326
+
327
+ status, reply, errors = action.safe_exec
328
+
329
+ @formatter.format(
330
+ status,
331
+ status ? reply : nil,
332
+ !status ? reply : nil,
333
+ errors
334
+ )
335
+ end
336
+
337
+ @sinatra.options route.url do |*args|
338
+ access_control
339
+ route_method = route.http_method.to_s.upcase
340
+
341
+ pass if params[:method] && params[:method] != route_method
342
+
343
+ authenticate!(v) if route.action.auth
344
+
345
+ begin
346
+ desc = route.action.describe(Context.new(settings.api_server, version: v,
347
+ action: route.action, url: route.url,
348
+ args: args, params: params,
349
+ user: current_user, endpoint: true))
350
+
351
+ unless desc
352
+ report_error(403, {}, 'Access denied. Insufficient permissions.')
353
+ end
354
+
355
+ rescue ActiveRecord::RecordNotFound
356
+ report_error(404, {}, 'Object not found')
357
+ end
358
+
359
+ @formatter.format(true, desc)
360
+ end
361
+ end
362
+
363
+ def describe(context)
364
+ context.version = @default_version
365
+
366
+ ret = {
367
+ default_version: @default_version,
368
+ versions: {default: describe_version(context)},
369
+ }
370
+
371
+ @versions.each do |v|
372
+ context.version = v
373
+ ret[:versions][v] = describe_version(context)
374
+ end
375
+
376
+ ret
377
+ end
378
+
379
+ def describe_version(context)
380
+ ret = {
381
+ authentication: @auth_chain.describe(context),
382
+ resources: {},
383
+ meta: Metadata.describe,
384
+ help: version_prefix(context.version)
385
+ }
386
+
387
+ #puts JSON.pretty_generate(@routes)
388
+
389
+ @routes[context.version][:resources].each do |resource, children|
390
+ r_name = resource.to_s.demodulize.underscore
391
+ r_desc = describe_resource(resource, children, context)
392
+
393
+ unless r_desc[:actions].empty? && r_desc[:resources].empty?
394
+ ret[:resources][r_name] = r_desc
395
+ end
396
+ end
397
+
398
+ ret
399
+ end
400
+
401
+ def describe_resource(r, hash, context)
402
+ r.describe(hash, context)
403
+ end
404
+
405
+ def version_prefix(v)
406
+ "#{@root}v#{v}/"
407
+ end
408
+
409
+ def add_auth_module(v, name, mod, prefix: '')
410
+ @routes[v] ||= {authentication: {name => {resources: {}}}}
411
+
412
+ HaveAPI.get_version_resources(mod, v).each do |r|
413
+ mount_resource("#{@root}_auth/#{prefix}/", v, r, @routes[v][:authentication][name][:resources])
414
+ end
415
+ end
416
+
417
+ def allow_header(name)
418
+ @allowed_headers << name unless @allowed_headers.include?(name)
419
+ @allowed_headers_str = nil
420
+ end
421
+
422
+ def allowed_headers
423
+ return @allowed_headers_str if @allowed_headers_str
424
+ @allowed_headers_str = @allowed_headers.join(',')
425
+ end
426
+
427
+ def app
428
+ @sinatra
429
+ end
430
+
431
+ def start!
432
+ @sinatra.run!
433
+ end
434
+
435
+ private
436
+ def do_authenticate(v, request)
437
+ @auth_chain.authenticate(v, request)
438
+ end
439
+ end
440
+ end
@@ -0,0 +1,103 @@
1
+ require 'rack/test'
2
+
3
+ module HaveAPI
4
+ # Contains methods for specification of API to be used in +description+ block.
5
+ module ApiBuilder
6
+ def auth_chain(chain)
7
+ @auth_chain = chain
8
+ end
9
+
10
+ def use_version(v)
11
+ before(:each) do
12
+ @versions = v
13
+ end
14
+ end
15
+
16
+ def default_version(v)
17
+ @default_version = v
18
+ end
19
+
20
+ def mount_to(path)
21
+ @mount = path
22
+ end
23
+
24
+ def login(*credentials)
25
+ @username, @password = credentials
26
+
27
+ before(:each) do
28
+ basic_authorize(*credentials)
29
+ end
30
+ end
31
+ end
32
+
33
+ # Helper methods for specs.
34
+ module SpecMethods
35
+ include Rack::Test::Methods
36
+
37
+ # This class wraps raw reply from the API and provides more friendly
38
+ # interface.
39
+ class ApiResponse
40
+ def initialize(body)
41
+ @data = JSON.parse(body, symbolize_names: true)
42
+ end
43
+
44
+ def status
45
+ @data[:status]
46
+ end
47
+
48
+ def ok?
49
+ @data[:status]
50
+ end
51
+
52
+ def failed?
53
+ !ok?
54
+ end
55
+
56
+ def response
57
+ @data[:response]
58
+ end
59
+
60
+ def message
61
+ @data[:message]
62
+ end
63
+
64
+ def errors
65
+ @data[:errors]
66
+ end
67
+
68
+ def [](k)
69
+ @data[:response][k]
70
+ end
71
+ end
72
+
73
+ def app
74
+ api = HaveAPI::Server.new
75
+ api.auth_chain << @auth_chain if @auth_chain
76
+ api.use_version(@versions || :all)
77
+ api.set_default_version(@default_version) if @default_version
78
+ api.mount(@mount || '/')
79
+ api.app
80
+ end
81
+
82
+ # Login with HTTP basic auth.
83
+ def login(*credentials)
84
+ basic_authorize(*credentials)
85
+ end
86
+
87
+ # Make API request.
88
+ # This method is a wrapper for Rack::Test::Methods. Input parameters
89
+ # are encoded into JSON and sent with correct Content-Type.
90
+ def api(http_method, url, params={})
91
+ method(http_method).call(
92
+ url,
93
+ params.to_json,
94
+ {'Content-Type' => 'application/json'}
95
+ )
96
+ end
97
+
98
+ # Return parsed API response.
99
+ def api_response
100
+ @api_response ||= ApiResponse.new(last_response.body)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,24 @@
1
+ # Just to represent boolean type in self-description
2
+ module Boolean
3
+ def self.to_b(str)
4
+ return true if str === true
5
+ return true if str =~ /^(true|t|yes|y|1)$/i
6
+
7
+ return false if str === false
8
+ return false if str =~ /^(false|f|no|n|0)$/i
9
+
10
+ false
11
+ end
12
+ end
13
+
14
+ module Datetime
15
+
16
+ end
17
+
18
+ module Custom
19
+
20
+ end
21
+
22
+ class Text < String
23
+
24
+ end
@@ -0,0 +1,3 @@
1
+ module HaveAPI
2
+ VERSION = '0.3.0'
3
+ end
@@ -0,0 +1,27 @@
1
+ <% yield %>
2
+ <div class="row-fluid">
3
+ <div class="col-sm-10">
4
+ <%= @content %>
5
+ </div>
6
+
7
+ <div class="col-sm-2 table-of-contents">
8
+ <div class="nav sidebar-nav-fixed" data-spy="affix" data-offset-top="100" data-offset-bottom="200">
9
+ <%= @sidebar %>
10
+ </div>
11
+ </div>
12
+ </div>
13
+
14
+ <script type="text/javascript">
15
+ $(document).ready(function(){
16
+
17
+ // Show/hide items in navigation
18
+ $('ul.top-level ul').css('display', 'none');
19
+
20
+ $('.table-of-contents').on('activate.bs.scrollspy', function(){
21
+ var j = $(this);
22
+
23
+ j.find('ul.top-level ul').css('display', 'none');
24
+ j.find('ul.top-level li.active > ul').css('display', 'block');
25
+ });
26
+ });
27
+ </script>
@@ -0,0 +1,20 @@
1
+ <h1>Contents</h1>
2
+ <ul class="top-level">
3
+ <li><a href="#client-definition">Client definition</a></li>
4
+ <li><a href="#design-rules">Design rules</a></li>
5
+ <li>
6
+ <a href="#necessary-features-to-implement">Necessary features to implement</a>
7
+ <ul>
8
+ <li><a href="#resource-tree">Resource tree</a></li>
9
+ <li><a href="#inputoutput-parameters">Input/output parameters</a></li>
10
+ <li><a href="#authentication">Authentication</a></li>
11
+ <li><a href="#object-like-access">Object-like access</a></li>
12
+ </ul>
13
+ </li>
14
+ <li>
15
+ <a href="#supplemental-features">Supplemental features</a>
16
+ <ul>
17
+ <li><a href="#client-sde-validations">Client-side validations</a></li>
18
+ </ul>
19
+ </li>
20
+ </ul>
@@ -0,0 +1,42 @@
1
+ <h1>Contents</h1>
2
+ <ul class="top-level">
3
+ <li><a href="#protocol-definition">Protocol definition</a></li>
4
+ <li><a href="#self-description">Self-description</a></li>
5
+ <li><a href="#envelope">Envelope</a></li>
6
+ <li>
7
+ <a href="#description-format">Description format</a>
8
+ <ul>
9
+ <li><a href="#version">Version</a></li>
10
+ <li>
11
+ <a href="#Authentication">Authentication</a>
12
+ <ul>
13
+ <li><a href="#http-basic-authentication">HTTP basic authentication</a></li>
14
+ <li><a href="#token-authentication">Token authentication</a></li>
15
+ </ul>
16
+ </li>
17
+ <li><a href="#resources">Resources</a></li>
18
+ <li>
19
+ <a href="#actions">Actions</a>
20
+ <ul>
21
+ <li><a href="#layouts">Layouts</a></li>
22
+ <li><a href="#namespace">Namespace</a></li>
23
+ </ul>
24
+ </li>
25
+ <li>
26
+ <a href="#parameters">Parameters</a>
27
+ <ul>
28
+ <li><a href="#data-types">Data types</a></li>
29
+ <li><a href="#resource-associations">Resource associations</a></li>
30
+ </ul>
31
+ </li>
32
+ <li><a href="#examples">Examples</a></li>
33
+ <li><a href="#list-api-versions">List API versions</a></li>
34
+ <li><a href="#describe-default-version">Describe default version</a></li>
35
+ <li><a href="#describe-the-whole-api">Describe the whole API</a></li>
36
+ </ul>
37
+ </li>
38
+ <li><a href="#authorization">Authorization</a></li>
39
+ <li><a href="#io">Input/output formats</a></li>
40
+ <li><a href="#request">Request</a></li>
41
+ <li><a href="#response">Response</a></li>
42
+ </ul>
@@ -0,0 +1,12 @@
1
+ <h1>API description</h1>
2
+ <h2>Available versions:</h2>
3
+ <ul>
4
+ <% @api[:versions].each do |v, info| %>
5
+ <% next if v == :default %>
6
+ <li>
7
+ <a href="<%= info[:help] %>">Version <%= v.to_s %></a>
8
+ <%= '(default)' if v == @api[:default_version] %>
9
+ </li>
10
+ <% end %>
11
+ </ul>
12
+ <h2><a href="/doc">Documentation</a></h2>