haveapi 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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>