Skip to content

Commit

Permalink
feat!: add ContentType, expanded Accept parsing
Browse files Browse the repository at this point in the history
BREAKING CHANGES

* ContentType{} to describe a full content type descriptor
* ParseAccept() returns a slice of acceptable ContentTypes found, ordered by
  quality factor
* CheckFormat() returns a single, preferred ContentType
* Remove spaces from default ContentType formatting
* Deprecate ResponseContentTypeHeader
* Deprecate RequestAcceptHeader
  • Loading branch information
rvagg committed Sep 7, 2023
1 parent d166740 commit 37a7201
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 118 deletions.
85 changes: 78 additions & 7 deletions http/constants.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,105 @@
package trustlesshttp

import "fmt"
import (
"strconv"
"strings"
)

type ContentTypeOrder string

const (
MimeTypeCar = "application/vnd.ipld.car" // The only accepted MIME type
MimeTypeCarVersion = "1" // We only accept version 1 of the MIME type
FormatParameterCar = "car" // The only valid format parameter value
FilenameExtCar = ".car" // The only valid filename extension
DefaultIncludeDupes = true // The default value for an unspecified "dups" parameter. See https://github.com/ipfs/specs/pull/412.
ResponseCacheControlHeader = "public, max-age=29030400, immutable" // Magic cache control values
DefaultIncludeDupes = true // The default value for an unspecified "dups" parameter.
DefaultOrder = ContentTypeOrderDfs // The default value for an unspecified "order" parameter.

ContentTypeOrderDfs ContentTypeOrder = "dfs"
ContentTypeOrderUnk ContentTypeOrder = "unk"
)

var (
ResponseChunkDelimeter = []byte("0\r\n") // An http/1.1 chunk delimeter, used for specifying an early end to the response
baseContentType = fmt.Sprintf("%s; version=%s; order=dfs", MimeTypeCar, MimeTypeCarVersion)
)

type ContentType struct {
Mime string
Order ContentTypeOrder
Duplicates bool
Quality float32
}

func (ct ContentType) String() string {
sb := strings.Builder{}
sb.WriteString(ct.Mime)
sb.WriteString(";version=")
sb.WriteString(MimeTypeCarVersion)
sb.WriteString(";order=")
sb.WriteString(string(ct.Order))
if ct.Duplicates {
sb.WriteString(";dups=y")
} else {
sb.WriteString(";dups=n")
}
if ct.Quality < 1 && ct.Quality >= 0.00 {
sb.WriteString(";q=")
// write quality with max 3 decimal places
sb.WriteString(strconv.FormatFloat(float64(ct.Quality), 'g', 3, 32))
}
return sb.String()
}

type ContentTypeOption func(ct *ContentType)

func WithContentTypeOrder(order ContentTypeOrder) ContentTypeOption {
return func(ct *ContentType) {
ct.Order = order
}
}

func WithContentTypeDuplicates(duplicates bool) ContentTypeOption {
return func(ct *ContentType) {
ct.Duplicates = duplicates
}
}

func WithContentTypeQuality(quality float32) ContentTypeOption {
return func(ct *ContentType) {
ct.Quality = quality
}
}

func NewContentType(opt ...ContentTypeOption) ContentType {
ct := ContentType{
Mime: MimeTypeCar,
Order: DefaultOrder,
Duplicates: DefaultIncludeDupes,
Quality: 1,
}
for _, o := range opt {
o(&ct)
}
return ct
}

// ResponseContentTypeHeader returns the value for the Content-Type header for a
// Trustless Gateway response which will vary depending on whether duplicates
// are included or not. Otherwise, the header is the same for all responses.
//
// DEPRECATED: Use NewContentType().String() instead.
func ResponseContentTypeHeader(duplicates bool) string {
if duplicates {
return baseContentType + "; dups=y"
}
return baseContentType + "; dups=n"
ct := NewContentType()
ct.Duplicates = duplicates
return ct.String()
}

// RequestAcceptHeader returns the value for the Accept header for a Trustless
// Gateway request which will vary depending on whether duplicates are included
// or not. Otherwise, the header is the same for all requests.
//
// DEPRECATED: Use NewContentType().String() instead.
func RequestAcceptHeader(duplicates bool) string {
return ResponseContentTypeHeader(duplicates)
}
17 changes: 13 additions & 4 deletions http/constants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@ import (
)

func TestContentType(t *testing.T) {
require.Equal(t, "application/vnd.ipld.car; version=1; order=dfs; dups=y", trustlesshttp.ResponseContentTypeHeader(true))
require.Equal(t, "application/vnd.ipld.car; version=1; order=dfs; dups=y", trustlesshttp.RequestAcceptHeader(true))
require.Equal(t, "application/vnd.ipld.car; version=1; order=dfs; dups=n", trustlesshttp.ResponseContentTypeHeader(false))
require.Equal(t, "application/vnd.ipld.car; version=1; order=dfs; dups=n", trustlesshttp.RequestAcceptHeader(false))
req := require.New(t)

req.Equal("application/vnd.ipld.car;version=1;order=dfs;dups=y", trustlesshttp.ResponseContentTypeHeader(true))
req.Equal("application/vnd.ipld.car;version=1;order=dfs;dups=y", trustlesshttp.RequestAcceptHeader(true))
req.Equal("application/vnd.ipld.car;version=1;order=dfs;dups=n", trustlesshttp.ResponseContentTypeHeader(false))
req.Equal("application/vnd.ipld.car;version=1;order=dfs;dups=n", trustlesshttp.RequestAcceptHeader(false))

req.Equal("application/vnd.ipld.car;version=1;order=dfs;dups=y", trustlesshttp.NewContentType().String())
req.Equal("application/vnd.ipld.car;version=1;order=dfs;dups=y;q=0.8", trustlesshttp.NewContentType(trustlesshttp.WithContentTypeQuality(0.8)).String())
req.Equal("application/vnd.ipld.car;version=1;order=dfs;dups=y;q=0.333", trustlesshttp.NewContentType(trustlesshttp.WithContentTypeQuality(1.0/3.0)).String())
req.Equal("application/vnd.ipld.car;version=1;order=dfs;dups=y", trustlesshttp.NewContentType(trustlesshttp.WithContentTypeQuality(-1.0)).String())
req.Equal("application/vnd.ipld.car;version=1;order=dfs;dups=n", trustlesshttp.NewContentType(trustlesshttp.WithContentTypeDuplicates(false)).String())
req.Equal("application/vnd.ipld.car;version=1;order=unk;dups=n", trustlesshttp.NewContentType(trustlesshttp.WithContentTypeDuplicates(false), trustlesshttp.WithContentTypeOrder(trustlesshttp.ContentTypeOrderUnk)).String())
}
155 changes: 88 additions & 67 deletions http/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"

"github.com/ipfs/go-cid"
Expand Down Expand Up @@ -73,31 +75,32 @@ func ParseFilename(req *http.Request) (string, error) {
//
// IPFS Trustless Gateway only allows the "car" format query parameter
// https://specs.ipfs.tech/http-gateways/path-gateway/#format-request-query-parameter
func CheckFormat(req *http.Request) (bool, error) {
includeDupes := DefaultIncludeDupes
func CheckFormat(req *http.Request) (ContentType, error) {
// check if format is "car"
format := req.URL.Query().Get("format")
var validFormat bool
if format != "" {
if format != FormatParameterCar {
return ContentType{}, fmt.Errorf("invalid format parameter; unsupported: %q", format)
}
validFormat = true
}

accept := req.Header.Get("Accept")
if accept != "" {
// check if Accept header includes application/vnd.ipld.car
var validAccept bool
validAccept, includeDupes = ParseAccept(accept)
if !validAccept {
return false, fmt.Errorf("invalid Accept header; unsupported: %q", accept)
accepts := ParseAccept(accept)
if len(accepts) == 0 {
return ContentType{}, fmt.Errorf("invalid Accept header; unsupported: %q", accept)
}
}
// check if format is "car"
format := req.URL.Query().Get("format")
if format != "" && format != FormatParameterCar {
return false, fmt.Errorf("invalid format parameter; unsupported: %q", format)
return accepts[0], nil // pick the top one we can support
}

// if neither are provided return
// one of them has to be given with a CAR type since we only return CAR data
if accept == "" && format == "" {
return false, fmt.Errorf("neither a valid Accept header nor format parameter were provided")
if validFormat {
return NewContentType(), nil // default is acceptable in this case (no accept but format=car)
}

return includeDupes, nil
return ContentType{}, fmt.Errorf("neither a valid Accept header nor format parameter were provided")
}

// ParseAccept validates a request Accept header and returns whether or not
Expand All @@ -106,8 +109,20 @@ func CheckFormat(req *http.Request) (bool, error) {
// This will operate the same as ParseContentType except that it is less strict
// with the format specifier, allowing for "application/*" and "*/*" as well as
// the standard "application/vnd.ipld.car".
func ParseAccept(acceptHeader string) (validAccept bool, includeDupes bool) {
return parseContentType(acceptHeader, false)
func ParseAccept(acceptHeader string) []ContentType {
acceptTypes := strings.Split(acceptHeader, ",")
accepts := make([]ContentType, 0, len(acceptTypes))
for _, acceptType := range acceptTypes {
accept, valid := parseContentType(acceptType, false)
if valid {
accepts = append(accepts, accept)
}
}
// sort accepts by ContentType#Quality
sort.SliceStable(accepts, func(i, j int) bool {
return accepts[i].Quality > accepts[j].Quality
})
return accepts
}

// ParseContentType validates a response Content-Type header and returns whether
Expand All @@ -116,62 +131,68 @@ func ParseAccept(acceptHeader string) (validAccept bool, includeDupes bool) {
// This will operate the same as ParseAccept except that it strictly only
// allows the "application/vnd.ipld.car" Content-Type.
func ParseContentType(contentTypeHeader string) (validContentType bool, includeDupes bool) {
return parseContentType(contentTypeHeader, true)
contentType, valid := parseContentType(contentTypeHeader, true)
if !valid {
return false, false
}
return true, contentType.Duplicates
}

func parseContentType(header string, strictType bool) (validAccept bool, includeDupes bool) {
acceptTypes := strings.Split(header, ",")
validAccept = false
includeDupes = DefaultIncludeDupes
for _, acceptType := range acceptTypes {
typeParts := strings.Split(acceptType, ";")
if typeParts[0] == MimeTypeCar || (!strictType && (typeParts[0] == "*/*" || typeParts[0] == "application/*")) {
validAccept = true
if typeParts[0] == MimeTypeCar {
// parse additional car attributes outlined in IPIP-412: https://github.com/ipfs/specs/pull/412
for _, nextPart := range typeParts[1:] {
pair := strings.Split(nextPart, "=")
if len(pair) == 2 {
attr := strings.TrimSpace(pair[0])
value := strings.TrimSpace(pair[1])
switch attr {
case "dups":
switch value {
case "y":
includeDupes = true
case "n":
includeDupes = false
default:
// don't accept unexpected values
validAccept = false
}
case "version":
switch value {
case MimeTypeCarVersion:
default:
validAccept = false
}
case "order":
switch value {
case "dfs":
case "unk":
default:
// we only do dfs, which also satisfies unk, future extensions are not yet supported
validAccept = false
}
default:
// ignore others
}
func parseContentType(header string, strictType bool) (ContentType, bool) {
typeParts := strings.Split(header, ";")
mime := strings.TrimSpace(typeParts[0])
if mime == MimeTypeCar || (!strictType && (mime == "*/*" || mime == "application/*")) {
contentType := NewContentType()
contentType.Mime = mime
// parse additional car attributes outlined in IPIP-412
// https://specs.ipfs.tech/http-gateways/trustless-gateway/
for _, nextPart := range typeParts[1:] {
pair := strings.Split(nextPart, "=")
if len(pair) == 2 {
attr := strings.TrimSpace(pair[0])
value := strings.TrimSpace(pair[1])
switch attr {
case "dups":
switch value {
case "y":
contentType.Duplicates = true
case "n":
contentType.Duplicates = false
default:
// don't accept unexpected values
return ContentType{}, false
}
case "version":
switch value {
case MimeTypeCarVersion:
default:
return ContentType{}, false
}
case "order":
switch value {
case "dfs":
contentType.Order = ContentTypeOrderDfs
case "unk":
contentType.Order = ContentTypeOrderUnk
default:
// we only do dfs, which also satisfies unk, future extensions are not yet supported
return ContentType{}, false
}
case "q":
// parse quality
quality, err := strconv.ParseFloat(value, 32)
if err != nil || quality < 0 || quality > 1 {
return ContentType{}, false
}
contentType.Quality = float32(quality)
default:

Check warning on line 188 in http/parse.go

View check run for this annotation

Codecov / codecov/patch

http/parse.go#L188

Added line #L188 was not covered by tests
// ignore others
}
}
// only break if further validation didn't fail
if validAccept {
break
}
}
return contentType, true
}
return
return ContentType{}, false
}

var (
Expand Down
Loading

0 comments on commit 37a7201

Please sign in to comment.