├── .github ├── CODE_OF_CONDUCT.md ├── pull_request_template.md └── workflows │ └── ci.yaml ├── .gitmodules ├── LICENSE ├── README.md ├── bareitem.go ├── bareitem_test.go ├── binary.go ├── binary_test.go ├── boolean.go ├── boolean_test.go ├── date.go ├── date_test.go ├── decimal.go ├── decimal_test.go ├── decode.go ├── decode_test.go ├── dictionary.go ├── dictionary_test.go ├── displaystring.go ├── displaystring_test.go ├── encode.go ├── encode_test.go ├── example_test.go ├── go.mod ├── go.sum ├── httpwg_test.go ├── innerlist.go ├── innerlist_test.go ├── integer.go ├── integer_test.go ├── item.go ├── item_test.go ├── key.go ├── key_test.go ├── list.go ├── list_test.go ├── member.go ├── params.go ├── params_test.go ├── string.go ├── string_test.go ├── token.go ├── token_test.go └── utils.go /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, ethnicity, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at contact+coc@mercure.rocks. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | env: 8 | GO111MODULE: 'on' 9 | 10 | jobs: 11 | lint: 12 | name: Lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v4 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | - name: Lint Go Code 20 | uses: golangci/golangci-lint-action@v6 21 | 22 | test: 23 | name: Test 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | go-version: ['stable', 'oldstable'] 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | - name: Set up Go 33 | uses: actions/setup-go@v5 34 | with: 35 | go-version: ${{ matrix.go-version }} 36 | - name: Test 37 | run: go test -race ${{ matrix.go-version == 'stable' && '-covermode atomic -coverprofile=profile.cov' || ''}} 38 | - name: Upload coverage results 39 | if: matrix.go-version == 'stable' 40 | uses: shogo82148/actions-goveralls@v1 41 | with: 42 | path-to-profile: profile.cov 43 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "structured-field-tests"] 2 | path = structured-field-tests 3 | url = https://github.com/httpwg/structured-field-tests 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Kévin Dunglas. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpsfv: Structured Field Values for HTTP in Go 2 | 3 | This [Go (golang)](https://golang.org) library implements parsing and serialization for [Structured Field Values for HTTP (RFC 9651 and 8941)](https://httpwg.org/specs/rfc9651.html). 4 | 5 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/dunglas/httpsfv)](https://pkg.go.dev/github.com/dunglas/httpsfv) 6 | ![CI](https://github.com/dunglas/httpsfv/workflows/CI/badge.svg) 7 | [![Coverage Status](https://coveralls.io/repos/github/dunglas/httpsfv/badge.svg?branch=master)](https://coveralls.io/github/dunglas/httpsfv?branch=master) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/dunglas/httpsfv)](https://goreportcard.com/report/github.com/dunglas/httpsfv) 9 | 10 | ## Features 11 | 12 | * Fully implementing the RFC 13 | * Compliant with [the official test suite](https://github.com/httpwg/structured-field-tests) 14 | * Unit and fuzz tested 15 | * Strongly-typed 16 | * Fast (see [the benchmark](httpwg_test.go)) 17 | * No dependencies 18 | 19 | ## Docs 20 | 21 | [Browse the documentation on Go.dev](https://pkg.go.dev/github.com/dunglas/httpsfv). 22 | 23 | ## Credits 24 | 25 | Created by [Kévin Dunglas](https://dunglas.fr). Sponsored by [Les-Tilleuls.coop](https://les-tilleuls.coop). 26 | -------------------------------------------------------------------------------- /bareitem.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // ErrInvalidBareItem is returned when a bare item is invalid. 12 | var ErrInvalidBareItem = errors.New( 13 | "invalid bare item type (allowed types are bool, string, int64, float64, []byte, time.Time and Token)", 14 | ) 15 | 16 | // assertBareItem asserts that v is a valid bare item 17 | // according to https://httpwg.org/specs/rfc9651.html#item. 18 | // 19 | // v can be either: 20 | // 21 | // * an integer (Section 3.3.1.) 22 | // * a decimal (Section 3.3.2.) 23 | // * a string (Section 3.3.3.) 24 | // * a token (Section 3.3.4.) 25 | // * a byte sequence (Section 3.3.5.) 26 | // * a boolean (Section 3.3.6.) 27 | // * a date (Section 3.3.7.) 28 | // * a display string (Section 3.3.8.) 29 | func assertBareItem(v interface{}) { 30 | switch v.(type) { 31 | case bool, 32 | string, 33 | int, 34 | int8, 35 | int16, 36 | int32, 37 | int64, 38 | uint, 39 | uint8, 40 | uint16, 41 | uint32, 42 | uint64, 43 | float32, 44 | float64, 45 | []byte, 46 | time.Time, 47 | Token, 48 | DisplayString: 49 | return 50 | default: 51 | panic(fmt.Errorf("%w: got %s", ErrInvalidBareItem, reflect.TypeOf(v))) 52 | } 53 | } 54 | 55 | // marshalBareItem serializes as defined in 56 | // https://httpwg.org/specs/rfc9651.html#ser-bare-item. 57 | func marshalBareItem(b *strings.Builder, v interface{}) error { 58 | switch v := v.(type) { 59 | case bool: 60 | return marshalBoolean(b, v) 61 | case string: 62 | return marshalString(b, v) 63 | case int64: 64 | return marshalInteger(b, v) 65 | case int, int8, int16, int32: 66 | return marshalInteger(b, reflect.ValueOf(v).Int()) 67 | case uint, uint8, uint16, uint32, uint64: 68 | // Casting an uint64 to an int64 is possible because the maximum allowed value is 999,999,999,999,999 69 | return marshalInteger(b, int64(reflect.ValueOf(v).Uint())) 70 | case float32, float64: 71 | return marshalDecimal(b, v.(float64)) 72 | case []byte: 73 | return marshalBinary(b, v) 74 | case time.Time: 75 | return marshalDate(b, v) 76 | case Token: 77 | return v.marshalSFV(b) 78 | case DisplayString: 79 | return v.marshalSFV(b) 80 | default: 81 | panic(ErrInvalidBareItem) 82 | } 83 | } 84 | 85 | // parseBareItem parses as defined in 86 | // https://httpwg.org/specs/rfc9651.html#parse-bare-item. 87 | func parseBareItem(s *scanner) (interface{}, error) { 88 | if s.eof() { 89 | return nil, &UnmarshalError{s.off, ErrUnexpectedEndOfString} 90 | } 91 | 92 | c := s.data[s.off] 93 | switch c { 94 | case '"': 95 | return parseString(s) 96 | case '?': 97 | return parseBoolean(s) 98 | case '*': 99 | return parseToken(s) 100 | case ':': 101 | return parseBinary(s) 102 | case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': 103 | return parseNumber(s) 104 | case '@': 105 | return parseDate(s) 106 | case '%': 107 | return parseDisplayString(s) 108 | default: 109 | if isAlpha(c) { 110 | return parseToken(s) 111 | } 112 | 113 | return nil, &UnmarshalError{s.off, ErrUnrecognizedCharacter} 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /bareitem_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestParseBareItem(t *testing.T) { 11 | t.Parallel() 12 | 13 | data := []struct { 14 | in string 15 | out interface{} 16 | err bool 17 | }{ 18 | {"?1", true, false}, 19 | {"?0", false, false}, 20 | {"22", int64(22), false}, 21 | {"-2.2", -2.2, false}, 22 | {`"foo"`, "foo", false}, 23 | {"abc", Token("abc"), false}, 24 | {"*abc", Token("*abc"), false}, 25 | {":YWJj:", []byte("abc"), false}, 26 | {"@1659578233", time.Unix(1659578233, 0), false}, 27 | {"", nil, true}, 28 | {"~", nil, true}, 29 | } 30 | 31 | for _, d := range data { 32 | s := &scanner{data: d.in} 33 | 34 | i, err := parseBareItem(s) 35 | if d.err && err == nil { 36 | t.Errorf("parseBareItem(%s): error expected", d.in) 37 | } 38 | 39 | if !d.err && !reflect.DeepEqual(d.out, i) { 40 | t.Errorf("parseBareItem(%s) = %v, %v; %v, expected", d.in, i, err, d.out) 41 | } 42 | } 43 | } 44 | 45 | func TestMarshalBareItem(t *testing.T) { 46 | t.Parallel() 47 | 48 | defer func() { 49 | if r := recover(); r == nil { 50 | t.Errorf("The code did not panic") 51 | } 52 | }() 53 | 54 | var b strings.Builder 55 | _ = marshalBareItem(&b, time.Second) 56 | } 57 | 58 | func TestAssertBareItem(t *testing.T) { 59 | t.Parallel() 60 | 61 | defer func() { 62 | if r := recover(); r == nil { 63 | t.Errorf("The code did not panic") 64 | } 65 | }() 66 | 67 | assertBareItem(time.Second) 68 | } 69 | -------------------------------------------------------------------------------- /binary.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | "strings" 7 | ) 8 | 9 | // ErrInvalidBinaryFormat is returned when the binary format is invalid. 10 | var ErrInvalidBinaryFormat = errors.New("invalid binary format") 11 | 12 | // marshalBinary serializes as defined in 13 | // https://httpwg.org/specs/rfc9651.html#ser-binary. 14 | func marshalBinary(b *strings.Builder, bs []byte) error { 15 | if err := b.WriteByte(':'); err != nil { 16 | return err 17 | } 18 | 19 | buf := make([]byte, base64.StdEncoding.EncodedLen(len(bs))) 20 | base64.StdEncoding.Encode(buf, bs) 21 | 22 | if _, err := b.Write(buf); err != nil { 23 | return err 24 | } 25 | 26 | return b.WriteByte(':') 27 | } 28 | 29 | // parseBinary parses as defined in 30 | // https://httpwg.org/specs/rfc9651.html#parse-binary. 31 | func parseBinary(s *scanner) ([]byte, error) { 32 | if s.eof() || s.data[s.off] != ':' { 33 | return nil, &UnmarshalError{s.off, ErrInvalidBinaryFormat} 34 | } 35 | s.off++ 36 | 37 | start := s.off 38 | 39 | for !s.eof() { 40 | c := s.data[s.off] 41 | if c == ':' { 42 | // base64decode 43 | decoded, err := base64.StdEncoding.DecodeString(s.data[start:s.off]) 44 | if err != nil { 45 | return nil, &UnmarshalError{s.off, err} 46 | } 47 | s.off++ 48 | 49 | return decoded, nil 50 | } 51 | 52 | if !isAlpha(c) && !isDigit(c) && c != '+' && c != '/' && c != '=' { 53 | return nil, &UnmarshalError{s.off, ErrInvalidBinaryFormat} 54 | } 55 | s.off++ 56 | } 57 | 58 | return nil, &UnmarshalError{s.off, ErrInvalidBinaryFormat} 59 | } 60 | -------------------------------------------------------------------------------- /binary_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestMarshalBinary(t *testing.T) { 10 | t.Parallel() 11 | 12 | var bd strings.Builder 13 | _ = marshalBinary(&bd, []byte{4, 2}) 14 | 15 | if bd.String() != ":BAI=:" { 16 | t.Error("marshalBinary(): invalid") 17 | } 18 | } 19 | 20 | func TestParseBinary(t *testing.T) { 21 | t.Parallel() 22 | 23 | data := []struct { 24 | in string 25 | out []byte 26 | err bool 27 | }{ 28 | {":YWJj:", []byte("abc"), false}, 29 | {":YW55IGNhcm5hbCBwbGVhc3VyZQ==:", []byte("any carnal pleasure"), false}, 30 | {":YW55IGNhcm5hbCBwbGVhc3Vy:", []byte("any carnal pleasur"), false}, 31 | {"", []byte{}, false}, 32 | {":", []byte{}, false}, 33 | {":YW55IGNhcm5hbCBwbGVhc3Vy", []byte{}, false}, 34 | {":YW55IGNhcm5hbCBwbGVhc3Vy~", []byte{}, false}, 35 | {":YW55IGNhcm5hbCBwbGVhc3VyZQ=:", []byte{}, false}, 36 | } 37 | 38 | for _, d := range data { 39 | s := &scanner{data: d.in} 40 | 41 | i, err := parseBinary(s) 42 | if d.err && err == nil { 43 | t.Errorf("parseBinary(%s): error expected", d.in) 44 | } 45 | 46 | if !d.err && !bytes.Equal(d.out, i) { 47 | t.Errorf("parseBinary(%s) = %v, %v; %v, expected", d.in, i, err, d.out) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /boolean.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | // ErrInvalidBooleanFormat is returned when a boolean format is invalid. 9 | var ErrInvalidBooleanFormat = errors.New("invalid boolean format") 10 | 11 | // marshalBoolean serializes as defined in 12 | // https://httpwg.org/specs/rfc9651.html#ser-boolean. 13 | func marshalBoolean(bd io.StringWriter, b bool) error { 14 | if b { 15 | _, err := bd.WriteString("?1") 16 | 17 | return err 18 | } 19 | 20 | _, err := bd.WriteString("?0") 21 | 22 | return err 23 | } 24 | 25 | // parseBoolean parses as defined in 26 | // https://httpwg.org/specs/rfc9651.html#parse-boolean. 27 | func parseBoolean(s *scanner) (bool, error) { 28 | if s.eof() || s.data[s.off] != '?' { 29 | return false, &UnmarshalError{s.off, ErrInvalidBooleanFormat} 30 | } 31 | s.off++ 32 | 33 | if s.eof() { 34 | return false, &UnmarshalError{s.off, ErrInvalidBooleanFormat} 35 | } 36 | 37 | switch s.data[s.off] { 38 | case '0': 39 | s.off++ 40 | 41 | return false, nil 42 | case '1': 43 | s.off++ 44 | 45 | return true, nil 46 | } 47 | 48 | return false, &UnmarshalError{s.off, ErrInvalidBooleanFormat} 49 | } 50 | -------------------------------------------------------------------------------- /boolean_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestMarshalBoolean(t *testing.T) { 9 | t.Parallel() 10 | 11 | var b strings.Builder 12 | 13 | _ = marshalBoolean(&b, true) 14 | 15 | if b.String() != "?1" { 16 | t.Error("Invalid marshaling") 17 | } 18 | 19 | b.Reset() 20 | _ = marshalBoolean(&b, false) 21 | 22 | if b.String() != "?0" { 23 | t.Error("Invalid marshaling") 24 | } 25 | } 26 | 27 | func TestParseBoolean(t *testing.T) { 28 | t.Parallel() 29 | 30 | data := []struct { 31 | in string 32 | out bool 33 | err bool 34 | }{ 35 | {"?1", true, false}, 36 | {"?0", false, false}, 37 | {"?2", false, true}, 38 | {"", false, true}, 39 | {"?", false, true}, 40 | } 41 | 42 | for _, d := range data { 43 | s := &scanner{data: d.in} 44 | 45 | i, err := parseBoolean(s) 46 | if d.err && err == nil { 47 | t.Errorf("parseBoolean(%s): error expected", d.in) 48 | } 49 | 50 | if !d.err && d.out != i { 51 | t.Errorf("parseBoolean(%s) = %v, %v; %v, expected", d.in, i, err, d.out) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /date.go: -------------------------------------------------------------------------------- 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 | } 42 | -------------------------------------------------------------------------------- /date_test.go: -------------------------------------------------------------------------------- 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, expected", d.in, i, err, d.out) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /decimal.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "math" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | const maxDecDigit = 3 12 | 13 | // ErrInvalidDecimal is returned when a decimal is invalid. 14 | var ErrInvalidDecimal = errors.New("the integer portion is larger than 12 digits: invalid decimal") 15 | 16 | // marshalDecimal serializes as defined in 17 | // https://httpwg.org/specs/rfc9651.html#ser-decimal. 18 | // 19 | // TODO(dunglas): add support for decimal float type when one will be available 20 | // (https://github.com/golang/go/issues/19787) 21 | func marshalDecimal(b io.StringWriter, d float64) error { 22 | const TH = 0.001 23 | 24 | rounded := math.RoundToEven(d/TH) * TH 25 | i, frac := math.Modf(rounded) 26 | 27 | if i < -999999999999 || i > 999999999999 { 28 | return ErrInvalidDecimal 29 | } 30 | 31 | if _, err := b.WriteString(strings.TrimRight(strconv.FormatFloat(rounded, 'f', 3, 64), "0")); err != nil { 32 | return err 33 | } 34 | 35 | if frac == 0 { 36 | _, err := b.WriteString("0") 37 | 38 | return err 39 | } 40 | 41 | return nil 42 | } 43 | 44 | func parseDecimal(s *scanner, decSepOff int, str string, neg bool) (float64, error) { 45 | if decSepOff == s.off-1 { 46 | return 0, &UnmarshalError{s.off, ErrInvalidDecimalFormat} 47 | } 48 | 49 | if len(s.data[decSepOff+1:s.off]) > maxDecDigit { 50 | return 0, &UnmarshalError{s.off, ErrNumberOutOfRange} 51 | } 52 | 53 | i, err := strconv.ParseFloat(str, 64) 54 | if err != nil { 55 | // Should never happen 56 | return 0, &UnmarshalError{s.off, err} 57 | } 58 | 59 | if neg { 60 | i = -i 61 | } 62 | 63 | return i, nil 64 | } 65 | -------------------------------------------------------------------------------- /decimal_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestMarshalDecimal(t *testing.T) { 9 | t.Parallel() 10 | 11 | data := []struct { 12 | in float64 13 | expected string 14 | valid bool 15 | }{ 16 | {10.0, "10.0", true}, 17 | {-10.123, "-10.123", true}, 18 | {10.1236, "10.124", true}, 19 | {-10.0, "-10.0", true}, 20 | {0, "0.0", true}, 21 | {-999999999999.0, "-999999999999.0", true}, 22 | {999999999999.0, "999999999999.0", true}, 23 | {9999999999999, "", false}, 24 | {-9999999999999.0, "", false}, 25 | {9999999999999.0, "", false}, 26 | {1.9, "1.9", true}, 27 | } 28 | 29 | var b strings.Builder 30 | 31 | for _, d := range data { 32 | b.Reset() 33 | 34 | err := marshalDecimal(&b, d.in) 35 | if d.valid && err != nil { 36 | t.Errorf("error not expected for %v, got %v", d.in, err) 37 | } else if !d.valid && err == nil { 38 | t.Errorf("error expected for %v, got %v", d.in, err) 39 | } 40 | 41 | if b.String() != d.expected { 42 | t.Errorf("got %v; want %v", b.String(), d.expected) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /decode.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // ErrUnexpectedEndOfString is returned when the end of string is unexpected. 9 | var ErrUnexpectedEndOfString = errors.New("unexpected end of string") 10 | 11 | // ErrUnrecognizedCharacter is returned when an unrecognized character in encountered. 12 | var ErrUnrecognizedCharacter = errors.New("unrecognized character") 13 | 14 | // UnmarshalError contains the underlying parsing error and the position at which it occurred. 15 | type UnmarshalError struct { 16 | off int 17 | err error 18 | } 19 | 20 | func (e *UnmarshalError) Error() string { 21 | if e.err != nil { 22 | return fmt.Sprintf("%s: character %d", e.err, e.off) 23 | } 24 | 25 | return fmt.Sprintf("unmarshal error: character %d", e.off) 26 | } 27 | 28 | func (e *UnmarshalError) Unwrap() error { 29 | return e.err 30 | } 31 | 32 | type scanner struct { 33 | data string 34 | off int 35 | } 36 | 37 | // scanWhileSp consumes spaces. 38 | func (s *scanner) scanWhileSp() { 39 | for !s.eof() { 40 | if s.data[s.off] != ' ' { 41 | return 42 | } 43 | 44 | s.off++ 45 | } 46 | } 47 | 48 | // scanWhileOWS consumes optional white space (OWS) characters. 49 | func (s *scanner) scanWhileOWS() { 50 | for !s.eof() { 51 | c := s.data[s.off] 52 | if c != ' ' && c != '\t' { 53 | return 54 | } 55 | 56 | s.off++ 57 | } 58 | } 59 | 60 | // eof returns true if the parser consumed all available characters. 61 | func (s *scanner) eof() bool { 62 | return s.off == len(s.data) 63 | } 64 | -------------------------------------------------------------------------------- /decode_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import "testing" 4 | 5 | func TestDecodeError(t *testing.T) { 6 | t.Parallel() 7 | 8 | _, err := UnmarshalItem([]string{"invalid-é"}) 9 | 10 | if err.Error() != "unmarshal error: character 8" { 11 | t.Error("invalid error") 12 | } 13 | 14 | _, err = UnmarshalItem([]string{`"é"`}) 15 | if err.Error() != "invalid string format: character 2" { 16 | t.Error("invalid error") 17 | } 18 | 19 | if err.(*UnmarshalError).Unwrap().Error() != "invalid string format" { 20 | t.Error("invalid wrapped error") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /dictionary.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // Dictionary is an ordered map of name-value pairs. 9 | // See https://httpwg.org/specs/rfc9651.html#dictionary 10 | // Values can be: 11 | // * Item (Section 3.3.) 12 | // * Inner List (Section 3.1.1.) 13 | type Dictionary struct { 14 | names []string 15 | values map[string]Member 16 | } 17 | 18 | // ErrInvalidDictionaryFormat is returned when a dictionary value is invalid. 19 | var ErrInvalidDictionaryFormat = errors.New("invalid dictionary format") 20 | 21 | // NewDictionary creates a new ordered map. 22 | func NewDictionary() *Dictionary { 23 | d := Dictionary{} 24 | d.names = []string{} 25 | d.values = map[string]Member{} 26 | 27 | return &d 28 | } 29 | 30 | // Get retrieves a member. 31 | func (d *Dictionary) Get(k string) (Member, bool) { 32 | v, ok := d.values[k] 33 | 34 | return v, ok 35 | } 36 | 37 | // Add appends a new member to the ordered list. 38 | func (d *Dictionary) Add(k string, v Member) { 39 | if _, exists := d.values[k]; !exists { 40 | d.names = append(d.names, k) 41 | } 42 | 43 | d.values[k] = v 44 | } 45 | 46 | // Del removes a member from the ordered list. 47 | func (d *Dictionary) Del(key string) bool { 48 | if _, ok := d.values[key]; !ok { 49 | return false 50 | } 51 | 52 | for i, k := range d.names { 53 | if k == key { 54 | d.names = append(d.names[:i], d.names[i+1:]...) 55 | 56 | break 57 | } 58 | } 59 | 60 | delete(d.values, key) 61 | 62 | return true 63 | } 64 | 65 | // Names retrieves the list of member names in the appropriate order. 66 | func (d *Dictionary) Names() []string { 67 | return d.names 68 | } 69 | 70 | func (d *Dictionary) marshalSFV(b *strings.Builder) error { 71 | last := len(d.names) - 1 72 | 73 | for m, k := range d.names { 74 | if err := marshalKey(b, k); err != nil { 75 | return err 76 | } 77 | 78 | v := d.values[k] 79 | 80 | if item, ok := v.(Item); ok && item.Value == true { 81 | if err := item.Params.marshalSFV(b); err != nil { 82 | return err 83 | } 84 | } else { 85 | if err := b.WriteByte('='); err != nil { 86 | return err 87 | } 88 | if err := v.marshalSFV(b); err != nil { 89 | return err 90 | } 91 | } 92 | 93 | if m != last { 94 | if _, err := b.WriteString(", "); err != nil { 95 | return err 96 | } 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | 103 | // UnmarshalDictionary parses a dictionary as defined in 104 | // https://httpwg.org/specs/rfc9651.html#parse-dictionary. 105 | func UnmarshalDictionary(v []string) (*Dictionary, error) { 106 | s := &scanner{ 107 | data: strings.Join(v, ","), 108 | } 109 | 110 | s.scanWhileSp() 111 | 112 | sfv, err := parseDictionary(s) 113 | if err != nil { 114 | return sfv, err 115 | } 116 | 117 | return sfv, nil 118 | } 119 | 120 | func parseDictionary(s *scanner) (*Dictionary, error) { 121 | d := NewDictionary() 122 | 123 | for !s.eof() { 124 | k, err := parseKey(s) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | var m Member 130 | 131 | if !s.eof() && s.data[s.off] == '=' { 132 | s.off++ 133 | m, err = parseItemOrInnerList(s) 134 | 135 | if err != nil { 136 | return nil, err 137 | } 138 | } else { 139 | p, err := parseParams(s) 140 | if err != nil { 141 | return nil, err 142 | } 143 | m = Item{true, p} 144 | } 145 | 146 | d.Add(k, m) 147 | s.scanWhileOWS() 148 | 149 | if s.eof() { 150 | return d, nil 151 | } 152 | 153 | if s.data[s.off] != ',' { 154 | return nil, &UnmarshalError{s.off, ErrInvalidDictionaryFormat} 155 | } 156 | s.off++ 157 | 158 | s.scanWhileOWS() 159 | 160 | if s.eof() { 161 | // there is a trailing comma 162 | return nil, &UnmarshalError{s.off, ErrInvalidDictionaryFormat} 163 | } 164 | } 165 | 166 | return d, nil 167 | } 168 | -------------------------------------------------------------------------------- /dictionary_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestMarshalDictionnary(t *testing.T) { 10 | t.Parallel() 11 | 12 | dict := NewDictionary() 13 | 14 | add := []struct { 15 | in string 16 | expected Member 17 | valid bool 18 | }{ 19 | {"f_o1o3-", NewItem(10.0), true}, 20 | {"deleteme", NewItem(""), true}, 21 | {"*f0.o*", NewItem(""), true}, 22 | {"t", NewItem(true), true}, 23 | {"f", NewItem(false), true}, 24 | {"b", NewItem([]byte{0, 1}), true}, 25 | {"0foo", NewItem(""), false}, 26 | {"mAj", NewItem(""), false}, 27 | {"_foo", NewItem(""), false}, 28 | {"foo", NewItem(Token("é")), false}, 29 | } 30 | 31 | var b strings.Builder 32 | 33 | for _, d := range add { 34 | vDict := NewDictionary() 35 | vDict.Add(d.in, d.expected) 36 | 37 | b.Reset() 38 | 39 | if valid := vDict.marshalSFV(&b) == nil; valid != d.valid { 40 | t.Errorf("(%v, %v).isValid() = %v; %v expected", d.in, d.expected, valid, d.valid) 41 | } 42 | 43 | if d.valid { 44 | dict.Add(d.in, d.expected) 45 | } 46 | } 47 | 48 | i := NewItem(123.0) 49 | dict.Add("f_o1o3-", i) 50 | 51 | newValue, _ := dict.Get("f_o1o3-") 52 | if newValue != i { 53 | t.Errorf(`Add("f_o1o3-") must overwrite the existing value`) 54 | } 55 | 56 | if !dict.Del("deleteme") { 57 | t.Errorf(`Del("deleteme") must return true`) 58 | } 59 | 60 | if dict.Del("deleteme") { 61 | t.Errorf(`the second call to Del("deleteme") must return false`) 62 | } 63 | 64 | if v, ok := dict.Get("*f0.o*"); v.(Item).Value != "" || !ok { 65 | t.Errorf(`Get("*f0.o*") = %v, %v; "", true expected`, v, ok) 66 | } 67 | 68 | if v, ok := dict.Get("notexist"); v != nil || ok { 69 | t.Errorf(`Get("notexist") = %v, %v; nil, false expected`, v, ok) 70 | } 71 | 72 | if k := dict.Names(); len(k) != 5 { 73 | t.Errorf(`Names() = %v; {"f_o1o3-", "*f0.o*"} expected`, k) 74 | } 75 | 76 | m, _ := dict.Get("f_o1o3-") 77 | i = m.(Item) 78 | i.Params.Add("foo", 9.5) 79 | 80 | b.Reset() 81 | _ = dict.marshalSFV(&b) 82 | 83 | if b.String() != `f_o1o3-=123.0;foo=9.5, *f0.o*="", t, f=?0, b=:AAE=:` { 84 | t.Errorf(`Dictionnary.marshalSFV(): invalid serialization: %v`, b.String()) 85 | } 86 | } 87 | 88 | func TestUnmarshalDictionary(t *testing.T) { 89 | t.Parallel() 90 | 91 | d1 := NewDictionary() 92 | d1.Add("a", NewItem(false)) 93 | d1.Add("b", NewItem(true)) 94 | 95 | c := NewItem(true) 96 | c.Params.Add("foo", Token("bar")) 97 | d1.Add("c", c) 98 | 99 | data := []struct { 100 | in []string 101 | expected *Dictionary 102 | valid bool 103 | }{ 104 | {[]string{"a=?0, b, c; foo=bar"}, d1, false}, 105 | {[]string{"a="}, nil, false}, 106 | {[]string{"a=?0, b", "c; foo=bar"}, d1, false}, 107 | {[]string{""}, NewDictionary(), false}, 108 | {[]string{"é"}, nil, true}, 109 | {[]string{`foo="é"`}, nil, true}, 110 | {[]string{`foo;é`}, nil, true}, 111 | {[]string{`f="foo" é`}, nil, true}, 112 | {[]string{`f="foo",`}, nil, true}, 113 | } 114 | 115 | for _, d := range data { 116 | l, err := UnmarshalDictionary(d.in) 117 | if d.valid && err == nil { 118 | t.Errorf("UnmarshalDictionary(%s): error expected", d.in) 119 | } 120 | 121 | if !d.valid && !reflect.DeepEqual(d.expected, l) { 122 | t.Errorf("UnmarshalDictionary(%s) = %v, %v; %v, expected", d.in, l, err, d.expected) 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /displaystring.go: -------------------------------------------------------------------------------- 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/rfc9651.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 | } 106 | -------------------------------------------------------------------------------- /displaystring_test.go: -------------------------------------------------------------------------------- 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, expected", d.in, i, err, d.out) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /encode.go: -------------------------------------------------------------------------------- 1 | // Package httpsfv implements serializing and parsing 2 | // of Structured Field Values for HTTP as defined in RFC 9651. 3 | // 4 | // Structured Field Values are either lists, dictionaries or items. Dedicated types are provided for all of them. 5 | // Dedicated types are also used for tokens, parameters and inner lists. 6 | // Other values are stored in native types: 7 | // 8 | // int64, for integers 9 | // float64, for decimals 10 | // string, for strings 11 | // byte[], for byte sequences 12 | // bool, for booleans 13 | // 14 | // The specification is available at https://httpwg.org/specs/rfc9651.html. 15 | package httpsfv 16 | 17 | import ( 18 | "strings" 19 | ) 20 | 21 | // marshaler is the interface implemented by types that can marshal themselves into valid SFV. 22 | type marshaler interface { 23 | marshalSFV(b *strings.Builder) error 24 | } 25 | 26 | // StructuredFieldValue represents a List, a Dictionary or an Item. 27 | type StructuredFieldValue interface { 28 | marshaler 29 | } 30 | 31 | // Marshal returns the HTTP Structured Value serialization of v 32 | // as defined in https://httpwg.org/specs/rfc9651.html#text-serialize. 33 | // 34 | // v must be a List, a Dictionary, an Item or an InnerList. 35 | func Marshal(v StructuredFieldValue) (string, error) { 36 | var b strings.Builder 37 | if err := v.marshalSFV(&b); err != nil { 38 | return "", err 39 | } 40 | 41 | return b.String(), nil 42 | } 43 | -------------------------------------------------------------------------------- /encode_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestMarshal(t *testing.T) { 9 | t.Parallel() 10 | 11 | i := NewItem(22.1) 12 | i.Params.Add("foo", true) 13 | i.Params.Add("bar", Token("baz")) 14 | 15 | d := NewDictionary() 16 | d.Add("i", i) 17 | 18 | tok := NewItem(Token("foo")) 19 | tok.Params.Add("a", "b") 20 | d.Add("tok", tok) 21 | 22 | date := NewItem(time.Date(1988, 21, 01, 0, 0, 0, 0, time.UTC)) 23 | d.Add("d", date) 24 | 25 | if res, _ := Marshal(d); res != `i=22.1;foo;bar=baz, tok=foo;a="b", d=@620611200` { 26 | t.Errorf("marshal: bad result: %q", res) 27 | } 28 | } 29 | 30 | func TestMarshalError(t *testing.T) { 31 | t.Parallel() 32 | 33 | if _, err := Marshal(NewItem(Token("à"))); err == nil { 34 | t.Errorf("marshal: error expected") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | func ExampleUnmarshalList() { 11 | h := http.Header{} 12 | h.Add("Preload", `"/member/*/author", "/member/*/comments"`) 13 | 14 | v, err := UnmarshalList(h["Preload"]) 15 | if err != nil { 16 | log.Fatalln("error: ", err) 17 | } 18 | 19 | fmt.Println("authors selector: ", v[0].(Item).Value) 20 | fmt.Println("comments selector: ", v[1].(Item).Value) 21 | // Output: 22 | // authors selector: /member/*/author 23 | // comments selector: /member/*/comments 24 | } 25 | 26 | func ExampleMarshal() { 27 | p := List{NewItem("/member/*/author"), NewItem("/member/*/comments")} 28 | 29 | v, err := Marshal(p) 30 | if err != nil { 31 | log.Fatalln("error: ", err) 32 | } 33 | 34 | h := http.Header{} 35 | h.Set("Preload", v) 36 | 37 | b := new(bytes.Buffer) 38 | _ = h.Write(b) 39 | 40 | fmt.Println(b.String()) 41 | // Output: Preload: "/member/*/author", "/member/*/comments" 42 | } 43 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dunglas/httpsfv 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunglas/httpsfv/4cd96cab33c4a28ca20f9a9a92d43ce85a9bf7ad/go.sum -------------------------------------------------------------------------------- /httpwg_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "encoding/base32" 5 | "encoding/json" 6 | "os" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | const ( 14 | ITEM = "item" 15 | LIST = "list" 16 | DICTIONARY = "dictionary" 17 | ) 18 | 19 | // test represents a test from the official test suite for the specification. 20 | // See https://github.com/httpwg/structured-field-tests. 21 | type test struct { 22 | Name string `json:"name"` 23 | Raw []string `json:"raw"` 24 | HeaderType string `json:"header_type"` 25 | Expected interface{} `json:"expected"` 26 | MustFail bool `json:"must_fail"` 27 | CanFail bool `json:"can_fail"` 28 | Canonical []string `json:"canonical"` 29 | } 30 | 31 | func valToBareItem(e interface{}) interface{} { 32 | bareItem, ok := e.(map[string]interface{}) 33 | if !ok { 34 | if number, ok := e.(json.Number); ok { 35 | if strings.Contains(number.String(), ".") { 36 | bi, _ := number.Float64() 37 | 38 | return bi 39 | } 40 | 41 | bi, _ := number.Int64() 42 | 43 | return bi 44 | } 45 | 46 | return e 47 | } 48 | 49 | switch bareItem["__type"] { 50 | case "binary": 51 | bi, _ := base32.StdEncoding.DecodeString(bareItem["value"].(string)) 52 | 53 | return bi 54 | case "token": 55 | return Token(bareItem["value"].(string)) 56 | case "date": 57 | u, _ := bareItem["value"].(json.Number).Int64() 58 | 59 | return time.Unix(u, 0) 60 | case "displaystring": 61 | return DisplayString(bareItem["value"].(string)) 62 | default: 63 | } 64 | 65 | panic("unknown type " + bareItem["__type"].(string)) 66 | } 67 | 68 | func populateParams(p *Params, e interface{}) { 69 | ex := e.([]interface{}) 70 | for _, l := range ex { 71 | v := l.([]interface{}) 72 | p.Add(v[0].(string), valToBareItem(v[1])) 73 | } 74 | } 75 | 76 | func valToItem(e interface{}) Item { 77 | if e == nil { 78 | return Item{} 79 | } 80 | 81 | ex := e.([]interface{}) 82 | i := NewItem(valToBareItem(ex[0])) 83 | populateParams(i.Params, ex[1]) 84 | 85 | return i 86 | } 87 | 88 | func valToInnerList(e []interface{}) InnerList { 89 | il := InnerList{} 90 | il.Params = NewParams() 91 | 92 | for _, i := range e[0].([]interface{}) { 93 | il.Items = append(il.Items, valToItem(i)) 94 | } 95 | 96 | populateParams(il.Params, e[1]) 97 | 98 | return il 99 | } 100 | 101 | func valToMember(e interface{}) Member { 102 | il := e.([]interface{}) 103 | if _, ok := il[0].([]interface{}); ok { 104 | return valToInnerList(il) 105 | } 106 | 107 | return valToItem(e) 108 | } 109 | 110 | func valToList(e interface{}) List { 111 | if e == nil { 112 | return nil 113 | } 114 | 115 | ex := e.([]interface{}) 116 | if len(ex) == 0 { 117 | return nil 118 | } 119 | 120 | l := List{} 121 | for _, m := range ex { 122 | l = append(l, valToMember(m)) 123 | } 124 | 125 | return l 126 | } 127 | 128 | func valToDictionary(e interface{}) *Dictionary { 129 | if e == nil { 130 | return nil 131 | } 132 | 133 | ex := e.([]interface{}) 134 | d := NewDictionary() 135 | 136 | for _, v := range ex { 137 | m := v.([]interface{}) 138 | d.Add(m[0].(string), valToMember(m[1])) 139 | } 140 | 141 | return d 142 | } 143 | 144 | func TestOfficialTestSuiteParsing(t *testing.T) { 145 | const dir = "structured-field-tests/" 146 | f, _ := os.Open(dir) 147 | files, _ := f.Readdir(-1) 148 | 149 | for _, fi := range files { 150 | n := fi.Name() 151 | if !strings.HasSuffix(n, ".json") { 152 | continue 153 | } 154 | 155 | file, _ := os.Open(dir + n) 156 | dec := json.NewDecoder(file) 157 | dec.UseNumber() 158 | 159 | var tests []test 160 | _ = dec.Decode(&tests) 161 | 162 | for _, te := range tests { 163 | t.Run(n+"/"+te.Name, func(t *testing.T) { 164 | var ( 165 | expected, got StructuredFieldValue 166 | err error 167 | ) 168 | 169 | switch te.HeaderType { 170 | case ITEM: 171 | expected = valToItem(te.Expected) 172 | got, err = UnmarshalItem(te.Raw) 173 | case LIST: 174 | expected = valToList(te.Expected) 175 | got, err = UnmarshalList(te.Raw) 176 | case DICTIONARY: 177 | expected = valToDictionary(te.Expected) 178 | got, err = UnmarshalDictionary(te.Raw) 179 | default: 180 | panic("unknown header type") 181 | } 182 | 183 | if te.MustFail && err == nil { 184 | t.Errorf("%s: %s: must fail", n, te.Name) 185 | 186 | return 187 | } 188 | 189 | if (!te.MustFail && !te.CanFail) && err != nil { 190 | t.Errorf("%s: %s: must not fail, got error %s", n, te.Name, err) 191 | 192 | return 193 | } 194 | 195 | if err == nil && !reflect.DeepEqual(expected, got) { 196 | t.Errorf("%s: %s: %#v expected, got %#v", n, te.Name, expected, got) 197 | } 198 | }) 199 | } 200 | } 201 | } 202 | 203 | func BenchmarkParsingOfficialExamples(b *testing.B) { 204 | file, _ := os.Open("structured-field-tests/examples.json") 205 | dec := json.NewDecoder(file) 206 | 207 | var tests []test 208 | _ = dec.Decode(&tests) 209 | 210 | for n := 0; n < b.N; n++ { 211 | for _, te := range tests { 212 | switch te.HeaderType { 213 | case ITEM: 214 | _, _ = UnmarshalItem(te.Raw) 215 | case LIST: 216 | _, _ = UnmarshalList(te.Raw) 217 | case DICTIONARY: 218 | _, _ = UnmarshalDictionary(te.Raw) 219 | } 220 | } 221 | } 222 | } 223 | 224 | func BenchmarkSerializingOfficialExamples(b *testing.B) { 225 | file, _ := os.Open("structured-field-tests/examples.json") 226 | dec := json.NewDecoder(file) 227 | dec.UseNumber() 228 | 229 | var tests []test 230 | _ = dec.Decode(&tests) 231 | 232 | var sfv []StructuredFieldValue 233 | 234 | for _, te := range tests { 235 | if te.CanFail || te.MustFail { 236 | continue 237 | } 238 | 239 | switch te.HeaderType { 240 | case ITEM: 241 | sfv = append(sfv, valToItem(te.Expected)) 242 | case LIST: 243 | sfv = append(sfv, valToList(te.Expected)) 244 | case DICTIONARY: 245 | sfv = append(sfv, valToDictionary(te.Expected)) 246 | } 247 | } 248 | 249 | for n := 0; n < b.N; n++ { 250 | for _, v := range sfv { 251 | _, _ = Marshal(v) 252 | } 253 | } 254 | } 255 | 256 | func TestOfficialTestSuiteSerialization(t *testing.T) { 257 | t.Parallel() 258 | 259 | const dir = "structured-field-tests/serialisation-tests/" 260 | 261 | f, _ := os.Open(dir) 262 | files, _ := f.Readdir(-1) 263 | 264 | for _, fi := range files { 265 | n := fi.Name() 266 | if !strings.HasSuffix(n, ".json") { 267 | continue 268 | } 269 | 270 | file, _ := os.Open(dir + n) 271 | dec := json.NewDecoder(file) 272 | dec.UseNumber() 273 | 274 | var tests []test 275 | _ = dec.Decode(&tests) 276 | 277 | for _, te := range tests { 278 | var sfv StructuredFieldValue 279 | 280 | switch te.HeaderType { 281 | case ITEM: 282 | sfv = valToItem(te.Expected) 283 | case LIST: 284 | sfv = valToList(te.Expected) 285 | case DICTIONARY: 286 | sfv = valToDictionary(te.Expected) 287 | default: 288 | panic("unknown header type") 289 | } 290 | 291 | canonical, err := Marshal(sfv) 292 | 293 | if te.MustFail && err == nil { 294 | t.Errorf("%s: %s: must fail", n, te.Name) 295 | 296 | continue 297 | } 298 | 299 | if (!te.MustFail && !te.CanFail) && err != nil { 300 | t.Errorf("%s: %s: must not fail, got error %s", n, te.Name, err) 301 | 302 | continue 303 | } 304 | 305 | if err == nil && te.Canonical[0] != canonical { 306 | t.Errorf("%s: %s: %#v expected, got %#v", n, te.Name, te.Canonical[0], canonical) 307 | } 308 | } 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /innerlist.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // ErrInvalidInnerListFormat is returned when an inner list format is invalid. 9 | var ErrInvalidInnerListFormat = errors.New("invalid inner list format") 10 | 11 | // InnerList represents an inner list as defined in 12 | // https://httpwg.org/specs/rfc9651.html#inner-list. 13 | type InnerList struct { 14 | Items []Item 15 | Params *Params 16 | } 17 | 18 | func (il InnerList) member() { 19 | } 20 | 21 | // marshalSFV serializes as defined in 22 | // https://httpwg.org/specs/rfc9651.html#ser-innerlist. 23 | func (il InnerList) marshalSFV(b *strings.Builder) error { 24 | if err := b.WriteByte('('); err != nil { 25 | return err 26 | } 27 | 28 | l := len(il.Items) 29 | for i := 0; i < l; i++ { 30 | if err := il.Items[i].marshalSFV(b); err != nil { 31 | return err 32 | } 33 | 34 | if i != l-1 { 35 | if err := b.WriteByte(' '); err != nil { 36 | return err 37 | } 38 | } 39 | } 40 | 41 | if err := b.WriteByte(')'); err != nil { 42 | return err 43 | } 44 | 45 | return il.Params.marshalSFV(b) 46 | } 47 | 48 | // parseInnerList parses as defined in 49 | // https://httpwg.org/specs/rfc9651.html#parse-item-or-list. 50 | func parseInnerList(s *scanner) (InnerList, error) { 51 | if s.eof() || s.data[s.off] != '(' { 52 | return InnerList{}, &UnmarshalError{s.off, ErrInvalidInnerListFormat} 53 | } 54 | s.off++ 55 | 56 | il := InnerList{nil, nil} 57 | 58 | for !s.eof() { 59 | s.scanWhileSp() 60 | 61 | if s.eof() { 62 | return InnerList{}, &UnmarshalError{s.off, ErrInvalidInnerListFormat} 63 | } 64 | 65 | if s.data[s.off] == ')' { 66 | s.off++ 67 | 68 | p, err := parseParams(s) 69 | if err != nil { 70 | return InnerList{}, err 71 | } 72 | 73 | il.Params = p 74 | 75 | return il, nil 76 | } 77 | 78 | i, err := parseItem(s) 79 | if err != nil { 80 | return InnerList{}, err 81 | } 82 | 83 | if s.eof() || (s.data[s.off] != ')' && s.data[s.off] != ' ') { 84 | return InnerList{}, &UnmarshalError{s.off, ErrInvalidInnerListFormat} 85 | } 86 | 87 | il.Items = append(il.Items, i) 88 | } 89 | 90 | return InnerList{}, &UnmarshalError{s.off, ErrInvalidInnerListFormat} 91 | } 92 | -------------------------------------------------------------------------------- /innerlist_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestInnerList(t *testing.T) { 9 | t.Parallel() 10 | 11 | foo := NewItem("foo") 12 | foo.Params.Add("a", true) 13 | foo.Params.Add("b", 1936) 14 | 15 | bar := NewItem(Token("bar")) 16 | bar.Params.Add("y", []byte{1, 3, 1, 2}) 17 | 18 | params := NewParams() 19 | params.Add("d", 18.71) 20 | 21 | i := InnerList{ 22 | []Item{foo, bar}, 23 | params, 24 | } 25 | 26 | var b strings.Builder 27 | _ = i.marshalSFV(&b) 28 | 29 | if b.String() != `("foo";a;b=1936 bar;y=:AQMBAg==:);d=18.71` { 30 | t.Errorf("invalid marshalSFV(): %v", b.String()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /integer.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "strconv" 7 | ) 8 | 9 | const maxDigit = 12 10 | 11 | // ErrNotDigit is returned when a character should be a digit but isn't. 12 | var ErrNotDigit = errors.New("character is not a digit") 13 | 14 | // ErrNumberOutOfRange is returned when the number is too large according to the specification. 15 | var ErrNumberOutOfRange = errors.New("integer or decimal out of range") 16 | 17 | // ErrInvalidDecimalFormat is returned when the decimal format is invalid. 18 | var ErrInvalidDecimalFormat = errors.New("invalid decimal format") 19 | 20 | const ( 21 | typeInteger = iota 22 | typeDecimal 23 | ) 24 | 25 | // marshalInteger serializes as defined in 26 | // https://httpwg.org/specs/rfc9651.html#integer. 27 | func marshalInteger(b io.StringWriter, i int64) error { 28 | if i < -999999999999999 || i > 999999999999999 { 29 | return ErrNumberOutOfRange 30 | } 31 | 32 | _, err := b.WriteString(strconv.FormatInt(i, 10)) 33 | 34 | return err 35 | } 36 | 37 | // parseNumber parses as defined in 38 | // https://httpwg.org/specs/rfc9651.html#parse-number. 39 | func parseNumber(s *scanner) (interface{}, error) { 40 | neg := isNeg(s) 41 | if neg && s.eof() { 42 | return 0, &UnmarshalError{s.off, ErrUnexpectedEndOfString} 43 | } 44 | 45 | if !isDigit(s.data[s.off]) { 46 | return 0, &UnmarshalError{s.off, ErrNotDigit} 47 | } 48 | 49 | start := s.off 50 | s.off++ 51 | 52 | var ( 53 | decSepOff int 54 | t = typeInteger 55 | ) 56 | 57 | for s.off < len(s.data) { 58 | size := s.off - start 59 | if (t == typeInteger && (size >= 15)) || size >= 16 { 60 | return 0, &UnmarshalError{s.off, ErrNumberOutOfRange} 61 | } 62 | 63 | c := s.data[s.off] 64 | if isDigit(c) { 65 | s.off++ 66 | 67 | continue 68 | } 69 | 70 | if t == typeInteger && c == '.' { 71 | if size > maxDigit { 72 | return 0, &UnmarshalError{s.off, ErrNumberOutOfRange} 73 | } 74 | 75 | t = typeDecimal 76 | decSepOff = s.off 77 | s.off++ 78 | 79 | continue 80 | } 81 | 82 | break 83 | } 84 | 85 | str := s.data[start:s.off] 86 | 87 | if t == typeInteger { 88 | return parseInteger(str, neg, s.off) 89 | } 90 | 91 | return parseDecimal(s, decSepOff, str, neg) 92 | } 93 | 94 | func isNeg(s *scanner) bool { 95 | if s.data[s.off] == '-' { 96 | s.off++ 97 | 98 | return true 99 | } 100 | 101 | return false 102 | } 103 | 104 | func parseInteger(str string, neg bool, off int) (int64, error) { 105 | i, err := strconv.ParseInt(str, 10, 64) 106 | if err != nil { 107 | // Should never happen 108 | return 0, &UnmarshalError{off, err} 109 | } 110 | 111 | if neg { 112 | i = -i 113 | } 114 | 115 | if i < -999999999999999 || i > 999999999999999 { 116 | return 0, &UnmarshalError{off, ErrNumberOutOfRange} 117 | } 118 | 119 | return i, err 120 | } 121 | -------------------------------------------------------------------------------- /integer_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestMarshalInteger(t *testing.T) { 9 | t.Parallel() 10 | 11 | data := []struct { 12 | in int64 13 | expected string 14 | valid bool 15 | }{ 16 | {10, "10", true}, 17 | {-10, "-10", true}, 18 | {0, "0", true}, 19 | {-999999999999999, "-999999999999999", true}, 20 | {999999999999999, "999999999999999", true}, 21 | {-9999999999999999, "", false}, 22 | {9999999999999999, "", false}, 23 | } 24 | 25 | var b strings.Builder 26 | 27 | for _, d := range data { 28 | b.Reset() 29 | 30 | err := marshalInteger(&b, d.in) 31 | if d.valid && err != nil { 32 | t.Errorf("error not expected for %v, got %v", d.in, err) 33 | } else if !d.valid && err == nil { 34 | t.Errorf("error expected for %v, got %v", d.in, err) 35 | } 36 | 37 | if b.String() != d.expected { 38 | t.Errorf("got %v; want %v", b.String(), d.expected) 39 | } 40 | } 41 | } 42 | 43 | func TestParseIntegerOrDecimal(t *testing.T) { 44 | t.Parallel() 45 | 46 | data := []struct { 47 | in string 48 | expected interface{} 49 | valid bool 50 | }{ 51 | {"1871", int64(1871), false}, 52 | {"-1871", int64(-1871), false}, 53 | {"18.71", 18.71, false}, 54 | {"-18.71", -18.71, false}, 55 | {"1871next", int64(1871), false}, 56 | {"-18.71next", -18.71, false}, 57 | {"-18.710", -18.71, false}, 58 | {"a", 0, true}, 59 | {"10.", 0, true}, 60 | {"10.1234", 0, true}, 61 | {"-", 0, true}, 62 | {"1234567890123456", 0, true}, 63 | {"123456789012345.6", 0, true}, 64 | {"1234567890123.", 0, true}, 65 | {"-9999999999999991", 0, true}, 66 | {"9999999999999991", 0, true}, 67 | } 68 | 69 | for _, d := range data { 70 | s := &scanner{data: d.in} 71 | 72 | i, err := parseNumber(s) 73 | if d.valid && err == nil { 74 | t.Errorf("parseIntegerOrDecimal(%s): error expected", d.in) 75 | } 76 | 77 | if !d.valid && d.expected != i { 78 | t.Errorf("parseIntegerOrDecimal(%s) = %v, %v; %v, expected", d.in, i, err, d.expected) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /item.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Item is a bare value and associated parameters. 8 | // See https://httpwg.org/specs/rfc9651.html#item. 9 | type Item struct { 10 | Value interface{} 11 | Params *Params 12 | } 13 | 14 | // NewItem returns a new Item. 15 | func NewItem(v interface{}) Item { 16 | assertBareItem(v) 17 | 18 | return Item{v, NewParams()} 19 | } 20 | 21 | func (i Item) member() { 22 | } 23 | 24 | // marshalSFV serializes as defined in 25 | // https://httpwg.org/specs/rfc9651.html#ser-item. 26 | func (i Item) marshalSFV(b *strings.Builder) error { 27 | if i.Value == nil { 28 | return ErrInvalidBareItem 29 | } 30 | 31 | if err := marshalBareItem(b, i.Value); err != nil { 32 | return err 33 | } 34 | 35 | return i.Params.marshalSFV(b) 36 | } 37 | 38 | // UnmarshalItem parses an item as defined in 39 | // https://httpwg.org/specs/rfc9651.html#parse-item. 40 | func UnmarshalItem(v []string) (Item, error) { 41 | s := &scanner{ 42 | data: strings.Join(v, ","), 43 | } 44 | 45 | s.scanWhileSp() 46 | 47 | sfv, err := parseItem(s) 48 | if err != nil { 49 | return Item{}, err 50 | } 51 | 52 | s.scanWhileSp() 53 | 54 | if !s.eof() { 55 | return Item{}, &UnmarshalError{off: s.off} 56 | } 57 | 58 | return sfv, nil 59 | } 60 | 61 | func parseItem(s *scanner) (Item, error) { 62 | bi, err := parseBareItem(s) 63 | if err != nil { 64 | return Item{}, err 65 | } 66 | 67 | p, err := parseParams(s) 68 | if err != nil { 69 | return Item{}, err 70 | } 71 | 72 | return Item{bi, p}, nil 73 | } 74 | -------------------------------------------------------------------------------- /item_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestMarshalItem(t *testing.T) { 10 | t.Parallel() 11 | 12 | data := []struct { 13 | in Item 14 | expected string 15 | valid bool 16 | }{ 17 | {NewItem(0), "0", true}, 18 | {NewItem(int8(-42)), "-42", true}, 19 | {NewItem(int16(-42)), "-42", true}, 20 | {NewItem(int32(-42)), "-42", true}, 21 | {NewItem(int64(-42)), "-42", true}, 22 | {NewItem(uint(42)), "42", true}, 23 | {NewItem(uint8(42)), "42", true}, 24 | {NewItem(uint16(42)), "42", true}, 25 | {NewItem(uint32(42)), "42", true}, 26 | {NewItem(uint64(42)), "42", true}, 27 | {NewItem(1.1), "1.1", true}, 28 | {NewItem(""), `""`, true}, 29 | {NewItem(Token("foo")), "foo", true}, 30 | {NewItem([]byte{0, 1}), ":AAE=:", true}, 31 | {NewItem(false), "?0", true}, 32 | {NewItem(int64(9999999999999999)), "", false}, 33 | {NewItem(9999999999999999.22), "", false}, 34 | {NewItem("Kévin"), "", false}, 35 | {NewItem(Token("/foo")), "", false}, 36 | {Item{}, "", false}, 37 | } 38 | 39 | for _, d := range data { 40 | r, err := Marshal(d.in) 41 | if d.valid && err != nil { 42 | t.Errorf("error not expected for %v, got %v", d.in, err) 43 | } else if !d.valid && err == nil { 44 | t.Errorf("error expected for %v, got %v", d.in, err) 45 | } 46 | 47 | if r != d.expected { 48 | t.Errorf("got %v; want %v", r, d.expected) 49 | } 50 | } 51 | } 52 | 53 | func TestParseItemParamsMarshalSFV(t *testing.T) { 54 | t.Parallel() 55 | 56 | i := NewItem(Token("bar")) 57 | i.Params.Add("foo", 0.0) 58 | i.Params.Add("baz", true) 59 | 60 | var b strings.Builder 61 | _ = i.marshalSFV(&b) 62 | 63 | if b.String() != "bar;foo=0.0;baz" { 64 | t.Error("marshalSFV(): invalid") 65 | } 66 | } 67 | 68 | func TestUnmarshalItem(t *testing.T) { 69 | t.Parallel() 70 | 71 | i1 := NewItem(true) 72 | i1.Params.Add("foo", true) 73 | i1.Params.Add("*bar", Token("tok")) 74 | 75 | data := []struct { 76 | in []string 77 | expected Item 78 | valid bool 79 | }{ 80 | {[]string{"?1;foo;*bar=tok"}, i1, false}, 81 | {[]string{" ?1;foo;*bar=tok "}, i1, false}, 82 | {[]string{`"foo`, `bar"`}, NewItem("foo,bar"), false}, 83 | {[]string{"é", ""}, Item{}, true}, 84 | {[]string{"tok;é"}, Item{}, true}, 85 | {[]string{" ?1;foo;*bar=tok é"}, Item{}, true}, 86 | } 87 | 88 | for _, d := range data { 89 | i, err := UnmarshalItem(d.in) 90 | if d.valid && err == nil { 91 | t.Errorf("UnmarshalItem(%s): error expected", d.in) 92 | } 93 | 94 | if !d.valid && !reflect.DeepEqual(d.expected, i) { 95 | t.Errorf("UnmarshalItem(%s) = %v, %v; %v, expected", d.in, i, err, d.expected) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /key.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | // ErrInvalidKeyFormat is returned when the format of a parameter or dictionary key is invalid. 10 | var ErrInvalidKeyFormat = errors.New("invalid key format") 11 | 12 | // isKeyChar checks if c is a valid key characters. 13 | func isKeyChar(c byte) bool { 14 | if isLowerCaseAlpha(c) || isDigit(c) { 15 | return true 16 | } 17 | 18 | switch c { 19 | case '_', '-', '.', '*': 20 | return true 21 | } 22 | 23 | return false 24 | } 25 | 26 | // checkKey checks if the given value is a valid parameter key according to 27 | // https://httpwg.org/specs/rfc9651.html#param. 28 | func checkKey(k string) error { 29 | if len(k) == 0 { 30 | return fmt.Errorf("a key cannot be empty: %w", ErrInvalidKeyFormat) 31 | } 32 | 33 | if !isLowerCaseAlpha(k[0]) && k[0] != '*' { 34 | return fmt.Errorf("a key must start with a lower case alpha character or *: %w", ErrInvalidKeyFormat) 35 | } 36 | 37 | for i := 1; i < len(k); i++ { 38 | if !isKeyChar(k[i]) { 39 | return fmt.Errorf("the character %c isn't allowed in a key: %w", k[i], ErrInvalidKeyFormat) 40 | } 41 | } 42 | 43 | return nil 44 | } 45 | 46 | // marshalKey serializes as defined in 47 | // https://httpwg.org/specs/rfc9651.html#ser-key. 48 | func marshalKey(b io.StringWriter, k string) error { 49 | if err := checkKey(k); err != nil { 50 | return err 51 | } 52 | 53 | _, err := b.WriteString(k) 54 | 55 | return err 56 | } 57 | 58 | // parseKey parses as defined in 59 | // https://httpwg.org/specs/rfc9651.html#parse-key. 60 | func parseKey(s *scanner) (string, error) { 61 | if s.eof() { 62 | return "", &UnmarshalError{s.off, ErrInvalidKeyFormat} 63 | } 64 | 65 | c := s.data[s.off] 66 | if !isLowerCaseAlpha(c) && c != '*' { 67 | return "", &UnmarshalError{s.off, ErrInvalidKeyFormat} 68 | } 69 | 70 | start := s.off 71 | s.off++ 72 | 73 | for !s.eof() { 74 | if !isKeyChar(s.data[s.off]) { 75 | break 76 | } 77 | s.off++ 78 | } 79 | 80 | return s.data[start:s.off], nil 81 | } 82 | -------------------------------------------------------------------------------- /key_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestMarshalKey(t *testing.T) { 9 | t.Parallel() 10 | 11 | data := []struct { 12 | in string 13 | expected string 14 | valid bool 15 | }{ 16 | {"f1oo", "f1oo", true}, 17 | {"*foo0", "*foo0", true}, 18 | {"", "", false}, 19 | {"1foo", "", false}, 20 | {"fOo", "", false}, 21 | } 22 | 23 | var b strings.Builder 24 | 25 | for _, d := range data { 26 | b.Reset() 27 | 28 | err := marshalKey(&b, d.in) 29 | if d.valid && err != nil { 30 | t.Errorf("error not expected for %v, got %v", d.in, err) 31 | } else if !d.valid && err == nil { 32 | t.Errorf("error expected for %v, got %v", d.in, err) 33 | } 34 | 35 | if b.String() != d.expected { 36 | t.Errorf("got %v; want %v", b.String(), d.expected) 37 | } 38 | } 39 | } 40 | 41 | func TestParseKey(t *testing.T) { 42 | t.Parallel() 43 | 44 | data := []struct { 45 | in string 46 | expected string 47 | err bool 48 | }{ 49 | {"t", "t", false}, 50 | {"tok", "tok", false}, 51 | {"*k-.*", "*k-.*", false}, 52 | {"k=", "k", false}, 53 | {"", "", true}, 54 | {"é", "", true}, 55 | } 56 | 57 | for _, d := range data { 58 | s := &scanner{data: d.in} 59 | 60 | i, err := parseKey(s) 61 | if d.err && err == nil { 62 | t.Errorf("parseKey(%s): error expected", d.in) 63 | } 64 | 65 | if !d.err && d.expected != i { 66 | t.Errorf("parseKey(%s) = %v, %v; %v, expected", d.in, i, err, d.expected) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /list.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // ErrInvalidListFormat is returned when the format of a list is invalid. 9 | var ErrInvalidListFormat = errors.New("invalid list format") 10 | 11 | // List contains items an inner lists. 12 | // 13 | // See https://httpwg.org/specs/rfc9651.html#list 14 | type List []Member 15 | 16 | // marshalSFV serializes as defined in 17 | // https://httpwg.org/specs/rfc9651.html#ser-list. 18 | func (l List) marshalSFV(b *strings.Builder) error { 19 | s := len(l) 20 | for i := 0; i < s; i++ { 21 | if err := l[i].marshalSFV(b); err != nil { 22 | return err 23 | } 24 | 25 | if i != s-1 { 26 | if _, err := b.WriteString(", "); err != nil { 27 | return err 28 | } 29 | } 30 | } 31 | 32 | return nil 33 | } 34 | 35 | // UnmarshalList parses a list as defined in 36 | // https://httpwg.org/specs/rfc9651.html#parse-list. 37 | func UnmarshalList(v []string) (List, error) { 38 | s := &scanner{ 39 | data: strings.Join(v, ","), 40 | } 41 | 42 | s.scanWhileSp() 43 | 44 | sfv, err := parseList(s) 45 | if err != nil { 46 | return List{}, err 47 | } 48 | 49 | return sfv, nil 50 | } 51 | 52 | // parseList parses as defined in 53 | // https://httpwg.org/specs/rfc9651.html#parse-list. 54 | func parseList(s *scanner) (List, error) { 55 | var l List 56 | 57 | for !s.eof() { 58 | m, err := parseItemOrInnerList(s) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | l = append(l, m) 64 | 65 | s.scanWhileOWS() 66 | 67 | if s.eof() { 68 | return l, nil 69 | } 70 | 71 | if s.data[s.off] != ',' { 72 | return nil, &UnmarshalError{s.off, ErrInvalidListFormat} 73 | } 74 | s.off++ 75 | 76 | s.scanWhileOWS() 77 | 78 | if s.eof() { 79 | // there is a trailing comma 80 | return nil, &UnmarshalError{s.off, ErrInvalidListFormat} 81 | } 82 | } 83 | 84 | return l, nil 85 | } 86 | 87 | // parseItemOrInnerList parses as defined in 88 | // https://httpwg.org/specs/rfc9651.html#parse-item-or-list. 89 | func parseItemOrInnerList(s *scanner) (Member, error) { 90 | if s.eof() { 91 | return nil, &UnmarshalError{s.off, ErrInvalidInnerListFormat} 92 | } 93 | 94 | if s.data[s.off] == '(' { 95 | return parseInnerList(s) 96 | } 97 | 98 | return parseItem(s) 99 | } 100 | -------------------------------------------------------------------------------- /list_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestMarshalList(t *testing.T) { 9 | t.Parallel() 10 | 11 | params := NewParams() 12 | params.Add("foo", true) 13 | params.Add("bar", Token("baz")) 14 | 15 | tokItem := NewItem(Token("tok")) 16 | tokItem.Params.Add("tp1", 42.42) 17 | tokItem.Params.Add("tp2", []byte{0, 1}) 18 | 19 | il := InnerList{ 20 | []Item{NewItem("il"), tokItem}, 21 | NewParams(), 22 | } 23 | il.Params.Add("ilp1", true) 24 | il.Params.Add("ilp2", false) 25 | 26 | data := []struct { 27 | in List 28 | expected string 29 | valid bool 30 | }{ 31 | {List{}, "", true}, 32 | {List{NewItem(true)}, "?1", true}, 33 | {List{Item{"hello", params}}, `"hello";foo;bar=baz`, true}, 34 | {List{il, Item{"hi", params}}, `("il" tok;tp1=42.42;tp2=:AAE=:);ilp1;ilp2=?0, "hi";foo;bar=baz`, true}, 35 | {List{NewItem(Token("é"))}, "", false}, 36 | {List{Item{}}, "", false}, 37 | } 38 | 39 | for _, d := range data { 40 | r, err := Marshal(d.in) 41 | if d.valid && err != nil { 42 | t.Errorf("error not expected for %v, got %v", d.in, err) 43 | } else if !d.valid && err == nil { 44 | t.Errorf("error expected for %v, got %v", d.in, err) 45 | } 46 | 47 | if r != d.expected { 48 | t.Errorf("got %v; want %v", r, d.expected) 49 | } 50 | } 51 | } 52 | 53 | func TestUnmarshalList(t *testing.T) { 54 | t.Parallel() 55 | 56 | l1 := List{Item{Token("foo"), NewParams()}, Item{Token("bar"), NewParams()}} 57 | 58 | il2 := Item{"foo", NewParams()} 59 | l2 := List{il2} 60 | il2.Params.Add("bar", true) 61 | il2.Params.Add("baz", Token("tok")) 62 | 63 | il3 := InnerList{[]Item{{Token("foo"), NewParams()}, {Token("bar"), NewParams()}}, NewParams()} 64 | il3.Params.Add("bat", true) 65 | l3 := List{il3} 66 | 67 | data := []struct { 68 | in []string 69 | out List 70 | err bool 71 | }{ 72 | {[]string{""}, nil, false}, 73 | {[]string{"foo,bar"}, l1, false}, 74 | {[]string{"foo, bar"}, l1, false}, 75 | {[]string{"foo,\t bar"}, l1, false}, 76 | {[]string{"foo", "bar"}, l1, false}, 77 | {[]string{`"foo";bar;baz=tok`}, l2, false}, 78 | {[]string{`(foo bar);bat`}, l3, false}, 79 | {[]string{`()`}, List{InnerList{nil, NewParams()}}, false}, 80 | {[]string{` "foo";bar;baz=tok, (foo bar);bat `}, List{il2, il3}, false}, 81 | {[]string{`foo,bar,`}, nil, true}, 82 | {[]string{`foo,baré`}, nil, true}, 83 | {[]string{`é`}, nil, true}, 84 | {[]string{`foo,"bar" é`}, nil, true}, 85 | {[]string{`(foo `}, nil, true}, 86 | {[]string{`(foo);é`}, nil, true}, 87 | {[]string{`("é")`}, nil, true}, 88 | {[]string{`(""`}, nil, true}, 89 | {[]string{`(`}, nil, true}, 90 | } 91 | 92 | for _, d := range data { 93 | l, err := UnmarshalList(d.in) 94 | if d.err && err == nil { 95 | t.Errorf("UnmarshalList(%s): error expected", d.in) 96 | } 97 | 98 | if !d.err && !reflect.DeepEqual(d.out, l) { 99 | t.Errorf("UnmarshalList(%s) = %t, %v; %t, expected", d.in, l, err, d.out) 100 | } 101 | } 102 | } 103 | 104 | func FuzzUnmarshalList(f *testing.F) { 105 | testCases := []string{"", 106 | "foo,bar", 107 | "foo, bar", 108 | "foo,\t bar", 109 | "foo", "bar", 110 | `"foo";bar;baz=tok`, 111 | `(foo bar);bat`, 112 | `()`, 113 | ` "foo";bar;baz=tok, (foo bar);bat `, 114 | `foo,bar,`, 115 | `foo,baré`, 116 | `é`, 117 | `foo,"bar" é`, 118 | `(foo `, 119 | `(foo);é`, 120 | `("é")`, 121 | `(""`, 122 | `(`, 123 | "1.9", 124 | } 125 | 126 | for _, t := range testCases { 127 | f.Add(t) 128 | } 129 | 130 | f.Fuzz(func(t *testing.T, b string) { 131 | unmarshaled, err := UnmarshalList([]string{b}) 132 | if err != nil { 133 | return 134 | } 135 | 136 | reMarshaled, err := Marshal(unmarshaled) 137 | if err != nil { 138 | t.Errorf("Unexpected marshaling error %q for %q, %#v", err, b, unmarshaled) 139 | } 140 | 141 | reUnmarshaled, err := UnmarshalList([]string{reMarshaled}) 142 | if err != nil { 143 | t.Errorf("Unexpected remarshaling error %q for %q; original %q", err, reMarshaled, b) 144 | } 145 | 146 | if !reflect.DeepEqual(unmarshaled, reUnmarshaled) { 147 | t.Errorf("Unmarshaled and re-unmarshaled doesn't match: %#v; %#v", unmarshaled, reUnmarshaled) 148 | } 149 | }) 150 | } 151 | -------------------------------------------------------------------------------- /member.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | // Member is a marker interface for members of dictionaries and lists. 4 | // 5 | // See https://httpwg.org/specs/rfc9651.html#list. 6 | type Member interface { 7 | member() 8 | marshaler 9 | } 10 | -------------------------------------------------------------------------------- /params.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // Params are an ordered map of key-value pairs that are associated with an item or an inner list. 9 | // 10 | // See https://httpwg.org/specs/rfc9651.html#param. 11 | type Params struct { 12 | names []string 13 | values map[string]interface{} 14 | } 15 | 16 | // ErrInvalidParameterFormat is returned when the format of a parameter is invalid. 17 | var ErrInvalidParameterFormat = errors.New("invalid parameter format") 18 | 19 | // ErrInvalidParameterValue is returned when a parameter key is invalid. 20 | var ErrInvalidParameterValue = errors.New("invalid parameter value") 21 | 22 | // ErrMissingParameters is returned when the Params structure is missing from the element. 23 | var ErrMissingParameters = errors.New("missing parameters") 24 | 25 | // NewParams creates a new ordered map. 26 | func NewParams() *Params { 27 | p := Params{} 28 | p.names = []string{} 29 | p.values = map[string]interface{}{} 30 | 31 | return &p 32 | } 33 | 34 | // Get retrieves a parameter. 35 | func (p *Params) Get(k string) (interface{}, bool) { 36 | v, ok := p.values[k] 37 | 38 | return v, ok 39 | } 40 | 41 | // Add appends a new parameter to the ordered list. 42 | // If the key already exists, overwrite its value. 43 | func (p *Params) Add(k string, v interface{}) { 44 | assertBareItem(v) 45 | 46 | if _, exists := p.values[k]; !exists { 47 | p.names = append(p.names, k) 48 | } 49 | 50 | p.values[k] = v 51 | } 52 | 53 | // Del removes a parameter from the ordered list. 54 | func (p *Params) Del(key string) bool { 55 | if _, ok := p.values[key]; !ok { 56 | return false 57 | } 58 | 59 | for i, k := range p.names { 60 | if k == key { 61 | p.names = append(p.names[:i], p.names[i+1:]...) 62 | 63 | break 64 | } 65 | } 66 | 67 | delete(p.values, key) 68 | 69 | return true 70 | } 71 | 72 | // Names retrieves the list of parameter names in the appropriate order. 73 | func (p *Params) Names() []string { 74 | return p.names 75 | } 76 | 77 | // marshalSFV serializes as defined in 78 | // https://httpwg.org/specs/rfc9651.html#ser-params. 79 | func (p *Params) marshalSFV(b *strings.Builder) error { 80 | if p == nil { 81 | return ErrMissingParameters 82 | } 83 | for _, k := range p.names { 84 | if err := b.WriteByte(';'); err != nil { 85 | return err 86 | } 87 | 88 | if err := marshalKey(b, k); err != nil { 89 | return err 90 | } 91 | 92 | v := p.values[k] 93 | if v == true { 94 | continue 95 | } 96 | 97 | if err := b.WriteByte('='); err != nil { 98 | return err 99 | } 100 | 101 | if err := marshalBareItem(b, v); err != nil { 102 | return err 103 | } 104 | } 105 | 106 | return nil 107 | } 108 | 109 | // parseParams parses as defined in 110 | // https://httpwg.org/specs/rfc9651.html#parse-param. 111 | func parseParams(s *scanner) (*Params, error) { 112 | p := NewParams() 113 | 114 | for !s.eof() { 115 | if s.data[s.off] != ';' { 116 | break 117 | } 118 | s.off++ 119 | s.scanWhileSp() 120 | 121 | k, err := parseKey(s) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | var i interface{} 127 | 128 | if !s.eof() && s.data[s.off] == '=' { 129 | s.off++ 130 | 131 | i, err = parseBareItem(s) 132 | if err != nil { 133 | return nil, err 134 | } 135 | } else { 136 | i = true 137 | } 138 | 139 | p.Add(k, i) 140 | } 141 | 142 | return p, nil 143 | } 144 | -------------------------------------------------------------------------------- /params_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestMarshalParameters(t *testing.T) { 10 | t.Parallel() 11 | 12 | p := NewParams() 13 | 14 | add := []struct { 15 | in string 16 | expected interface{} 17 | valid bool 18 | }{ 19 | {"f_o1o3-", 10.0, true}, 20 | {"deleteme", "", true}, 21 | {"*f0.o*", "", true}, 22 | {"t", true, true}, 23 | {"f", false, true}, 24 | {"b", []byte{0, 1}, true}, 25 | {"0foo", "", false}, 26 | {"mAj", "", false}, 27 | {"_foo", "", false}, 28 | {"foo", "é", false}, 29 | } 30 | 31 | var b strings.Builder 32 | 33 | for _, d := range add { 34 | vParams := NewParams() 35 | vParams.Add(d.in, d.expected) 36 | 37 | b.Reset() 38 | 39 | if valid := vParams.marshalSFV(&b) == nil; valid != d.valid { 40 | t.Errorf("(%v, %v).isValid() = %v; %v expected", d.in, d.expected, valid, d.valid) 41 | } 42 | 43 | if d.valid { 44 | p.Add(d.in, d.expected) 45 | } 46 | } 47 | 48 | p.Add("f_o1o3-", 123.0) 49 | 50 | newValue, _ := p.Get("f_o1o3-") 51 | if newValue != 123.0 { 52 | t.Errorf(`Add("f_o1o3-") must overwrite the existing value`) 53 | } 54 | 55 | if !p.Del("deleteme") { 56 | t.Errorf(`Del("deleteme") must return true`) 57 | } 58 | 59 | if p.Del("deleteme") { 60 | t.Errorf(`the second call to Del("deleteme") must return false`) 61 | } 62 | 63 | if v, ok := p.Get("*f0.o*"); v != "" || !ok { 64 | t.Errorf(`Get("*f0.o*") = %v, %v; "", true expected`, v, ok) 65 | } 66 | 67 | if v, ok := p.Get("notexist"); v != nil || ok { 68 | t.Errorf(`Get("notexist") = %v, %v; nil, false expected`, v, ok) 69 | } 70 | 71 | if k := p.Names(); len(k) != 5 { 72 | t.Errorf(`Names() = %v; {"f_o1o3-", "*f0.o*"} expected`, k) 73 | } 74 | 75 | b.Reset() 76 | 77 | if err := p.marshalSFV(&b); b.String() != `;f_o1o3-=123.0;*f0.o*="";t;f=?0;b=:AAE=:` { 78 | t.Errorf(`marshalSFV(): invalid serialization: %v (%v)`, b.String(), err) 79 | } 80 | } 81 | 82 | func TestParseParameters(t *testing.T) { 83 | t.Parallel() 84 | 85 | p0 := NewParams() 86 | p0.Add("foo", true) 87 | p0.Add("*bar", "baz") 88 | 89 | data := []struct { 90 | in string 91 | out *Params 92 | err bool 93 | }{ 94 | {`;foo=?1;*bar="baz" foo`, p0, false}, 95 | {`;foo;*bar="baz" foo`, p0, false}, 96 | {`;é=?0`, p0, true}, 97 | {`;foo=é`, p0, true}, 98 | } 99 | 100 | for _, d := range data { 101 | s := &scanner{data: d.in} 102 | 103 | p, err := parseParams(s) 104 | if d.err && err == nil { 105 | t.Errorf("parseParameters(%s): error expected", d.in) 106 | } 107 | 108 | if !d.err && !reflect.DeepEqual(p, d.out) { 109 | t.Errorf("parseParameters(%s) = %v, %v; %v, expected", d.in, p, err, d.out) 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /string.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | // ErrInvalidStringFormat is returned when a string format is invalid. 10 | var ErrInvalidStringFormat = errors.New("invalid string format") 11 | 12 | // marshalString serializes as defined in 13 | // https://httpwg.org/specs/rfc9651.html#ser-string. 14 | func marshalString(b *strings.Builder, s string) error { 15 | if err := b.WriteByte('"'); err != nil { 16 | return err 17 | } 18 | 19 | for i := 0; i < len(s); i++ { 20 | if s[i] <= '\u001F' || s[i] >= unicode.MaxASCII { 21 | return ErrInvalidStringFormat 22 | } 23 | 24 | switch s[i] { 25 | case '"', '\\': 26 | if err := b.WriteByte('\\'); err != nil { 27 | return err 28 | } 29 | } 30 | 31 | if err := b.WriteByte(s[i]); err != nil { 32 | return err 33 | } 34 | } 35 | 36 | if err := b.WriteByte('"'); err != nil { 37 | return err 38 | } 39 | 40 | return nil 41 | } 42 | 43 | // parseString parses as defined in 44 | // https://httpwg.org/specs/rfc9651.html#parse-string. 45 | func parseString(s *scanner) (string, error) { 46 | if s.eof() || s.data[s.off] != '"' { 47 | return "", &UnmarshalError{s.off, ErrInvalidStringFormat} 48 | } 49 | s.off++ 50 | 51 | var b strings.Builder 52 | 53 | for !s.eof() { 54 | c := s.data[s.off] 55 | s.off++ 56 | 57 | switch c { 58 | case '\\': 59 | if s.eof() { 60 | return "", &UnmarshalError{s.off, ErrInvalidStringFormat} 61 | } 62 | 63 | n := s.data[s.off] 64 | if n != '"' && n != '\\' { 65 | return "", &UnmarshalError{s.off, ErrInvalidStringFormat} 66 | } 67 | s.off++ 68 | 69 | if err := b.WriteByte(n); err != nil { 70 | return "", err 71 | } 72 | 73 | continue 74 | case '"': 75 | return b.String(), nil 76 | default: 77 | if c <= '\u001F' || c >= unicode.MaxASCII { 78 | return "", &UnmarshalError{s.off, ErrInvalidStringFormat} 79 | } 80 | 81 | if err := b.WriteByte(c); err != nil { 82 | return "", err 83 | } 84 | } 85 | } 86 | 87 | return "", &UnmarshalError{s.off, ErrInvalidStringFormat} 88 | } 89 | -------------------------------------------------------------------------------- /string_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "unicode" 7 | ) 8 | 9 | func TestMarshalString(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 | {`f"oo`, `"f\"oo"`, true}, 19 | {`f\oo`, `"f\\oo"`, true}, 20 | {`f\"oo`, `"f\\\"oo"`, true}, 21 | {"", `""`, true}, 22 | {"H3lLo", `"H3lLo"`, true}, 23 | {"hel\tlo", `"hel`, false}, 24 | {"hel\x1flo", `"hel`, false}, 25 | {"hel\x7flo", `"hel`, false}, 26 | {"Kévin", `"K`, false}, 27 | {"\t", `"`, false}, 28 | } 29 | 30 | var b strings.Builder 31 | 32 | for _, d := range data { 33 | b.Reset() 34 | 35 | err := marshalString(&b, d.in) 36 | if d.valid && err != nil { 37 | t.Errorf("error not expected for %v, got %v", d.in, err) 38 | } else if !d.valid && err == nil { 39 | t.Errorf("error expected for %v, got %v", d.in, err) 40 | } 41 | 42 | if b.String() != d.expected { 43 | t.Errorf("got %v; want %v", b.String(), d.expected) 44 | } 45 | } 46 | } 47 | 48 | func TestParseString(t *testing.T) { 49 | t.Parallel() 50 | 51 | data := []struct { 52 | in string 53 | out string 54 | err bool 55 | }{ 56 | {`"foo"`, "foo", false}, 57 | {`"b\"a\\r"`, `b"a\r`, false}, 58 | {"", "", true}, 59 | {"a", "", true}, 60 | {`"\`, "", true}, 61 | {`"\o`, "", true}, 62 | {string([]byte{'"', 0}), "", true}, 63 | {string([]byte{'"', unicode.MaxASCII}), "", true}, 64 | {`"foo`, "", true}, 65 | } 66 | 67 | for _, d := range data { 68 | s := &scanner{data: d.in} 69 | 70 | i, err := parseString(s) 71 | if d.err && err == nil { 72 | t.Errorf("parseString(%s): error expected", d.in) 73 | } 74 | 75 | if !d.err && d.out != i { 76 | t.Errorf("parseString(%s) = %v, %v; %v, expected", d.in, i, err, d.out) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /token.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | // isExtendedTchar checks if c is a valid token character as defined in the spec. 10 | func isExtendedTchar(c byte) bool { 11 | if isAlpha(c) || isDigit(c) { 12 | return true 13 | } 14 | 15 | switch c { 16 | case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~', ':', '/': 17 | return true 18 | } 19 | 20 | return false 21 | } 22 | 23 | // ErrInvalidTokenFormat is returned when a token format is invalid. 24 | var ErrInvalidTokenFormat = errors.New("invalid token format") 25 | 26 | // Token represents a token as defined in 27 | // https://httpwg.org/specs/rfc9651.html#token. 28 | // A specific type is used to distinguish tokens from strings. 29 | type Token string 30 | 31 | // marshalSFV serializes as defined in 32 | // https://httpwg.org/specs/rfc9651.html#ser-token. 33 | func (t Token) marshalSFV(b io.StringWriter) error { 34 | if len(t) == 0 { 35 | return fmt.Errorf("a token cannot be empty: %w", ErrInvalidTokenFormat) 36 | } 37 | 38 | if !isAlpha(t[0]) && t[0] != '*' { 39 | return fmt.Errorf("a token must start with an alpha character or *: %w", ErrInvalidTokenFormat) 40 | } 41 | 42 | for i := 1; i < len(t); i++ { 43 | if !isExtendedTchar(t[i]) { 44 | return fmt.Errorf("the character %c isn't allowed in a token: %w", t[i], ErrInvalidTokenFormat) 45 | } 46 | } 47 | 48 | _, err := b.WriteString(string(t)) 49 | 50 | return err 51 | } 52 | 53 | // parseToken parses as defined in 54 | // https://httpwg.org/specs/rfc9651.html#parse-token. 55 | func parseToken(s *scanner) (Token, error) { 56 | if s.eof() || (!isAlpha(s.data[s.off]) && s.data[s.off] != '*') { 57 | return "", &UnmarshalError{s.off, ErrInvalidTokenFormat} 58 | } 59 | 60 | start := s.off 61 | s.off++ 62 | 63 | for !s.eof() { 64 | if !isExtendedTchar(s.data[s.off]) { 65 | break 66 | } 67 | s.off++ 68 | } 69 | 70 | return Token(s.data[start:s.off]), nil 71 | } 72 | -------------------------------------------------------------------------------- /token_test.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestMarshalToken(t *testing.T) { 9 | t.Parallel() 10 | 11 | data := []struct { 12 | in string 13 | valid bool 14 | }{ 15 | {"abc'!#$%*+-.^_|~:/`", true}, 16 | {"H3lLo", true}, 17 | {"a*foo", true}, 18 | {"a!1", true}, 19 | {"a#1", true}, 20 | {"a$1", true}, 21 | {"a%1", true}, 22 | {"a&1", true}, 23 | {"a'1", true}, 24 | {"a*1", true}, 25 | {"a+1", true}, 26 | {"a-1", true}, 27 | {"a.1", true}, 28 | {"a^1", true}, 29 | {"a_1", true}, 30 | {"a`1", true}, 31 | {"a|1", true}, 32 | {"a~1", true}, 33 | {"a:1", true}, 34 | {"a/1", true}, 35 | {`0foo`, false}, 36 | {`!foo`, false}, 37 | {"1abc", false}, 38 | {"", false}, 39 | {"hel\tlo", false}, 40 | {"hel\x1flo", false}, 41 | {"hel\x7flo", false}, 42 | {"Kévin", false}, 43 | } 44 | 45 | var b strings.Builder 46 | 47 | for _, d := range data { 48 | b.Reset() 49 | 50 | err := Token(d.in).marshalSFV(&b) 51 | if d.valid && err != nil { 52 | t.Errorf("error not expected for %v, got %v", d.in, err) 53 | } else if !d.valid && err == nil { 54 | t.Errorf("error expected for %v, got %v", d.in, err) 55 | } 56 | 57 | if d.valid && b.String() != d.in { 58 | t.Errorf("got %v; want %v", b.String(), d.in) 59 | } 60 | } 61 | } 62 | 63 | func TestParseToken(t *testing.T) { 64 | t.Parallel() 65 | 66 | data := []struct { 67 | in string 68 | out Token 69 | err bool 70 | }{ 71 | {"t", Token("t"), false}, 72 | {"tok", Token("tok"), false}, 73 | {"*t!o&k", Token("*t!o&k"), false}, 74 | {"t=", Token("t"), false}, 75 | {"", Token(""), true}, 76 | {"é", Token(""), true}, 77 | } 78 | 79 | for _, d := range data { 80 | s := &scanner{data: d.in} 81 | 82 | i, err := parseToken(s) 83 | if d.err && err == nil { 84 | t.Errorf("parseToken(%s): error expected", d.in) 85 | } 86 | 87 | if !d.err && d.out != i { 88 | t.Errorf("parseToken(%s) = %v, %v; %v, expected", d.in, i, err, d.out) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package httpsfv 2 | 3 | // isLowerCaseAlpha checks if c is a lower cased alpha character. 4 | func isLowerCaseAlpha(c byte) bool { 5 | return 'a' <= c && c <= 'z' 6 | } 7 | 8 | // isAlpha checks if c is an alpha character. 9 | func isAlpha(c byte) bool { 10 | return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') 11 | } 12 | 13 | // isDigit checks if c is a digit. 14 | func isDigit(c byte) bool { 15 | return '0' <= c && c <= '9' 16 | } 17 | --------------------------------------------------------------------------------