require "../headers" require "../status" require "../cookie" class HTTP::Server # The response to configure and write to in an `HTTP::Server` handler. # # The response `status` and `headers` must be configured before writing # the response body. Once response output is written, changing the `status` # and `headers` properties has no effect. # # The `HTTP::Server::Response` is a write-only `IO`, so all `IO` methods are available # in it. # # A response can be upgraded with the `upgrade` method. Once invoked, headers # are written and the connection `IO` (a socket) is yielded to the given block. # This is useful to implement protocol upgrades, such as websockets. class Response < IO # The response headers (`HTTP::Headers`). These must be set before writing to the response. getter headers : HTTP::Headers # The version of the HTTP::Request that created this response. getter version : String # The `IO` to which output is written. This can be changed/wrapped to filter # the response body (for example to compress the output). property output : IO # :nodoc: def version=(version : String) : String check_headers @version = version end # The status code of this response, which must be set before writing the response # body. If not set, the default value is 200 (OK). getter status : HTTP::Status def status=(status : HTTP::Status) : HTTP::Status check_headers @status = status end # :nodoc: property upgrade_handler : (IO ->)? @cookies : HTTP::Cookies? # :nodoc: def initialize(@io : IO, @version = "HTTP/1.1") @headers = Headers.new @status = :ok @wrote_headers = false @output = output = @original_output = Output.new(@io) output.response = self end # :nodoc: def reset : Nil # This method is called by RequestProcessor to avoid allocating a new instance for each iteration. @headers.clear @cookies = nil @status = :ok @status_message = nil @wrote_headers = false @output = @original_output @original_output.reset end # Convenience method to set the `Content-Type` header. def content_type=(content_type : String) : String check_headers headers["Content-Type"] = content_type end # Convenience method to get `Content-Type` header. def content_type : String? headers["Content-Type"]? end # Convenience method to set the `Content-Length` header. def content_length=(content_length : Int) : String check_headers headers["Content-Length"] = content_length.to_s end # Convenience method to get the `Content-Length` header. def content_length : Int64? headers["Content-Length"]?.try(&.to_i64) end # Convenience method to retrieve the HTTP status code. def status_code : Int32 status.code end # Convenience method to set the HTTP status code. def status_code=(status_code : Int32) : Int32 self.status = HTTP::Status.new(status_code) status_code end # See `IO#write(slice)`. def write(slice : Bytes) : Nil return if slice.empty? @output.write(slice) end # Convenience method to set cookies, see `HTTP::Cookies`. def cookies : HTTP::Cookies @cookies ||= HTTP::Cookies.new end # :nodoc: def read(slice : Bytes) : NoReturn raise "Can't read from HTTP::Server::Response" end # Upgrades this response, writing headers and yielding the connection `IO` (a socket) to the given block. # This is useful to implement protocol upgrades, such as websockets. def upgrade(&block : IO ->) : Nil write_headers @upgrade_handler = block end # Flushes the output. This method must be implemented if wrapping the response output. def flush : Nil @output.flush end # Closes this response, writing headers and body if not done yet. # This method must be implemented if wrapping the response output. def close : Nil return if closed? @output.close end # Returns `true` if this response has been closed. def closed? : Bool @output.closed? end # Sets the status message. def status_message=(status_message : String?) : String check_headers @status_message = status_message end # Returns the status message. # # Defaults to description of `#status`. def status_message : String? @status_message || @status.description end # Sends *status* and *message* as response. # # This method calls `#reset` to remove any previous settings and writes the # given *status* and *message* to the response IO. Finally, it closes the # response. # # If *message* is `nil`, the default message for *status* is used provided # by `HTTP::Status#description`. # # Raises `IO::Error` if the response is closed or headers were already # sent. def respond_with_status(status : HTTP::Status, message : String? = nil) : Nil check_headers reset @status = status @status_message = message ||= @status.description self.content_type = "text/plain" self << @status.code << ' ' << message << '\n' close end # :ditto: def respond_with_status(status : Int, message : String? = nil) : Nil respond_with_status(HTTP::Status.new(status), message) end # Sends a redirect to *location*. # # The value of *location* gets encoded with `URI.encode`. # # The *status* determines the HTTP status code which can be # `HTTP::Status::FOUND` (`302`) for a temporary redirect or # `HTTP::Status::MOVED_PERMANENTLY` (`301`) for a permanent redirect. # # The response gets closed. # # Raises `IO::Error` if the response is closed or headers were already # sent. def redirect(location : String | URI, status : HTTP::Status = :found) : Nil check_headers self.status = status headers["Location"] = if location.is_a? URI location.to_s else String.build do |io| URI.encode(location.to_s, io) do |byte| URI.reserved?(byte) || URI.unreserved?(byte) end end end close end private def check_headers raise IO::Error.new "Closed stream" if @original_output.closed? if wrote_headers? raise IO::Error.new("Headers already sent") end end protected def write_headers @io << @version << ' ' << @status.code << ' ' << status_message << "\r\n" headers.each do |name, values| values.each do |value| @io << name << ": " << value << "\r\n" end end @io << "\r\n" @wrote_headers = true end protected def wrote_headers? @wrote_headers end protected def has_cookies? !@cookies.nil? end # :nodoc: class Output < IO include IO::Buffered property! response : Response @chunked : Bool @io : IO def initialize(@io) @chunked = false @closed = false end def reset : Nil @in_buffer_rem = Bytes.empty @out_count = 0 @sync = false @flush_on_newline = false @chunked = false @closed = false end private def unbuffered_read(slice : Bytes) : Int32 raise "Can't read from HTTP::Server::Response" end private def unbuffered_write(slice : Bytes) : Nil return if slice.empty? if response.headers["Transfer-Encoding"]? == "chunked" @chunked = true elsif !response.wrote_headers? if response.version != "HTTP/1.0" && !response.headers.has_key?("Content-Length") response.headers["Transfer-Encoding"] = "chunked" @chunked = true end end ensure_headers_written if @chunked slice.size.to_s(@io, 16) @io << "\r\n" @io.write(slice) @io << "\r\n" else @io.write(slice) end rescue ex : IO::Error unbuffered_close raise ClientError.new("Error while writing data to the client", ex) end def closed? : Bool @closed end def close : Nil return if closed? # Conditionally determine based on status if the `content-length` header should be added automatically. # See https://tools.ietf.org/html/rfc7230#section-3.3.2. status = response.status set_content_length = !(status.not_modified? || status.no_content? || status.informational?) if !response.wrote_headers? && !response.headers.has_key?("Transfer-Encoding") && !response.headers.has_key?("Content-Length") && set_content_length response.content_length = @out_count end ensure_headers_written super if @chunked @io << "0\r\n\r\n" @io.flush end end private def ensure_headers_written unless response.wrote_headers? if response.has_cookies? response.cookies.add_response_headers(response.headers) end response.write_headers end end private def unbuffered_close : Nil @closed = true end private def unbuffered_rewind : Nil raise "Can't rewind to HTTP::Server::Response" end private def unbuffered_flush : Nil @io.flush rescue ex : IO::Error unbuffered_close raise ClientError.new("Error while flushing data to the client", ex) end end end class ClientError < Exception end end