rack-backend-api 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2011 Mickael Riga
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,202 @@
1
+ BACKEND API
2
+ ===========
3
+
4
+ The inspiration for this Rack middleware is the CouchDB API.
5
+ We wanted to create a middleware that provides every URLs you need in order to build a CMS
6
+ while only concentrating on the interface.
7
+ All the database interactions are handled by the API.
8
+
9
+ The project is made with a Rack middleware and a small adapter for the Sequel ORM.
10
+ One of the chapter explains how to create an adpater for another ORM (if you do one, please share).
11
+
12
+ Also this tool is part of a toolkit that is made for creating a CMS (in a modular way).
13
+ Here are the others:
14
+
15
+ - [Crushyform](https://github.com/mig-hub/sequel-crushyform): A Sequel plugin for building forms in a painless way and as flexible as possible.
16
+ - [Stash Magic](https://github.com/mig-hub/stash_magic): A simple attachment system that also handles thumbnails or other styles via ImageMagick. Originaly tested on Sequel ORM but purposedly easy to plug to something else.
17
+ - [Cerberus](https://github.com/mig-hub/cerberus): A Rack middleware for form-based authentication.
18
+
19
+ This project is still at an early stage so don't hesitate to ask any question if the documentation lacks something.
20
+ And a good way to get started is to try the example in the example folder of this library.
21
+ It is a very basic (but complete) admin system.
22
+ A lot better if you have [Crushyform](https://rubygems.org/gems/sequel-crushyform) installed (nothing more to do except having it installed).
23
+
24
+ Once you are in the root directory of the library, you can start it with:
25
+
26
+ rackup example/config.ru
27
+
28
+ The file `basic_admin.rb` contains the Backend itself and shows how to use the URLs of the API.
29
+
30
+ You can use it for any kind of Rack application (Sinatra, Ramaze, Merb, Rails...).
31
+ Ramaze/Innate is not the most obvious to use as a middleware, but this is the one I use the most,
32
+ so drop me a line if you don't know how to do.
33
+
34
+ HOW TO INSTALL
35
+ ==============
36
+
37
+ This is a Gem so you can install it with:
38
+
39
+ sudo gem install rack-backend-api
40
+
41
+ HOW TO USE IT
42
+ =============
43
+
44
+ BackendAPI is a Rack middleware that you have to put before your actual backend/CMS,
45
+ and generaly after an authentication middleware.
46
+ And it takes care of everything involving interraction with your database.
47
+
48
+ In reality, it does not HAVE to be with the Backend but it makes sense and that way,
49
+ both share the authentication middleware.
50
+
51
+ A rackup stack for your application might look like this:
52
+
53
+ map '/' do
54
+ run Frontend
55
+ end
56
+
57
+ map '/admin' do
58
+ use Rack::Auth::Basic, "your-realm" do |username, password|
59
+ [username, password] == ['username', 'password']
60
+ end
61
+ use BackendAPI
62
+ run Backend
63
+ end
64
+
65
+ Your backend receives every request that the Restful API doesn't recognize.
66
+ The BackendAPI recognizes requests following this scheme:
67
+
68
+ METHOD /Backend-path/model_class/ID
69
+
70
+ The ID is not always relevant.
71
+ So if you have a model class called BlogPost and you want to get the form for the entry with ID 4:
72
+
73
+ GET /admin/blog_post/4
74
+
75
+ If you don't put an ID, it means you want the form for a brand new entry:
76
+
77
+ GET /admin/blog_post
78
+
79
+ Then if you need to delete the entry with ID 4:
80
+
81
+ DELETE /admin/blog_post/4
82
+
83
+ To be honest, that is almost everything you need because the ORM adapter builds the forms
84
+ and therefore use the right action and method for POST and PUT requests.
85
+
86
+ The problem sometimes with a Restful API is that in real life,
87
+ in spite of the fact that not every requests are GET or POST it is sometimes forced.
88
+ The href of a link is always a GET, and the method for a form is
89
+ overriden if it is not GET or POST.
90
+
91
+ This is why Rack has a very handy middleware called MethodOverride.
92
+ You don't have to `use` it because BackendAPI puts it on the stack for you.
93
+ Basically when you have it, you can send the method you really wanted in the POSTed parameter called "_method",
94
+ and the middleware override the method for you.
95
+ This is how the adapter makes forms with PUT requests.
96
+
97
+ But unfortunately you can only use MethodOverride on POST requests,
98
+ but you might want to have it on links.
99
+
100
+ Here is a concrete example:
101
+ You want to put in your CMS a link for deleting blog post.
102
+ But a link is going to be a GET request.
103
+ Of course you could use Ajax and anyway you probably will,
104
+ but it is a good practice to make it possible without javascript.
105
+ So your link could look like this:
106
+
107
+ <a href="/admin/blog_post/4?_method=DELETE"> X </a>
108
+
109
+ But it doesn't work because links are GET requests.
110
+ Fortunately this is a common task so there is a method that makes DELETE buttons available as a form:
111
+
112
+ @blog_post.backend_delete_form("/admin/blog_post/4", { :destination => "/admin/list/blog_post" })
113
+
114
+ The `:destination` is where you go when the job is done.
115
+ You also can change the option `:submit_text` which is what the button says.
116
+ By default, the DELETE form button says "X".
117
+
118
+ The `:destination` option is also in the API as `_destination`.
119
+ Use it in order to specify where to go when the entry is validated.
120
+ Because before it is validated you'll get the form again with error messages.
121
+
122
+ Say we need a link for creating a blog post, and then when validated, we want to go back to the list page:
123
+
124
+ <a href="/admin/blog_post?_destination=%2Fadmin%2Flist%2Fblog_post"> Create new Blog Post </a>
125
+
126
+ Of course, the page `/admin/list/blog_post` is a page of your Backend/CMS.
127
+ The form will be POSTed because there is no ID, which means it is a new entry.
128
+ On that list page, you could have a list of your posts with an "Edit" link:
129
+
130
+ My Interesting Post Number 4 - <a href="/admin/blog_post/4?_destination=%2Fadmin%2Flist%2Fblog_post"> Edit </a>
131
+
132
+ You also have another option called `fields` which allows you to say which fields you want in that form.
133
+ The purpose of that is mainly to be able to edit a single value at a time:
134
+
135
+ Title: My Super Blog - <a href="/admin/blog_post/4?fields[]=title&_destination=%2Fadmin%2Flist%2Fblog_post"> Edit </a>
136
+
137
+ This will make a link to a form for editing the title of that Blog Post.
138
+ Please note that the option `fields` is an array.
139
+
140
+ Also don't forget to escape the URI like in the examples above.
141
+ You can do that with Rack::Utils :
142
+
143
+ ::Rack::Utils.escape "/admin/list/blog_post"
144
+
145
+ The option `:submit_text` is also available through the API as `_submit_text`.
146
+ It says "SAVE" by default but you might want it to say "CREATE" and "UPDATE" in appropriate cases,
147
+ like we did in the example.
148
+
149
+ A LITTLE BIT OF JAVASCRIPT
150
+ ==========================
151
+
152
+ This is a very crude way of dealing with forms I have to say.
153
+ And it is only like that for having unobtrusive javascript.
154
+ When you use the BackendAPI, your CMS is just about nice tricks,
155
+ nice interface using Ajax and getting the best of what the API has to offer.
156
+
157
+ The forms are really meant to be requested via an XHR,
158
+ so that you have access to style and javascript widgets like a date-picker for example.
159
+
160
+ HOW TO PLUG AN ORM
161
+ ==================
162
+
163
+ For the moment the only adapter available is for the Sequel ORM,
164
+ but it should be fairly easy to create one for any other ORM.
165
+ If you do one for DataMapper or whatever, please consider contributing
166
+ because it would make BackendAPI more interesting.
167
+
168
+ The adapter for Sequel is a regular plugin, but you don't have to declare it.
169
+ It is done automatically if the constant `Sequel` is defined.
170
+
171
+ Here are the methods to implement, most of them are just aliases for having a single name:
172
+
173
+ - `Model::backend_get( id )` Should return a single database entry with the id provided
174
+ - `Model::backend_post( hash-of-values )` Generaly equivalent to Model::new, it creates a new entry with provided values and without validating or saving
175
+ - `Model#backend_delete` Instance method that destroys the entry
176
+ - `Model#backend_put( hash-of-values )` Generaly equivalent to Model::update, it updates an existing entry with provided values and without validating or saving
177
+
178
+ Others are slightly more sophisticated:
179
+
180
+ - `Model#backend_save?` Returns true if the entry is validated and saved. It generally triggers the error messages for the form as well.
181
+ - `Model#default_backend_columns` This the list of columns in the forms when the list of fields is not provided via `fields` option
182
+ - `Model#backend_form( action_url, columns=nil, options={} )` It is only the wrapping of the form without the actual fields. Try to implement it like the Sequel one.
183
+ - `Model#backend_fields( columns )` These are the actual fields. There is a default behavior that basically puts a textarea for everything. That works in most cases but this is meant to be overriden for a better solution. We recommand [Crushyform](https://rubygems.org/gems/sequel-crushyform) for Sequel because we did it so we know it plays well with BackendAPI, and also because you don't have anything more to do. BackendAPI knows you have [Crushyform](https://rubygems.org/gems/sequel-crushyform) and use it to create the fields.
184
+ - `Model#backend_delete_form( action_url, options={})` Basically sugar for Model#backend_form but with an empty array for columns, and these options `{:submit_text=>'X', :method=>'DELETE'}` predefined which you can override. We've seen before that it is for creating DELETE forms.
185
+
186
+ THANX
187
+ =====
188
+
189
+ I'd like to thank [Manveru](https://github.com/manveru), [Pistos](https://github.com/pistos) and many others on the #ramaze IRC channel for being friendly, helpful and obviously savy.
190
+
191
+ Also I'd like to thank [Konstantine Hasse](https://github.com/rkh) for the same reasons as he helped me many times on #rack issues,
192
+ and because [almost-sinatra](https://github.com/rkh/almost-sinatra) is just made with the 8 nicest lines of code to read.
193
+
194
+ CHANGE LOG
195
+ ==========
196
+
197
+ 0.0.1 First version
198
+
199
+ COPYRIGHT
200
+ =========
201
+
202
+ (c) 2011 Mickael Riga - see file LICENSE for details
@@ -0,0 +1,31 @@
1
+ BASIC_ADMIN = proc{
2
+
3
+ map '/' do
4
+ run proc{|env|
5
+ title = "<h1>Main Menu</h1>\n"
6
+ menu = env['basic_admin.models'].map do |m|
7
+ "<p><a href='%s/list/%s'>%s Management</a></p>\n" % [env['SCRIPT_NAME'],m,m]
8
+ end.join
9
+ [200,{'Content-Type'=>'text/html'},[title,menu]]
10
+ }
11
+ end
12
+ map '/list' do
13
+ run proc{|env|
14
+ path = env['SCRIPT_NAME']+env['PATH_INFO']
15
+ escaped_path = ::Rack::Utils::escape path
16
+ root_path = env['SCRIPT_NAME'].slice(0,env['SCRIPT_NAME'].rindex('/'))
17
+ model_name = env['PATH_INFO'][1..-1]
18
+ api_model_path = "%s/%s" % [root_path,model_name]
19
+ title = "<h1>%s List</h1>\n" % [model_name]
20
+ create_link = "<p><a href='%s?_destination=%s&_submit_text=CREATE'>Create %s</a></p>\n" % [api_model_path,escaped_path,model_name]
21
+ list = eval(model_name).all.map do |m|
22
+ api_inst_path = "%s/%s" % [api_model_path,m.id]
23
+ link = "<a href='%s?_destination=%s&_submit_text=UPDATE'>%s</a>\n" % [api_inst_path,escaped_path,m.to_label]
24
+ delete_form = m.backend_delete_form(api_inst_path, {:destination=>path})
25
+ link+delete_form
26
+ end.join
27
+ [200,{'Content-Type'=>'text/html'},[title,create_link,list]]
28
+ }
29
+ end
30
+
31
+ }
data/example/config.ru ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env rackup
2
+
3
+ ::Dir.chdir(::File.dirname(__FILE__)+'/..')
4
+ $:.unshift './lib'
5
+ require 'rubygems'
6
+ require 'test/db'
7
+ require 'backend_api'
8
+ require 'example/basic_admin'
9
+
10
+ use ::Rack::ContentLength
11
+ map '/' do
12
+ run proc{ |env|
13
+ [200,{'Content-Type'=>'text/html'}, ["Here is the Front-end. Real action is in: <a href='/admin'>/admin</a>."]]
14
+ }
15
+ end
16
+
17
+ map '/admin' do
18
+ use ::Rack::Config do |env|
19
+ env['basic_admin.models'] = [:Author, :Haiku]
20
+ end
21
+ use BackendAPI
22
+ run ::Rack::Builder.app(&BASIC_ADMIN)
23
+ end
@@ -0,0 +1,120 @@
1
+ class BackendAPI
2
+ VERSION = [0,0,1]
3
+ WRAP = <<-EOT
4
+ <!doctype html>
5
+ <html>
6
+ <head><meta charset="UTF-8" /><title>%s</title></head>
7
+ <body>
8
+ %s
9
+ </body>
10
+ </html>
11
+ EOT
12
+
13
+ # Automatically use MethodOverride before
14
+ # Thx Konstantin Haase for the trick
15
+ def self.new(*); ::Rack::MethodOverride.new(super); end
16
+ def initialize(app=nil); @app = app; end
17
+ def call(env); dup.call!(env); end
18
+
19
+ def call!(env)
20
+ @req = ::Rack::Request.new(env)
21
+ @res = ::Rack::Response.new
22
+
23
+ # Simple dispatcher
24
+ @model_name, @id, *a = @req.path_info.split('/').find_all{|s| s!=''}
25
+
26
+ # Special case
27
+ return @res.finish{@res.write(v)} if @model_name=='_version'
28
+
29
+ build_model_vars
30
+ __send__(@req.request_method.downcase) unless @res.status==404
31
+
32
+ @res.status==404&&!@app.nil? ? @app.call(env) : @res.finish
33
+ end
34
+
35
+ private
36
+
37
+ # =========
38
+ # = Paths =
39
+ # =========
40
+
41
+ def v; VERSION.join('.'); end
42
+
43
+ # Create
44
+ def post
45
+ return put unless @id.nil?
46
+ @model_instance = @model_class.backend_post(@req['model'])
47
+ save_and_respond
48
+ end
49
+
50
+ # Read
51
+ def get
52
+ @model_instance ||= @model_class.backend_post
53
+ @model_instance.backend_put @req['model']
54
+ form = @model_instance.backend_form(@req.path, @req['fields'], :destination => @req['_destination'], :submit_text => @req['_submit_text'] )
55
+ @res.write(wrap_form(form))
56
+ end
57
+
58
+ # Update
59
+ def put
60
+ @model_instance.backend_put @req['model']
61
+ save_and_respond
62
+ end
63
+
64
+ # Delete
65
+ def delete
66
+ @model_instance.backend_delete
67
+ @req['_destination'].nil? ? @res.status=204 : @res.redirect(::Rack::Utils::unescape(@req['_destination'])) # 204 No Content
68
+ end
69
+
70
+ # Cost less than checking if is not GET, POST, PUT or DELETE
71
+ def head; get; end; def options; get; end; def patch; get; end; def trace; get; end
72
+
73
+ # ===========
74
+ # = Helpers =
75
+ # ===========
76
+
77
+ def build_model_vars
78
+ @model_class_name = camel_case(@model_name)
79
+ if !@model_name.nil? && ::Object.const_defined?(@model_class_name)
80
+ @model_class = eval(@model_class_name)
81
+ @model_instance = @model_class.backend_get(@id.to_i) unless @id.nil?
82
+ @req['model'] ||= {}
83
+ else
84
+ @res.status=404 # Not Found
85
+ @res.headers['X-Cascade']='pass'
86
+ @res.write 'Not Found'
87
+ end
88
+ end
89
+
90
+ def camel_case(s)
91
+ s.to_s.split('_').map{|e|e.capitalize}.join
92
+ end
93
+
94
+ def save_and_respond
95
+ if @model_instance.backend_save?
96
+ if @req['_destination'].nil?
97
+ @res.write 'OK'
98
+ @res.status=201 # Created
99
+ else
100
+ @res.redirect(::Rack::Utils::unescape(@req['_destination']))
101
+ end
102
+ else
103
+ form = @model_instance.backend_form(@req.path, @req['model'].keys, :destination => @req['_destination'], :submit_text => @req['_submit_text'])
104
+ @res.write(wrap_form(form))
105
+ @res.status=400 # Bad Request
106
+ end
107
+ end
108
+
109
+ def wrap_form(form)
110
+ if @req.xhr?
111
+ form
112
+ else
113
+ WRAP % [@model_class_name, form]
114
+ end
115
+ end
116
+
117
+ end
118
+
119
+ # Require Adapter when known ORM detected
120
+ ::Sequel::Model.plugin :rack_backend_api_adapter if defined? Sequel
@@ -0,0 +1,56 @@
1
+ module ::Sequel::Plugins::RackBackendApiAdapter
2
+
3
+ module ClassMethods
4
+ end
5
+
6
+ module InstanceMethods
7
+
8
+ def self.included(model_class)
9
+ model_class.class_eval do
10
+ class << self
11
+ alias backend_get []
12
+ alias backend_post new
13
+ end
14
+ alias backend_delete destroy
15
+ alias backend_put set
16
+ end
17
+ end
18
+
19
+ def backend_save?; valid? && save; end
20
+
21
+ def backend_form(url, cols=nil, opts={})
22
+ cols ||= default_backend_columns
23
+ fields_list = respond_to?(:crushyform) ? crushyform(cols) : backend_fields(cols)
24
+ o = "<form action='#{url}' method='POST' #{"enctype='multipart/form-data'" if fields_list.match(/type='file'/)} class='backend-form'>\n"
25
+ o << fields_list
26
+ method = self.new? ? 'POST' : 'PUT'
27
+ o << "<input type='hidden' name='_method' value='#{opts[:method] || method}' />\n"
28
+ o << "<input type='hidden' name='_destination' value='#{opts[:destination]}' />\n" unless opts[:destination].nil?
29
+ o << "<input type='hidden' name='_submit_text' value='#{opts[:submit_text]}' />\n" unless opts[:submit_text].nil?
30
+ o << "<input type='submit' name='save' value='#{opts[:submit_text] || 'SAVE'}' />\n"
31
+ o << "</form>\n"
32
+ o
33
+ end
34
+
35
+ def backend_delete_form(url, opts={}); backend_form(url, [], {:submit_text=>'X', :method=>'DELETE'}.update(opts)); end
36
+
37
+ # Silly but usable form prototype
38
+ # Not really meant to be used in a real case
39
+ # It uses a textarea for everything
40
+ # Override it
41
+ # Or even better, use Sequel-Crushyform plugin instead
42
+ def backend_fields(cols)
43
+ o = ''
44
+ cols.each do |c|
45
+ identifier = "#{id.to_i}-#{self.class}-#{c}"
46
+ o << "<label for='#{identifier}'>#{c.to_s.capitalize}</label><br />\n"
47
+ o << "<textarea id='#{identifier}' name='model[#{c}]'>#{self.send(c)}</textarea><br />\n"
48
+ end
49
+ end
50
+
51
+ # Can be overridden
52
+ def default_backend_columns; columns - [:id]; end
53
+
54
+ end
55
+
56
+ end
@@ -0,0 +1,12 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'rack-backend-api'
3
+ s.version = "0.0.1"
4
+ s.platform = Gem::Platform::RUBY
5
+ s.summary = "A Rack middleware that provides a simple API for your Admin section"
6
+ s.description = "The purpose of this Rack Middleware is to provide an API that interfaces with database actions in order to build a CMS."
7
+ s.files = `git ls-files`.split("\n").sort
8
+ s.require_path = './lib'
9
+ s.author = "Mickael Riga"
10
+ s.email = "mig@mypeplum.com"
11
+ s.homepage = "http://github.com/mig-hub/backend-api"
12
+ end
data/test/db.rb ADDED
@@ -0,0 +1,49 @@
1
+ require 'sequel'
2
+ ::Sequel::Model.plugin :schema
3
+ ::Sequel::Model.plugin :crushyform rescue nil
4
+ DB = ::Sequel.sqlite
5
+
6
+ class Haiku < ::Sequel::Model
7
+ set_schema do
8
+ primary_key :id
9
+ String :title
10
+ text :body
11
+ Boolean :published, :default => true
12
+ foreign_key :author_id, :authors
13
+ end
14
+ create_table unless table_exists?
15
+ many_to_one :author
16
+ def validate
17
+ errors[:title] << "Should start with a decent char" if title.to_s!='' && title[0]<65
18
+ end
19
+ end
20
+
21
+ class Author < ::Sequel::Model
22
+ set_schema do
23
+ primary_key :id
24
+ String :name, :crushyform=>{:required=>true}
25
+ String :surname, :crushyform=>{:required=>true}
26
+ end
27
+ create_table unless table_exists?
28
+ one_to_many :haikus
29
+ def validate
30
+ errors[:name] << 'Cannot be blank' if name.to_s==''
31
+ errors[:surname] << 'Cannot be blank' if surname.to_s==''
32
+ end
33
+ end
34
+
35
+ class Pic < ::Sequel::Model
36
+ set_schema do
37
+ primary_key :id
38
+ String :image, :crushyform=>{:type=>:attachment}
39
+ end
40
+ create_table unless table_exists?
41
+ end
42
+
43
+ Haiku.create( :title=>'Autumn', :body=>"Rust the ground\nFlush the branches\nReveal the trees" )
44
+ Haiku.create( :title=>'Winter', :body=>"There is snow\nIt covers you\nBut you are still the most beautiful" )
45
+ Haiku.create( :title=>'Spring', :body=>"No inspiration" )
46
+
47
+ Author.create(:name=>'Ray',:surname=>'Bradbury')
48
+ Author.create(:name=>'Jorge Luis',:surname=>'Borges')
49
+ Author.create(:name=>'Yasunari', :surname=>'Kawabata')
@@ -0,0 +1,160 @@
1
+ require 'rubygems'
2
+ require 'bacon'
3
+ require 'rack'
4
+ require 'fileutils' # fix Rack missing
5
+
6
+ Bacon.summary_on_exit
7
+
8
+ # Helpers
9
+ F = ::File
10
+ D = ::Dir
11
+ ROOT = F.dirname(__FILE__)+'/..'
12
+ def req_lint(app)
13
+ ::Rack::MockRequest.new(::Rack::Lint.new(app))
14
+ end
15
+ dummy_app = proc{|env|[200,{'Content-Type'=>'text/plain'},['dummy']]}
16
+
17
+ $:.unshift ROOT+'/lib'
18
+ require ROOT+'/test/db.rb'
19
+ require 'backend_api'
20
+
21
+ def wrap(title, form) #mock wrapped versions of forms when not XHR
22
+ BackendAPI::WRAP % [title,form]
23
+ end
24
+
25
+ describe 'API Misc' do
26
+ should "Send 404 X-cascade if no response at the bottom of the Rack stack - Builder::run" do
27
+ res = req_lint(BackendAPI.new).get('/zzz')
28
+ res.status.should==404
29
+ res.headers['X-Cascade'].should=='pass'
30
+ end
31
+ should 'Follow the Rack stack if response is not found - Builder::use' do
32
+ res = req_lint(BackendAPI.new(dummy_app)).get('/')
33
+ res.status.should==200
34
+ res.body.should=='dummy'
35
+ end
36
+ should "Have a special path for sending version" do
37
+ res = req_lint(BackendAPI.new(dummy_app)).get('/_version')
38
+ res.status.should==200
39
+ res.body.should==BackendAPI::VERSION.join('.')
40
+ end
41
+ end
42
+
43
+ describe 'API Post' do
44
+ should "Create a new entry in the database and send a 201 response" do
45
+ res = req_lint(BackendAPI.new).post('/haiku', :params => {'model' => {'title' => 'Summer', 'body' => "Summer was missing\nI cannot accept that\nI need to bake in the sun"}})
46
+ res.status.should==201 # Created
47
+ haiku = Haiku.order(:id).last
48
+ haiku.title.should=='Summer'
49
+ haiku.body.should=="Summer was missing\nI cannot accept that\nI need to bake in the sun"
50
+ end
51
+ should "Fallback to an update if there is an id provided" do
52
+ req_lint(BackendAPI.new).post('/haiku/4', :params => {'model' => {'title' => 'Summer is not new !!!'}})
53
+ Haiku.filter(:title => 'Summer is not new !!!').first.id.should==4
54
+ end
55
+ should "Accept a new entry with no attributes as long as it is valid" do
56
+ res = req_lint(BackendAPI.new).post('/haiku')
57
+ res.status.should==201
58
+ end
59
+ should "Send back the appropriate form when the creation is not valid" do
60
+
61
+ res = req_lint(BackendAPI.new).post('/haiku', :params => {'model' => {'title' => '13'}})
62
+ res.status.should==400
63
+ compared = Haiku.new.set('title' => '13')
64
+ compared.valid?
65
+ res.body.should==wrap('Haiku', compared.backend_form('/haiku', ['title']))
66
+
67
+ # Not wrapped when XHR
68
+ res = req_lint(BackendAPI.new).post('/haiku', "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest", :params => {'model' => {'title' => '13'}})
69
+ res.status.should==400
70
+ res.body.should==compared.backend_form('/haiku', ['title'])
71
+ end
72
+ should "Accept a destination for when Entry is validated and request is not XHR" do
73
+ res = req_lint(BackendAPI.new(dummy_app)).post('/haiku', :params => {'_destination' => 'http://www.domain.com/list.xml', 'model' => {'title' => 'Destination Summer'}})
74
+ res.status.should==302
75
+ res.headers['Location']=='http://www.domain.com/list.xml'
76
+ Haiku.order(:id).last.title.should=='Destination Summer'
77
+ end
78
+ should "keep destination until form is validated" do
79
+ req_lint(BackendAPI.new).post('/haiku', :params => {'_destination' => '/', 'model' => {'title' => '13'}}).body.should.match(/name='_destination'.*value='\/'/)
80
+ end
81
+ end
82
+
83
+ describe 'API Get' do
84
+ should "Return the form for a fresh entry when no id is provided" do
85
+ req_lint(BackendAPI.new).get('/haiku').body.should==wrap('Haiku', Haiku.new.backend_form('/haiku'))
86
+ end
87
+ should "Return the form for an update when id is provided" do
88
+ req_lint(BackendAPI.new).get('/haiku/3').body.should==wrap('Haiku', Haiku[3].backend_form('/haiku/3'))
89
+ end
90
+ should "Be able to send a form with selected set of fields" do
91
+ req_lint(BackendAPI.new).get('/haiku', :params => {'fields' => ['title']}).body.should==wrap('Haiku', Haiku.new.backend_form('/haiku', ['title']))
92
+ req_lint(BackendAPI.new).get('/haiku/3', :params => {'fields' => ['title']}).body.should==wrap('Haiku', Haiku[3].backend_form('/haiku/3', ['title']))
93
+ end
94
+ should "Update the entry before building the form if model parameter is used" do
95
+ update = {'title' => 'Changed'}
96
+ req_lint(BackendAPI.new).get('/haiku', :params => {'model' => update}).body.should==wrap('Haiku', Haiku.new.set(update).backend_form('/haiku'))
97
+ req_lint(BackendAPI.new).get('/haiku/3', :params => {'model' => update}).body.should==wrap('Haiku', Haiku[3].set(update).backend_form('/haiku/3'))
98
+ end
99
+ should "Return a partial if the request is XHR" do
100
+ req_lint(BackendAPI.new).get('/haiku', "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest").body.should==Haiku.new.backend_form('/haiku')
101
+ end
102
+ end
103
+
104
+ describe 'API Put' do
105
+ should "Update a database entry that exists and send a 201 response" do
106
+ res = req_lint(BackendAPI.new).put('/haiku/3', :params => {'model' => {'body' => "Maybe I have no inspiration\nBut at least\nIt should be on three lines"}})
107
+ res.status.should==201 # Created
108
+ haiku = Haiku[3]
109
+ haiku.body.should=="Maybe I have no inspiration\nBut at least\nIt should be on three lines"
110
+ haiku.title.should=='Spring'
111
+ end
112
+ should "Work with MethodOverride" do
113
+ req_lint(BackendAPI.new).post('/haiku/3', :params => {'_method' => 'PUT', 'model' => {'title' => 'Spring Wow !!!'}})
114
+ Haiku[3].title.should=='Spring Wow !!!'
115
+ end
116
+ should "Not break if one updates with no changes" do
117
+ res = req_lint(BackendAPI.new).put('/haiku/3')
118
+ res.status.should==201
119
+ end
120
+ should "Send back the appropriate form when the creation is not valid" do
121
+
122
+ res = req_lint(BackendAPI.new).put('/haiku/3', :params => {'model' => {'title' => '13'}})
123
+ res.status.should==400
124
+ compared = Haiku[3].set('title' => '13')
125
+ compared.valid?
126
+ res.body.should==wrap('Haiku', compared.backend_form('/haiku/3', ['title']))
127
+
128
+ # Not wrapped when XHR
129
+ res = req_lint(BackendAPI.new).put('/haiku/3', "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest", :params => {'model' => {'title' => '13'}})
130
+ res.status.should==400
131
+ res.body.should==compared.backend_form('/haiku/3', ['title'])
132
+ end
133
+ should "Accept a destination for when Update is validated and request is not XHR" do
134
+ res = req_lint(BackendAPI.new(dummy_app)).post('/haiku/3', :params => {'_method' => 'PUT', '_destination' => '/', 'model' => {'title' => 'Spring destination !!!'}})
135
+ res.status.should==302
136
+ res.headers['Location']=='/'
137
+ Haiku[3].title.should=='Spring destination !!!'
138
+ end
139
+ should "keep destination until form is validated" do
140
+ req_lint(BackendAPI.new).put('/haiku/3', :params => {'_destination' => '/', 'model' => {'title' => '13'}}).body.should.match(/name='_destination'.*value='\/'/)
141
+ end
142
+ end
143
+
144
+ describe 'API Delete' do
145
+ should "Delete a database entry that exists and send a 204 response" do
146
+ res = req_lint(BackendAPI.new).delete('/haiku/1')
147
+ res.status.should==204 # No Content
148
+ Haiku[1].should==nil
149
+ end
150
+ should "Work with MethodOverride" do
151
+ req_lint(BackendAPI.new(dummy_app)).post('/haiku/2', :params => {'_method' => 'DELETE'})
152
+ Haiku[2].should==nil
153
+ end
154
+ should "Accept a destination" do
155
+ res = req_lint(BackendAPI.new).delete('/haiku/3', :params => {'_destination' => '/'})
156
+ res.status.should==302
157
+ res.headers['Location']=='/'
158
+ Haiku[3].should==nil
159
+ end
160
+ end
@@ -0,0 +1,79 @@
1
+ require 'rubygems'
2
+ require 'bacon'
3
+ Bacon.summary_on_exit
4
+
5
+ # Helpers
6
+ F = ::File
7
+ D = ::Dir
8
+ ROOT = F.dirname(__FILE__)+'/..'
9
+ $:.unshift ROOT+'/lib'
10
+ require ROOT+'/test/db.rb'
11
+ ::Sequel::Model.plugin :rack_backend_api_adapter
12
+
13
+ describe 'Sequel Adapter' do
14
+
15
+ should 'Define Model#default_backend_columns' do
16
+ Haiku.new.default_backend_columns.should==(Haiku.columns - [:id])
17
+ end
18
+
19
+ should 'Make forms for the correct action' do
20
+ Haiku.new.backend_form('/url').should.match(/action='\/url'/)
21
+ end
22
+
23
+ should 'Make forms including the requested fields - full form' do
24
+ haiku = Haiku.new
25
+ full_form = haiku.backend_form('/url')
26
+ haiku.default_backend_columns.each do |c|
27
+ full_form.should.match(/#{Regexp.escape haiku.crushyfield(c)}/)
28
+ end
29
+ end
30
+
31
+ should 'Make forms including the requested fields - partial form' do
32
+ haiku = Haiku.new
33
+ partial_form = haiku.backend_form('/url', [:title])
34
+ partial_form.should.match(/#{Regexp.escape haiku.crushyfield(:title)}/)
35
+ list = haiku.default_backend_columns - [:title]
36
+ list.each do |c|
37
+ partial_form.should.not.match(/#{Regexp.escape haiku.crushyfield(c)}/)
38
+ end
39
+ end
40
+
41
+ should 'Have Method Override value POST/PUT automatically set by default in the form' do
42
+ Haiku.new.backend_form('/url').should.match(/name='_method' value='POST'/)
43
+ Haiku.first.backend_form('/url').should.match(/name='_method' value='PUT'/)
44
+ end
45
+
46
+ should 'Not have a _destination field if the option is not used in the form' do
47
+ Haiku.new.backend_form('/url').should.not.match(/name='_destination'/)
48
+ end
49
+
50
+ should 'Have a _destination field if the option is used in the form' do
51
+ Haiku.new.backend_form('/url', nil, {:destination=>"/moon"}).should.match(/name='_destination' value='\/moon'/)
52
+ end
53
+
54
+ should 'Make forms with enctype automated' do
55
+ Haiku.new.backend_form('/url').should.not.match(/enctype='multipart\/form-data'/)
56
+ Pic.new.backend_form('/url').should.match(/enctype='multipart\/form-data'/)
57
+ end
58
+
59
+ should 'Be able to change text for the submit button of the form and keep it when validation does not pass straight away' do
60
+ f = Haiku.new.backend_form('/url')
61
+ f.should.match(/<input type='submit' name='save' value='SAVE' \/>/)
62
+ f.should.not.match(/name='_submit_text'/)
63
+ f = Haiku.new.backend_form('/url', nil, {:submit_text=>'CREATE'})
64
+ f.should.match(/<input type='submit' name='save' value='CREATE' \/>/)
65
+ f.should.match(/name='_submit_text' value='CREATE'/)
66
+ end
67
+
68
+ should 'Have a backend_delete_form method - pure HTTP way of deleting records with HTTP DELETE method' do
69
+ form = Haiku.first.backend_delete_form('/url')
70
+ form.should.match(/name='_method' value='DELETE'/)
71
+ form.should.match(/<input type='submit' name='save' value='X' \/>/)
72
+ form.scan(/input/).size.should==3
73
+ form = Haiku.first.backend_delete_form('/url', {:submit_text=>'Destroy', :destination=>'/moon'})
74
+ form.should.match(/<input type='submit' name='save' value='Destroy' \/>/)
75
+ form.should.match(/name='_destination' value='\/moon'/)
76
+ form.scan(/input/).size.should==4
77
+ end
78
+
79
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack-backend-api
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Mickael Riga
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-06-21 00:00:00 +01:00
19
+ default_executable:
20
+ dependencies: []
21
+
22
+ description: The purpose of this Rack Middleware is to provide an API that interfaces with database actions in order to build a CMS.
23
+ email: mig@mypeplum.com
24
+ executables: []
25
+
26
+ extensions: []
27
+
28
+ extra_rdoc_files: []
29
+
30
+ files:
31
+ - LICENSE
32
+ - README.md
33
+ - example/basic_admin.rb
34
+ - example/config.ru
35
+ - lib/backend_api.rb
36
+ - lib/sequel_rack_backend_api_adapter.rb
37
+ - rack-backend-api.gemspec
38
+ - test/db.rb
39
+ - test/spec_backend_api.rb
40
+ - test/spec_sequel_adapter.rb
41
+ has_rdoc: true
42
+ homepage: http://github.com/mig-hub/backend-api
43
+ licenses: []
44
+
45
+ post_install_message:
46
+ rdoc_options: []
47
+
48
+ require_paths:
49
+ - ./lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ none: false
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ hash: 3
56
+ segments:
57
+ - 0
58
+ version: "0"
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ none: false
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ hash: 3
65
+ segments:
66
+ - 0
67
+ version: "0"
68
+ requirements: []
69
+
70
+ rubyforge_project:
71
+ rubygems_version: 1.4.2
72
+ signing_key:
73
+ specification_version: 3
74
+ summary: A Rack middleware that provides a simple API for your Admin section
75
+ test_files: []
76
+