rack-backend-api 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
+
|