Skip to content

Commit

Permalink
feat!: allow for application/vnd.ipld.raw accept and content-type
Browse files Browse the repository at this point in the history
BREAKING CHANGE as CheckFormat() now returns a slice with a preference ordered
list
  • Loading branch information
rvagg committed Oct 7, 2023
1 parent 4923cfb commit 4ea9415
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 74 deletions.
34 changes: 23 additions & 11 deletions http/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import (
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
MimeTypeCar = "application/vnd.ipld.car" // One of two acceptable MIME types
MimeTypeRaw = "application/vnd.ipld.raw" // One of two acceptable MIME types
MimeTypeCarVersion = "1" // We only accept version 1 of the CAR MIME type
FormatParameterCar = "car" // One of two acceptable format parameter values
FormatParameterRaw = "raw" // One of two acceptable format parameter values
FilenameExtCar = ".car" // The only valid filename extension
ResponseCacheControlHeader = "public, max-age=29030400, immutable" // Magic cache control values
DefaultIncludeDupes = true // The default value for an unspecified "dups" parameter.
Expand All @@ -37,14 +39,16 @@ type ContentType struct {
func (ct ContentType) String() string {
sb := strings.Builder{}
sb.WriteString(ct.MimeType)
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.MimeType == MimeTypeCar {
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=")
Expand All @@ -54,6 +58,14 @@ func (ct ContentType) String() string {
return sb.String()
}

func (ct ContentType) IsRaw() bool {
return ct.MimeType == MimeTypeRaw
}

func (ct ContentType) IsCar() bool {
return ct.MimeType == MimeTypeCar || ct.MimeType == "application/*" || ct.MimeType == "*/*"
}

// WithOrder returns a new ContentType with the specified order.
func (ct ContentType) WithOrder(order ContentTypeOrder) ContentType {
ct.Order = order
Expand Down
111 changes: 61 additions & 50 deletions http/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,55 +60,63 @@ func ParseFilename(req *http.Request) (string, error) {
return "", nil
}

// CheckFormat validates that the data being requested is of the type CAR.
// CheckFormat validates that the data being requested is of a compatible
// content type. If the request is valid, a slice of ContentType descriptors
// is returned, in preference order. If the request is invalid, an error is
// returned.
//
// We do this validation because the IPFS Path Gateway spec allows for
// additional response formats that the IPFS Trustless Gateway spec does not
// currently support, so we throw an error in the cases where the request is
// requesting one the unsupported response formats. IPFS Trustless Gateway only
// supports returning CAR data.
// supports returning CAR, or raw block data.
//
// The spec outlines that the requesting format can be provided
// via the Accept header or the format query parameter.
//
// IPFS Trustless Gateway only allows the application/vnd.ipld.car Accept header
// IPFS Trustless Gateway only allows the application/vnd.ipld.car
// and application/vnd.ipld.raw Accept headers
// https://specs.ipfs.tech/http-gateways/path-gateway/#accept-request-header
//
// IPFS Trustless Gateway only allows the "car" format query parameter
// IPFS Trustless Gateway only allows the "car" and "raw" format query
// parameters
// https://specs.ipfs.tech/http-gateways/path-gateway/#format-request-query-parameter
func CheckFormat(req *http.Request) (ContentType, error) {
// check if format is "car"
func CheckFormat(req *http.Request) ([]ContentType, error) {
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
switch format { // initial check, but we also want to check Accept before we allow this
case "", FormatParameterCar, FormatParameterRaw:
default:
return nil, fmt.Errorf("invalid format parameter; unsupported: %q", format)
}

accept := req.Header.Get("Accept")
if accept != "" {
// check if Accept header includes application/vnd.ipld.car
// check if Accept header includes what we need
accepts := ParseAccept(accept)
if len(accepts) == 0 {
return ContentType{}, fmt.Errorf("invalid Accept header; unsupported: %q", accept)
return nil, fmt.Errorf("invalid Accept header; unsupported: %q", accept)
}
return accepts[0], nil // pick the top one we can support
return accepts, nil // pick the top one we can support
}

if validFormat {
return DefaultContentType(), nil // default is acceptable in this case (no accept but format=car)
if format != "" {
switch format {
case FormatParameterCar:
return []ContentType{DefaultContentType().WithMimeType(MimeTypeCar)}, nil
case FormatParameterRaw:
return []ContentType{DefaultContentType().WithMimeType(MimeTypeRaw)}, nil
}
}

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

// ParseAccept validates a request Accept header and returns whether or not
// duplicate blocks are allowed in the response.
//
// 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".
// the standard "application/vnd.ipld.car" and "application/vnd.ipld.raw".
func ParseAccept(acceptHeader string) []ContentType {
acceptTypes := strings.Split(acceptHeader, ",")
accepts := make([]ContentType, 0, len(acceptTypes))
Expand All @@ -130,16 +138,16 @@ func ParseAccept(acceptHeader string) []ContentType {
// the header value was valid or not.
//
// This will operate similar to ParseAccept except that it strictly only
// allows the "application/vnd.ipld.car" Content-Type (and it won't accept
// comma separated list of content types).
// allows the "application/vnd.ipld.car" and "application/vnd.ipld.raw"
// Content-Types (and it won't accept comma separated list of content types).
func ParseContentType(contentTypeHeader string) (ContentType, bool) {
return parseContentType(contentTypeHeader, true)
}

func parseContentType(header string, strictType bool) (ContentType, bool) {
typeParts := strings.Split(header, ";")
mime := strings.TrimSpace(typeParts[0])
if mime == MimeTypeCar || (!strictType && (mime == "*/*" || mime == "application/*")) {
if mime == MimeTypeCar || mime == MimeTypeRaw || (!strictType && (mime == "*/*" || mime == "application/*")) {
contentType := DefaultContentType().WithMimeType(mime)
// parse additional car attributes outlined in IPIP-412
// https://specs.ipfs.tech/http-gateways/trustless-gateway/
Expand All @@ -148,42 +156,45 @@ func parseContentType(header string, strictType bool) (ContentType, bool) {
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
if mime == MimeTypeCar {
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
}
default:
// don't accept unexpected values
return ContentType{}, false
// ignore others
}
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":
}
if attr == "q" {
// parse quality
quality, err := strconv.ParseFloat(value, 32)
if err != nil || quality < 0 || quality > 1 {
return ContentType{}, false
}
contentType.Quality = float32(quality)
default:
// ignore others
}
}
}
Expand Down
35 changes: 22 additions & 13 deletions http/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,19 +109,24 @@ func TestCheckFormat(t *testing.T) {
name string
accept string
query string
expectAccept trustlesshttp.ContentType
expectAccept []trustlesshttp.ContentType
err string
}{
{"empty (err)", "", "", trustlesshttp.ContentType{}, "neither a valid Accept header nor format parameter were provided"},
{"format=bop (err)", "", "format=bop", trustlesshttp.DefaultContentType(), "invalid format parameter; unsupported: \"bop\""},
{"format=car", "", "format=car", trustlesshttp.DefaultContentType(), ""},
{"plain accept", "application/vnd.ipld.car", "", trustlesshttp.DefaultContentType(), ""},
{"accept dups", "application/vnd.ipld.car; dups=y", "", trustlesshttp.DefaultContentType(), ""},
{"accept no dups", "application/vnd.ipld.car; dups=n", "", trustlesshttp.DefaultContentType().WithDuplicates(false), ""},
{"accept no dups and cruft", "application/vnd.ipld.car; dups=n; bip; bop", "", trustlesshttp.DefaultContentType().WithDuplicates(false), ""},
{"valid accept but format=bop (err)", "application/vnd.ipld.car; dups=y", "format=bop", trustlesshttp.DefaultContentType(), "invalid format parameter; unsupported: \"bop\""},
{"valid accept but format=car", "application/vnd.ipld.car; dups=y", "format=car", trustlesshttp.DefaultContentType(), ""},
{"invalid accept but format=car", "application/vnd.ipld.car; dups=YES!", "format=car", trustlesshttp.DefaultContentType().WithDuplicates(false), "invalid Accept header; unsupported"},
{"empty (err)", "", "", []trustlesshttp.ContentType{{}}, "neither a valid Accept header nor format parameter were provided"},
{"format=bop (err)", "", "format=bop", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType()}, "invalid format parameter; unsupported: \"bop\""},
{"format=car", "", "format=car", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType()}, ""},
{"format=raw", "", "format=raw", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType().WithMimeType(trustlesshttp.MimeTypeRaw)}, ""},
{"car accept", "application/vnd.ipld.car", "", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType()}, ""},
{"raw accept", "application/vnd.ipld.raw", "", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType().WithMimeType(trustlesshttp.MimeTypeRaw)}, ""},
{"raw accept plus garbage", "application/vnd.ipld.raw; ignore; this", "", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType().WithMimeType(trustlesshttp.MimeTypeRaw)}, ""},
{"accept dups", "application/vnd.ipld.car; dups=y", "", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType()}, ""},
{"accept no dups", "application/vnd.ipld.car; dups=n", "", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType().WithDuplicates(false)}, ""},
{"accept no dups and cruft", "application/vnd.ipld.car; dups=n; bip; bop", "", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType().WithDuplicates(false)}, ""},
{"valid accept but format=bop (err)", "application/vnd.ipld.car; dups=y", "format=bop", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType()}, "invalid format parameter; unsupported: \"bop\""},
{"valid accept but format=car", "application/vnd.ipld.car; dups=y", "format=car", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType()}, ""},
{"invalid accept but format=car", "application/vnd.ipld.car; dups=YES!", "format=car", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType().WithDuplicates(false)}, "invalid Accept header; unsupported"},
{"invalid accept but format=raw", "application/vnd.ipld.car; dups=YES!", "format=raw", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType().WithMimeType(trustlesshttp.MimeTypeRaw)}, "invalid Accept header; unsupported"},
{"ordered, valid", "application/vnd.ipld.raw, application/*, application/vnd.ipld.car; dups=y", "", []trustlesshttp.ContentType{trustlesshttp.DefaultContentType().WithMimeType(trustlesshttp.MimeTypeRaw), trustlesshttp.DefaultContentType().WithMimeType("application/*"), trustlesshttp.DefaultContentType().WithDuplicates(true)}, ""},
} {
t.Run(tc.name, func(t *testing.T) {
req := &http.Request{}
Expand Down Expand Up @@ -149,12 +154,14 @@ func TestParseContentType(t *testing.T) {
expectContentType trustlesshttp.ContentType
}{
{"empty (err)", "", false, trustlesshttp.ContentType{}},
{"plain", "application/vnd.ipld.car", true, trustlesshttp.DefaultContentType()},
{"car", "application/vnd.ipld.car", true, trustlesshttp.DefaultContentType()},
{"raw", "application/vnd.ipld.raw", true, trustlesshttp.DefaultContentType().WithMimeType(trustlesshttp.MimeTypeRaw)},
{"*/*", "*/*", false, trustlesshttp.ContentType{}},
{"application/*", "application/*", false, trustlesshttp.ContentType{}},
{"dups", "application/vnd.ipld.car; dups=y", true, trustlesshttp.DefaultContentType()},
{"no dups", "application/vnd.ipld.car; dups=n", true, trustlesshttp.DefaultContentType().WithDuplicates(false)},
{"no dups and cruft", "application/vnd.ipld.car; dups=n; bip; bop", true, trustlesshttp.DefaultContentType().WithDuplicates(false)},
{"raw and cruft", "application/vnd.ipld.raw; bip; bop", true, trustlesshttp.DefaultContentType().WithMimeType(trustlesshttp.MimeTypeRaw)},
{"version=1", "application/vnd.ipld.car; version=1; dups=n", true, trustlesshttp.DefaultContentType().WithDuplicates(false)},
{"version=2", "application/vnd.ipld.car; version=2; dups=n", false, trustlesshttp.ContentType{}},
{"order=dfs", "application/vnd.ipld.car; order=dfs; dups=n", true, trustlesshttp.DefaultContentType().WithDuplicates(false)},
Expand Down Expand Up @@ -201,14 +208,16 @@ func TestParseAccept(t *testing.T) {

{
"ordered",
"application/vnd.ipld.car;dups=n;order=unk;q=0.8, text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.1, application/vnd.ipld.car;dups=y;order=dfs;q=0.9 , application/vnd.ipld.car, application/vnd.ipld.car;dups=y;order=unk;q=0.7, application/vnd.ipld.car;dups=y;order=dfs;q=0.7",
"application/vnd.ipld.car;dups=n;order=unk;q=0.8, text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.1, application/vnd.ipld.car;dups=y;order=dfs;q=0.9 , application/vnd.ipld.car, application/vnd.ipld.raw,application/vnd.ipld.raw;q=0.1, application/vnd.ipld.car;dups=y;order=unk;q=0.7, application/vnd.ipld.car;dups=y;order=dfs;q=0.7",
[]trustlesshttp.ContentType{
{MimeType: trustlesshttp.MimeTypeCar, Duplicates: true, Order: trustlesshttp.ContentTypeOrderDfs, Quality: 1.0},
{MimeType: trustlesshttp.MimeTypeRaw, Duplicates: true, Order: trustlesshttp.ContentTypeOrderDfs, Quality: 1.0},
{MimeType: trustlesshttp.MimeTypeCar, Duplicates: true, Order: trustlesshttp.ContentTypeOrderDfs, Quality: 0.9},
{MimeType: trustlesshttp.MimeTypeCar, Duplicates: false, Order: trustlesshttp.ContentTypeOrderUnk, Quality: 0.8},
{MimeType: trustlesshttp.MimeTypeCar, Duplicates: true, Order: trustlesshttp.ContentTypeOrderUnk, Quality: 0.7},
{MimeType: trustlesshttp.MimeTypeCar, Duplicates: true, Order: trustlesshttp.ContentTypeOrderDfs, Quality: 0.7},
{MimeType: "*/*", Duplicates: true, Order: trustlesshttp.ContentTypeOrderDfs, Quality: 0.1},
{MimeType: trustlesshttp.MimeTypeRaw, Duplicates: true, Order: trustlesshttp.ContentTypeOrderDfs, Quality: 0.1},
},
},
} {
Expand Down

0 comments on commit 4ea9415

Please sign in to comment.