fishwife 1.1.1-java

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.
@@ -0,0 +1,19 @@
1
+ #--
2
+ # Copyright (c) 2011 David Kellum
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License"); you
5
+ # may not use this file except in compliance with the License. You may
6
+ # obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
13
+ # implied. See the License for the specific language governing
14
+ # permissions and limitations under the License.
15
+ #++
16
+
17
+ module Fishwife
18
+ VERSION = '1.1.1'
19
+ end
@@ -0,0 +1,100 @@
1
+ #--
2
+ # Copyright (c) 2011 David Kellum
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License"); you
5
+ # may not use this file except in compliance with the License. You may
6
+ # obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
13
+ # implied. See the License for the specific language governing
14
+ # permissions and limitations under the License.
15
+ #++
16
+
17
+ module Fishwife
18
+ class HttpServer < RJack::Jetty::ServerFactory
19
+
20
+ attr_accessor :host
21
+
22
+ # Create the server with specified options:
23
+ #
24
+ # :host::
25
+ # String specifying the IP address to bind to (default: 0.0.0.0)
26
+ #
27
+ # :port::
28
+ # String or integer with the port to bind to (default: 9292).
29
+ # Jetty picks if given port 0 (and port can be read on return
30
+ # from start.)
31
+ #
32
+ # :min_threads::
33
+ # Minimum number of threads to keep in pool (default: 5)
34
+ #
35
+ # :max_threads::
36
+ # Maximum threads to create in pool (default: 50)
37
+ #
38
+ # :max_idle_time_ms::
39
+ # Maximum idle time for a connection in milliseconds (default: 10_000)
40
+ #
41
+ # :request_log_file::
42
+ # Request log to file name or :stderr (default: nil, no log)
43
+ def initialize( options = {} )
44
+ super()
45
+
46
+ @server = nil
47
+ @host = nil
48
+
49
+ self.min_threads = 5
50
+ self.max_threads = 50
51
+ self.port = 9292
52
+
53
+ options = Hash[ options.map { |o| [ o[0].to_s.downcase.to_sym, o[1] ] } ]
54
+
55
+ # Translate option values from possible Strings
56
+ [:port, :min_threads, :max_threads, :max_idle_time_ms].each do |k|
57
+ v = options[k]
58
+ options[k] = v.to_i if v
59
+ end
60
+
61
+ v = options[ :request_log_file ]
62
+ options[ :request_log_file ] = v.to_sym if v == 'stderr'
63
+
64
+ # Apply options as setters
65
+ options.each do |k,v|
66
+ setter = "#{k}=".to_sym
67
+ send( setter, v ) if respond_to?( setter )
68
+ end
69
+ end
70
+
71
+ # Start the server, given rack app to run
72
+ def start( app )
73
+ set_context_servlets( '/', { '/*' => RackServlet.new( app ) } )
74
+
75
+ @server = create
76
+ @server.start
77
+ # Recover the server port in case 0 was given.
78
+ self.port = @server.connectors[0].local_port
79
+
80
+ @server
81
+ end
82
+
83
+ # Join with started server so main thread doesn't exit.
84
+ def join
85
+ @server.join if @server
86
+ end
87
+
88
+ # Stop the server to allow graceful shutdown
89
+ def stop
90
+ @server.stop if @server
91
+ end
92
+
93
+ def create_connectors
94
+ super.tap do |ctrs|
95
+ ctrs.first.host = @host if ctrs.first
96
+ end
97
+ end
98
+
99
+ end
100
+ end
@@ -0,0 +1,246 @@
1
+ #--
2
+ # Copyright (c) 2011 David Kellum
3
+ # Copyright (c) 2010-2011 Don Werve
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License"); you
6
+ # may not use this file except in compliance with the License. You may
7
+ # obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+ #++
17
+
18
+ #
19
+ # Wraps a Rack application in a Java servlet.
20
+ #
21
+ # Relevant documentation:
22
+ #
23
+ # http://rack.rubyforge.org/doc/SPEC.html
24
+ # http://java.sun.com/j2ee/sdk_1.3/techdocs/api/javax
25
+ # /servlet/http/HttpServlet.html
26
+ #
27
+ module Fishwife
28
+ java_import 'javax.servlet.http.HttpServlet'
29
+
30
+ class RackServlet < HttpServlet
31
+ java_import 'java.io.FileInputStream'
32
+ java_import 'org.eclipse.jetty.continuation.ContinuationSupport'
33
+
34
+ def initialize( app )
35
+ super()
36
+ @log = RJack::SLF4J[ self.class ]
37
+ @app = app
38
+ end
39
+
40
+ # Takes an incoming request (as a Java Servlet) and dispatches it
41
+ # to the rack application setup via [rackup]. All this really
42
+ # involves is translating the various bits of the Servlet API into
43
+ # the Rack API on the way in, and translating the response back on
44
+ # the way out.
45
+ #
46
+ # Also, we implement a common extension to the Rack api for
47
+ # asynchronous request processing. We supply an 'async.callback'
48
+ # parameter in env to the Rack application. If we catch an :async
49
+ # symbol thrown by the app, we initiate a Jetty continuation.
50
+ #
51
+ # When 'async.callback' gets a response with empty headers and an
52
+ # empty body, we declare the async response finished.
53
+ def service(request, response)
54
+ # Turn the ServletRequest into a Rack env hash
55
+ env = servlet_to_rack(request)
56
+
57
+ # Handle asynchronous responses via Servlet continuations.
58
+ continuation = ContinuationSupport.getContinuation(request)
59
+
60
+ # If this is an expired connection, do nothing.
61
+ return if continuation.isExpired
62
+
63
+ # We should never be re-dispatched.
64
+ raise("Request re-dispatched.") unless continuation.isInitial
65
+
66
+ # Add our own special bits to the rack environment so that Rack
67
+ # middleware can have access to the Java internals.
68
+ env['rack.java.servlet'] = true
69
+ env['rack.java.servlet.request'] = request
70
+ env['rack.java.servlet.response'] = response
71
+ env['rack.java.servlet.continuation'] = continuation
72
+
73
+ # Add an callback that can be used to add results to the
74
+ # response asynchronously.
75
+ env['async.callback'] = lambda do |rack_response|
76
+ servlet_response = continuation.getServletResponse
77
+ rack_to_servlet(rack_response, servlet_response) and
78
+ continuation.complete
79
+ end
80
+
81
+ # Execute the Rack request.
82
+ catch(:async) do
83
+ rack_response = @app.call(env)
84
+
85
+ # For apps that don't throw :async.
86
+ unless(rack_response[0] == -1)
87
+ # Nope, nothing asynchronous here.
88
+ rack_to_servlet(rack_response, response)
89
+ return
90
+ end
91
+ end
92
+
93
+ # If we got here, this is a continuation.
94
+ continuation.suspend(response)
95
+
96
+ rescue NativeException => n
97
+ @log.warn( "On service (native): #{n.cause.to_string}" )
98
+ raise n.cause
99
+ rescue Exception => e
100
+ @log.error( "On service: #{e}" )
101
+ raise e
102
+ end
103
+
104
+ private
105
+
106
+ # Turns a Servlet request into a Rack request hash.
107
+ def servlet_to_rack(request)
108
+ # The Rack request that we will pass on.
109
+ env = Hash.new
110
+
111
+ # Map Servlet bits to Rack bits.
112
+ env['REQUEST_METHOD'] = request.getMethod
113
+ qstring = request.getQueryString.to_s #or empty string
114
+ env['QUERY_STRING'] = qstring
115
+ env['SERVER_NAME'] = request.getServerName
116
+ env['SERVER_PORT'] = request.getServerPort.to_s
117
+ env['rack.version'] = Rack::VERSION
118
+ env['rack.url_scheme'] = request.getScheme
119
+ env['HTTP_VERSION'] = request.getProtocol
120
+ env["SERVER_PROTOCOL"] = request.getProtocol
121
+ env['REMOTE_ADDR'] = request.getRemoteAddr
122
+ env['REMOTE_HOST'] = request.getRemoteHost
123
+
124
+ # request.getPathInfo seems to be blank, so we're using the URI.
125
+ env['REQUEST_PATH'] = request.getRequestURI
126
+ env['PATH_INFO'] = request.getRequestURI
127
+ env['SCRIPT_NAME'] = ""
128
+
129
+ # Rack says URI, but it hands off a URL.
130
+ req_uri = request.getRequestURL.toString
131
+
132
+ # Java chops off the query string, but a Rack application will
133
+ # expect it, so we'll add it back if present
134
+ req_uri << '?' << qstring unless qstring.empty?
135
+ env['REQUEST_URI'] = req_uri
136
+
137
+ # CONTENT_TYPE/LENGTH are handled specifically, not in headers.
138
+ ctype = request.getContentType
139
+ env['CONTENT_TYPE'] = ctype if ctype && !ctype.empty?
140
+ clength = request.getContentLength
141
+ env['CONTENT_LENGTH'] = clength.to_s if clength != -1
142
+
143
+ # JRuby is like the matrix, only there's no spoon or fork().
144
+ env['rack.multiprocess'] = false
145
+ env['rack.multithread'] = true
146
+ env['rack.run_once'] = false
147
+
148
+ # Populate the HTTP headers.
149
+ hn = request.getHeaderNames
150
+ if hn.respond_to?( :each )
151
+ hn.each do |header_name|
152
+ header = header_name.upcase.tr('-', '_')
153
+ next if header == 'CONTENT_TYPE' || header == 'CONTENT_LENGTH'
154
+ env[ 'HTTP_' + header ] = request.getHeader( header_name )
155
+ end
156
+ else
157
+ @log.warn( "Weird headers: [#{ hn.to_s }]" )
158
+ end
159
+
160
+ # The input stream is a wrapper around the Java InputStream.
161
+ env['rack.input'] = request.getInputStream.to_io
162
+
163
+ # The output stream defaults to stderr.
164
+ env['rack.errors'] ||= $stderr
165
+
166
+ # All done, hand back the Rack request.
167
+ env
168
+ end
169
+
170
+ # Turns a Rack response into a Servlet response; can be called
171
+ # multiple times. Returns true if this is the full request (either
172
+ # a synchronous request or the last part of an async request),
173
+ # false otherwise.
174
+ #
175
+ # Note that keep-alive *only* happens if we get either a pathname
176
+ # (because we can find the length ourselves), or if we get a
177
+ # Content-Length header as part of the response. While we can
178
+ # readily buffer the response object to figure out how long it is,
179
+ # we have no guarantee that we aren't going to be buffering
180
+ # something *huge*.
181
+ #
182
+ # http://docstore.mik.ua/orelly/java-ent/servlet/ch05_03.htm
183
+ def rack_to_servlet(rack_response, response)
184
+ # Split apart the Rack response.
185
+ status, headers, body = rack_response
186
+
187
+ # We assume the request is finished if we got empty headers,
188
+ # an empty body, and we have a committed response.
189
+ finished = ( headers.empty? and
190
+ body.respond_to?(:empty?) and body.empty?)
191
+ return(true) if (finished and response.isCommitted)
192
+
193
+ # No need to send headers again if we've already shipped data
194
+ # out on an async request.
195
+ unless(response.isCommitted)
196
+ # Set the HTTP status code.
197
+ response.setStatus(status.to_i)
198
+
199
+ # Did we get a Content-Length header?
200
+ content_length = headers.delete('Content-Length')
201
+ response.setContentLength(content_length.to_i) if content_length
202
+
203
+ # Did we get a Content-Type header?
204
+ content_type = headers.delete('Content-Type')
205
+ response.setContentType(content_type) if content_type
206
+
207
+ # Add all the result headers.
208
+ headers.each do |h, v|
209
+ v.split("\n").each { |val| response.addHeader(h, val) }
210
+ end
211
+ end
212
+
213
+ # How else would we write output?
214
+ output = response.getOutputStream
215
+
216
+ # Turn the body into something nice and Java-y.
217
+ if(body.respond_to?(:to_path))
218
+ # We've been told to serve a file; use FileInputStream to
219
+ # stream the file directly to the servlet, because this is a
220
+ # lot faster than doing it with Ruby.
221
+ file = java.io.File.new(body.to_path)
222
+
223
+ # We set the content-length so we can use Keep-Alive, unless
224
+ # this is an async request.
225
+ response.setContentLength(file.length) unless content_length
226
+
227
+ # Stream the file directly.
228
+ buffer = Java::byte[4096].new
229
+ input_stream = FileInputStream.new(file)
230
+ while((count = input_stream.read(buffer)) != -1)
231
+ output.write(buffer, 0, count)
232
+ end
233
+ input_stream.close
234
+ else
235
+ body.each { |l| output.write(l.to_java_bytes) }
236
+ end
237
+
238
+ # Close the body if we're supposed to.
239
+ body.close if body.respond_to?(:close)
240
+
241
+ # All done.
242
+ output.flush
243
+ false
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,38 @@
1
+ #--
2
+ # Copyright (c) 2011 David Kellum
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License"); you
5
+ # may not use this file except in compliance with the License. You may
6
+ # obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
13
+ # implied. See the License for the specific language governing
14
+ # permissions and limitations under the License.
15
+ #++
16
+
17
+ require 'fishwife'
18
+
19
+ module Rack
20
+ module Handler
21
+ # Rack expects Rack::Handler::Fishwife via require 'rack/handler/fishwife'
22
+ class Fishwife
23
+
24
+ # Called by rack to run
25
+ def self.run( app, opts = {} )
26
+ @server = ::Fishwife::HttpServer.new( opts )
27
+ @server.start( app )
28
+ @server.join
29
+ end
30
+
31
+ # Called by rack
32
+ def self.shutdown
33
+ @server.stop if @server
34
+ end
35
+
36
+ end
37
+ end
38
+ end
@@ -0,0 +1 @@
1
+ Hello, world!
Binary file
@@ -0,0 +1,183 @@
1
+ #--
2
+ # Copyright (c) 2011 David Kellum
3
+ # Copyright (c) 2010-2011 Don Werve
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License"); you
6
+ # may not use this file except in compliance with the License. You may
7
+ # obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+ #++
17
+
18
+ require 'spec_helper'
19
+ require 'test_app'
20
+ require 'thread'
21
+ require 'digest/md5'
22
+ require 'base64'
23
+
24
+ describe Fishwife do
25
+ def get(path, headers = {})
26
+ Net::HTTP.start(@options[:host], @options[:port]) do |http|
27
+ request = Net::HTTP::Get.new(path, headers)
28
+ http.request(request)
29
+ end
30
+ end
31
+
32
+ def post(path, params = nil, headers = {}, body = nil)
33
+ Net::HTTP.start(@options[:host], @options[:port]) do |http|
34
+ request = Net::HTTP::Post.new(path, headers)
35
+ request.form_data = params if params
36
+ request.body = body if body
37
+ http.request(request)
38
+ end
39
+ end
40
+
41
+ before(:all) do
42
+ @lock = Mutex.new
43
+ @app = Rack::Lint.new(TestApp.new)
44
+ @options = { :host => '127.0.0.1', :port => 9201 }
45
+ Net::HTTP.version_1_2
46
+ @server = Fishwife::HttpServer.new(@options)
47
+ @server.start(@app)
48
+ end
49
+
50
+ after(:all) do
51
+ @server.stop
52
+ end
53
+
54
+ it "returns 200 OK" do
55
+ response = get("/ping")
56
+ response.code.should == "200"
57
+ end
58
+
59
+ it "returns 403 FORBIDDEN" do
60
+ response = get("/error/403")
61
+ response.code.should == "403"
62
+ end
63
+
64
+ it "returns 404 NOT FOUND" do
65
+ response = get("/jimmy/hoffa")
66
+ response.code.should == "404"
67
+ end
68
+
69
+ it "sets Rack headers" do
70
+ response = get("/echo")
71
+ response.code.should == "200"
72
+ content = YAML.load(response.body)
73
+ content["rack.version"].should == [ 1, 1 ]
74
+ content["rack.multithread"].should be_true
75
+ content["rack.multiprocess"].should be_false
76
+ content["rack.run_once"].should be_false
77
+ end
78
+
79
+ it "passes form variables via GET" do
80
+ response = get("/echo?answer=42")
81
+ response.code.should == "200"
82
+ content = YAML.load(response.body)
83
+ content['request.params']['answer'].should == '42'
84
+ end
85
+
86
+ it "passes form variables via POST" do
87
+ question = "What is the answer to life, the universe, and everything?"
88
+ response = post("/echo", 'question' => question)
89
+ response.code.should == "200"
90
+ content = YAML.load(response.body)
91
+ content['request.params']['question'].should == question
92
+ end
93
+
94
+ it "passes custom headers" do
95
+ response = get("/echo", "X-My-Header" => "Pancakes")
96
+ response.code.should == "200"
97
+ content = YAML.load(response.body)
98
+ content["HTTP_X_MY_HEADER"].should == "Pancakes"
99
+ end
100
+
101
+ it "returns multiple values of the same header" do
102
+ response = get("/multi_headers")
103
+ response['Warning'].should == "warn-1, warn-2"
104
+ # Net::HTTP handle multiple headers with join( ", " )
105
+ end
106
+
107
+ it "lets the Rack app know it's running as a servlet" do
108
+ response = get("/echo", 'answer' => '42')
109
+ response.code.should == "200"
110
+ content = YAML.load(response.body)
111
+ content['rack.java.servlet'].should be_true
112
+ end
113
+
114
+ it "is clearly Jetty" do
115
+ response = get("/ping")
116
+ response['server'].should =~ /jetty/i
117
+ end
118
+
119
+ it "sets the server port and hostname" do
120
+ response = get("/echo")
121
+ content = YAML.load(response.body)
122
+ content["SERVER_PORT"].should == "9201"
123
+ content["SERVER_NAME"].should == "127.0.0.1"
124
+ end
125
+
126
+ it "passes the URI scheme" do
127
+ response = get("/echo")
128
+ content = YAML.load(response.body)
129
+ content['rack.url_scheme'].should == 'http'
130
+ end
131
+
132
+ it "supports file downloads" do
133
+ response = get("/download")
134
+ response.code.should == "200"
135
+ response['Content-Type'].should == 'image/png'
136
+ response['Content-Disposition'].should ==
137
+ 'attachment; filename=reddit-icon.png'
138
+ checksum = Digest::MD5.hexdigest(response.body)
139
+ checksum.should == '8da4b60a9bbe205d4d3699985470627e'
140
+ end
141
+
142
+ it "supports file uploads" do
143
+ boundary = '349832898984244898448024464570528145'
144
+ content = []
145
+ content << "--#{boundary}"
146
+ content << 'Content-Disposition: form-data; name="file"; ' +
147
+ 'filename="reddit-icon.png"'
148
+ content << 'Content-Type: image/png'
149
+ content << 'Content-Transfer-Encoding: base64'
150
+ content << ''
151
+ content <<
152
+ Base64.encode64( File.read('spec/data/reddit-icon.png') ).strip
153
+ content << "--#{boundary}--"
154
+ body = content.map { |l| l + "\r\n" }.join('')
155
+ headers = { "Content-Type" =>
156
+ "multipart/form-data; boundary=#{boundary}" }
157
+ response = post("/upload", nil, headers, body)
158
+ response.code.should == "200"
159
+ response.body.should == '8da4b60a9bbe205d4d3699985470627e'
160
+ end
161
+
162
+ it "handles async requests" do
163
+ lock = Mutex.new
164
+ buffer = Array.new
165
+
166
+ clients = 10.times.map do |index|
167
+ Thread.new do
168
+ Net::HTTP.start(@options[:host], @options[:port]) do |http|
169
+ response = http.get("/pull")
170
+ lock.synchronize {
171
+ buffer << "#{index}: #{response.body}" }
172
+ end
173
+ end
174
+ end
175
+
176
+ lock.synchronize { buffer.should be_empty }
177
+ post("/push", 'message' => "one")
178
+ clients.each { |c| c.join }
179
+ lock.synchronize { buffer.should_not be_empty }
180
+ lock.synchronize { buffer.count.should == 10 }
181
+ end
182
+
183
+ end