Skip to content

Commit

Permalink
Add custom time format implementations (#5123)
Browse files Browse the repository at this point in the history
* Add custom format implementations for ISO 8601, RFC 3339, RFC 2822
* Add formatter and parser class methods to `Time` for easier access
* Add custom formatter for HTTP date format
  • Loading branch information
straight-shoota authored and bcardiff committed Jun 10, 2018
1 parent 44ff90e commit 36647c8
Show file tree
Hide file tree
Showing 24 changed files with 1,124 additions and 302 deletions.
6 changes: 3 additions & 3 deletions spec/std/http/http_spec.cr
Expand Up @@ -37,15 +37,15 @@ describe HTTP do
parsed_time.to_utc.to_s.should eq("2011-09-10 02:36:00 UTC")
end

describe "generates RFC 1123" do
describe "generates HTTP date" do
it "without time zone" do
time = Time.utc(1994, 11, 6, 8, 49, 37, nanosecond: 0)
HTTP.rfc1123_date(time).should eq("Sun, 06 Nov 1994 08:49:37 GMT")
HTTP.format_time(time).should eq("Sun, 06 Nov 1994 08:49:37 GMT")
end

it "with local time zone" do
time = Time.new(1994, 11, 6, 8, 49, 37, nanosecond: 0, location: Time::Location.load("Europe/Berlin"))
HTTP.rfc1123_date(time).should eq(time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT"))
HTTP.format_time(time).should eq(time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT"))
end
end

Expand Down
12 changes: 6 additions & 6 deletions spec/std/http/server/handlers/static_file_handler_spec.cr
Expand Up @@ -24,26 +24,26 @@ describe HTTP::StaticFileHandler do
context "with header If-Modified-Since" do
it "should return 304 Not Modified if file mtime is equal" do
headers = HTTP::Headers.new
headers["If-Modified-Since"] = HTTP.rfc1123_date(File.info("#{__DIR__}/static/test.txt").modification_time)
headers["If-Modified-Since"] = HTTP.format_time(File.info("#{__DIR__}/static/test.txt").modification_time)
response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: true
response.status_code.should eq(304)
response.headers["Last-Modified"].should eq(HTTP.rfc1123_date(File.info("#{__DIR__}/static/test.txt").modification_time))
response.headers["Last-Modified"].should eq(HTTP.format_time(File.info("#{__DIR__}/static/test.txt").modification_time))
end

it "should return 304 Not Modified if file mtime is older" do
headers = HTTP::Headers.new
headers["If-Modified-Since"] = HTTP.rfc1123_date(File.info("#{__DIR__}/static/test.txt").modification_time + 1.hour)
headers["If-Modified-Since"] = HTTP.format_time(File.info("#{__DIR__}/static/test.txt").modification_time + 1.hour)
response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: true
response.status_code.should eq(304)
response.headers["Last-Modified"].should eq(HTTP.rfc1123_date(File.info("#{__DIR__}/static/test.txt").modification_time))
response.headers["Last-Modified"].should eq(HTTP.format_time(File.info("#{__DIR__}/static/test.txt").modification_time))
end

it "should serve file if file mtime is younger" do
headers = HTTP::Headers.new
headers["If-Modified-Since"] = HTTP.rfc1123_date(File.info("#{__DIR__}/static/test.txt").modification_time - 1.hour)
headers["If-Modified-Since"] = HTTP.format_time(File.info("#{__DIR__}/static/test.txt").modification_time - 1.hour)
response = handle HTTP::Request.new("GET", "/test.txt")
response.status_code.should eq(200)
response.headers["Last-Modified"].should eq(HTTP.rfc1123_date(File.info("#{__DIR__}/static/test.txt").modification_time))
response.headers["Last-Modified"].should eq(HTTP.format_time(File.info("#{__DIR__}/static/test.txt").modification_time))
response.body.should eq(File.read("#{__DIR__}/static/test.txt"))
end
end
Expand Down
12 changes: 10 additions & 2 deletions spec/std/json/serialization_spec.cr
Expand Up @@ -169,7 +169,9 @@ describe "JSON serialization" do
end

it "deserializes Time" do
Time.from_json(%("2016-11-16T09:55:48-03:00")).to_utc.should eq(Time.utc(2016, 11, 16, 12, 55, 48))
Time.from_json(%("2016-11-16T09:55:48-0300")).to_utc.should eq(Time.utc(2016, 11, 16, 12, 55, 48))
Time.from_json(%("20161116T095548-03:00")).to_utc.should eq(Time.utc(2016, 11, 16, 12, 55, 48))
end

describe "parse exceptions" do
Expand Down Expand Up @@ -361,8 +363,14 @@ describe "JSON serialization" do
{"foo" => {"bar" => 1}}.to_pretty_json(indent: " ").should eq(%({\n "foo": {\n "bar": 1\n }\n}))
end

it "does for time" do
Time.utc(2016, 11, 16, 12, 55, 48).to_json.should eq(%("2016-11-16T12:55:48+0000"))
describe "Time" do
it "#to_json" do
Time.utc(2016, 11, 16, 12, 55, 48).to_json.should eq(%("2016-11-16T12:55:48Z"))
end

it "omit sub-second precision" do
Time.utc(2016, 11, 16, 12, 55, 48, nanosecond: 123456789).to_json.should eq(%("2016-11-16T12:55:48Z"))
end
end
end
end
115 changes: 115 additions & 0 deletions spec/std/time/custom_formats_spec.cr
@@ -0,0 +1,115 @@
require "spec"

describe "Time::Format" do
describe "RFC_3339" do
it "parses regular format" do
time = Time.utc(2016, 2, 15)
Time::Format::RFC_3339.format(time).should eq "2016-02-15T00:00:00Z"
Time::Format::RFC_3339.parse("2016-02-15T00:00:00+00:00").should eq time
Time::Format::RFC_3339.parse("2016-02-15t00:00:00+00:00").should eq time
Time::Format::RFC_3339.parse("2016-02-15 00:00:00+00:00").should eq time
Time::Format::RFC_3339.parse("2016-02-15T00:00:00Z").should eq time
Time::Format::RFC_3339.parse("2016-02-15T00:00:00.0000000+00:00").should eq time
end
end

describe "RFC_2822" do
it "parses regular format" do
time = Time.utc(2016, 2, 15)
Time::Format::RFC_2822.format(time).should eq "Mon, 15 Feb 2016 00:00:00 +0000"
Time::Format::RFC_2822.parse("Mon, 15 Feb 2016 00:00:00 +0000").should eq time
Time::Format::RFC_2822.parse("Mon, 15 Feb 16 00:00 UT").should eq time
Time::Format::RFC_2822.parse(" Mon , 14 Feb 2016 20 : 00 : 00 EDT (comment)").to_utc.should eq time
end
end

describe "ISO_8601_DATE" do
it "formats default format" do
time = Time.utc(1985, 4, 12)
Time::Format::ISO_8601_DATE.format(time).should eq "1985-04-12"
end

it "parses calendar date" do
time = Time.utc(1985, 4, 12)
Time::Format::ISO_8601_DATE.parse("1985-04-12").should eq(time)
Time::Format::ISO_8601_DATE.parse("19850412").should eq(time)
end

it "parses ordinal date" do
time = Time.utc(1985, 4, 12)
Time::Format::ISO_8601_DATE.parse("1985-102").should eq(time)
Time::Format::ISO_8601_DATE.parse("1985102").should eq(time)
end

it "parses week date" do
time = Time.utc(1985, 4, 12)
Time::Format::ISO_8601_DATE.parse("1985-W15-5").should eq(time)
Time::Format::ISO_8601_DATE.parse("1985W155").should eq(time)

Time::Format::ISO_8601_DATE.parse("2004-W53-6").should eq(Time.utc(2005, 1, 1))
Time::Format::ISO_8601_DATE.parse("2004-W53-7").should eq(Time.utc(2005, 1, 2))
Time::Format::ISO_8601_DATE.parse("2005-W52-6").should eq(Time.utc(2005, 12, 31))
Time::Format::ISO_8601_DATE.parse("2005-W52-7").should eq(Time.utc(2006, 1, 1))
Time::Format::ISO_8601_DATE.parse("2006-W01-1").should eq(Time.utc(2006, 1, 2))
Time::Format::ISO_8601_DATE.parse("2006-W52-7").should eq(Time.utc(2006, 12, 31))
Time::Format::ISO_8601_DATE.parse("2007-W01-1").should eq(Time.utc(2007, 1, 1))
Time::Format::ISO_8601_DATE.parse("2007-W52-7").should eq(Time.utc(2007, 12, 30))
Time::Format::ISO_8601_DATE.parse("2008-W01-1").should eq(Time.utc(2007, 12, 31))
Time::Format::ISO_8601_DATE.parse("2008-W01-2").should eq(Time.utc(2008, 1, 1))
Time::Format::ISO_8601_DATE.parse("2008-W52-7").should eq(Time.utc(2008, 12, 28))
Time::Format::ISO_8601_DATE.parse("2009-W01-1").should eq(Time.utc(2008, 12, 29))
Time::Format::ISO_8601_DATE.parse("2009-W01-2").should eq(Time.utc(2008, 12, 30))
Time::Format::ISO_8601_DATE.parse("2009-W01-3").should eq(Time.utc(2008, 12, 31))
Time::Format::ISO_8601_DATE.parse("2009-W01-4").should eq(Time.utc(2009, 1, 1))
Time::Format::ISO_8601_DATE.parse("2009-W53-4").should eq(Time.utc(2009, 12, 31))
Time::Format::ISO_8601_DATE.parse("2009-W53-5").should eq(Time.utc(2010, 1, 1))
Time::Format::ISO_8601_DATE.parse("2009-W53-6").should eq(Time.utc(2010, 1, 2))
Time::Format::ISO_8601_DATE.parse("2009-W53-7").should eq(Time.utc(2010, 1, 3))
end
end

describe "ISO_8601_DATE_TIME" do
it "formats default format" do
time = Time.utc(1985, 4, 12, 23, 20, 50)
Time::Format::ISO_8601_DATE_TIME.format(time).should eq "1985-04-12T23:20:50Z"
end

it "parses calendar date" do
time = Time.utc(1985, 4, 12, 23, 20, 50)
Time::Format::ISO_8601_DATE_TIME.parse("1985-04-12T23:20:50Z").should eq(time)
Time::Format::ISO_8601_DATE_TIME.parse("19850412T232050Z").should eq(time)
end

it "parses ordinal date" do
time = Time.utc(1985, 4, 12, 23, 20, 50)
Time::Format::ISO_8601_DATE_TIME.parse("1985-102T23:20:50Z").should eq(time)
Time::Format::ISO_8601_DATE_TIME.parse("1985102T232050Z").should eq(time)
end

it "parses hour:minutes" do
time = Time.utc(1985, 4, 12, 23, 20)
Time::Format::ISO_8601_DATE_TIME.parse("1985-102T23:20Z").should eq(time)
Time::Format::ISO_8601_DATE_TIME.parse("1985102T2320Z").should eq(time)
end

it "parses decimal fractions" do
time = Time.utc(1985, 4, 12, 23, 30)
Time::Format::ISO_8601_DATE_TIME.parse("1985-4-12T23.5Z").should eq(time)
Time::Format::ISO_8601_DATE_TIME.parse("1985-4-12T23.5Z").should eq(time)
Time::Format::ISO_8601_DATE_TIME.parse("1985-4-12T23.50000000000Z").should eq(time)
Time::Format::ISO_8601_DATE_TIME.parse("1985-4-12T23.50000000000Z").should eq(time)
end

it "parses hour" do
time = Time.utc(1985, 4, 12, 23)
Time::Format::ISO_8601_DATE_TIME.parse("1985-102T23Z").should eq(time)
Time::Format::ISO_8601_DATE_TIME.parse("1985102T23Z").should eq(time)
end

it "week date" do
time = Time.utc(1985, 4, 12, 23, 20, 50)
Time::Format::ISO_8601_DATE_TIME.parse("1985-W15-5T23:20:50Z").should eq(time)
Time::Format::ISO_8601_DATE_TIME.parse("1985W155T23:20:50Z").should eq(time)
end
end
end
13 changes: 13 additions & 0 deletions spec/std/time/time_spec.cr
Expand Up @@ -514,6 +514,14 @@ describe Time do
end
end

it "formats standard formats" do
time = Time.utc(2016, 2, 15)
time.to_rfc3339.should eq "2016-02-15T00:00:00Z"
Time.parse_rfc3339(time.to_rfc3339).should eq time
time.to_rfc2822.should eq "Mon, 15 Feb 2016 00:00:00 +0000"
Time.parse_rfc2822(time.to_rfc2822).should eq time
end

it "parses empty" do
t = Time.parse("", "", Time::Location.local)
t.year.should eq(1)
Expand Down Expand Up @@ -631,6 +639,11 @@ describe Time do
time.offset.should eq 4 * 3600 + 12 * 60 + 39
time.utc?.should be_false
time.location.fixed?.should be_true

time = Time.parse("-04:12:39", "%::z")
time.offset.should eq -1 * (4 * 3600 + 12 * 60 + 39)
time.utc?.should be_false
time.location.fixed?.should be_true
end

# TODO %Z
Expand Down
2 changes: 1 addition & 1 deletion spec/std/yaml/serialization_spec.cr
Expand Up @@ -306,7 +306,7 @@ describe "YAML serialization" do

it "does for utc time with nanoseconds" do
time = Time.utc(2010, 11, 12, 1, 2, 3, nanosecond: 456_000_000)
time.to_yaml.should eq("--- 2010-11-12 01:02:03.456\n...\n")
time.to_yaml.should eq("--- 2010-11-12 01:02:03.456000000\n...\n")
end

it "does for bytes" do
Expand Down
36 changes: 21 additions & 15 deletions src/http/common.cr
Expand Up @@ -4,8 +4,6 @@
{% end %}

module HTTP
private DATE_PATTERNS = {"%a, %d %b %Y %H:%M:%S %z", "%d %b %Y %H:%M:%S %z", "%A, %d-%b-%y %H:%M:%S %z", "%a %b %e %H:%M:%S %Y"}

# :nodoc:
MAX_HEADER_SIZE = 16_384

Expand Down Expand Up @@ -227,25 +225,33 @@ module HTTP
ComputedContentTypeHeader.new(content_type.strip, nil)
end

# Parse a time string using the formats specified by [RFC 2616](https://tools.ietf.org/html/rfc2616#section-3.3.1)
#
# ```
# HTTP.parse_time("Sun, 14 Feb 2016 21:00:00 GMT") # => "2016-02-14 21:00:00 UTC"
# HTTP.parse_time("Sunday, 14-Feb-16 21:00:00 GMT") # => "2016-02-14 21:00:00 UTC"
# HTTP.parse_time("Sun Feb 14 21:00:00 2016") # => "2016-02-14 21:00:00 UTC"
# ```
#
# Uses `Time::Format::HTTP_DATE` as parser.
def self.parse_time(time_str : String) : Time?
DATE_PATTERNS.each do |pattern|
begin
return Time.parse(time_str, pattern, location: Time::Location::UTC)
rescue Time::Format::Error
end
end

nil
Time::Format::HTTP_DATE.parse(time_str)
rescue Time::Format::Error
end

# Format a Time object as a String using the format specified by [RFC 1123](https://tools.ietf.org/html/rfc1123#page-55).
# Format a `Time` object as a `String` using the format specified as `sane-cookie-date`
# by [RFC 6265](https://tools.ietf.org/html/rfc6265#section-4.1.1) which is
# according to [RFC 2616](https://tools.ietf.org/html/rfc2616#section-3.3.1) a
# [RFC 1123](https://tools.ietf.org/html/rfc1123#page-55) format with explicit
# timezone `GMT` (interpreted as `UTC`).
#
# ```
# HTTP.rfc1123_date(Time.new(2016, 2, 15)) # => "Sun, 14 Feb 2016 21:00:00 GMT"
# HTTP.format_time(Time.new(2016, 2, 15)) # => "Sun, 14 Feb 2016 21:00:00 GMT"
# ```
def self.rfc1123_date(time : Time) : String
# TODO: GMT should come from the Time classes instead
time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT")
#
# Uses `Time::Format::HTTP_DATE` as formatter.
def self.format_time(time : Time) : String
Time::Format::HTTP_DATE.format(time)
end

# Dequotes an [RFC 2616](https://tools.ietf.org/html/rfc2616#page-17)
Expand Down
2 changes: 1 addition & 1 deletion src/http/cookie.cr
Expand Up @@ -31,7 +31,7 @@ module HTTP
header << "#{URI.escape @name}=#{URI.escape value}"
header << "; domain=#{domain}" if domain
header << "; path=#{path}" if path
header << "; expires=#{HTTP.rfc1123_date(expires)}" if expires
header << "; expires=#{HTTP.format_time(expires)}" if expires
header << "; Secure" if @secure
header << "; HttpOnly" if @http_only
header << "; #{@extension}" if @extension
Expand Down
2 changes: 1 addition & 1 deletion src/http/server/handlers/static_file_handler.cr
Expand Up @@ -65,7 +65,7 @@ class HTTP::StaticFileHandler
directory_listing(context.response, request_path, file_path)
elsif is_file
last_modified = File.info(file_path).modification_time
context.response.headers["Last-Modified"] = HTTP.rfc1123_date(last_modified)
context.response.headers["Last-Modified"] = HTTP.format_time(last_modified)

if if_modified_since = context.request.headers["If-Modified-Since"]?
header_time = HTTP.parse_time(if_modified_since)
Expand Down
8 changes: 8 additions & 0 deletions src/json/from_json.cr
Expand Up @@ -230,6 +230,14 @@ def Union.new(pull : JSON::PullParser)
raise JSON::ParseException.new("Couldn't parse #{self} from #{string}", *location)
end

# Reads a string from JSON parser as a time formated according to [RFC 3339](https://tools.ietf.org/html/rfc3339)
# or other variations of [ISO 8601](http://xml.coverpages.org/ISO-FDIS-8601.pdf).
#
# The JSON format itself does not specify a time data type, this method just
# assumes that a string holding a ISO 8601 time format can be # interpreted as a
# time value.
#
# See `#to_json` for reference.
def Time.new(pull : JSON::PullParser)
Time::Format::ISO_8601_DATE_TIME.parse(pull.read_string)
end
Expand Down
10 changes: 9 additions & 1 deletion src/json/to_json.cr
Expand Up @@ -123,8 +123,16 @@ struct Enum
end

struct Time
# Emits a string formated according to [RFC 3339](https://tools.ietf.org/html/rfc3339)
# ([ISO 8601](http://xml.coverpages.org/ISO-FDIS-8601.pdf) profile).
#
# The JSON format itself does not specify a time data type, this method just
# assumes that a string holding a RFC 3339 time format will be interpreted as
# a time value.
#
# See `#from_json` for reference.
def to_json(json : JSON::Builder)
json.string(Time::Format::ISO_8601_DATE_TIME.format(self))
json.string(Time::Format::RFC_3339.format(self, fraction_digits: 0))
end
end

Expand Down

0 comments on commit 36647c8

Please sign in to comment.