Skip to content

Commit 49d6e19

Browse files
committed
feat: support for RFC 9651
1 parent 0bbc76e commit 49d6e19

22 files changed

+364
-61
lines changed

bareitem.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import (
55
"fmt"
66
"reflect"
77
"strings"
8+
"time"
89
)
910

1011
// ErrInvalidBareItem is returned when a bare item is invalid.
1112
var ErrInvalidBareItem = errors.New(
12-
"invalid bare item type (allowed types are bool, string, int64, float64, []byte and Token)",
13+
"invalid bare item type (allowed types are bool, string, int64, float64, []byte, time.Time and Token)",
1314
)
1415

1516
// assertBareItem asserts that v is a valid bare item
16-
// according to https://httpwg.org/specs/rfc8941.html#item.
17+
// according to https://httpwg.org/specs/rfc9651.html#item.
1718
//
1819
// v can be either:
1920
//
@@ -23,6 +24,7 @@ var ErrInvalidBareItem = errors.New(
2324
// * a token (Section 3.3.4.)
2425
// * a byte sequence (Section 3.3.5.)
2526
// * a boolean (Section 3.3.6.)
27+
// * a date (Section 3.3.7.)
2628
func assertBareItem(v interface{}) {
2729
switch v.(type) {
2830
case bool,
@@ -40,7 +42,9 @@ func assertBareItem(v interface{}) {
4042
float32,
4143
float64,
4244
[]byte,
43-
Token:
45+
time.Time,
46+
Token,
47+
DisplayString:
4448
return
4549
default:
4650
panic(fmt.Errorf("%w: got %s", ErrInvalidBareItem, reflect.TypeOf(v)))
@@ -66,8 +70,12 @@ func marshalBareItem(b *strings.Builder, v interface{}) error {
6670
return marshalDecimal(b, v.(float64))
6771
case []byte:
6872
return marshalBinary(b, v)
73+
case time.Time:
74+
return marshalDate(b, v)
6975
case Token:
7076
return v.marshalSFV(b)
77+
case DisplayString:
78+
return v.marshalSFV(b)
7179
default:
7280
panic(ErrInvalidBareItem)
7381
}
@@ -92,6 +100,10 @@ func parseBareItem(s *scanner) (interface{}, error) {
92100
return parseBinary(s)
93101
case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
94102
return parseNumber(s)
103+
case '@':
104+
return parseDate(s)
105+
case '%':
106+
return parseDisplayString(s)
95107
default:
96108
if isAlpha(c) {
97109
return parseToken(s)

bareitem_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func TestParseBareItem(t *testing.T) {
2323
{"abc", Token("abc"), false},
2424
{"*abc", Token("*abc"), false},
2525
{":YWJj:", []byte("abc"), false},
26+
{"@1659578233", time.Unix(1659578233, 0), false},
2627
{"", nil, true},
2728
{"~", nil, true},
2829
}

binary_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"testing"
77
)
88

9-
func TestBinary(t *testing.T) {
9+
func TestMarshalBinary(t *testing.T) {
1010
t.Parallel()
1111

1212
var bd strings.Builder

boolean_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"testing"
66
)
77

8-
func TestBooleanMarshalSFV(t *testing.T) {
8+
func TestMarshalBoolean(t *testing.T) {
99
t.Parallel()
1010

1111
var b strings.Builder

date.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package httpsfv
2+
3+
import (
4+
"errors"
5+
"io"
6+
"time"
7+
)
8+
9+
var ErrInvalidDateFormat = errors.New("invalid date format")
10+
11+
// marshalDate serializes as defined in
12+
// https://httpwg.org/specs/rfc9651.html#ser-date.
13+
func marshalDate(b io.StringWriter, i time.Time) error {
14+
_, err := b.WriteString("@")
15+
if err != nil {
16+
return err
17+
}
18+
19+
return marshalInteger(b, i.Unix())
20+
}
21+
22+
// parseDate parses as defined in
23+
// https://httpwg.org/specs/rfc9651.html#parse-date.
24+
func parseDate(s *scanner) (time.Time, error) {
25+
if s.eof() || s.data[s.off] != '@' {
26+
return time.Time{}, &UnmarshalError{s.off, ErrInvalidDateFormat}
27+
}
28+
s.off++
29+
30+
n, err := parseNumber(s)
31+
if err != nil {
32+
return time.Time{}, &UnmarshalError{s.off, ErrInvalidDateFormat}
33+
}
34+
35+
i, ok := n.(int64)
36+
if !ok {
37+
return time.Time{}, &UnmarshalError{s.off, ErrInvalidDateFormat}
38+
}
39+
40+
return time.Unix(i, 0), nil
41+
}

date_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package httpsfv
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestMarshalDate(t *testing.T) {
10+
t.Parallel()
11+
12+
data := []struct {
13+
in time.Time
14+
expected string
15+
valid bool
16+
}{
17+
{time.Unix(1659578233, 0), "@1659578233", true},
18+
{time.Unix(9999999999999999, 0), "@", false},
19+
}
20+
21+
var b strings.Builder
22+
23+
for _, d := range data {
24+
b.Reset()
25+
26+
err := marshalDate(&b, d.in)
27+
if d.valid && err != nil {
28+
t.Errorf("error not expected for %v, got %v", d.in, err)
29+
} else if !d.valid && err == nil {
30+
t.Errorf("error expected for %v, got %v", d.in, err)
31+
}
32+
33+
if b.String() != d.expected {
34+
t.Errorf("got %v; want %v", b.String(), d.expected)
35+
}
36+
}
37+
}
38+
39+
func TestParseDate(t *testing.T) {
40+
t.Parallel()
41+
42+
data := []struct {
43+
in string
44+
out time.Time
45+
err bool
46+
}{
47+
{"@1659578233", time.Unix(1659578233, 0), false},
48+
{"invalid", time.Time{}, true},
49+
}
50+
51+
for _, d := range data {
52+
s := &scanner{data: d.in}
53+
54+
i, err := parseDate(s)
55+
if d.err && err == nil {
56+
t.Errorf("parse%s): error expected", d.in)
57+
}
58+
59+
if !d.err && d.out != i {
60+
t.Errorf("parse%s) = %v, %v; %v, <nil> expected", d.in, i, err, d.out)
61+
}
62+
}
63+
}

decimal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"testing"
66
)
77

8-
func TestDecimalMarshalSFV(t *testing.T) {
8+
func TestMarshalDecimal(t *testing.T) {
99
t.Parallel()
1010

1111
data := []struct {

dictionary_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"testing"
77
)
88

9-
func TestDictionnary(t *testing.T) {
9+
func TestMarshalDictionnary(t *testing.T) {
1010
t.Parallel()
1111

1212
dict := NewDictionary()

displaystring.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package httpsfv
2+
3+
import (
4+
"encoding/hex"
5+
"errors"
6+
"strings"
7+
"unicode"
8+
"unicode/utf8"
9+
)
10+
11+
type DisplayString string
12+
13+
var ErrInvalidDisplayString = errors.New("invalid display string type")
14+
15+
var notVcharOrSp = &unicode.RangeTable{
16+
R16: []unicode.Range16{
17+
{0x0000, 0x001f, 1},
18+
{0x007f, 0x00ff, 1},
19+
},
20+
LatinOffset: 2,
21+
}
22+
23+
// marshalSFV serializes as defined in
24+
// https://httpwg.org/specs/rfc8941.html#ser-string.
25+
func (s DisplayString) marshalSFV(b *strings.Builder) error {
26+
if _, err := b.WriteString(`%"`); err != nil {
27+
return err
28+
}
29+
30+
for i := 0; i < len(s); i++ {
31+
if s[i] == '%' || s[i] == '"' || unicode.Is(notVcharOrSp, rune(s[i])) {
32+
b.WriteRune('%')
33+
b.WriteString(hex.EncodeToString([]byte{s[i]}))
34+
35+
continue
36+
}
37+
38+
b.WriteByte(s[i])
39+
}
40+
41+
b.WriteByte('"')
42+
43+
return nil
44+
}
45+
46+
// parseDisplayString parses as defined in
47+
// https://httpwg.org/specs/rfc9651.html#parse-display.
48+
func parseDisplayString(s *scanner) (DisplayString, error) {
49+
if s.eof() || len(s.data[s.off:]) < 2 || s.data[s.off:2] != `%"` {
50+
return "", &UnmarshalError{s.off, ErrInvalidDisplayString}
51+
}
52+
s.off += 2
53+
54+
var b strings.Builder
55+
for !s.eof() {
56+
c := s.data[s.off]
57+
s.off++
58+
59+
switch c {
60+
case '%':
61+
if len(s.data[s.off:]) < 2 {
62+
return "", &UnmarshalError{s.off, ErrInvalidDisplayString}
63+
}
64+
c0 := unhex(s.data[s.off])
65+
if c0 == 0 {
66+
return "", &UnmarshalError{s.off, ErrInvalidDisplayString}
67+
}
68+
69+
c1 := unhex(s.data[s.off+1])
70+
if c1 == 0 {
71+
return "", &UnmarshalError{s.off, ErrInvalidDisplayString}
72+
}
73+
74+
b.WriteByte(c0<<4 | c1)
75+
s.off += 2
76+
case '"':
77+
r := b.String()
78+
if !utf8.ValidString(r) {
79+
return "", ErrInvalidDisplayString
80+
}
81+
82+
return DisplayString(r), nil
83+
84+
default:
85+
if unicode.Is(notVcharOrSp, rune(c)) {
86+
return "", &UnmarshalError{s.off, ErrInvalidDisplayString}
87+
}
88+
89+
b.WriteByte(c)
90+
}
91+
}
92+
93+
return "", &UnmarshalError{s.off, ErrInvalidDisplayString}
94+
}
95+
96+
func unhex(c byte) byte {
97+
switch {
98+
case '0' <= c && c <= '9':
99+
return c - '0'
100+
case 'a' <= c && c <= 'f':
101+
return c - 'a' + 10
102+
default:
103+
return 0
104+
}
105+
}

displaystring_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package httpsfv
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestMarshalDisplayString(t *testing.T) {
10+
t.Parallel()
11+
12+
data := []struct {
13+
in string
14+
expected string
15+
valid bool
16+
}{
17+
{"foo", `%"foo"`, true},
18+
{"Kévin", `%"K%c3%a9vin"`, true},
19+
}
20+
21+
var b strings.Builder
22+
23+
for _, d := range data {
24+
b.Reset()
25+
26+
err := DisplayString(d.in).marshalSFV(&b)
27+
if d.valid && err != nil {
28+
t.Errorf("error not expected for %v, got %v", d.in, err)
29+
} else if !d.valid && err == nil {
30+
t.Errorf("error expected for %v, got %v", d.in, err)
31+
}
32+
33+
if b.String() != d.expected {
34+
t.Errorf("got %v; want %v", b.String(), d.expected)
35+
}
36+
}
37+
}
38+
39+
func TestParseDisplayString(t *testing.T) {
40+
t.Parallel()
41+
42+
data := []struct {
43+
in string
44+
out string
45+
err bool
46+
}{
47+
{`%"foo"`, "foo", false},
48+
{`%"K%c3%a9vin"`, "Kévin", false},
49+
{`%"K%00vin"`, "", true},
50+
{`"K%e9vin"`, "", true},
51+
{`%K%e9vin"`, "", true},
52+
{`%"K%e9vin`, "", true},
53+
}
54+
55+
for _, d := range data {
56+
s := &scanner{data: d.in}
57+
58+
i, err := parseDisplayString(s)
59+
if d.err && err == nil {
60+
t.Errorf("parse(%s): error expected", d.in)
61+
}
62+
63+
if !d.err && d.out != string(i) {
64+
fmt.Printf("%q\n", i)
65+
fmt.Printf("%q\n", d.out)
66+
t.Errorf("parse(%s) = %v, %v; %v, <nil> expected", d.in, i, err, d.out)
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)