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 +19 -0
- data/README.md +202 -0
- data/example/basic_admin.rb +31 -0
- data/example/config.ru +23 -0
- data/lib/backend_api.rb +120 -0
- data/lib/sequel_rack_backend_api_adapter.rb +56 -0
- data/rack-backend-api.gemspec +12 -0
- data/test/db.rb +49 -0
- data/test/spec_backend_api.rb +160 -0
- data/test/spec_sequel_adapter.rb +79 -0
- metadata +76 -0
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
|
data/lib/backend_api.rb
ADDED
@@ -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
|
+
|