arrow 1.0.7

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.
Files changed (198) hide show
  1. data/ChangeLog +1590 -0
  2. data/LICENSE +28 -0
  3. data/README +75 -0
  4. data/Rakefile +366 -0
  5. data/Rakefile.local +63 -0
  6. data/data/arrow/applets/TEMPLATE.rb.tpl +53 -0
  7. data/data/arrow/applets/args.rb +50 -0
  8. data/data/arrow/applets/config.rb +55 -0
  9. data/data/arrow/applets/error.rb +63 -0
  10. data/data/arrow/applets/files.rb +46 -0
  11. data/data/arrow/applets/inspect.rb +46 -0
  12. data/data/arrow/applets/nosuchapplet.rb +31 -0
  13. data/data/arrow/applets/status.rb +92 -0
  14. data/data/arrow/applets/test.rb +133 -0
  15. data/data/arrow/applets/tutorial/counter.rb +96 -0
  16. data/data/arrow/applets/tutorial/dingus.rb +67 -0
  17. data/data/arrow/applets/tutorial/hello.rb +34 -0
  18. data/data/arrow/applets/tutorial/hello2.rb +73 -0
  19. data/data/arrow/applets/tutorial/imgtext.rb +90 -0
  20. data/data/arrow/applets/tutorial/imgtext2.rb +286 -0
  21. data/data/arrow/applets/tutorial/index.rb +36 -0
  22. data/data/arrow/applets/tutorial/logo.rb +98 -0
  23. data/data/arrow/applets/tutorial/memcache.rb +61 -0
  24. data/data/arrow/applets/tutorial/missing.rb +37 -0
  25. data/data/arrow/applets/tutorial/protected.rb +100 -0
  26. data/data/arrow/applets/tutorial/redirector.rb +52 -0
  27. data/data/arrow/applets/tutorial/rndimages.rb +159 -0
  28. data/data/arrow/applets/tutorial/sharenotes.rb +83 -0
  29. data/data/arrow/applets/tutorial/subclassed-hello.rb +32 -0
  30. data/data/arrow/applets/tutorial/superhello.rb +72 -0
  31. data/data/arrow/applets/tutorial/timeclock.rb +78 -0
  32. data/data/arrow/applets/view-applet.rb +123 -0
  33. data/data/arrow/applets/view-template.rb +85 -0
  34. data/data/arrow/applets/wiki.rb +274 -0
  35. data/data/arrow/templates/TEMPLATE.tmpl.tpl +36 -0
  36. data/data/arrow/templates/applet-status.tmpl +153 -0
  37. data/data/arrow/templates/args-display.tmpl +120 -0
  38. data/data/arrow/templates/config/display-table.tmpl +36 -0
  39. data/data/arrow/templates/config/display.tmpl +36 -0
  40. data/data/arrow/templates/counter-deleted.tmpl +33 -0
  41. data/data/arrow/templates/counter.tmpl +59 -0
  42. data/data/arrow/templates/dingus.tmpl +55 -0
  43. data/data/arrow/templates/enumtable.tmpl +8 -0
  44. data/data/arrow/templates/error-display.tmpl +92 -0
  45. data/data/arrow/templates/filemap.tmpl +89 -0
  46. data/data/arrow/templates/hello-world-src.tmpl +34 -0
  47. data/data/arrow/templates/hello-world.tmpl +60 -0
  48. data/data/arrow/templates/imgtext/fontlist.tmpl +46 -0
  49. data/data/arrow/templates/imgtext/form.tmpl +70 -0
  50. data/data/arrow/templates/imgtext/reload-error.tmpl +40 -0
  51. data/data/arrow/templates/imgtext/reload.tmpl +55 -0
  52. data/data/arrow/templates/inspect/display.tmpl +80 -0
  53. data/data/arrow/templates/loginform.tmpl +64 -0
  54. data/data/arrow/templates/logout.tmpl +32 -0
  55. data/data/arrow/templates/memcache/display.tmpl +41 -0
  56. data/data/arrow/templates/navbar.incl +27 -0
  57. data/data/arrow/templates/nosuchapplet.tmpl +32 -0
  58. data/data/arrow/templates/printsource.tmpl +35 -0
  59. data/data/arrow/templates/protected.tmpl +36 -0
  60. data/data/arrow/templates/rndimages.tmpl +38 -0
  61. data/data/arrow/templates/service-response.tmpl +13 -0
  62. data/data/arrow/templates/sharenotes/display.tmpl +38 -0
  63. data/data/arrow/templates/status.tmpl +120 -0
  64. data/data/arrow/templates/templateviewer.tmpl +43 -0
  65. data/data/arrow/templates/test/harness.tmpl +57 -0
  66. data/data/arrow/templates/test/list.tmpl +48 -0
  67. data/data/arrow/templates/test/problem.tmpl +42 -0
  68. data/data/arrow/templates/tutorial/index.tmpl +37 -0
  69. data/data/arrow/templates/tutorial/missingapplet.tmpl +29 -0
  70. data/data/arrow/templates/view-applet-nosuch.tmpl +32 -0
  71. data/data/arrow/templates/view-applet.tmpl +40 -0
  72. data/data/arrow/templates/view-template.tmpl +83 -0
  73. data/data/arrow/templates/wiki/formerror.tmpl +47 -0
  74. data/data/arrow/templates/wiki/markup_help.incl +6 -0
  75. data/data/arrow/templates/wiki/new.tmpl +56 -0
  76. data/data/arrow/templates/wiki/new_system.tmpl +122 -0
  77. data/data/arrow/templates/wiki/sectionlist.tmpl +43 -0
  78. data/data/arrow/templates/wiki/show.tmpl +34 -0
  79. data/docs/manual/layouts/default.page +43 -0
  80. data/docs/manual/lib/api-filter.rb +81 -0
  81. data/docs/manual/lib/editorial-filter.rb +64 -0
  82. data/docs/manual/lib/examples-filter.rb +244 -0
  83. data/docs/manual/lib/links-filter.rb +117 -0
  84. data/lib/apache/fakerequest.rb +448 -0
  85. data/lib/apache/logger.rb +33 -0
  86. data/lib/arrow.rb +51 -0
  87. data/lib/arrow/acceptparam.rb +207 -0
  88. data/lib/arrow/applet.rb +725 -0
  89. data/lib/arrow/appletmixins.rb +218 -0
  90. data/lib/arrow/appletregistry.rb +590 -0
  91. data/lib/arrow/applettestcase.rb +503 -0
  92. data/lib/arrow/broker.rb +255 -0
  93. data/lib/arrow/cache.rb +176 -0
  94. data/lib/arrow/config-loaders/yaml.rb +75 -0
  95. data/lib/arrow/config.rb +615 -0
  96. data/lib/arrow/constants.rb +24 -0
  97. data/lib/arrow/cookie.rb +359 -0
  98. data/lib/arrow/cookieset.rb +108 -0
  99. data/lib/arrow/dispatcher.rb +368 -0
  100. data/lib/arrow/dispatcherloader.rb +50 -0
  101. data/lib/arrow/exceptions.rb +61 -0
  102. data/lib/arrow/fallbackhandler.rb +48 -0
  103. data/lib/arrow/formvalidator.rb +631 -0
  104. data/lib/arrow/htmltokenizer.rb +343 -0
  105. data/lib/arrow/logger.rb +488 -0
  106. data/lib/arrow/logger/apacheoutputter.rb +69 -0
  107. data/lib/arrow/logger/arrayoutputter.rb +63 -0
  108. data/lib/arrow/logger/coloroutputter.rb +111 -0
  109. data/lib/arrow/logger/fileoutputter.rb +96 -0
  110. data/lib/arrow/logger/htmloutputter.rb +54 -0
  111. data/lib/arrow/logger/outputter.rb +123 -0
  112. data/lib/arrow/mixins.rb +425 -0
  113. data/lib/arrow/monkeypatches.rb +94 -0
  114. data/lib/arrow/object.rb +117 -0
  115. data/lib/arrow/path.rb +196 -0
  116. data/lib/arrow/service.rb +447 -0
  117. data/lib/arrow/session.rb +289 -0
  118. data/lib/arrow/session/dbstore.rb +100 -0
  119. data/lib/arrow/session/filelock.rb +160 -0
  120. data/lib/arrow/session/filestore.rb +132 -0
  121. data/lib/arrow/session/id.rb +98 -0
  122. data/lib/arrow/session/lock.rb +253 -0
  123. data/lib/arrow/session/md5id.rb +42 -0
  124. data/lib/arrow/session/nulllock.rb +42 -0
  125. data/lib/arrow/session/posixlock.rb +166 -0
  126. data/lib/arrow/session/sha1id.rb +54 -0
  127. data/lib/arrow/session/store.rb +366 -0
  128. data/lib/arrow/session/usertrackid.rb +52 -0
  129. data/lib/arrow/spechelpers.rb +73 -0
  130. data/lib/arrow/template.rb +713 -0
  131. data/lib/arrow/template/attr.rb +31 -0
  132. data/lib/arrow/template/call.rb +31 -0
  133. data/lib/arrow/template/comment.rb +33 -0
  134. data/lib/arrow/template/container.rb +118 -0
  135. data/lib/arrow/template/else.rb +41 -0
  136. data/lib/arrow/template/elsif.rb +44 -0
  137. data/lib/arrow/template/escape.rb +53 -0
  138. data/lib/arrow/template/export.rb +87 -0
  139. data/lib/arrow/template/for.rb +145 -0
  140. data/lib/arrow/template/if.rb +78 -0
  141. data/lib/arrow/template/import.rb +119 -0
  142. data/lib/arrow/template/include.rb +206 -0
  143. data/lib/arrow/template/iterator.rb +208 -0
  144. data/lib/arrow/template/nodes.rb +734 -0
  145. data/lib/arrow/template/parser.rb +571 -0
  146. data/lib/arrow/template/prettyprint.rb +53 -0
  147. data/lib/arrow/template/render.rb +191 -0
  148. data/lib/arrow/template/selectlist.rb +94 -0
  149. data/lib/arrow/template/set.rb +87 -0
  150. data/lib/arrow/template/timedelta.rb +81 -0
  151. data/lib/arrow/template/unless.rb +78 -0
  152. data/lib/arrow/template/urlencode.rb +51 -0
  153. data/lib/arrow/template/yield.rb +139 -0
  154. data/lib/arrow/templatefactory.rb +125 -0
  155. data/lib/arrow/testcase.rb +567 -0
  156. data/lib/arrow/transaction.rb +608 -0
  157. data/rake/191_compat.rb +26 -0
  158. data/rake/dependencies.rb +76 -0
  159. data/rake/documentation.rb +114 -0
  160. data/rake/helpers.rb +502 -0
  161. data/rake/hg.rb +282 -0
  162. data/rake/manual.rb +787 -0
  163. data/rake/packaging.rb +129 -0
  164. data/rake/publishing.rb +278 -0
  165. data/rake/style.rb +62 -0
  166. data/rake/svn.rb +668 -0
  167. data/rake/testing.rb +187 -0
  168. data/rake/verifytask.rb +64 -0
  169. data/spec/arrow/acceptparam_spec.rb +157 -0
  170. data/spec/arrow/applet_spec.rb +575 -0
  171. data/spec/arrow/appletmixins_spec.rb +409 -0
  172. data/spec/arrow/appletregistry_spec.rb +294 -0
  173. data/spec/arrow/broker_spec.rb +153 -0
  174. data/spec/arrow/config_spec.rb +224 -0
  175. data/spec/arrow/cookieset_spec.rb +164 -0
  176. data/spec/arrow/dispatcher_spec.rb +137 -0
  177. data/spec/arrow/dispatcherloader_spec.rb +65 -0
  178. data/spec/arrow/formvalidator_spec.rb +781 -0
  179. data/spec/arrow/logger_spec.rb +346 -0
  180. data/spec/arrow/mixins_spec.rb +120 -0
  181. data/spec/arrow/service_spec.rb +645 -0
  182. data/spec/arrow/session_spec.rb +121 -0
  183. data/spec/arrow/template/iterator_spec.rb +222 -0
  184. data/spec/arrow/templatefactory_spec.rb +185 -0
  185. data/spec/arrow/transaction_spec.rb +319 -0
  186. data/spec/arrow_spec.rb +37 -0
  187. data/spec/lib/appletmatchers.rb +281 -0
  188. data/spec/lib/constants.rb +77 -0
  189. data/spec/lib/helpers.rb +41 -0
  190. data/spec/lib/matchers.rb +44 -0
  191. data/tests/cookie.tests.rb +310 -0
  192. data/tests/path.tests.rb +157 -0
  193. data/tests/session.tests.rb +111 -0
  194. data/tests/session_id.tests.rb +82 -0
  195. data/tests/session_lock.tests.rb +191 -0
  196. data/tests/session_store.tests.rb +53 -0
  197. data/tests/template.tests.rb +1360 -0
  198. metadata +339 -0
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'arrow/path'
4
+ require 'arrow/exceptions'
5
+ require 'arrow/mixins'
6
+ require 'arrow/logger'
7
+
8
+
9
+ # This class is the abstract base class for all Arrow objects. Most of the
10
+ # Arrow classes inherit from this.
11
+ #
12
+ # == To Do
13
+ #
14
+ # All of this stuff should really be factored out into mixins.
15
+ #
16
+ # == Authors
17
+ #
18
+ # * Michael Granger <ged@FaerieMUD.org>
19
+ #
20
+ # Please see the file LICENSE in the top-level directory for licensing details.
21
+ #
22
+ class Arrow::Object < ::Object
23
+ include Arrow::Loggable
24
+
25
+
26
+ ### Create a method that warns of deprecation for an instance method. If
27
+ ### <tt>newSym</tt> is specified, the method is being renamed, and this
28
+ ### method acts like an <tt>alias_method</tt> that logs a warning; if
29
+ ### not, it is being removed, and the target method will be aliased to
30
+ ### an internal method and wrapped in a warning method with the original
31
+ ### name.
32
+ def self::deprecate_method( oldSym, newSym=oldSym )
33
+ warningMessage = ''
34
+
35
+ # If the method is being removed, alias it away somewhere and build
36
+ # an appropriate warning message. Otherwise, just build a warning
37
+ # message.
38
+ if oldSym == newSym
39
+ newSym = ("__deprecated_" + oldSym.to_s + "__").to_sym
40
+ warningMessage = "%s#%s is deprecated" %
41
+ [ self.name, oldSym.to_s ]
42
+ alias_method newSym, oldSym
43
+ else
44
+ warningMessage = "%s#%s is deprecated; use %s#%s instead" %
45
+ [ self.name, oldSym.to_s, self.name, newSym.to_s ]
46
+ end
47
+
48
+ # Build the method that logs a warning and then calls the true
49
+ # method.
50
+ class_eval %Q{
51
+ def #{oldSym.to_s}( *args, &block )
52
+ self.log.notice "warning: %s: #{warningMessage}" % [ caller(1) ]
53
+ send( #{newSym.inspect}, *args, &block )
54
+ rescue => err
55
+ # Mangle exceptions to point someplace useful
56
+ Kernel.raise err, err.message, err.backtrace[2..-1]
57
+ end
58
+ }
59
+ rescue Exception => err
60
+ # Mangle exceptions to point someplace useful
61
+ frames = err.backtrace
62
+ frames.shift while frames.first =~ /#{__FILE__}/
63
+ Kernel.raise err, err.message, frames
64
+ end
65
+
66
+
67
+ ### Like Object.deprecate_method, but for class methods.
68
+ def self::deprecate_class_method( oldSym, newSym=oldSym )
69
+ warningMessage = ''
70
+
71
+ # If the method is being removed, alias it away somewhere and build
72
+ # an appropriate warning message. Otherwise, just build a warning
73
+ # message.
74
+ if oldSym == newSym
75
+ newSym = ("__deprecated_" + oldSym.to_s + "__").to_sym
76
+ warningMessage = "%s::%s is deprecated" %
77
+ [ self.name, oldSym.to_s ]
78
+ alias_class_method newSym, oldSym
79
+ else
80
+ warningMessage = "%s::%s is deprecated; use %s::%s instead" %
81
+ [ self.name, oldSym.to_s, self.name, newSym.to_s ]
82
+ end
83
+
84
+ # Build the method that logs a warning and then calls the true
85
+ # method.
86
+ class_eval %Q{
87
+ def self::#{oldSym.to_s}( *args, &block )
88
+ Arrow::Logger.notice "warning: %s: #{warningMessage}" % [ caller(1) ]
89
+ send( #{newSym.inspect}, *args, &block )
90
+ rescue => err
91
+ # Mangle exceptions to point someplace useful
92
+ Kernel.raise err, err.message, err.backtrace[2..-1]
93
+ end
94
+ }
95
+ end
96
+
97
+
98
+ ### Store the name of the file from which the inheriting +klass+ is
99
+ ### being loaded.
100
+ def self::inherited( klass )
101
+ unless klass.instance_variables.include?( "@sourcefile" )
102
+ sourcefile = caller(1).find {|frame|
103
+ /inherited/ !~ frame
104
+ }.sub( /^([^:]+):.*/, "\\1" )
105
+ klass.instance_variable_set( "@sourcefile", sourcefile )
106
+ end
107
+
108
+ unless klass.respond_to?( :sourcefile )
109
+ class << klass
110
+ attr_reader :sourcefile
111
+ end
112
+ end
113
+ end
114
+
115
+
116
+ end # class Arrow::Object
117
+
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rbconfig'
4
+ require 'forwardable'
5
+ require 'pathname'
6
+
7
+ require 'arrow'
8
+ require 'arrow/monkeypatches'
9
+ require 'arrow/constants'
10
+ require 'arrow/mixins'
11
+ require 'arrow/exceptions'
12
+
13
+ # The Arrow::Path class, which represents a collection of paths to
14
+ # search for various resources. Instances of this class are used to
15
+ # search for templates, applets, and other resources loaded by the
16
+ # server from a configured list of directories.
17
+ #
18
+ # == Synopsis
19
+ #
20
+ # require 'arrow/path'
21
+ #
22
+ # # Constructed from a String with PATH_SEPARATOR characters:
23
+ # template_path = Arrow::Path.new( ".:/www/templates:/usr/local/www/templates" )
24
+ #
25
+ # # ...or from an Array of Strings
26
+ # template_path = Arrow::Path.new([ '.', '/www/templates', '/usr/local/www/templates' ])
27
+ #
28
+ # # Return only those paths that exist, are directories, are readable
29
+ # # by the current user, and are not world-writable. This will use a
30
+ # # cached value if it has been built within
31
+ # # Arrow::Path::DEFAULT_CACHE_LIFESPAN seconds of the last fetch.
32
+ # paths = template_path.valid_dirs
33
+ #
34
+ # # Fetch without caching
35
+ # template_path.find_valid_dirs
36
+ #
37
+ # # ...or turn caching off and fetch
38
+ # template_path.cache_lifespan = 0
39
+ # paths = template_path.valid_dirs
40
+ #
41
+ # == Authors
42
+ #
43
+ # * Michael Granger <ged@FaerieMUD.org>
44
+ #
45
+ # Please see the file LICENSE in the top-level directory for licensing details.
46
+ #
47
+ class Arrow::Path
48
+ include Enumerable,
49
+ Arrow::Loggable,
50
+ Arrow::Constants
51
+
52
+ extend Forwardable
53
+
54
+
55
+ # The character to split path Strings on, and join on when
56
+ # converting back to a String.
57
+ SEPARATOR = File::PATH_SEPARATOR
58
+
59
+ # How many seconds to cache directory stat information, in seconds.
60
+ DEFAULT_CACHE_LIFESPAN = 1.5
61
+
62
+
63
+ #############################################################
64
+ ### C L A S S M E T H O D S
65
+ #############################################################
66
+
67
+ ### Return the YAML type for this class
68
+ def self::to_yaml_type
69
+ "!%s/arrowPath" % [ Arrow::Constants::YAML_DOMAIN ]
70
+ end
71
+
72
+
73
+ #############################################################
74
+ ### I N S T A N C E M E T H O D S
75
+ #############################################################
76
+
77
+ ### Create a new Arrow::Path object for the specified +path+, which can
78
+ ### be either a String containing directory names separated by
79
+ ### File::PATH_SEPARATOR, an Array of directory names, or an object
80
+ ### which returns such an Array when #to_a is called on it. If
81
+ ### +cache_lifespan+ is non-zero, the Array of valid directories will be
82
+ ### cached for +cache_lifespan+ seconds to save calls to stat().
83
+ def initialize( path=[], cache_lifespan=DEFAULT_CACHE_LIFESPAN )
84
+ @dirs = case path
85
+ when Array
86
+ path.flatten
87
+ when String
88
+ path.split(SEPARATOR)
89
+ else
90
+ path.to_a.flatten
91
+ end
92
+
93
+ @dirs.collect! {|dir| dir.untaint.to_s }
94
+
95
+ @valid_dirs = []
96
+ @cache_lifespan = cache_lifespan
97
+ @last_stat = Time.at(0)
98
+ end
99
+
100
+
101
+ ######
102
+ public
103
+ ######
104
+
105
+ # The raw list of directories contained in the path, including invalid
106
+ # (non-existent or unreadable) ones.
107
+ attr_accessor :dirs
108
+
109
+ # How long (in seconds) to cache the list of good
110
+ # directories. Setting this to 0 turns off caching.
111
+ attr_accessor :cache_lifespan
112
+
113
+
114
+ ### Fetch the list of valid directories, using a cached value if the
115
+ ### path has caching enabled (which is the default). Otherwise, it
116
+ ### fetches the valid list via #find_valid_dirs and caches the result
117
+ ### for #cache_lifespan seconds. If caching is disabled, this is
118
+ ### equivalent to just calling #find_valid_dirs.
119
+ def valid_dirs
120
+ if ( @cache_lifespan.nonzero? &&
121
+ ((Time.now - @last_stat) < @cache_lifespan) )
122
+ self.log.debug "Returning cached dirs."
123
+ return @valid_dirs
124
+ end
125
+
126
+ @valid_dirs = self.find_valid_dirs
127
+ @last_stat = Time.now
128
+
129
+ return @valid_dirs
130
+ end
131
+
132
+
133
+ ### Fetch the list of paths in the search path, vetted to only contain
134
+ ### those that are not tainted, exist, are directories, are readable
135
+ ### by the current user, and are not world-writable.
136
+ def find_valid_dirs
137
+ return @dirs.find_all do |dir|
138
+ if dir.tainted?
139
+ self.log.info "Discarding tainted directory entry %p" % [ dir ]
140
+ next
141
+ end
142
+
143
+ path = Pathname.new( dir )
144
+
145
+ if ! path.exist?
146
+ self.log.debug "Discarding non-existant path: %s" % [ path ]
147
+ next false
148
+ elsif ! path.directory?
149
+ self.log.debug "Discarding non-directory: %s" % [ path ]
150
+ next false
151
+ elsif ! path.readable?
152
+ self.log.debug "Discarding unreadable directory: %s" % [ path ]
153
+ next false
154
+ elsif( (path.stat.mode & 0002).nonzero? )
155
+ self.log.debug "Discarding world-writable directory: %s" % [ path ]
156
+ next false
157
+ end
158
+
159
+ true
160
+ end.map {|pn| pn.to_s }
161
+ end
162
+
163
+
164
+ # Generate Array-ish methods that delegate to self.dirs
165
+ def_delegators :@dirs,
166
+ *(Array.instance_methods(false) -
167
+ Enumerable.instance_methods(false) -
168
+ [:to_yaml, :inspect, :to_s])
169
+
170
+
171
+ ### Enumerable interface method. Iterate over the list of valid dirs
172
+ ### in this path, calling the specified block for each.
173
+ def each( &block )
174
+ self.valid_dirs.each( &block )
175
+ end
176
+
177
+
178
+ ### Return the path as a <tt>SEPARATOR</tt>-separated String.
179
+ def to_s
180
+ return self.valid_dirs.join( SEPARATOR )
181
+ end
182
+
183
+
184
+ ### Return the path as YAML text
185
+ def to_yaml( opts={} )
186
+ require 'yaml'
187
+ YAML.quick_emit( self.object_id, opts ) {|out|
188
+ out.seq( self.class.to_yaml_type ){|seq|
189
+ seq.add( self.dirs )
190
+ }
191
+ }
192
+ end
193
+
194
+ end # class Arrow::Path
195
+
196
+
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/ruby
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+
6
+ require 'arrow/applet'
7
+ require 'arrow/acceptparam'
8
+
9
+ #
10
+ # This file contains the Arrow::Service class, a derivative of
11
+ # Arrow::Applet that provides some conveniences for creating REST-style
12
+ # service applets.
13
+ #
14
+ # It provides:
15
+ # * automatic content-type negotiation
16
+ # * automatic API description-generation for service actions
17
+ # * new action dispatch mechanism that takes the HTTP request method into account
18
+ # * convenience functions for returning a non-OK HTTP status
19
+ #
20
+ # == Authors
21
+ #
22
+ # * Michael Granger <ged@FaerieMUD.org>
23
+ #
24
+ # Please see the file LICENSE in the top-level directory for licensing details.
25
+ #
26
+ class Arrow::Service < Arrow::Applet
27
+ include Arrow::Loggable,
28
+ Arrow::HTMLUtilities,
29
+ Arrow::Constants
30
+
31
+ # Subversion revision
32
+ SVNRev = %q$Rev$
33
+
34
+ # VCS Id
35
+ SvnId = %q$Id$
36
+
37
+ # Map of HTTP methods to their Ruby equivalents as tuples of the form:
38
+ # [ :method_without_args, :method_with_args ]
39
+ METHOD_MAPPING = {
40
+ 'OPTIONS' => [ :options, :options ],
41
+ 'GET' => [ :fetch_all, :fetch ],
42
+ 'HEAD' => [ :fetch_all, :fetch ],
43
+ 'POST' => [ :create, :create ],
44
+ 'PUT' => [ :update_all, :update ],
45
+ 'DELETE' => [ :delete_all, :delete ],
46
+ }
47
+
48
+ # Map of Ruby methods to their HTTP equivalents from either the single or collection URIs
49
+ HTTP_METHOD_MAPPING = {
50
+ :single => {
51
+ :options => 'OPTIONS',
52
+ :fetch => 'GET',
53
+ :create => 'POST',
54
+ :update => 'PUT',
55
+ :delete => 'DELETE',
56
+ },
57
+ :collection => {
58
+ :options => 'OPTIONS',
59
+ :fetch_all => 'GET',
60
+ :create => 'POST',
61
+ :update_all => 'PUT',
62
+ :delete_all => 'DELETE',
63
+ },
64
+ }
65
+
66
+ # A registry of HTTP status codes that don't allow an entity body in the response.
67
+ BODILESS_HTTP_RESPONSE_CODES = [
68
+ Apache::HTTP_CONTINUE,
69
+ Apache::HTTP_SWITCHING_PROTOCOLS,
70
+ Apache::HTTP_PROCESSING,
71
+ Apache::HTTP_NO_CONTENT,
72
+ Apache::HTTP_RESET_CONTENT,
73
+ Apache::HTTP_NOT_MODIFIED,
74
+ Apache::HTTP_USE_PROXY,
75
+ ]
76
+
77
+ # The list of content-types and the corresponding message to send to transform
78
+ # a Ruby object to that content type, in order of preference. See #negotiate_content.
79
+ SERIALIZERS = [
80
+ ['application/json', :to_json],
81
+ ['text/x-yaml', :to_yaml],
82
+ ['application/xml+rubyobject', :to_xml],
83
+ [RUBY_MARSHALLED_MIMETYPE, :dump],
84
+ ]
85
+
86
+ # The list of content-types and the corresponding method on the service to use to
87
+ # transform it into something useful.
88
+ DESERIALIZERS = {
89
+ 'application/json' => :deserialize_json_body,
90
+ 'text/x-yaml' => :deserialize_yaml_body,
91
+ 'application/x-www-form-urlencoded' => :deserialize_form_body,
92
+ 'multipart/form-data' => :deserialize_form_body,
93
+ RUBY_MARSHALLED_MIMETYPE => :deserialize_marshalled_body,
94
+ }
95
+
96
+
97
+ # The content-type that's used for HTTP content negotiation if none
98
+ # is set on the transaction
99
+ DEFAULT_CONTENT_TYPE = RUBY_OBJECT_MIMETYPE
100
+
101
+ # The key for POSTed/PUT JSON entity bodies that will be unwrapped as a simple string value.
102
+ # This is necessary because JSON doesn't have a simple value type of its own, whereas all
103
+ # the other serialization types do.
104
+ SPECIAL_JSON_KEY = 'single_value'
105
+
106
+ # Struct for containing thrown HTTP status responses
107
+ StatusResponse = Struct.new( "ArrowServiceStatusResponse", :status, :message )
108
+
109
+
110
+ ######
111
+ public
112
+ ######
113
+
114
+ ### OPTIONS /
115
+ ### Return a service document containing links to all
116
+ ### :TODO: Integrate HTTP Access Control preflighted requests?
117
+ ### (https://developer.mozilla.org/en/HTTP_access_control)
118
+ def options( txn, *args )
119
+ allowed_methods = self.allowed_methods( args )
120
+ txn.headers_out['Allow'] = allowed_methods.join(', ')
121
+ txn.content_type = RUBY_OBJECT_MIMETYPE
122
+
123
+ return allowed_methods
124
+ end
125
+
126
+
127
+ #########
128
+ protected
129
+ #########
130
+
131
+ ### Map the request in the given +txn+ to an action and return its name as a Symbol.
132
+ def get_action_name( txn, id=nil, *args )
133
+ http_method = txn.request_method
134
+ self.log.debug "Looking up service action for %s %s (%p)" %
135
+ [ http_method, txn.uri, args ]
136
+
137
+ tuple = METHOD_MAPPING[ txn.request_method ] or return :not_allowed
138
+ self.log.debug "Method mapping for %s is %p" % [ txn.request_method, tuple ]
139
+
140
+ if args.empty?
141
+ self.log.debug " URI refers to top-level resource"
142
+ msym = tuple[ id ? 1 : 0 ]
143
+ self.log.debug " picked the %p method (%s ID argument)" %
144
+ [ msym, id ? 'has an' : 'no' ]
145
+
146
+ else
147
+ self.log.debug " URI refers to a sub-resource (args = %p)" % [ args ]
148
+ ops = args.collect {|arg| arg[/^([a-z]\w+)$/, 1].untaint }
149
+
150
+ mname = "%s_%s" % [ tuple[1], ops.compact.join('_') ]
151
+ msym = mname.to_sym
152
+ self.log.debug " picked the %p method (args = %p)" % [ msym, args ]
153
+ end
154
+
155
+ return msym, id, *args
156
+ end
157
+
158
+
159
+ ### Given a +txn+, an +action+ name, and any other remaining URI path +args+ from
160
+ ### the request, return a Method object that will handle the request (or at least something
161
+ ### #call-able with #arity).
162
+ def find_action_method( txn, action, *args )
163
+ return self.method( action ) if self.respond_to?( action )
164
+
165
+ # Otherwise, return an appropriate error response
166
+ self.log.error "request for unimplemented %p action for %s" % [ action, txn.uri ]
167
+ return self.method( :not_allowed )
168
+ end
169
+
170
+
171
+ ### Overridden to provide content-negotiation and error-handling.
172
+ def call_action_method( txn, action, id=nil, *args )
173
+ self.log.debug "calling %p( id: %p, args: %p ) for service request" %
174
+ [ action, id, args ]
175
+ content = nil
176
+
177
+ # Run the action. If it executes normally, 'content' will contain the
178
+ # object that should make up the response entity body. If :finish is
179
+ # thrown early, e.g. via #finish_with, content will be nil and
180
+ # http_status_response should contain a StatusResponse struct
181
+ http_status_response = catch( :finish ) do
182
+ if id
183
+ id = self.validate_id( id )
184
+ content = action.call( txn, id )
185
+ else
186
+ content = action.call( txn )
187
+ end
188
+
189
+ self.log.debug " service finished successfully"
190
+ nil # rvalue for catch
191
+ end
192
+
193
+ # Handle finishing with a status first
194
+ if content
195
+ txn.status ||= Apache::HTTP_OK
196
+ return self.negotiate_content( txn, content )
197
+ elsif http_status_response
198
+ status_code = http_status_response[:status].to_i
199
+ msg = http_status_response[:message]
200
+ return self.prepare_status_response( txn, status_code, msg )
201
+ end
202
+
203
+ return nil
204
+ rescue => err
205
+ raise if err.class.name =~ /^Spec::/
206
+
207
+ msg = "%s: %s %s" % [ err.class.name, err.message, err.backtrace.first ]
208
+ self.log.error( msg )
209
+ return self.prepare_status_response( txn, Apache::SERVER_ERROR, msg )
210
+ end
211
+
212
+
213
+ ### Return a METHOD_NOT_ALLOWED response
214
+ def not_allowed( txn, *args )
215
+ txn.err_headers_out['Allow'] = self.build_allow_header( args )
216
+ finish_with( Apache::METHOD_NOT_ALLOWED, "%s is not allowed" % [txn.request_method] )
217
+ end
218
+
219
+
220
+ ### Return a valid 'Allow' header for the receiver for the given +path_components+ (relative to
221
+ ### its mountpoint)
222
+ def build_allow_header( path_components )
223
+ return self.allowed_methods( path_components ).join(', ')
224
+ end
225
+
226
+
227
+ ### Return an Array of valid HTTP methods for the given +path_components+
228
+ def allowed_methods( path_components )
229
+ type = path_components.empty? ? :collection : :single
230
+ allowed = HTTP_METHOD_MAPPING[ type ].keys.
231
+ find_all {|msym| self.respond_to?(msym) }.
232
+ inject([]) {|ary,msym| ary << HTTP_METHOD_MAPPING[type][msym]; ary }
233
+
234
+ allowed += ['HEAD'] if allowed.include?( 'GET' )
235
+ return allowed.uniq.sort
236
+ end
237
+
238
+ ### Validates the given string as a non-negative integer, either
239
+ ### returning it after untainting it or aborting with BAD_REQUEST. Override this
240
+ ### in your service if your resource IDs aren't integers.
241
+ def validate_id( id )
242
+ self.log.debug "validating ID %p" % [ id ]
243
+ finish_with Apache::BAD_REQUEST, "missing ID" if id.nil?
244
+ finish_with Apache::BAD_REQUEST, "malformed or invalid ID: #{id}" unless
245
+ id =~ /^\d+$/
246
+
247
+ id.untaint
248
+ return Integer( id )
249
+ end
250
+
251
+
252
+ ### Format the given +content+ according to the content-negotiation
253
+ ### headers of the request in the given +txn+.
254
+ def negotiate_content( txn, content )
255
+ current_type = txn.content_type
256
+
257
+ # If the content is already in a form the client understands, just return it
258
+ # TODO: q-value upgrades?
259
+ if current_type && txn.accepts?( current_type )
260
+ self.log.debug " '%s' content already in acceptable form for '%s'" %
261
+ [ current_type, txn.normalized_accept_string ]
262
+ return content
263
+ else
264
+ self.log.info "Negotiating a response which matches '%s' from a %p entity body" %
265
+ [ txn.normalized_accept_string, current_type || content.class ]
266
+
267
+ # See if SERIALIZERS has an available transform that the request
268
+ # accepts and the content supports.
269
+ SERIALIZERS.each do |type, msg|
270
+ if txn.explicitly_accepts?( type ) && content.respond_to?( msg )
271
+ self.log.debug " using %p to serialize the content to %p" % [ msg, type ]
272
+ serialized = content.send( msg )
273
+ txn.content_type = type
274
+ return serialized
275
+ end
276
+ end
277
+ self.log.debug " no matching serializers, trying a hypertext response"
278
+
279
+ # If the client can accept HTML, try to make an HTML response from whatever we have.
280
+ if txn.accepts_html?
281
+ self.log.debug " client accepts HTML"
282
+ return prepare_hypertext_response( txn, content )
283
+ end
284
+
285
+ return prepare_status_response( txn, Apache::NOT_ACCEPTABLE, "" )
286
+ end
287
+ end
288
+
289
+
290
+ ### Set up the response in the specified +txn+ based on the specified +status_code+
291
+ ### and +message+.
292
+ def prepare_status_response( txn, status_code, message )
293
+ self.log.info "Non-OK response: %d (%s)" % [ status_code, message ]
294
+
295
+ txn.status = status_code
296
+
297
+ # Some status codes allow explanatory text to be returned; some forbid it.
298
+ unless BODILESS_HTTP_RESPONSE_CODES.include?( status_code )
299
+ txn.content_type = 'text/plain'
300
+ return message.to_s
301
+ end
302
+
303
+ # For bodiless responses, just tell the dispatcher that we've handled
304
+ # everything.
305
+ return true
306
+ end
307
+
308
+
309
+ ### Convert the specified +content+ to HTML and return it wrapped in a minimal
310
+ ### (X)HTML document. The +content+ will be transformed into an HTML fragment via
311
+ ### its #html_inspect method (if it has one), or via
312
+ ### Arrow::HtmlInspectableObject#make_html_for_object
313
+ def prepare_hypertext_response( txn, content )
314
+ self.log.debug "Preparing a hypertext response out of %p" %
315
+ [ txn.content_type || content.class ]
316
+
317
+ body = self.make_hypertext_from_content( content )
318
+
319
+ # Generate an HTML response
320
+ tmpl = self.load_template( :service )
321
+ tmpl.body = body
322
+ tmpl.txn = txn
323
+ tmpl.applet = self
324
+
325
+ txn.content_type = HTML_MIMETYPE
326
+ # txn.content_encoding = 'utf8'
327
+
328
+ return tmpl
329
+ end
330
+ template :service => 'service-response.tmpl'
331
+
332
+
333
+ ### Make HTML from the given +content+, either via its #html_inspect method, or via
334
+ ### Arrow::HTMLUtilities.make_html_for_object if it doesn't respond to #html_inspect.
335
+ def make_hypertext_from_content( content )
336
+ if content.respond_to?( :html_inspect )
337
+ self.log.debug " making hypertext from %p using %p" %
338
+ [ content, content.method(:html_inspect) ]
339
+ body = content.html_inspect
340
+ elsif content.respond_to?( :fetch ) && content.respond_to?( :collect )
341
+ self.log.debug " recursively hypertexting a collection"
342
+ body = content.collect {|o| self.make_hypertext_from_content(o) }.join("\n")
343
+ else
344
+ self.log.debug " using the generic HTML inspector"
345
+ body = make_html_for_object( content )
346
+ end
347
+
348
+ return body
349
+ end
350
+
351
+
352
+ ### Read the request body from the specified transaction, deserialize it if
353
+ ### necessary, and return one or more Ruby objects. If there isn't a deserializer
354
+ ### in DESERIALIZERS that matches the request's `Content-type`, the request
355
+ ### is aborted with an "Unsupported Media Type" (415) response.
356
+ def deserialize_request_body( txn )
357
+ content_type = txn.headers_in['content-type'].sub( /;.*/, '' ).strip
358
+ self.log.debug "Trying to deserialize a %p request body." % [ content_type ]
359
+
360
+ mname = DESERIALIZERS[ content_type ]
361
+
362
+ if mname && self.respond_to?( mname )
363
+ self.log.debug " calling deserializer: #%s" % [ mname ]
364
+ return self.send( mname, txn )
365
+ else
366
+ self.log.error " no support for %p requests: %s" % [
367
+ content_type,
368
+ mname ? "no implementation of the #{mname} method" : "unknown content-type"
369
+ ]
370
+ finish_with( Apache::HTTP_UNSUPPORTED_MEDIA_TYPE,
371
+ "don't know how to handle %p requests" % [content_type, txn.request_method] )
372
+ end
373
+ end
374
+
375
+
376
+ ### Deserialize the given transaction's request body from an HTML form.
377
+ def deserialize_form_body( txn )
378
+ return txn.all_params
379
+ end
380
+
381
+
382
+ ### Deserialize the given transaction's request body as JSON and return it.
383
+ def deserialize_json_body( txn )
384
+ rval = JSON.load( txn )
385
+ if rval.is_a?( Hash ) && rval.keys == [ SPECIAL_JSON_KEY ]
386
+ return rval[ SPECIAL_JSON_KEY ]
387
+ else
388
+ return rval
389
+ end
390
+ end
391
+
392
+
393
+ ### Deserialize the given transaction's request body as YAML and return it.
394
+ def deserialize_yaml_body( txn )
395
+ return YAML.load( txn )
396
+ end
397
+
398
+
399
+ ### Deserialize the given transaction's request body as a marshalled Ruby
400
+ ### object and return it.
401
+ def deserialize_marshalled_body( txn )
402
+ return Marshal.load( txn )
403
+ end
404
+
405
+
406
+ #######
407
+ private
408
+ #######
409
+
410
+ ### Abort the current execution and return a response with the specified
411
+ ### http_status code immediately. The specified +message+ will be logged,
412
+ ### and will be included in any message that is returned as part of the
413
+ ### response.
414
+ def finish_with( http_status, message, otherstuff={} )
415
+ http_response = otherstuff.merge( :status => http_status, :message => message )
416
+ throw :finish, http_response
417
+ end
418
+
419
+
420
+ ### Deep untaint an object structure and return it.
421
+ def untaint_values( obj )
422
+ self.log.debug "Untainting a result %s" % [ obj.class.name ]
423
+ return obj unless obj.tainted?
424
+ newobj = nil
425
+
426
+ case obj
427
+ when Hash
428
+ newobj = {}
429
+ obj.each do |key,val|
430
+ newobj[ key ] = untaint_values( val )
431
+ end
432
+
433
+ when Array
434
+ # Arrow::Logger[ self ].debug "Untainting array %p" % val
435
+ newobj = obj.collect {|v| v.dup.untaint}
436
+
437
+ else
438
+ # Arrow::Logger[ self ].debug "Untainting %p" % val
439
+ newobj = obj.dup
440
+ newobj.untaint
441
+ end
442
+
443
+ return newobj
444
+ end
445
+
446
+ end # class Arrow::Service
447
+