marley 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,39 @@
1
+
2
+ module Marley
3
+ module Joints
4
+ class UserBasedNavigation < Joint
5
+ LOGIN_FORM= [:instance,{:url => 'login',:description => 'Existing users please log in here:',:new_rec => true,:schema => [[:text,'name',RESTRICT_REQ],[:password,'password',RESTRICT_REQ]]}]
6
+ module ClassMethods
7
+ module User
8
+ def sections
9
+ end
10
+ end
11
+ end
12
+ module Resources
13
+ class MainMenu
14
+ attr_accessor :title,:name,:description, :items
15
+ def self.rest_get
16
+ new.to_json
17
+ end
18
+ def initialize
19
+ if $request[:user].new?
20
+ u=$request[:user].to_a
21
+ u[1].merge!({:description => 'If you don\'t already have an account, please create one here:'})
22
+ @title="Welcome to #{$request[:opts][:app_name]}"
23
+ @description='Login or signup here.'
24
+ @items=[LOGIN_FORM,u]
25
+ else
26
+ $request[:user].class.sections
27
+ end
28
+ end
29
+ def self.requires_user?
30
+ true
31
+ end
32
+ def to_json
33
+ [:section,{:title => @title,:description => @description,:name => @name, :navigation => @items}]
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/ruby
2
+ require 'json/ext'
3
+ require 'rack'
4
+ require 'rack/auth/basic'
5
+ require 'rack/builder'
6
+ require 'sequel_plugins'
7
+ require 'controllers'
8
+ require 'reggae'
9
+ require 'logger'
10
+ Sequel.extension :inflector
11
+
12
+ Sequel::Model.plugin :rest_convenience
13
+ Sequel::Model.plugin :rest_authorization
14
+ Sequel::Model.plugin :validation_helpers
15
+ Sequel::Plugins::ValidationHelpers::DEFAULT_OPTIONS.merge!(:presence => {:message => 'is required'})
16
+ Sequel::Model.plugin :timestamps, :create => :date_created, :update => :date_updated
17
+
18
+ log_fn='log/marley.log'
19
+ $log=Logger.new(File.exists?(log_fn) ? log_fn : $stdout)
20
+
21
+
22
+ module Marley #The main Marley namespace.
23
+ JOINT_DIRS=[File.expand_path("joints/",File.dirname(__FILE__)),"#{Dir.pwd}/joints"]
24
+ DEFAULT_OPTS={:http_auth => true,:app_name => 'Application',:port => 1620,:default_user_class => :User, :auth_class => :User,:default_resource => 'Menu', :server => 'thin'}
25
+ RESP_CODES={'get' => 200,'post' => 201,'put' => 204,'delete' => 204}
26
+
27
+ module Resources #All objects in the Resources namespace are exposed by the server.
28
+ end
29
+ require 'joint' #this needs to happen after Marley::Resources is defined
30
+ def self.config(opts=nil)
31
+ @marley_opts||=DEFAULT_OPTS
32
+ @marley_opts.merge!(opts) if opts
33
+ yield @marley_opts if block_given?
34
+ @marley_opts
35
+ end
36
+ def self.joint(joint_name, *opts)
37
+ joint_d=JOINT_DIRS.find {|d| File.exists?("#{d}/#{joint_name}.rb") }
38
+ require "#{joint_d}/#{joint_name}"
39
+ @marley_opts[:client] && @marley_opts[:client].joint(joint_d,joint_name)
40
+ joint=Marley::Joints.const_get(joint_name.camelize).new(*opts).smoke
41
+ end
42
+ def self.run(opts={})
43
+ @marley_opts||=DEFAULT_OPTS
44
+ marley_opts=@marley_opts.merge!(opts)
45
+ Rack::Handler.get(marley_opts[:server]).run(Rack::Builder.new {
46
+ use Rack::Reloader,0
47
+ use Rack::Static, :urls => [opts[:image_path]] if opts[:image_path]
48
+ run(Marley::Router.new(marley_opts))
49
+ }.to_app,{:Port => @marley_opts[:port]})
50
+ end
51
+ class Router #the default Marley router. Creates the $request object, locates the resource requested and calls either its controller's or its own rest verb method
52
+ def initialize(opts={},app=nil)
53
+ @opts=DEFAULT_OPTS.merge(opts)
54
+ end
55
+ def call(env)
56
+ request= Rack::Request.new(env)
57
+ @auth = Rack::Auth::Basic::Request.new(env)
58
+ $request={:request => request,:opts => @opts}
59
+ $request[:get_params]=Marley::Utils.hash_keys_to_syms(request.GET)
60
+ $request[:post_params]=Marley::Utils.hash_keys_to_syms(request.POST)
61
+ $request[:content_type]=request.xhr? ? 'application/json' : env['HTTP_ACCEPT'].to_s.sub(/,.*/,'')
62
+ $request[:content_type]='text/html' unless $request[:content_type] > ''
63
+ $request[:content_type]='application/json' if env['rack.test']==true #there has to be a better way to do this...
64
+ if @opts[:http_auth]
65
+ if (@auth.provided? && @auth.basic? && @auth.credentials)
66
+ $request[:user]=Resources.const_get(@opts[:auth_class]).authenticate(@auth.credentials)
67
+ raise AuthenticationError unless $request[:user]
68
+ else
69
+ $request[:user]=Resources.const_get(@opts[:default_user_class]).new
70
+ end
71
+ end
72
+ $request[:path]=request.path.sub(/\/\/+/,'/').split('/')[1..-1]
73
+ verb=request.request_method.downcase
74
+ verb=$request[:post_params].delete(:_method).match(/^(put|delete)$/i)[1] rescue verb
75
+ $request[:verb]="rest_#{verb}"
76
+ rn=$request[:path] ? $request[:path][0].camelize : @opts[:default_resource]
77
+ raise RoutingError unless Resources.constants.include?(rn)
78
+ @resource=Resources.const_get(rn)
79
+ raise AuthenticationError if @opts[:http_auth] && @resource.respond_to?('requires_user?') && @resource.requires_user? && $request[:user].new?
80
+ @controller=@resource.respond_to?($request[:verb]) ? @resource : @resource.controller
81
+ json=@controller.send($request[:verb]).to_json
82
+ html=@opts[:client] ? @opts[:client].to_s(json) : json
83
+ resp_code=RESP_CODES[verb]
84
+ headers||={'Content-Type' => "#{$request[:content_type]}; charset=utf-8"}
85
+ [resp_code,headers,$request[:content_type].match(/json/) ? json : html]
86
+ rescue Sequel::ValidationFailed
87
+ ValidationError.new($!.errors).response
88
+ rescue
89
+ ($!.class.new.respond_to?(:response) ? $!.class : MarleyError).new.response
90
+ ensure
91
+ $log.info $request.merge({:request => nil,:user => $request[:user] ? $request[:user].name : nil})
92
+ end
93
+ end
94
+ class MarleyError < StandardError
95
+ class << self
96
+ attr_accessor :resp_code,:headers,:description,:details
97
+ end
98
+ @resp_code=500
99
+ def initialize
100
+ self.class.details=self.backtrace
101
+ end
102
+ def log_error
103
+ $log.fatal("#$!.message}\n#{$!.backtrace}")
104
+ end
105
+ def response
106
+ log_error
107
+ json=[:error,{:error_type => self.class.name.underscore.sub(/_error$/,'').sub(/^marley\//,''),:description => self.class.description, :error_details => self.class.details}].to_json
108
+ self.class.headers||={'Content-Type' => "#{$request[:content_type]}; charset=utf-8"}
109
+ [self.class.resp_code,self.class.headers,json]
110
+ end
111
+ end
112
+ class ValidationError < MarleyError
113
+ @resp_code=400
114
+ def initialize(errors)
115
+ self.class.details=errors
116
+ end
117
+ def log_error
118
+ $log.error(self.class.details)
119
+ end
120
+ end
121
+ class AuthenticationError < MarleyError
122
+ @resp_code=401
123
+ @headers={'WWW-Authenticate' => %(Basic realm="Application")}
124
+ def log_error
125
+ $log.error("Authentication failed for #{@auth.credentials[0]}") if (@auth && @auth.provided? && @auth.basic? && @auth.credentials)
126
+ end
127
+ end
128
+ class AuthorizationError < MarleyError
129
+ @resp_code=403
130
+ @description='You are not authorized for this operation'
131
+ def log_error
132
+ $log.error("Authorizationt Error:#{self.message}")
133
+ end
134
+ end
135
+ class RoutingError < MarleyError
136
+ @resp_code=404
137
+ @description='Not Found'
138
+ def log_error
139
+ $log.fatal("path:#{$request[:path]}\n msg:#{$!.message}\n backtrace:#{$!.backtrace}")
140
+ end
141
+ end
142
+ module Utils
143
+ def self.hash_keys_to_syms(hsh)
144
+ hsh.inject({}) {|h,(k,v)| h[k.to_sym]= v.class==Hash ? hash_keys_to_syms(v) : v;h }
145
+ end
146
+ end
147
+ end
148
+ at_exit {Marley.run if ARGV[0]=='run'}
@@ -0,0 +1,110 @@
1
+
2
+ module Marley
3
+ class Reggae < Array
4
+ class << self
5
+ attr_accessor :valid_properties
6
+ def mk_prop_methods
7
+ @valid_properties && @valid_properties.each do |meth|
8
+ define_method(meth) {properties[meth].respond_to?(:to_resource) ? properties[meth].to_resource : properties[meth]}
9
+ define_method(:"#{meth}=") {|val|properties[meth]=val}
10
+ end
11
+ end
12
+ def get_resource(*args)
13
+ self.new(*args).to_resource
14
+ end
15
+ end
16
+ def initialize(*args)
17
+ super
18
+ self.class.mk_prop_methods
19
+ end
20
+ def resource_type
21
+ [String, Symbol].include?(self[0].class) ? self[0].to_s : nil
22
+ end
23
+ def is_resource?
24
+ ! resource_type.nil?
25
+ end
26
+ def properties
27
+ self[1].class==Hash ? Utils.hash_keys_to_syms(self[1]) : nil
28
+ end
29
+ def contents
30
+ is_resource? ? Reggae.new(self[2 .. -1]) : nil
31
+ end
32
+ def contents=(*args)
33
+ self[2]=*args
34
+ while length>3;delete_at -1;end
35
+ end
36
+ def [](*args)
37
+ super.class==Array ? Reggae.new(super).to_resource : super
38
+ end
39
+ def to_resource
40
+ is_resource? ? Marley.const_get("Reggae#{resource_type.camelize}".to_sym).new(self) : self
41
+ end
42
+ def find_instances(rn,instances=Reggae.new([]))
43
+ if self.class==ReggaeInstance && self.name.to_s==rn
44
+ instances << self
45
+ else
46
+ (is_resource? ? contents : self).each {|a| a && Reggae.new(a).to_resource.find_instances(rn,instances)}
47
+ end
48
+ instances
49
+ end
50
+ end
51
+ class ReggaeResource < Reggae
52
+ def initialize(*args)
53
+ super
54
+ unshift self.class.to_s.sub(/.*Reggae/,'').underscore.to_sym unless is_resource?
55
+ end
56
+ end
57
+ class ReggaeSection < ReggaeResource
58
+ self.valid_properties=[:title,:description]
59
+ def navigation
60
+ properties[:navigation].map{|n|Reggae.get_resource(n)}
61
+ end
62
+ end
63
+ class ReggaeLink < ReggaeResource
64
+ self.valid_properties=[:title,:description,:url]
65
+ end
66
+ class ReggaeInstance < ReggaeResource
67
+ self.valid_properties=[:name,:new_rec,:search,:url,:get_actions,:delete_action]
68
+ def schema
69
+ ReggaeSchema.new(self.properties[:schema])
70
+ end
71
+ def to_params
72
+ resource_name=name
73
+ schema.inject({}) do |params,spec|
74
+ s=ReggaeColSpec.new(spec)
75
+ params["#{resource_name}[#{s.col_name}]"]=s.col_value unless (s.col_restrictions & RESTRICT_RO > 0)
76
+ params
77
+ end
78
+ end
79
+ def instance_action_url(action_name)
80
+ "#{url}#{action_name}" if get_actions.include?(action_name.to_s)
81
+ end
82
+ end
83
+ class ReggaeInstanceList < ReggaeResource
84
+ self.valid_properties=[:name,:description,:get_actions,:delete_action,:items]
85
+ def schema
86
+ ReggaeSchema.new(self.properties[:schema])
87
+ end
88
+ end
89
+ class ReggaeMsg < ReggaeResource
90
+ self.valid_properties=[:title,:description]
91
+ end
92
+ class ReggaeError < ReggaeResource
93
+ self.valid_properties=[:error_type,:description,:error_details]
94
+ end
95
+ class ReggaeSchema < Array
96
+ def [](i)
97
+ if i.class==Fixnum
98
+ ReggaeColSpec.new(super)
99
+ else
100
+ self[find_index {|cs|ReggaeColSpec.new(cs).col_name==i.to_s}]
101
+ end
102
+ end
103
+ end
104
+ class ReggaeColSpec < Array
105
+ ['col_type','col_name','col_restrictions', 'col_value'].each_with_index do |prop_name, i|
106
+ define_method(prop_name.to_sym) {self[i]}
107
+ define_method(:"#{prop_name}=") {|val|self[i]=val}
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,141 @@
1
+ RESTRICT_HIDE=1
2
+ RESTRICT_RO=2
3
+ RESTRICT_REQ=4
4
+ TYPE_INDEX=0
5
+ NAME_INDEX=1
6
+ RESTRICTIONS_INDEX=2
7
+ module Sequel::Plugins::RestConvenience
8
+ module ClassMethods
9
+ def controller
10
+ Marley::ModelController.new(self)
11
+ end
12
+ def resource_name
13
+ self.name.sub(/.*::/,'').underscore
14
+ end
15
+ def reggae_link(action=nil)
16
+ [:link,{:url => "/#{self.resource_name}/#{action}",:title => "#{action.humanize} #{self.resource_name.humanize}".strip}]
17
+ end
18
+ def list(params=nil)
19
+ user=$request[:user]
20
+ if user.respond_to?(otm=self.resource_name.pluralize)
21
+ if user.method(otm).arity==0
22
+ if (relationship=user.send(otm)).respond_to?(:filter)
23
+ relationship.filter($request[:get_params][resource_name.to_sym])
24
+ else
25
+ user.send(otm)
26
+ end
27
+ else
28
+ user.send(otm,$request[:get_params][resource_name.to_sym])
29
+ end
30
+ else
31
+ raise Marley::AuthorizationError
32
+ end
33
+ end
34
+ def autocomplete(input_content)
35
+ filter(:name.like("#{input_content.strip}%")).map {|rec| [rec.id, rec.name]}
36
+ end
37
+ end
38
+ module InstanceMethods
39
+ def get_actions; [];end
40
+ def edit; self; end
41
+ def rest_cols
42
+ columns.reject do |c|
43
+ if new?
44
+ c.to_s.match(/(^id$)|(_type$)|(date_(created|updated))/)
45
+ else
46
+ c.to_s.match(/_type$/)
47
+ end
48
+ end
49
+ end
50
+ def hidden_cols
51
+ columns.select {|c| c.to_s.match(/(_id$)/)}
52
+ end
53
+ def write_cols
54
+ rest_cols.reject {|c| c.to_s.match(/(^id$)|(date_(created|updated))/)}
55
+ end
56
+ def required_cols;[];end
57
+ def rest_schema
58
+ rest_cols.map do |col_name|
59
+ db_spec=db_schema.to_hash[col_name]
60
+ col_type=db_spec ? db_spec[:db_type].downcase : col_name
61
+ restrictions=0
62
+ restrictions|=RESTRICT_HIDE if hidden_cols.include?(col_name)
63
+ restrictions|=RESTRICT_RO unless write_cols.include?(col_name)
64
+ restrictions|=RESTRICT_REQ if required_cols.include?(col_name) || (db_spec && !db_spec[:allow_null])
65
+ [col_type, col_name, restrictions,send(col_name)]
66
+ end
67
+ end
68
+ def to_s
69
+ respond_to?('name') ? name : id.to_s
70
+ end
71
+ def to_a
72
+ a=Marley::ReggaeInstance.new([ {:name => self.class.resource_name,:url => url ,:new_rec => self.new?,:schema => rest_schema,:get_actions => get_actions}])
73
+ if respond_to?(:rest_associations) && ! new?
74
+ a.contents=rest_associations.map do |assoc|
75
+ (assoc.class==Symbol ? send(assoc) : assoc).map{|instance| instance.to_a}
76
+ end
77
+ end
78
+ a
79
+ end
80
+ def to_json
81
+ to_a.to_json
82
+ end
83
+ def url(action=nil)
84
+ "/#{self.class.resource_name}/#{self[:id]}/#{action}".sub('//','/')
85
+ end
86
+ def reggae_link(action=nil)
87
+ [:link,{:url => url,:title => "#{action.humanize}"}]
88
+ end
89
+ end
90
+ end
91
+ module Sequel::Plugins::RestAuthorization
92
+ module ClassMethods
93
+ attr_accessor :owner_col, :allowed_get_methods
94
+ def inherited(c)
95
+ super
96
+ c.owner_col=@owner_col
97
+ c.allowed_get_methods=@allowed_get_methods
98
+ end
99
+ def requires_user?(verb=nil,meth=nil);true;end
100
+ def authorize(meth)
101
+ if respond_to?(auth_type="authorize_#{$request[:verb]}")
102
+ send(auth_type,meth)
103
+ else
104
+ case $request[:verb]
105
+ when 'rest_put','rest_delete'
106
+ false
107
+ when 'rest_post'
108
+ new($request[:post_params][resource_name.to_sym]||{}).current_user_role=='owner' && meth.nil?
109
+ when 'rest_get'
110
+ (@allowed_get_methods || ['section','list','new']).include?(meth)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ module InstanceMethods
116
+ def after_initialize
117
+ send("#{self.class.owner_col}=",$request[:user][:id]) if self.class.owner_col && new?
118
+ end
119
+ def requires_user?(verb=nil,meth=nil);true;end
120
+ def authorize(meth)
121
+ if respond_to?(auth_type="authorize_#{$request[:verb]}")
122
+ send(auth_type,meth)
123
+ else
124
+ current_user_role=='owner'
125
+ end
126
+ end
127
+ def current_user_role
128
+ "owner" if owners.include?($request[:user])
129
+ end
130
+ def owners
131
+ if self.class.to_s.match(/User$/)||self.class.superclass.to_s.match(/User$/)
132
+ [self]
133
+ elsif @owner_col
134
+ [User[send(@owner_col)]]
135
+ else
136
+ self.class.association_reflections.select {|k,v| v[:type]==:many_to_one}.map {|a| self.send(a[0]) && self.send(a[0]).owners}.flatten.compact
137
+ end
138
+ end
139
+ end
140
+ end
141
+
@@ -0,0 +1,52 @@
1
+ require "rack/test"
2
+ require 'reggae'
3
+ module Marley
4
+ #simple mocking framework; could be expanded to a general use client by adding display code.
5
+ class TestClient
6
+ CRUD2REST={'create' => 'post','read' => 'get','update' => 'put', 'del' => 'delete'}
7
+ DEFAULT_OPTS={:url => nil,:root_url => nil, :resource_name => nil, :instance_id => nil, :method => nil, :extention =>nil, :auth => nil, :code => nil, :debug => nil}
8
+ include Rack::Test::Methods
9
+ attr_reader :opts
10
+ def app
11
+ Marley::Router.new
12
+ end
13
+ def initialize(opts)
14
+ @opts=DEFAULT_OPTS.merge(opts)
15
+ end
16
+ def make_url(opts=nil)
17
+ opts||=@opts
18
+ opts[:url] || '/' + [:root_url, :resource_name, :instance_id, :method].map {|k| opts[k]}.compact.join('/') + opts[:extention].to_s
19
+ end
20
+ def process(verb,params={},opts={})
21
+ opts||={}
22
+ opts=@opts.merge(opts)
23
+ expected_code=opts[:code] || RESP_CODES[verb]
24
+ if opts[:debug]
25
+ p opts
26
+ p "#{verb} to: '#{make_url(opts)}'"
27
+ p params
28
+ p opts[:auth]
29
+ end
30
+ authorize opts[:auth][0],opts[:auth][1] if opts[:auth]
31
+ header 'Authorization',nil unless opts[:auth] #clear auth from previous requests
32
+ send(verb,make_url(opts),params)
33
+ p last_response.status if opts[:debug]
34
+ p expected_code if opts[:debug]
35
+ return false unless (expected_code || RESP_CODES[method])==last_response.status
36
+ Reggae.get_resource(JSON.parse(last_response.body)) rescue last_response.body
37
+ end
38
+ ['create','read','update','del'].each do |op|
39
+ define_method op.to_sym, Proc.new { |params,opts|
40
+ process(CRUD2REST[op],params,opts)
41
+ }
42
+ end
43
+ DEFAULT_OPTS.keys.each do |opt|
44
+ define_method opt, Proc.new {
45
+ @opts[opt]
46
+ }
47
+ define_method "#{opt}=", Proc.new { |val|
48
+ @opts[opt]=val
49
+ }
50
+ end
51
+ end
52
+ end