Skip to content

Commit 2688767

Browse files
authored
feat(gateway): IPIP-523 format query over Accept header (#1074)
* feat(gateway): IPIP-523 format query param takes precedence over Accept header this change simplifies precedence rules by making the ?format= URL query parameter always take priority over the Accept HTTP header when both are present. in practice, this is largely compatible with existing browser use cases since browsers send Accept headers with wildcards which were already treated as non-specific. prioritizing ?format= also ensures deterministic HTTP caching behavior, protecting against CDNs that comingle different response types under the same cache key. the only breaking change is for edge cases where a client sends both a specific Accept header and a different ?format= value. previously Accept would win, now ?format= wins. this scenario is rare and arguably represents client misconfiguration. when detected, gateway returns HTTP 400 to signal the ambiguity. specs: ipfs/specs#523 tests: ipfs/gateway-conformance#252 * docs(changelog): add IPIP-523 to unreleased * fix(gateway): IPIP-523 ?format always wins over Accept header remove HTTP 400 error for conflicting ?format and Accept values. instead, ?format silently takes precedence, which is simpler and less breaking for browser clients that send Accept headers automatically. * ci: use gateway-conformance with IPIP-523 tests temporary switch to ipfs/gateway-conformance#252 * chore(ci): switch to gateway-conformance@v0.9
1 parent 63b6a19 commit 2688767

File tree

6 files changed

+98
-31
lines changed

6 files changed

+98
-31
lines changed

.github/workflows/gateway-conformance.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
steps:
2323
# 1. Download the gateway-conformance fixtures
2424
- name: Download gateway-conformance fixtures
25-
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.8
25+
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.9
2626
with:
2727
output: fixtures
2828
merged: true
@@ -47,7 +47,7 @@ jobs:
4747

4848
# 4. Run the gateway-conformance tests
4949
- name: Run gateway-conformance tests without IPNS and DNSLink
50-
uses: ipfs/gateway-conformance/.github/actions/test@v0.8
50+
uses: ipfs/gateway-conformance/.github/actions/test@v0.9
5151
with:
5252
gateway-url: http://127.0.0.1:8040
5353
subdomain-url: http://example.net:8040
@@ -84,7 +84,7 @@ jobs:
8484
steps:
8585
# 1. Download the gateway-conformance fixtures
8686
- name: Download gateway-conformance fixtures
87-
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.8
87+
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.9
8888
with:
8989
output: fixtures
9090
merged: true
@@ -114,7 +114,7 @@ jobs:
114114

115115
# 4. Run the gateway-conformance tests
116116
- name: Run gateway-conformance tests without IPNS and DNSLink
117-
uses: ipfs/gateway-conformance/.github/actions/test@v0.8
117+
uses: ipfs/gateway-conformance/.github/actions/test@v0.9
118118
with:
119119
gateway-url: http://127.0.0.1:8040 # we test gateway that is backed by a remote block gateway
120120
subdomain-url: http://example.net:8040
@@ -152,7 +152,7 @@ jobs:
152152
steps:
153153
# 1. Download the gateway-conformance fixtures
154154
- name: Download gateway-conformance fixtures
155-
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.8
155+
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.9
156156
with:
157157
output: fixtures
158158
merged: true
@@ -182,7 +182,7 @@ jobs:
182182

183183
# 4. Run the gateway-conformance tests
184184
- name: Run gateway-conformance tests without IPNS and DNSLink
185-
uses: ipfs/gateway-conformance/.github/actions/test@v0.8
185+
uses: ipfs/gateway-conformance/.github/actions/test@v0.9
186186
with:
187187
gateway-url: http://127.0.0.1:8040 # we test gateway that is backed by a remote car gateway
188188
subdomain-url: http://example.net:8040

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ The following emojis are used to highlight certain changes:
1818

1919
### Changed
2020

21+
- `gateway`: ✨ [IPIP-523](https://github.com/ipfs/specs/pull/523) `?format=` URL query parameter now takes precedence over `Accept` HTTP header, ensuring deterministic HTTP cache behavior and allowing browsers to use `?format=` even when they send `Accept` headers with specific content types. [#1074](https://github.com/ipfs/boxo/pull/1074)
22+
2123
### Removed
2224

2325
### Fixed

gateway/gateway_test.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,30 @@ func TestHeaders(t *testing.T) {
575575
runTest("DNSLink gateway with ?format="+formatParam, "/empty-dir/?format="+formatParam, "", dnslinkGatewayHost, "")
576576
}
577577

578-
runTest("Accept: application/vnd.ipld.car overrides ?format=raw in Content-Location", contentPath+"?format=raw", "application/vnd.ipld.car", "", contentPath+"?format=car")
578+
// IPIP-523: Test that matching ?format and Accept work together (Accept params are used)
579+
runTest("Matching ?format=car and Accept: application/vnd.ipld.car;version=1;order=dfs;dups=n", contentPath+"?format=car", "application/vnd.ipld.car;version=1;order=dfs;dups=n", "", "")
580+
581+
// IPIP-523: Test that conflicting ?format and Accept uses ?format (URL wins)
582+
t.Run("Conflicting ?format and Accept uses ?format from URL", func(t *testing.T) {
583+
t.Parallel()
584+
req := mustNewRequest(t, http.MethodGet, ts.URL+contentPath+"?format=raw", nil)
585+
req.Header.Set("Accept", "application/vnd.ipld.car")
586+
resp := mustDoWithoutRedirect(t, req)
587+
defer resp.Body.Close()
588+
require.Equal(t, http.StatusOK, resp.StatusCode)
589+
require.Equal(t, rawResponseFormat, resp.Header.Get("Content-Type"))
590+
})
591+
592+
// IPIP-523: Browser Accept header with wildcards should not interfere with ?format
593+
t.Run("Browser Accept header does not interfere with ?format=raw", func(t *testing.T) {
594+
t.Parallel()
595+
req := mustNewRequest(t, http.MethodGet, ts.URL+contentPath+"?format=raw", nil)
596+
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
597+
resp := mustDoWithoutRedirect(t, req)
598+
defer resp.Body.Close()
599+
require.Equal(t, http.StatusOK, resp.StatusCode)
600+
require.Equal(t, rawResponseFormat, resp.Header.Get("Content-Type"))
601+
})
579602
})
580603
}
581604

gateway/handler.go

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -666,15 +666,36 @@ func init() {
666666
}
667667
}
668668

669-
// return explicit response format if specified in request as query parameter or via Accept HTTP header
669+
// customResponseFormat determines the response format and extracts any parameters
670+
// from the ?format= query parameter and Accept HTTP header.
671+
//
672+
// This function is format-agnostic: it handles generic HTTP content negotiation
673+
// and returns parameters embedded in the Accept header (e.g., "application/vnd.ipld.car;order=dfs").
674+
//
675+
// Format-specific URL query parameters (e.g., ?car-order=, ?car-dups= for CAR)
676+
// are intentionally NOT handled here. They are processed by format-specific
677+
// handlers which merge Accept header params with URL params, giving URL params
678+
// precedence per IPIP-523. See buildCarParams() for the CAR example. This
679+
// pattern can be extended for other formats that need URL-based parameters.
670680
func customResponseFormat(r *http.Request) (mediaType string, params map[string]string, err error) {
671-
// First, inspect Accept header, as it may not only include content type, but also optional parameters.
681+
// Check ?format= URL query parameter first (IPIP-523).
682+
formatParam := r.URL.Query().Get("format")
683+
var formatMediaType string
684+
if formatParam != "" {
685+
if responseFormat, ok := formatParamToResponseFormat[formatParam]; ok {
686+
formatMediaType = responseFormat
687+
}
688+
}
689+
690+
// Inspect Accept header for vendor-specific content types and optional parameters
672691
// such as CAR version or additional ones from IPIP-412.
673692
//
674693
// Browsers and other user agents will send Accept header with generic types like:
675694
// Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
676695
// We only care about explicit, vendor-specific content-types and respond to the first match (in order).
677696
// TODO: make this RFC compliant and respect weights (eg. return CAR for Accept:application/vnd.ipld.dag-json;q=0.1,application/vnd.ipld.car;q=0.2)
697+
var acceptMediaType string
698+
var acceptParams map[string]string
678699
for _, header := range r.Header.Values("Accept") {
679700
for _, value := range strings.Split(header, ",") {
680701
accept := strings.TrimSpace(value)
@@ -688,16 +709,29 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string]
688709
if err != nil {
689710
return "", nil, err
690711
}
691-
return mediatype, params, nil
712+
acceptMediaType = mediatype
713+
acceptParams = params
714+
break
692715
}
693716
}
717+
if acceptMediaType != "" {
718+
break
719+
}
694720
}
695721

696-
// If no Accept header, translate query param to a content type, if present.
697-
if formatParam := r.URL.Query().Get("format"); formatParam != "" {
698-
if responseFormat, ok := formatParamToResponseFormat[formatParam]; ok {
699-
return responseFormat, nil, nil
722+
// ?format takes precedence (IPIP-523), even when Accept header specifies a different format.
723+
// This ensures deterministic HTTP caching and allows browsers to use ?format reliably.
724+
if formatMediaType != "" {
725+
// Use Accept params only if Accept matches ?format (e.g., for CAR version/order params)
726+
if acceptMediaType == formatMediaType {
727+
return formatMediaType, acceptParams, nil
700728
}
729+
return formatMediaType, nil, nil
730+
}
731+
732+
// Fall back to Accept header if no ?format query param.
733+
if acceptMediaType != "" {
734+
return acceptMediaType, acceptParams, nil
701735
}
702736

703737
// If none of special-cased content types is found, return empty string
@@ -717,10 +751,9 @@ func addContentLocation(r *http.Request, w http.ResponseWriter, rq *requestData)
717751

718752
format := responseFormatToFormatParam[rq.responseFormat]
719753

720-
// Skip Content-Location if there is no conflict between
721-
// 'format' in URL and value in 'Accept' header.
722-
// If both are present and don't match, we continue and generate
723-
// Content-Location to ensure value from Accept overrides 'format' from URL.
754+
// Skip Content-Location if ?format is already present in URL and matches
755+
// the response format. Content-Location is only needed when format was
756+
// requested via Accept header without ?format in URL.
724757
if urlFormat := r.URL.Query().Get("format"); urlFormat != "" && urlFormat == format {
725758
return
726759
}
@@ -737,9 +770,13 @@ func addContentLocation(r *http.Request, w http.ResponseWriter, rq *requestData)
737770
}
738771
query.Set("format", format)
739772

740-
// Set response params as query elements.
773+
// Set response params as query elements, but only if URL doesn't already
774+
// have them (URL query params take precedence per IPIP-523).
741775
for k, v := range rq.responseParams {
742-
query.Set(format+"-"+k, v)
776+
paramKey := format + "-" + k
777+
if !query.Has(paramKey) {
778+
query.Set(paramKey, v)
779+
}
743780
}
744781

745782
w.Header().Set("Content-Location", path+"?"+query.Encode())

gateway/handler_car.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -142,20 +142,20 @@ func buildCarParams(r *http.Request, contentTypeParams map[string]string) (CarPa
142142
params.Scope = DagScopeAll
143143
}
144144

145-
// application/vnd.ipld.car content type parameters from Accept header
146-
147-
// Get CAR version, duplicates and order from the query parameters and override
148-
// with parameters from Accept header if they exist, since they have priority.
149-
versionStr := queryParams.Get(carVersionKey)
150-
duplicatesStr := queryParams.Get(carDuplicatesKey)
151-
orderStr := queryParams.Get(carOrderKey)
152-
if v, ok := contentTypeParams["version"]; ok {
145+
// application/vnd.ipld.car content type parameters from Accept header and URL query
146+
147+
// Get CAR version, duplicates and order from Accept header first,
148+
// then override with URL query parameters if they exist (IPIP-523).
149+
versionStr := contentTypeParams["version"]
150+
duplicatesStr := contentTypeParams["dups"]
151+
orderStr := contentTypeParams["order"]
152+
if v := queryParams.Get(carVersionKey); v != "" {
153153
versionStr = v
154154
}
155-
if v, ok := contentTypeParams["order"]; ok {
155+
if v := queryParams.Get(carOrderKey); v != "" {
156156
orderStr = v
157157
}
158-
if v, ok := contentTypeParams["dups"]; ok {
158+
if v := queryParams.Get(carDuplicatesKey); v != "" {
159159
duplicatesStr = v
160160
}
161161

gateway/handler_car_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,13 @@ func TestCarParams(t *testing.T) {
9494
{"application/vnd.ipld.car; dups=n", nil, DagOrderDFS, DuplicateBlocksExcluded},
9595
{"application/vnd.ipld.car", nil, DagOrderDFS, DuplicateBlocksExcluded},
9696
{"application/vnd.ipld.car;version=1;order=dfs;dups=y", nil, DagOrderDFS, DuplicateBlocksIncluded},
97-
{"application/vnd.ipld.car;version=1;order=dfs;dups=y", url.Values{"car-order": []string{"unk"}}, DagOrderDFS, DuplicateBlocksIncluded},
97+
// IPIP-523: URL query params take priority over Accept header params
98+
{"application/vnd.ipld.car;version=1;order=dfs;dups=y", url.Values{"car-order": []string{"unk"}}, DagOrderUnknown, DuplicateBlocksIncluded},
9899
{"application/vnd.ipld.car;version=1;dups=y", url.Values{"car-order": []string{"unk"}}, DagOrderUnknown, DuplicateBlocksIncluded},
100+
// IPIP-523: URL params work without Accept header (non-default dups to detect wiring bugs)
101+
{"", url.Values{"format": []string{"car"}, "car-dups": []string{"y"}}, DagOrderDFS, DuplicateBlocksIncluded},
102+
// IPIP-523: URL dups=y overrides Accept dups=n
103+
{"application/vnd.ipld.car;order=dfs;dups=n", url.Values{"car-dups": []string{"y"}}, DagOrderDFS, DuplicateBlocksIncluded},
99104
}
100105
for _, test := range tests {
101106
r := mustNewRequest(t, http.MethodGet, "http://example.com/?"+test.params.Encode(), nil)

0 commit comments

Comments
 (0)