| @@ -0,0 +1,69 @@ | ||
| require 'webmachine/quoted_string' | ||
|
|
||
| module Webmachine | ||
| # A wrapper around entity tags that encapsulates their semantics. | ||
| # This class by itself represents a "strong" entity tag. | ||
| class ETag | ||
| include QuotedString | ||
| # The pattern for a weak entity tag | ||
| WEAK_ETAG = /^W\/#{QUOTED_STRING}$/.freeze | ||
|
|
||
| def self.new(etag) | ||
| return etag if ETag === etag | ||
| klass = etag =~ WEAK_ETAG ? WeakETag : self | ||
| klass.send(:allocate).tap do |obj| | ||
| obj.send(:initialize, etag) | ||
| end | ||
| end | ||
|
|
||
| attr_reader :etag | ||
|
|
||
| def initialize(etag) | ||
| @etag = quote(etag) | ||
| end | ||
|
|
||
| # An entity tag is equivalent to another entity tag if their | ||
| # quoted values are equivalent. It is also equivalent to a String | ||
| # which represents the equivalent ETag. | ||
| def ==(other) | ||
| case other | ||
| when ETag | ||
| other.etag == @etag | ||
| when String | ||
| quote(other) == @etag | ||
| end | ||
| end | ||
|
|
||
| # Converts the entity tag into a string appropriate for use in a | ||
| # header. | ||
| def to_s | ||
| quote @etag | ||
| end | ||
| end | ||
|
|
||
| # A Weak Entity Tag, which can be used to compare entities which are | ||
| # semantically equivalent, but do not have the same byte-content. A | ||
| # WeakETag is equivalent to another entity tag if the non-weak | ||
| # portions are equivalent. It is also equivalent to a String which | ||
| # represents the equivalent strong or weak ETag. | ||
| class WeakETag < ETag | ||
| # Converts the WeakETag to a String for use in a header. | ||
| def to_s | ||
| "W/#{super}" | ||
| end | ||
|
|
||
| private | ||
| def unquote(str) | ||
| if str =~ WEAK_ETAG | ||
| unescape_quotes $1 | ||
| else | ||
| super | ||
| end | ||
| end | ||
|
|
||
| def quote(str) | ||
| str = unescape_quotes($1) if str =~ WEAK_ETAG | ||
| super | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,179 @@ | ||
| require 'securerandom' # For AS::Notifications | ||
| require 'as/notifications' | ||
| require 'webmachine/events/instrumented_event' | ||
|
|
||
| module Webmachine | ||
| # {Webmachine::Events} implements the | ||
| # [ActiveSupport::Notifications](http://rubydoc.info/gems/activesupport/ActiveSupport/Notifications) | ||
| # instrumentation API. It delegates to the configured backend. | ||
| # The default backend is | ||
| # [AS::Notifications](http://rubydoc.info/gems/as-notifications/AS/Notifications). | ||
| # | ||
| # # Published events | ||
| # | ||
| # Webmachine publishes some internal events by default. All of them use | ||
| # the `wm.` prefix. | ||
| # | ||
| # ## `wm.dispatch` ({.instrument}) | ||
| # | ||
| # The payload hash includes the following keys. | ||
| # | ||
| # * `:resource` - The resource class name | ||
| # * `:request` - A copy of the request object | ||
| # * `:code` - The response code | ||
| # | ||
| module Events | ||
| class << self | ||
| # The class that {Webmachine::Events} delegates all messages to. | ||
| # (default [AS::Notifications](http://rubydoc.info/gems/as-notifications/AS/Notifications)) | ||
| # | ||
| # It can be changed to an | ||
| # [ActiveSupport::Notifications](http://rubydoc.info/gems/activesupport/ActiveSupport/Notifications) | ||
| # compatible backend early in the application startup process. | ||
| # | ||
| # @example | ||
| # require 'webmachine' | ||
| # require 'active_support/notifications' | ||
| # | ||
| # Webmachine::Events.backend = ActiveSupport::Notifications | ||
| # | ||
| # Webmachine::Application.new {|app| | ||
| # # setup application | ||
| # }.run | ||
| attr_accessor :backend | ||
|
|
||
| # Publishes the given arguments to all listeners subscribed to the given | ||
| # event name. | ||
| # @param name [String] the event name | ||
| # @example | ||
| # Webmachine::Events.publish('wm.foo', :hello => 'world') | ||
| def publish(name, *args) | ||
| backend.publish(name, *args) | ||
| end | ||
|
|
||
| # Instrument the given block by measuring the time taken to execute it | ||
| # and publish it. Notice that events get sent even if an error occurs | ||
| # in the passed-in block. | ||
| # | ||
| # If an error happens during an instrumentation the payload will | ||
| # have a key `:exception` with an array of two elements as value: | ||
| # a string with the name of the error class, and the error | ||
| # message. (when using the default | ||
| # [AS::Notifications](http://rubydoc.info/gems/as-notifications/AS/Notifications) | ||
| # backend) | ||
| # | ||
| # @param name [String] the event name | ||
| # @param payload [Hash] the initial payload | ||
| # | ||
| # @example | ||
| # Webmachine::Events.instrument('wm.dispatch') do |payload| | ||
| # execute_some_method | ||
| # | ||
| # payload[:custom_payload_value] = 'important' | ||
| # end | ||
| def instrument(name, payload = {}, &block) | ||
| backend.instrument(name, payload, &block) | ||
| end | ||
|
|
||
| # Subscribes to the given event name. | ||
| # | ||
| # @note The documentation of this method describes its behaviour with the | ||
| # default backed [AS::Notifications](http://rubydoc.info/gems/as-notifications/AS/Notifications). | ||
| # It can change if a different backend is used. | ||
| # | ||
| # @overload subscribe(name) | ||
| # Subscribing to an {.instrument} event. The block arguments can be | ||
| # passed to {Webmachine::Events::InstrumentedEvent}. | ||
| # | ||
| # @param name [String, Regexp] the event name to subscribe to | ||
| # @yieldparam name [String] the event name | ||
| # @yieldparam start [Time] the event start time | ||
| # @yieldparam end [Time] the event end time | ||
| # @yieldparam event_id [String] the event id | ||
| # @yieldparam payload [Hash] the event payload | ||
| # @return [Object] the subscriber object (type depends on the backend implementation) | ||
| # | ||
| # @example | ||
| # # Subscribe to all 'wm.dispatch' events | ||
| # Webmachine::Events.subscribe('wm.dispatch') {|*args| | ||
| # event = Webmachine::Events::InstrumentedEvent.new(*args) | ||
| # } | ||
| # | ||
| # # Subscribe to all events that start with 'wm.' | ||
| # Webmachine::Events.subscribe(/wm\.*/) {|*args| } | ||
| # | ||
| # @overload subscribe(name) | ||
| # Subscribing to a {.publish} event. | ||
| # | ||
| # @param name [String, Regexp] the event name to subscribe to | ||
| # @yieldparam name [String] the event name | ||
| # @yieldparam *args [Array] the published arguments | ||
| # @return [Object] the subscriber object (type depends on the backend implementation) | ||
| # | ||
| # @example | ||
| # Webmachine::Events.subscribe('custom.event') {|name, *args| | ||
| # args #=> [obj1, obj2, {:num => 1}] | ||
| # } | ||
| # | ||
| # Webmachine::Events.publish('custom.event', obj1, obj2, {:num => 1}) | ||
| # | ||
| # @overload subscribe(name, listener) | ||
| # Subscribing with a listener object instead of a block. The listener | ||
| # object must respond to `#call`. | ||
| # | ||
| # @param name [String, Regexp] the event name to subscribe to | ||
| # @param listener [#call] a listener object | ||
| # @return [Object] the subscriber object (type depends on the backend implementation) | ||
| # | ||
| # @example | ||
| # class CustomListener | ||
| # def call(name, *args) | ||
| # # ... | ||
| # end | ||
| # end | ||
| # | ||
| # Webmachine::Events.subscribe('wm.dispatch', CustomListener.new) | ||
| # | ||
| def subscribe(name, *args, &block) | ||
| backend.subscribe(name, *args, &block) | ||
| end | ||
|
|
||
| # Subscribe to an event temporarily while the block runs. | ||
| # | ||
| # @note The documentation of this method describes its behaviour with the | ||
| # default backed [AS::Notifications](http://rubydoc.info/gems/as-notifications/AS/Notifications). | ||
| # It can change if a different backend is used. | ||
| # | ||
| # The callback in the following example will be called for all | ||
| # "sql.active_record" events instrumented during the execution of the | ||
| # block. The callback is unsubscribed automatically after that. | ||
| # | ||
| # @example | ||
| # callback = lambda {|name, *args| handle_event(name, *args) } | ||
| # | ||
| # Webmachine::Events.subscribed(callback, 'sql.active_record') do | ||
| # call_active_record | ||
| # end | ||
| def subscribed(callback, name, &block) | ||
| backend.subscribed(callback, name, &block) | ||
| end | ||
|
|
||
| # Unsubscribes the given subscriber. | ||
| # | ||
| # @note The documentation of this method describes its behaviour with the | ||
| # default backed [AS::Notifications](http://rubydoc.info/gems/as-notifications/AS/Notifications). | ||
| # It can change if a different backend is used. | ||
| # | ||
| # @param subscriber [Object] the subscriber object (type depends on the backend implementation) | ||
| # @example | ||
| # subscriber = Webmachine::Events.subscribe('wm.dispatch') {|*args| } | ||
| # | ||
| # Webmachine::Events.unsubscribe(subscriber) | ||
| def unsubscribe(subscriber) | ||
| backend.unsubscribe(subscriber) | ||
| end | ||
| end | ||
|
|
||
| self.backend = AS::Notifications | ||
| end | ||
| end |
| @@ -0,0 +1,19 @@ | ||
| require 'delegate' | ||
| require 'as/notifications/instrumenter' | ||
|
|
||
| module Webmachine | ||
| module Events | ||
| # {Webmachine::Events::InstrumentedEvent} delegates to | ||
| # [AS::Notifications::Event](http://rubydoc.info/gems/as-notifications/AS/Notifications/Event). | ||
| # | ||
| # The class | ||
| # [AS::Notifications::Event](http://rubydoc.info/gems/as-notifications/AS/Notifications/Event) | ||
| # is able to take the arguments of an {Webmachine::Events.instrument} event | ||
| # and provide an object-oriented interface to that data. | ||
| class InstrumentedEvent < SimpleDelegator | ||
| def initialize(*args) | ||
| super(AS::Notifications::Event.new(*args)) | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,25 @@ | ||
| require 'webmachine/constants' | ||
|
|
||
| module Webmachine | ||
| module HeaderNegotiation | ||
| def ensure_date_header(res) | ||
| if (200..499).include?(res.code) | ||
| res.headers[DATE] ||= Time.now.httpdate | ||
| end | ||
| end | ||
|
|
||
| def ensure_content_length(res) | ||
| body = res.body | ||
| case | ||
| when res.headers[TRANSFER_ENCODING] | ||
| return | ||
| when [204, 205, 304].include?(res.code) | ||
| res.headers.delete CONTENT_LENGTH | ||
| when body != nil | ||
| res.headers[CONTENT_LENGTH] = body.respond_to?(:bytesize) ? body.bytesize.to_s : body.length.to_s | ||
| else | ||
| res.headers[CONTENT_LENGTH] = '0' | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,39 @@ | ||
| module Webmachine | ||
| # Helper methods for dealing with the 'quoted-string' type often | ||
| # present in header values. | ||
| module QuotedString | ||
| # The pattern for a 'quoted-string' type | ||
| QUOTED_STRING = /"((?:\\"|[^"])*)"/.freeze | ||
|
|
||
| # The pattern for a 'quoted-string' type, without any other content. | ||
| QS_ANCHORED = /^#{QUOTED_STRING}$/.freeze | ||
|
|
||
| # Removes surrounding quotes from a quoted-string | ||
| def unquote(str) | ||
| if str =~ QS_ANCHORED | ||
| unescape_quotes $1 | ||
| else | ||
| str | ||
| end | ||
| end | ||
|
|
||
| # Ensures that quotes exist around a quoted-string | ||
| def quote(str) | ||
| if str =~ QS_ANCHORED | ||
| str | ||
| else | ||
| %Q{"#{escape_quotes str}"} | ||
| end | ||
| end | ||
|
|
||
| # Escapes quotes within a quoted string. | ||
| def escape_quotes(str) | ||
| str.gsub(/"/, '\\"') | ||
| end | ||
|
|
||
| # Unescapes quotes within a quoted string | ||
| def unescape_quotes(str) | ||
| str.gsub(%r{\\}, '') | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,62 @@ | ||
| module Webmachine::RescuableException | ||
| require_relative 'errors' | ||
| require 'set' | ||
|
|
||
| UNRESCUABLE_DEFAULTS = [ | ||
| Webmachine::MalformedRequest, | ||
| NoMemoryError, SystemExit, SignalException | ||
| ].freeze | ||
|
|
||
| UNRESCUABLE = Set.new UNRESCUABLE_DEFAULTS.dup | ||
| private_constant :UNRESCUABLE | ||
|
|
||
| def self.===(e) | ||
| case e | ||
| when *UNRESCUABLE then false | ||
| else true | ||
| end | ||
| end | ||
|
|
||
| # | ||
| # Remove modifications to Webmachine::RescuableException. | ||
| # Restores default list of unrescue-able exceptions. | ||
| # | ||
| # @return [nil] | ||
| # | ||
| def self.default! | ||
| UNRESCUABLE.replace Set.new(UNRESCUABLE_DEFAULTS.dup) | ||
| nil | ||
| end | ||
|
|
||
| # | ||
| # @return [Array<Exception>] | ||
| # Returns an Array of exceptions that will not be | ||
| # rescued by {Webmachine::Resource#handle_exception}. | ||
| # | ||
| def self.UNRESCUABLEs | ||
| UNRESCUABLE.to_a | ||
| end | ||
|
|
||
| # | ||
| # Add a variable number of exceptions that should be rescued by | ||
| # {Webmachine::Resource#handle_exception}. See {UNRESCUABLE_DEFAULTS} | ||
| # for a list of exceptions that are not caught by default. | ||
| # | ||
| # @param (see #remove) | ||
| # | ||
| def self.add(*exceptions) | ||
| exceptions.each{|e| UNRESCUABLE.delete(e)} | ||
| end | ||
|
|
||
| # | ||
| # Remove a variable number of exceptions from being rescued by | ||
| # {Webmachine::Resource#handle_exception}. See {UNRESCUABLE_DEFAULTS} | ||
| # for a list of exceptions that are not caught by default. | ||
| # | ||
| # @param [Exception] *exceptions | ||
| # A subclass of Exception. | ||
| # | ||
| def self.remove(*exceptions) | ||
| exceptions.each{|e| UNRESCUABLE.add(e)} | ||
| end | ||
| end |
| @@ -0,0 +1,17 @@ | ||
| require 'webmachine/etags' | ||
|
|
||
| module Webmachine | ||
| class Resource | ||
| module EntityTags | ||
| # Marks a generated entity tag (etag) as "weak", meaning that | ||
| # other representations of the resource may be semantically equivalent. | ||
| # @return [WeakETag] a weak version of the given ETag string | ||
| # @param [String] str the ETag to mark as weak | ||
| # @see http://tools.ietf.org/html/rfc2616#section-13.3.3 | ||
| # @see http://tools.ietf.org/html/rfc2616#section-14.19 | ||
| def weak_etag(str) | ||
| WeakETag.new(str) | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,20 @@ | ||
| module Webmachine | ||
| class Resource | ||
| # Contains {Resource} methods related to the visual debugger. | ||
| module Tracing | ||
| # Whether this resource should be traced. By default, tracing is | ||
| # disabled, but you can override it by setting the @trace | ||
| # instance variable in the initialize method, or by overriding | ||
| # this method. When enabled, traces can be visualized using the | ||
| # web debugging interface. | ||
| # @example | ||
| # def initialize | ||
| # @trace = true | ||
| # end | ||
| # @api callback | ||
| def trace? | ||
| !!@trace | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1 @@ | ||
| IO response body |
| @@ -0,0 +1,171 @@ | ||
| require "webmachine/spec/test_resource" | ||
| require "net/http" | ||
|
|
||
| ADDRESS = "127.0.0.1" | ||
|
|
||
| shared_examples_for :adapter_lint do | ||
| attr_reader :client | ||
|
|
||
| class TestApplicationNotResponsive < Timeout::Error; end | ||
|
|
||
| def find_free_port | ||
| temp_server = TCPServer.new(ADDRESS, 0) | ||
| port = temp_server.addr[1] | ||
| temp_server.close # only frees Ruby resource, socket is in TIME_WAIT at OS level | ||
| # so we can't have our adapter use it too quickly | ||
|
|
||
| sleep(0.1) # 'Wait' for temp_server to *really* close, not just TIME_WAIT | ||
| port | ||
| end | ||
|
|
||
| def create_test_application(port) | ||
| Webmachine::Application.new.tap do |application| | ||
| application.dispatcher.add_route ["test"], Test::Resource | ||
|
|
||
| application.configure do |c| | ||
| c.ip = ADDRESS | ||
| c.port = port | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def run_application(adapter_class, application) | ||
| adapter = adapter_class.new(application) | ||
| Thread.abort_on_exception = true | ||
| Thread.new { adapter.run } | ||
| end | ||
|
|
||
| def wait_until_server_responds_to(client) | ||
| Timeout.timeout(5, TestApplicationNotResponsive) do | ||
| begin | ||
| client.start | ||
| rescue Errno::ECONNREFUSED | ||
| sleep(0.01) | ||
| retry | ||
| end | ||
| end | ||
| end | ||
|
|
||
| before(:all) do | ||
| @port = find_free_port | ||
| application = create_test_application(@port) | ||
|
|
||
| adapter_class = described_class | ||
| @server_thread = run_application(adapter_class, application) | ||
|
|
||
| @client = Net::HTTP.new(application.configuration.ip, @port) | ||
| wait_until_server_responds_to(client) | ||
| end | ||
|
|
||
| after(:all) do | ||
| @client.finish | ||
| @server_thread.kill | ||
| end | ||
|
|
||
| it "provides the request URI" do | ||
| request = Net::HTTP::Get.new("/test") | ||
| request["Accept"] = "test/response.request_uri" | ||
| response = client.request(request) | ||
| expect(response.body).to eq("http://#{ADDRESS}:#{@port}/test") | ||
| end | ||
|
|
||
| # context do | ||
| # let(:address) { "::1" } | ||
|
|
||
| # it "provides the IPv6 request URI" do | ||
| # request = Net::HTTP::Get.new("/test") | ||
| # request["Accept"] = "test/response.request_uri" | ||
| # response = client.request(request) | ||
| # expect(response.body).to eq("http://[#{address}]:#{port}/test") | ||
| # end | ||
| # end | ||
|
|
||
| it "provides a string-like request body" do | ||
| request = Net::HTTP::Put.new("/test") | ||
| request.body = "Hello, World!" | ||
| request["Content-Type"] = "test/request.stringbody" | ||
| response = client.request(request) | ||
| expect(response["Content-Length"]).to eq("21") | ||
| expect(response.body).to eq("String: Hello, World!") | ||
| end | ||
|
|
||
| it "provides an enumerable request body" do | ||
| request = Net::HTTP::Put.new("/test") | ||
| request.body = "Hello, World!" | ||
| request["Content-Type"] = "test/request.enumbody" | ||
| response = client.request(request) | ||
| expect(response["Content-Length"]).to eq("19") | ||
| expect(response.body).to eq("Enum: Hello, World!") | ||
| end | ||
|
|
||
| it "handles missing pages" do | ||
| request = Net::HTTP::Get.new("/missing") | ||
| response = client.request(request) | ||
| expect(response.code).to eq("404") | ||
| expect(response["Content-Type"]).to eq("text/html") | ||
| end | ||
|
|
||
| it "handles empty response bodies" do | ||
| request = Net::HTTP::Post.new("/test") | ||
| request.body = "" | ||
| response = client.request(request) | ||
| expect(response.code).to eq("204") | ||
| expect(["0", nil]).to include(response["Content-Length"]) | ||
| expect(response.body).to be_nil | ||
| end | ||
|
|
||
| it "handles string response bodies" do | ||
| request = Net::HTTP::Get.new("/test") | ||
| request["Accept"] = "test/response.stringbody" | ||
| response = client.request(request) | ||
| expect(response["Content-Length"]).to eq("20") | ||
| expect(response.body).to eq("String response body") | ||
| end | ||
|
|
||
| it "handles enumerable response bodies" do | ||
| request = Net::HTTP::Get.new("/test") | ||
| request["Accept"] = "test/response.enumbody" | ||
| response = client.request(request) | ||
| expect(response["Transfer-Encoding"]).to eq("chunked") | ||
| expect(response.body).to eq("Enumerable response body") | ||
| end | ||
|
|
||
| it "handles proc response bodies" do | ||
| request = Net::HTTP::Get.new("/test") | ||
| request["Accept"] = "test/response.procbody" | ||
| response = client.request(request) | ||
| expect(response["Transfer-Encoding"]).to eq("chunked") | ||
| expect(response.body).to eq("Proc response body") | ||
| end | ||
|
|
||
| it "handles fiber response bodies" do | ||
| request = Net::HTTP::Get.new("/test") | ||
| request["Accept"] = "test/response.fiberbody" | ||
| response = client.request(request) | ||
| expect(response["Transfer-Encoding"]).to eq("chunked") | ||
| expect(response.body).to eq("Fiber response body") | ||
| end | ||
|
|
||
| it "handles io response bodies" do | ||
| request = Net::HTTP::Get.new("/test") | ||
| request["Accept"] = "test/response.iobody" | ||
| response = client.request(request) | ||
| expect(response["Content-Length"]).to eq("17") | ||
| expect(response.body).to eq("IO response body\n") | ||
| end | ||
|
|
||
| it "handles request cookies" do | ||
| request = Net::HTTP::Get.new("/test") | ||
| request["Accept"] = "test/response.cookies" | ||
| request["Cookie"] = "echo=echocookie" | ||
| response = client.request(request) | ||
| expect(response.body).to eq("echocookie") | ||
| end | ||
|
|
||
| it "handles response cookies" do | ||
| request = Net::HTTP::Get.new("/test") | ||
| request["Accept"] = "test/response.cookies" | ||
| response = client.request(request) | ||
| expect(response["Set-Cookie"]).to eq("cookie=monster, rodeo=clown") | ||
| end | ||
| end |
| @@ -0,0 +1,84 @@ | ||
| module Test | ||
| class Resource < Webmachine::Resource | ||
| def allowed_methods | ||
| ["GET", "PUT", "POST"] | ||
| end | ||
|
|
||
| def content_types_accepted | ||
| [ | ||
| ["test/request.stringbody", :from_string], | ||
| ["test/request.enumbody", :from_enum] | ||
| ] | ||
| end | ||
|
|
||
| def content_types_provided | ||
| [ | ||
| ["test/response.stringbody", :to_string], | ||
| ["test/response.enumbody", :to_enum], | ||
| ["test/response.procbody", :to_proc], | ||
| ["test/response.fiberbody", :to_fiber], | ||
| ["test/response.iobody", :to_io_body], | ||
| ["test/response.cookies", :to_cookies], | ||
| ["test/response.request_uri", :to_request_uri], | ||
| ["test/response.rack_env", :to_rack_env] | ||
| ] | ||
| end | ||
|
|
||
| def from_string | ||
| response.body = "String: #{request.body.to_s}" | ||
| end | ||
|
|
||
| def from_enum | ||
| response.body = "Enum: " | ||
| request.body.each do |part| | ||
| response.body += part | ||
| end | ||
| end | ||
|
|
||
| # Response intentionally left blank to test 204 support | ||
| def process_post | ||
| true | ||
| end | ||
|
|
||
| def to_string | ||
| "String response body" | ||
| end | ||
|
|
||
| def to_enum | ||
| ["Enumerable ", "response " "body"] | ||
| end | ||
|
|
||
| def to_proc | ||
| Proc.new { "Proc response body" } | ||
| end | ||
|
|
||
| def to_fiber | ||
| Fiber.new do | ||
| Fiber.yield "Fiber " | ||
| Fiber.yield "response " | ||
| "body" | ||
| end | ||
| end | ||
|
|
||
| def to_io_body | ||
| File.new(File.expand_path('../IO_response.body', __FILE__)) | ||
| end | ||
|
|
||
| def to_cookies | ||
| response.set_cookie("cookie", "monster") | ||
| response.set_cookie("rodeo", "clown") | ||
| # FIXME: Mongrel/WEBrick fail if this method returns nil | ||
| # Might be a net/http issue. Is this a bug? | ||
| # @see Flow#o18, Helpers#encode_body_if_set | ||
| request.cookies["echo"] || "" | ||
| end | ||
|
|
||
| def to_request_uri | ||
| request.uri.to_s | ||
| end | ||
|
|
||
| def to_rack_env | ||
| request.env.to_json | ||
| end | ||
| end | ||
| end |
| @@ -1,63 +1,11 @@ | ||
| module Webmachine | ||
| # Namespace for classes that support streaming response bodies. | ||
| module Streaming | ||
| end # module Streaming | ||
| end # module Webmachine | ||
|
|
||
| require 'webmachine/streaming/encoder' | ||
| require 'webmachine/streaming/enumerable_encoder' | ||
| require 'webmachine/streaming/io_encoder' | ||
| require 'webmachine/streaming/callable_encoder' | ||
| require 'webmachine/streaming/fiber_encoder' |
| @@ -0,0 +1,21 @@ | ||
| module Webmachine | ||
| module Streaming | ||
| # Implements a streaming encoder for callable bodies, such as | ||
| # Proc. (essentially futures) | ||
| # @api private | ||
| class CallableEncoder < Encoder | ||
| # Encodes the output of the body Proc. | ||
| # @return [String] | ||
| def call | ||
| resource.send(encoder, resource.send(charsetter, body.call.to_s)) | ||
| end | ||
|
|
||
| # Converts this encoder into a Proc. | ||
| # @return [Proc] a closure that wraps the {#call} method | ||
| # @see #call | ||
| def to_proc | ||
| method(:call).to_proc | ||
| end | ||
| end # class CallableEncoder | ||
| end | ||
| end |
| @@ -0,0 +1,24 @@ | ||
| module Webmachine | ||
| module Streaming | ||
| # Subclasses of this class implement means for streamed/chunked | ||
| # response bodies to be coerced to the negotiated character set and | ||
| # encoded automatically as they are output to the client. | ||
| # @api private | ||
| class Encoder | ||
| attr_accessor :resource, :encoder, :charsetter, :body | ||
|
|
||
| def initialize(resource, encoder, charsetter, body) | ||
| @resource, @encoder, @charsetter, @body = resource, encoder, charsetter, body | ||
| end | ||
|
|
||
| protected | ||
| # @return [true, false] whether the stream will be modified by | ||
| # the encoder and/or charsetter. Only returns true if using the | ||
| # built-in "encode_identity" and "charset_nop" methods. | ||
| def is_unencoded? | ||
| encoder.to_s == "encode_identity" && | ||
| charsetter.to_s == "charset_nop" | ||
| end | ||
| end # class Encoder | ||
| end # module Streaming | ||
| end # module Webmachine |
| @@ -0,0 +1,20 @@ | ||
| module Webmachine | ||
| module Streaming | ||
| # Implements a streaming encoder for Enumerable response bodies, such as | ||
| # Arrays. | ||
| # @api private | ||
| class EnumerableEncoder < Encoder | ||
| include Enumerable | ||
|
|
||
| # Iterates over the body, encoding and yielding individual chunks | ||
| # of the response entity. | ||
| # @yield [chunk] | ||
| # @yieldparam [String] chunk a chunk of the response, encoded | ||
| def each | ||
| body.each do |block| | ||
| yield resource.send(encoder, resource.send(charsetter, block.to_s)) | ||
| end | ||
| end | ||
| end # class EnumerableEncoder | ||
| end # module Streaming | ||
| end # module Webmachine |
| @@ -0,0 +1,21 @@ | ||
| require 'fiber' | ||
|
|
||
| module Webmachine | ||
| module Streaming | ||
| # Implements a streaming encoder for Fibers with the same API as the | ||
| # EnumerableEncoder. This will resume the Fiber until it terminates | ||
| # or returns a falsey value. | ||
| # @api private | ||
| class FiberEncoder < Encoder | ||
| include Enumerable | ||
|
|
||
| # Iterates over the body by yielding to the fiber. | ||
| # @api private | ||
| def each | ||
| while body.alive? && chunk = body.resume | ||
| yield resource.send(encoder, resource.send(charsetter, chunk.to_s)) | ||
| end | ||
| end | ||
| end # class FiberEncoder | ||
| end | ||
| end |
| @@ -0,0 +1,75 @@ | ||
| require 'stringio' | ||
|
|
||
| module Webmachine | ||
| module Streaming | ||
| # Implements a streaming encoder for IO response bodies, such as | ||
| # File objects. | ||
| # @api private | ||
| class IOEncoder < Encoder | ||
| include Enumerable | ||
| CHUNK_SIZE = 8192 | ||
| # Iterates over the IO, encoding and yielding individual chunks | ||
| # of the response entity. | ||
| # @yield [chunk] | ||
| # @yieldparam [String] chunk a chunk of the response, encoded | ||
| def each | ||
| while chunk = body.read(CHUNK_SIZE) and chunk != "" | ||
| yield resource.send(encoder, resource.send(charsetter, chunk)) | ||
| end | ||
| end | ||
|
|
||
| # If IO#copy_stream is supported, and the stream is unencoded, | ||
| # optimize the output by copying directly. Otherwise, defers to | ||
| # using #each. | ||
| # @param [IO] outstream the output stream to copy the body into | ||
| def copy_stream(outstream) | ||
| if can_copy_stream? | ||
| IO.copy_stream(body, outstream) | ||
| else | ||
| each {|chunk| outstream << chunk } | ||
| end | ||
| end | ||
|
|
||
| # Allows the response body to be converted to a IO object. | ||
| # @return [IO,nil] the body as a IO object, or nil. | ||
| def to_io | ||
| IO.try_convert(body) | ||
| end | ||
|
|
||
| # Returns the length of the IO stream, if known. Returns nil if | ||
| # the stream uses an encoder or charsetter that might modify the | ||
| # length of the stream, or the stream size is unknown. | ||
| # @return [Integer] the size, in bytes, of the underlying IO, or | ||
| # nil if unsupported | ||
| def size | ||
| if is_unencoded? | ||
| if is_string_io? | ||
| body.size | ||
| else | ||
| begin | ||
| body.stat.size | ||
| rescue SystemCallError | ||
| # IO objects might raise an Errno if stat is unsupported. | ||
| nil | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def empty? | ||
| size == 0 | ||
| end | ||
|
|
||
| alias bytesize size | ||
|
|
||
| private | ||
| def can_copy_stream? | ||
| IO.respond_to?(:copy_stream) && is_unencoded? && !is_string_io? | ||
| end | ||
|
|
||
| def is_string_io? | ||
| StringIO === body | ||
| end | ||
| end # class IOEncoder | ||
| end # module Streaming | ||
| end # module Webmachine |
| @@ -0,0 +1,93 @@ | ||
| require 'webmachine/events' | ||
| require 'webmachine/trace/resource_proxy' | ||
| require 'webmachine/trace/fsm' | ||
| require 'webmachine/trace/pstore_trace_store' | ||
| require 'webmachine/trace/trace_resource' | ||
| require 'webmachine/trace/listener' | ||
|
|
||
| module Webmachine | ||
| # Contains means to enable the Webmachine visual debugger. | ||
| module Trace | ||
| module_function | ||
| # Classes that implement storage for visual debugger traces. | ||
| TRACE_STORES = { | ||
| :memory => Hash, | ||
| :pstore => PStoreTraceStore | ||
| } | ||
|
|
||
| DEFAULT_TRACE_SUBSCRIBER = Webmachine::Events.subscribe( | ||
| /wm\.trace\..+/, | ||
| Webmachine::Trace::Listener.new | ||
| ) | ||
|
|
||
| # Determines whether this resource instance should be traced. | ||
| # @param [Webmachine::Resource] resource a resource instance | ||
| # @return [true, false] whether to trace the resource | ||
| def trace?(resource) | ||
| # For now this defers to the resource to enable tracing in the | ||
| # initialize method. At a later time, we can add tracing at the | ||
| # Application level, perhaps. | ||
| resource.trace? | ||
| end | ||
|
|
||
| # Records a trace from a processed request. This is automatically | ||
| # called by {Webmachine::Trace::ResourceProxy} when finishing the | ||
| # request. | ||
| # @api private | ||
| # @param [String] key a unique identifier for the request | ||
| # @param [Array] trace the raw trace generated by processing the | ||
| # request | ||
| def record(key, trace) | ||
| trace_store[key] = trace | ||
| end | ||
|
|
||
| # Retrieves keys of traces that have been recorded. This is used | ||
| # to present a list of available traces in the visual debugger. | ||
| # @api private | ||
| # @return [Array<String>] a list of recorded traces | ||
| def traces | ||
| trace_store.keys | ||
| end | ||
|
|
||
| # Fetches a given trace from the trace store. This is used to | ||
| # send specific trace information to the visual debugger. | ||
| # @api private | ||
| # @param [String] key the trace's key | ||
| # @return [Array] the raw trace | ||
| def fetch(key) | ||
| trace_store.fetch(key) | ||
| end | ||
|
|
||
| # Sets the trace storage method. The first parameter should be a | ||
| # Symbol, followed by any additional options for the | ||
| # store. Defaults to :memory, which is an in-memory Hash. | ||
| # @example | ||
| # Webmachine::Trace.trace_store = :pstore, "/tmp/webmachine.trace" | ||
| def trace_store=(*args) | ||
| @trace_store = nil | ||
| @trace_store_opts = args | ||
| end | ||
| self.trace_store = :memory | ||
|
|
||
| def trace_store | ||
| @trace_store ||= begin | ||
| opts = Array(@trace_store_opts).dup | ||
| type = opts.shift | ||
| TRACE_STORES[type].new(*opts) | ||
| end | ||
| end | ||
| private :trace_store | ||
|
|
||
| # Sets the trace listener objects. | ||
| # Defaults to Webmachine::Trace::Listener.new. | ||
| # @param [Array<Object>] listeners a list of event listeners | ||
| # @return [Array<Object>] a list of event subscribers | ||
| def trace_listener=(listeners) | ||
| Webmachine::Events.unsubscribe(DEFAULT_TRACE_SUBSCRIBER) | ||
|
|
||
| Array(listeners).map do |listener| | ||
| Webmachine::Events.subscribe(/wm\.trace\..+/, listener) | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,78 @@ | ||
| module Webmachine | ||
| module Trace | ||
| # This module is injected into {Webmachine::Decision::FSM} when | ||
| # tracing is enabled for a resource, enabling the capturing of | ||
| # traces. | ||
| module FSM | ||
| # Overrides the default resource accessor so that incoming | ||
| # callbacks are traced. | ||
| def initialize(_resource, _request, _response) | ||
| if trace? | ||
| class << self | ||
| def resource | ||
| @resource_proxy ||= ResourceProxy.new(@resource) | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def trace? | ||
| Trace.trace?(@resource) | ||
| end | ||
|
|
||
| # Adds the request to the trace. | ||
| # @param [Webmachine::Request] request the request to be traced | ||
| def trace_request(request) | ||
| response.trace << { | ||
| :type => :request, | ||
| :method => request.method, | ||
| :path => request.uri.request_uri.to_s, | ||
| :headers => request.headers, | ||
| :body => request.body.to_s | ||
| } if trace? | ||
| end | ||
|
|
||
| # Adds the response to the trace and then commits the trace to | ||
| # separate storage which can be discovered by the debugger. | ||
| # @param [Webmachine::Response] response the response to be traced | ||
| def trace_response(response) | ||
| response.trace << { | ||
| :type => :response, | ||
| :code => response.code.to_s, | ||
| :headers => response.headers, | ||
| :body => trace_response_body(response.body) | ||
| } if trace? | ||
| ensure | ||
| Webmachine::Events.publish('wm.trace.record', { | ||
| :trace_id => resource.object_id.to_s, | ||
| :trace => response.trace | ||
| }) if trace? | ||
| end | ||
|
|
||
| # Adds a decision to the trace. | ||
| # @param [Symbol] decision the decision being processed | ||
| def trace_decision(decision) | ||
| response.trace << {:type => :decision, :decision => decision} if trace? | ||
| end | ||
|
|
||
| private | ||
| # Works around streaming encoders where possible | ||
| def trace_response_body(body) | ||
| case body | ||
| when Streaming::FiberEncoder | ||
| # TODO: figure out how to properly rewind or replay the | ||
| # fiber | ||
| body.inspect | ||
| when Streaming::EnumerableEncoder | ||
| body.body.join | ||
| when Streaming::CallableEncoder | ||
| body.body.call.to_s | ||
| when Streaming::IOEncoder | ||
| body.body.inspect | ||
| else | ||
| body.to_s | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,12 @@ | ||
| module Webmachine | ||
| module Trace | ||
| class Listener | ||
| def call(name, payload) | ||
| key = payload.fetch(:trace_id) | ||
| trace = payload.fetch(:trace) | ||
|
|
||
| Webmachine::Trace.record(key, trace) | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,39 @@ | ||
| require 'pstore' | ||
|
|
||
| module Webmachine | ||
| module Trace | ||
| # Implements a trace storage using PStore from Ruby's standard | ||
| # library. To use this trace store, specify the :pstore engine | ||
| # and a path where it can store traces: | ||
| # @example | ||
| # Webmachine::Trace.trace_store = :pstore, "/tmp/webmachine.trace" | ||
| class PStoreTraceStore | ||
| # @api private | ||
| # @param [String] path where to store traces in a PStore | ||
| def initialize(path) | ||
| @pstore = PStore.new(path) | ||
| end | ||
|
|
||
| # Lists the recorded traces | ||
| # @api private | ||
| # @return [Array] a list of recorded traces | ||
| def keys | ||
| @pstore.transaction(true) { @pstore.roots } | ||
| end | ||
|
|
||
| # Fetches a trace from the store | ||
| # @api private | ||
| # @param [String] key the trace to fetch | ||
| # @return [Array] a raw trace | ||
| def fetch(key) | ||
| @pstore.transaction(true) { @pstore[key] } | ||
| end | ||
|
|
||
| # Records a trace in the store | ||
| # @api private | ||
| def []=(key, trace) | ||
| @pstore.transaction { @pstore[key] = trace } | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,107 @@ | ||
| require 'webmachine/resource' | ||
|
|
||
| module Webmachine | ||
| module Trace | ||
| # This class is injected into the decision FSM as a stand-in for | ||
| # the resource when tracing is enabled. It proxies all callbacks | ||
| # to the resource so that they get logged in the trace. | ||
| class ResourceProxy | ||
| # @return [Webmachine::Resource] the wrapped resource | ||
| attr_reader :resource | ||
|
|
||
| # Callback methods that can return data that refers to | ||
| # user-defined callbacks that are not in the canonical set, | ||
| # including body-producing or accepting methods, encoders and | ||
| # charsetters. | ||
| CALLBACK_REFERRERS = [:content_types_accepted, :content_types_provided, | ||
| :encodings_provided, :charsets_provided] | ||
|
|
||
| # Creates a {ResourceProxy} that decorates the passed | ||
| # {Webmachine::Resource} such that callbacks invoked by the | ||
| # {Webmachine::Decision::FSM} will be logged in the response's | ||
| # trace. | ||
| def initialize(resource) | ||
| @resource = resource | ||
| @dynamic_callbacks = Module.new | ||
| extend @dynamic_callbacks | ||
| end | ||
|
|
||
| # Create wrapper methods for every exposed callback | ||
| Webmachine::Resource::Callbacks.instance_methods(false).each do |c| | ||
| define_method c do |*args| | ||
| proxy_callback c, *args | ||
| end | ||
| end | ||
|
|
||
| def charset_nop(*args) | ||
| proxy_callback :charset_nop, *args | ||
| end | ||
|
|
||
| # Calls the resource's finish_request method and then sets the trace id | ||
| # header in the response. | ||
| def finish_request(*args) | ||
| proxy_callback :finish_request, *args | ||
| ensure | ||
| resource.response.headers['X-Webmachine-Trace-Id'] = object_id.to_s | ||
| end | ||
|
|
||
| private | ||
| # Proxy a given callback to the inner resource, decorating with traces | ||
| def proxy_callback(callback, *args) | ||
| # Log inputs and attempt | ||
| resource.response.trace << attempt(callback, args) | ||
| # Do the call | ||
| _result = resource.send(callback, *args) | ||
| add_dynamic_callback_proxies(_result) if CALLBACK_REFERRERS.include?(callback.to_sym) | ||
| resource.response.trace << result(_result) | ||
| _result | ||
| rescue => exc | ||
| exc.backtrace.reject! {|s| s.include?(__FILE__) } | ||
| resource.response.trace << exception(exc) | ||
| raise | ||
| end | ||
|
|
||
| # Creates a log entry for the entry to a resource callback. | ||
| def attempt(callback, args) | ||
| log = {:type => :attempt} | ||
| method = resource.method(callback) | ||
| if method.owner == ::Webmachine::Resource::Callbacks | ||
| log[:name] = "(default)##{method.name}" | ||
| else | ||
| log[:name] = "#{method.owner.name}##{method.name}" | ||
| log[:source] = method.source_location.join(":") if method.respond_to?(:source_location) | ||
| end | ||
| unless args.empty? | ||
| log[:args] = args | ||
| end | ||
| log | ||
| end | ||
|
|
||
| # Creates a log entry for the result of a resource callback | ||
| def result(result) | ||
| {:type => :result, :value => result} | ||
| end | ||
|
|
||
| # Creates a log entry for an exception that was raised from a callback | ||
| def exception(e) | ||
| {:type => :exception, | ||
| :class => e.class.name, | ||
| :backtrace => e.backtrace, | ||
| :message => e.message } | ||
| end | ||
|
|
||
| # Adds proxy methods for callbacks that are dynamically referred to. | ||
| def add_dynamic_callback_proxies(pairs) | ||
| pairs.to_a.each do |(_, m)| | ||
| unless respond_to?(m) | ||
| @dynamic_callbacks.module_eval do | ||
| define_method m do |*args| | ||
| proxy_callback m, *args | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,54 @@ | ||
| <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | ||
| <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> | ||
| <head> | ||
| <title>Webmachine Trace <%= name %></title> | ||
| <link rel="stylesheet" type="text/css" href="static/wmtrace.css" /> | ||
| <script type="text/javascript" src="static/wmtrace.js"></script> | ||
| <script type="text/javascript"> | ||
| <!-- | ||
| var request = <%= treq %>; | ||
| var response = <%= tres %>; | ||
| var trace = <%= trace %> | ||
| --> | ||
| </script> | ||
| </head> | ||
| <body> | ||
| <div id="zoompanel"> | ||
| <button id="zoomout">zoom out</button> | ||
| <button id="zoomin">zoom in</button> | ||
| </div> | ||
| <canvas id="v3map" width="3138" height="2184"></canvas> | ||
| <div id="sizetest"></div> | ||
| <div id="preview"> | ||
| <div id="previewid"></div> | ||
| <ul id="previewcalls"></ul> | ||
| </div> | ||
| <div id="infopanel"> | ||
| <div id="infocontrols"> | ||
| <div id="requesttab" class="selectedtab">Q</div> | ||
| <div id="responsetab">R</div> | ||
| <div id="decisiontab">D</div> | ||
| </div> | ||
| <div id="requestdetail"> | ||
| <div> | ||
| <span id="requestmethod"></span> <span id="requestpath"></span> | ||
| </div> | ||
| <table id="requestheaders" class="headers"></table> | ||
| <pre id="requestbody"></pre> | ||
| </div> | ||
| <div id="responsedetail"> | ||
| <div id="responsecode"></div> | ||
| <table id="responseheaders" class="headers"></table> | ||
| <pre id="responsebody"></pre> | ||
| </div> | ||
| <div id="decisiondetail"> | ||
| <div>Decision: <select id="decisionid"></select></div> | ||
| <div>Calls: <select id="decisioncalls"></select></div> | ||
| <div>Input:</div> | ||
| <pre id="callinput"></pre> | ||
| <div>Output:</div> | ||
| <pre id="calloutput"></pre> | ||
| </div> | ||
| </div> | ||
| </body> | ||
| </html> |
| @@ -0,0 +1,14 @@ | ||
| <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | ||
| <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> | ||
| <head> | ||
| <title>Webmachine Trace List</title> | ||
| </head> | ||
| <body> | ||
| <h1>Traces</h1> | ||
| <ul> | ||
| <% traces.each do |(trace, uri)| %> | ||
| <li><a href="<%= uri %>"><%= trace %></a></li> | ||
| <% end %> | ||
| </ul> | ||
| </body> | ||
| </html> |
| @@ -0,0 +1,123 @@ | ||
| body { | ||
| margin:0px; | ||
| padding:0px; | ||
| } | ||
|
|
||
| canvas#v3map { | ||
| margin-top:2em; | ||
| z-index: 1; | ||
| } | ||
|
|
||
| div#sizetest { | ||
| width:100%; | ||
| } | ||
|
|
||
| div#zoompanel { | ||
| height:2em; | ||
| position:fixed; | ||
| z-index:10; | ||
| } | ||
|
|
||
| div#preview { | ||
| position:absolute; | ||
| display:none; | ||
| background:#dddddd; | ||
| border:1px solid #999999; | ||
| } | ||
|
|
||
| div#preview ul { | ||
| padding: 0px 0px 0px 0.5em; | ||
| margin: 0px; | ||
| list-style: none; | ||
| } | ||
|
|
||
| div#infopanel { | ||
| z-index:20; | ||
| background:#dddddd; | ||
| position:fixed; | ||
| top:0px; | ||
| right:0px; | ||
| bottom:0px; | ||
| left:75%; | ||
| min-width:30em; | ||
| padding:5px; | ||
| } | ||
|
|
||
| div#infocontrols { | ||
| position:absolute; | ||
| top:0px; | ||
| bottom:0px; | ||
| left:-5px; | ||
| width:5px; | ||
| background:#999999; | ||
| cursor:ew-resize; | ||
| } | ||
|
|
||
| div#infocontrols div { | ||
| position:absolute; | ||
| left:-15px; | ||
| width:20px; | ||
| height:49px; | ||
| background:#999999; | ||
| cursor:pointer; | ||
| } | ||
|
|
||
| div#infocontrols div.selectedtab { | ||
| background:#dddddd; | ||
| border-top: 1px solid #999999; | ||
| border-left: 1px solid #999999; | ||
| border-bottom: 1px solid #999999; | ||
| } | ||
|
|
||
| div#requesttab { | ||
| top:2px; | ||
| } | ||
|
|
||
| div#responsetab { | ||
| top:54px; | ||
| } | ||
|
|
||
| div#decisiontab { | ||
| top:106px; | ||
| } | ||
|
|
||
| div#requestdetail, div#responsedetail, div#decisiondetail { | ||
| height:100%; | ||
| } | ||
|
|
||
| div#responsedetail, div#decisiondetail { | ||
| display:none; | ||
| } | ||
|
|
||
| div#infopanel ul { | ||
| list-style:none; | ||
| padding-left:0px; | ||
| height:5em; | ||
| overflow-y:scroll; | ||
| } | ||
|
|
||
| pre { | ||
| height:40%; | ||
| overflow: auto; | ||
| } | ||
|
|
||
| table.headers { | ||
| border-collapse: collapse; | ||
| margin: 10px 20px 10px 0; | ||
| } | ||
|
|
||
| table.headers th { | ||
| text-align: right ! important; | ||
| text-transform: capitalize; | ||
| white-space: nowrap; | ||
| vertical-align: top; | ||
| } | ||
|
|
||
| table.headers td, table.headers th { | ||
| padding: 5px 3px; | ||
| } | ||
|
|
||
| div#responsebody, div#requestbody { | ||
| height:70%; | ||
| overflow-y:scroll; | ||
| } |