Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/nack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
require_relative "nack/mime"
require_relative "nack/query_parser"
require_relative "nack/request"
require_relative "nack/response"
require_relative "nack/spec"
require_relative "nack/utils"
114 changes: 114 additions & 0 deletions lib/nack/response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# frozen_string_literal: true

module Nack
class Response
COOKIE_NAME = /\A[0-9A-Za-z!#$%&'*+\-.^_`|~]+\z/
COOKIE_VALUE = /\A("?)[!#-+\--:<-\[\]-~]*\1\z/
IMF_FIXDATE = "%a, %d %b %Y %H:%M:%S GMT"
COOKIE_DOMAIN_LABEL = /[0-9A-Za-z](?:[0-9A-Za-z-]{0,61}[0-9A-Za-z])?/
COOKIE_DOMAIN = /\A#{COOKIE_DOMAIN_LABEL}(?:\.#{COOKIE_DOMAIN_LABEL})*\z/
COOKIE_PATH = /\A[ -:<-~]*\z/
COOKIE_SAME_SITE = /\A(?:strict|lax|none)\z/i

class BodyWithTrailers
attr_reader :trailers

def initialize(body, trailers)
@body = body
@trailers = trailers
end

def each
yield @body
end
end
private_constant :BodyWithTrailers

attr_accessor :status
attr_reader :headers, :body

def initialize(status = 200, headers = [].freeze, body = nil)
@status = status
@headers = Headers.new(headers)
@body = body ? body.b : "".b
end

def content_type
headers["content-type"]
end

def content_type=(content_type)
content_type ? headers["content-type"] = content_type : headers.delete("content-type")
end

def content_length
content_length = headers["content-length"]
content_length.to_i if Spec.valid_content_length?(content_length)
end

def content_length=(content_length)
if content_length.nil?
headers.delete("content-length")
elsif content_length.is_a?(Integer) && content_length >= 0
headers["content-length"] = content_length.to_s
else
raise SpecError, "invalid content length: #{content_length.inspect}"
end
end

def location
headers["location"]
end

def location=(location)
location ? headers["location"] = location : headers.delete("location")
end

def redirect(location, status = 302)
self.location = location
self.status = status
end

def set_cookie(name, value, expires: nil, max_age: nil, domain: nil, path: nil, secure: false, http_only: false, same_site: nil)
expires = expires&.getutc
raise ArgumentError, "invalid cookie name: #{name.inspect}" unless COOKIE_NAME.match?(name)
raise ArgumentError, "invalid cookie value: #{value.inspect}" unless COOKIE_VALUE.match?(value)
raise ArgumentError, "invalid cookie expires: #{expires.inspect}" unless expires.nil? || (1900..9999).cover?(expires.year)
raise ArgumentError, "invalid cookie max age: #{max_age.inspect}" if max_age && !(max_age.is_a?(Integer) && max_age.positive?)
raise ArgumentError, "invalid cookie domain: #{domain.inspect}" unless domain.nil? || COOKIE_DOMAIN.match?(domain)
raise ArgumentError, "invalid cookie path: #{path.inspect}" unless path.nil? || COOKIE_PATH.match?(path)
raise ArgumentError, "invalid cookie same site: #{same_site.inspect}" unless same_site.nil? || COOKIE_SAME_SITE.match?(same_site)
cookie = "#{name}=#{value}#{
"; expires=#{expires.strftime(IMF_FIXDATE)}" if expires}#{
"; max-age=#{max_age}" if max_age}#{
"; domain=#{domain}" if domain}#{
"; path=#{path}" if path}#{
"; secure" if secure}#{
"; httponly" if http_only}#{
"; samesite=#{same_site}" if same_site}"
# rfc6265bis section 4.1.1 forbids two set-cookie fields of one name in a response.
headers.delete_if do |field_name, field_value|
field_name == "set-cookie" && field_value.start_with?(name) && field_value.match?(/\G[ \t]*=/, name.size)
end
headers.add("set-cookie", cookie)
end

def delete_cookie(name, **attributes)
set_cookie(name, "", **attributes, expires: Time.at(0), max_age: nil)
end

def write(chunk)
@body.append_as_bytes(chunk)
end

def trailers
@trailers ||= Headers.new
end

def finish
return [status, headers.to_a, ""] if status == 204 || status == 205 || status == 304
trailer_fields = @trailers&.to_a
[status, headers.to_a, (trailer_fields.nil? || trailer_fields.empty?) ? body : BodyWithTrailers.new(body, trailer_fields)]
end
end
end
112 changes: 112 additions & 0 deletions test/spec_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# frozen_string_literal: true

require_relative "helper"

describe Nack::Response do
it "sets and deletes the content type, content length and location" do
res = Nack::Response.new
res.content_type = "a"
res.content_length = 1
res.location = "b"
[res.content_type, res.content_length, res.location].must_equal ["a", 1, "b"]
res.content_type = nil
res.content_length = nil
res.location = nil
res.headers.to_a.must_equal []
end

it "reads no content length from a malformed field" do
Nack::Response.new(200, [["content-length", "-1"]]).content_length.must_be_nil
end

it "rejects a content length other than a non-negative Integer" do
res = Nack::Response.new
-> { res.content_length = -1 }.must_raise Nack::SpecError
-> { res.content_length = "1" }.must_raise Nack::SpecError
res.content_length = 0
res.headers.to_a.must_equal [["content-length", "0"]]
end

it "redirects" do
res = Nack::Response.new
res.redirect("a")
[res.status, res.location].must_equal [302, "a"]
res.redirect("b", 301)
[res.status, res.location].must_equal [301, "b"]
end

it "rejects a cookie outside the rfc6265bis grammar" do
res = Nack::Response.new
-> { res.set_cookie("", "1") }.must_raise ArgumentError
-> { res.set_cookie("a=a", "1") }.must_raise ArgumentError
-> { res.set_cookie("a", "1;") }.must_raise ArgumentError
-> { res.set_cookie("a", "\"1") }.must_raise ArgumentError
-> { res.set_cookie("a", "1", expires: Time.new(1900, 1, 1, 8, 59, 59, "+09:00")) }.must_raise ArgumentError
-> { res.set_cookie("a", "1", expires: Time.utc(10000)) }.must_raise ArgumentError
-> { res.set_cookie("a", "1", max_age: 0) }.must_raise ArgumentError
-> { res.set_cookie("a", "1", max_age: "1") }.must_raise ArgumentError
-> { res.set_cookie("a", "1", domain: ".b") }.must_raise ArgumentError
-> { res.set_cookie("a", "1", domain: "-b") }.must_raise ArgumentError
-> { res.set_cookie("a", "1", domain: "b-") }.must_raise ArgumentError
-> { res.set_cookie("a", "1", domain: "b" * 64) }.must_raise ArgumentError
-> { res.set_cookie("a", "1", path: ";") }.must_raise ArgumentError
-> { res.set_cookie("a", "1", path: "é") }.must_raise ArgumentError
-> { res.set_cookie("a", "1", same_site: "LaxLax") }.must_raise ArgumentError
res.headers.to_a.must_equal []
end

it "sets cookies" do
res = Nack::Response.new
res.set_cookie("a", "\"1\"")
res.set_cookie("b", "", expires: Time.new(1900, 1, 1, 9, 0, 0, "+09:00"), max_age: 1, domain: "0-a.b", path: "a b", secure: true, http_only: true, same_site: "Lax")
res.set_cookie("c", "", expires: Time.utc(9999), domain: "b" * 63)
res.headers.get_all("set-cookie").must_equal [
"a=\"1\"",
"b=; expires=Mon, 01 Jan 1900 00:00:00 GMT; max-age=1; domain=0-a.b; path=a b; secure; httponly; samesite=Lax",
"c=; expires=Fri, 01 Jan 9999 00:00:00 GMT; domain=#{"b" * 63}"
]
end

it "replaces a cookie of the same name, keeping the other fields in order" do
res = Nack::Response.new(200, [["set-cookie", "a=1"], ["set-cookie", "a =2"], ["set-cookie", "a\t=3"], ["set-cookie", "ab=4"], ["set-cookie", "a b=5"], ["b", "a=6"]])
res.set_cookie("a", "7")
res.headers.to_a.must_equal [["set-cookie", "ab=4"], ["set-cookie", "a b=5"], ["b", "a=6"], ["set-cookie", "a=7"]]
end

it "deletes a cookie with an expiry in the past" do
res = Nack::Response.new
res.delete_cookie("a", max_age: 1, path: "b")
res.headers.to_a.must_equal [["set-cookie", "a=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=b"]]
end

it "finishes into a status, fields and the written body" do
res = Nack::Response.new(201, [["a", "1"]], "b")
res.write("\xFF".b)
res.write("é")
res.finish.must_equal [201, [["a", "1"]], "b\xFF\xC3\xA9".b]
Nack::Response.new.finish.must_equal [200, [], ""]
end

it "drops the body and trailers of a 204, a 205 and a 304 but not of a 101" do
res = Nack::Response.new(204, [], "a")
res.trailers.add("b", "1")
res.finish.must_equal [204, [], ""]
Nack::Response.new(205, [], "a").finish.must_equal [205, [], ""]
Nack::Response.new(304, [], "a").finish.must_equal [304, [], ""]
Nack::Response.new(101, [], "a").finish.must_equal [101, [], "a"]
end

it "finishes into a String body when no trailer is added" do
res = Nack::Response.new(200, [], "a")
res.trailers
res.finish.must_equal [200, [], "a"]
end

it "carries trailers on a body responding to #each" do
res = Nack::Response.new(200, [], "a")
res.trailers.add("b", "1")
_, _, body = res.finish
body.to_enum(:each).to_a.must_equal ["a"]
body.trailers.must_equal [["b", "1"]]
end
end
Loading