Skip to content

Commit 006bfc8

Browse files
authored
feat: add support for HEAD requests and probe path (#195)
Closes: #170
1 parent f1c1c8c commit 006bfc8

File tree

3 files changed

+232
-9
lines changed

3 files changed

+232
-9
lines changed

carstream.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package frisbii
33
import (
44
"bytes"
55
"context"
6+
"fmt"
67
"io"
8+
"sync"
79

810
// codecs we care about
911

@@ -16,13 +18,46 @@ import (
1618

1719
"github.com/ipfs/go-cid"
1820
"github.com/ipld/go-car/v2"
21+
"github.com/ipld/go-car/v2/storage"
1922
"github.com/ipld/go-car/v2/storage/deferred"
2023
"github.com/ipld/go-ipld-prime/datamodel"
2124
"github.com/ipld/go-ipld-prime/linking"
2225
cidlink "github.com/ipld/go-ipld-prime/linking/cid"
2326
trustlessutils "github.com/ipld/go-trustless-utils"
2427
)
2528

29+
var (
30+
// ProbeCID is the special identity CID used for probing the gateway
31+
// bafkqaaa is an identity CID with empty content
32+
ProbeCID = cid.MustParse("bafkqaaa")
33+
34+
// probeCarBytes is the pre-generated CAR response for the probe CID.
35+
// Since this is always the same, we generate it once and reuse it.
36+
probeCarBytes []byte
37+
probeCarOnce sync.Once
38+
)
39+
40+
// getProbeCarBytes generates or returns the cached probe CAR response
41+
func getProbeCarBytes() []byte {
42+
probeCarOnce.Do(func() {
43+
var buf bytes.Buffer
44+
// Create a simple CAR v1 with the probe CID as root
45+
carWriter, err := storage.NewWritable(&buf, []cid.Cid{ProbeCID}, car.WriteAsCarV1(true))
46+
if err != nil {
47+
// This should never happen with valid inputs
48+
panic(fmt.Sprintf("failed to create probe CAR writer: %v", err))
49+
}
50+
// Identity CIDs are not stored by default (no StoreIdentityCIDs option),
51+
// so we just finalize to get a CAR with only the header.
52+
// The spec says identity block MAY be skipped in the data section.
53+
if err = carWriter.Finalize(); err != nil {
54+
panic(fmt.Sprintf("failed to finalize probe CAR: %v", err))
55+
}
56+
probeCarBytes = buf.Bytes()
57+
})
58+
return probeCarBytes
59+
}
60+
2661
// StreamCar streams a DAG in CARv1 format to the given writer, using the given
2762
// selector.
2863
func StreamCar(

httpipfs.go

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,12 @@ func NewHttpIpfsHandlerFunc(
197197
}
198198
}
199199

200-
// filter out everything but GET requests
200+
// filter out everything but GET and HEAD requests
201201
switch req.Method {
202-
case http.MethodGet:
202+
case http.MethodGet, http.MethodHead:
203203
break
204204
default:
205-
res.Header().Add("Allow", http.MethodGet)
205+
res.Header().Add("Allow", http.MethodGet+", "+http.MethodHead)
206206
logError(http.StatusMethodNotAllowed, errors.New("method not allowed"))
207207
return
208208
}
@@ -280,7 +280,7 @@ func NewHttpIpfsHandlerFunc(
280280
fileName = fmt.Sprintf("%s%s", rootCid.String(), trustlesshttp.FilenameExtCar)
281281
}
282282

283-
var writer io.Writer = newIpfsResponseWriter(res, cfg.MaxResponseBytes, func() {
283+
setHeaders := func() {
284284
// called once we start writing blocks into the CAR (on the first Put())
285285

286286
close(bytesWrittenCh) // signal that we've started writing, so we can't log errors to the response now
@@ -300,7 +300,9 @@ func NewHttpIpfsHandlerFunc(
300300
res.Header().Set("X-Content-Type-Options", "nosniff")
301301
res.Header().Set("X-Ipfs-Path", "/"+datamodel.ParsePath(req.URL.Path).String())
302302
res.Header().Set("Vary", "Accept, Accept-Encoding")
303-
})
303+
}
304+
305+
var writer io.Writer = newIpfsResponseWriter(res, cfg.MaxResponseBytes, setHeaders)
304306

305307
if lrw, ok := res.(*LoggingResponseWriter); ok {
306308
writer = &countingWriter{writer, lrw}
@@ -310,15 +312,44 @@ func NewHttpIpfsHandlerFunc(
310312
}
311313
}
312314

313-
if accept.IsRaw() {
314-
// send the raw block bytes as the response
315+
// For HEAD requests, we only set headers, no body
316+
isHeadRequest := req.Method == http.MethodHead
317+
318+
// Special handling for probe CID
319+
if rootCid.Equals(ProbeCID) {
320+
// Probe path handling - special identity CID with empty content
321+
if isHeadRequest {
322+
// For HEAD, just set headers without body
323+
setHeaders()
324+
} else if accept.IsRaw() {
325+
// For raw format, return empty body (identity CID has no content)
326+
// Write empty response for GET (identity CID has empty content)
327+
_, _ = writer.Write([]byte{})
328+
} else {
329+
// For CAR format, write the pre-generated probe CAR bytes
330+
if _, err := writer.Write(getProbeCarBytes()); err != nil {
331+
logger.Debugw("probe CID CAR streaming error", "cid", rootCid, "err", err)
332+
logError(http.StatusInternalServerError, err)
333+
}
334+
}
335+
} else if isHeadRequest {
336+
// HEAD request - verify content exists and set headers, but don't send body
337+
// For both raw and CAR formats, we verify by checking if the root block exists
338+
if _, err := lsys.LoadRaw(linking.LinkContext{Ctx: reqCtx}, cidlink.Link{Cid: rootCid}); err != nil {
339+
logError(http.StatusInternalServerError, err)
340+
} else {
341+
// Content exists, set headers without body
342+
setHeaders()
343+
}
344+
} else if accept.IsRaw() {
345+
// GET request for raw block - send the actual block
315346
if byts, err := lsys.LoadRaw(linking.LinkContext{Ctx: reqCtx}, cidlink.Link{Cid: rootCid}); err != nil {
316347
logError(http.StatusInternalServerError, err)
317348
} else if _, err := writer.Write(byts); err != nil {
318349
logError(http.StatusInternalServerError, err)
319350
}
320351
} else {
321-
// IsCar, so stream the CAR as the response
352+
// GET request for CAR - stream the CAR
322353
if err := StreamCar(reqCtx, lsys, writer, request); err != nil {
323354
logger.Debugw("error streaming CAR", "cid", rootCid, "err", err)
324355
logError(http.StatusInternalServerError, err)

httpipfs_test.go

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,24 @@ func TestHttpIpfsHandler(t *testing.T) {
3838
name string
3939
path string
4040
accept string
41+
method string
4142
expectedStatusCode int
4243
expectedBody string
44+
checkHeaders bool
4345
}{
4446
{
4547
name: "404",
4648
path: "/not here",
4749
expectedStatusCode: http.StatusNotFound,
4850
expectedBody: "not found",
4951
},
52+
{
53+
name: "HEAD 404",
54+
path: "/not here",
55+
method: http.MethodHead,
56+
expectedStatusCode: http.StatusNotFound,
57+
expectedBody: "", // HEAD should have no body
58+
},
5059
{
5160
name: "bad cid",
5261
path: "/ipfs/foobarbaz",
@@ -96,7 +105,11 @@ func TestHttpIpfsHandler(t *testing.T) {
96105
} {
97106
t.Run(testCase.name, func(t *testing.T) {
98107
req := require.New(t)
99-
request, err := http.NewRequest(http.MethodGet, testServer.URL+testCase.path, nil)
108+
method := testCase.method
109+
if method == "" {
110+
method = http.MethodGet
111+
}
112+
request, err := http.NewRequest(method, testServer.URL+testCase.path, nil)
100113
req.NoError(err)
101114
if testCase.accept != "" {
102115
request.Header.Set("Accept", testCase.accept)
@@ -107,6 +120,150 @@ func TestHttpIpfsHandler(t *testing.T) {
107120
body, err := io.ReadAll(res.Body)
108121
req.NoError(err)
109122
req.Equal(testCase.expectedBody, string(body))
123+
124+
// For HEAD requests, verify headers are set but body is empty
125+
if method == http.MethodHead && testCase.checkHeaders {
126+
req.NotEmpty(res.Header.Get("Content-Type"))
127+
req.NotEmpty(res.Header.Get("Etag"))
128+
req.Empty(string(body))
129+
}
130+
})
131+
}
132+
}
133+
134+
func TestProbePathAndHeadRequests(t *testing.T) {
135+
req := require.New(t)
136+
137+
// Set up a basic link system with some test data
138+
lsys := cidlink.DefaultLinkSystem()
139+
store := &memstore.Store{}
140+
lsys.SetReadStorage(&trustlesstestutil.CorrectedMemStore{ParentStore: store})
141+
lsys.SetWriteStorage(store)
142+
143+
// Create a simple test block
144+
testData := []byte("test content")
145+
testLink, err := lsys.Store(linking.LinkContext{}, cidlink.LinkPrototype{Prefix: cid.Prefix{
146+
Version: 1,
147+
Codec: cid.Raw,
148+
MhType: multihash.SHA2_256,
149+
MhLength: -1,
150+
}}, basicnode.NewBytes(testData))
151+
req.NoError(err)
152+
testCid := testLink.(cidlink.Link).Cid
153+
154+
handler := frisbii.NewHttpIpfs(context.Background(), lsys)
155+
testServer := httptest.NewServer(handler)
156+
defer testServer.Close()
157+
158+
testCases := []struct {
159+
name string
160+
method string
161+
path string
162+
accept string
163+
expectedStatusCode int
164+
expectEmptyBody bool
165+
checkHeaders bool
166+
}{
167+
// Probe path tests - bafkqaaa is the special probe CID
168+
{
169+
name: "GET probe path with raw format",
170+
method: http.MethodGet,
171+
path: "/ipfs/bafkqaaa",
172+
accept: trustlesshttp.MimeTypeRaw,
173+
expectedStatusCode: http.StatusOK,
174+
expectEmptyBody: true, // Identity CID has empty content
175+
checkHeaders: true,
176+
},
177+
{
178+
name: "HEAD probe path with raw format",
179+
method: http.MethodHead,
180+
path: "/ipfs/bafkqaaa",
181+
accept: trustlesshttp.MimeTypeRaw,
182+
expectedStatusCode: http.StatusOK,
183+
expectEmptyBody: true,
184+
checkHeaders: true,
185+
},
186+
{
187+
name: "GET probe path with CAR format",
188+
method: http.MethodGet,
189+
path: "/ipfs/bafkqaaa",
190+
accept: trustlesshttp.MimeTypeCar,
191+
expectedStatusCode: http.StatusOK,
192+
expectEmptyBody: false, // CAR will have header
193+
checkHeaders: true,
194+
},
195+
{
196+
name: "HEAD probe path with CAR format",
197+
method: http.MethodHead,
198+
path: "/ipfs/bafkqaaa",
199+
accept: trustlesshttp.MimeTypeCar,
200+
expectedStatusCode: http.StatusOK,
201+
expectEmptyBody: true, // HEAD always has empty body
202+
checkHeaders: true,
203+
},
204+
// Regular CID HEAD tests
205+
{
206+
name: "HEAD existing block with raw format",
207+
method: http.MethodHead,
208+
path: "/ipfs/" + testCid.String(),
209+
accept: trustlesshttp.MimeTypeRaw,
210+
expectedStatusCode: http.StatusOK,
211+
expectEmptyBody: true,
212+
checkHeaders: true,
213+
},
214+
{
215+
name: "HEAD existing block with CAR format",
216+
method: http.MethodHead,
217+
path: "/ipfs/" + testCid.String(),
218+
accept: trustlesshttp.MimeTypeCar,
219+
expectedStatusCode: http.StatusOK,
220+
expectEmptyBody: true,
221+
checkHeaders: true,
222+
},
223+
{
224+
name: "HEAD non-existing block",
225+
method: http.MethodHead,
226+
path: "/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi",
227+
accept: trustlesshttp.MimeTypeRaw,
228+
expectedStatusCode: http.StatusInternalServerError,
229+
expectEmptyBody: true,
230+
},
231+
}
232+
233+
for _, tc := range testCases {
234+
t.Run(tc.name, func(t *testing.T) {
235+
request, err := http.NewRequest(tc.method, testServer.URL+tc.path, nil)
236+
req.NoError(err)
237+
if tc.accept != "" {
238+
request.Header.Set("Accept", tc.accept)
239+
}
240+
241+
res, err := http.DefaultClient.Do(request)
242+
req.NoError(err)
243+
req.Equal(tc.expectedStatusCode, res.StatusCode)
244+
245+
body, err := io.ReadAll(res.Body)
246+
req.NoError(err)
247+
248+
if tc.expectEmptyBody {
249+
req.Empty(body, "Expected empty body but got: %s", string(body))
250+
}
251+
252+
if tc.checkHeaders && tc.expectedStatusCode == http.StatusOK {
253+
req.NotEmpty(res.Header.Get("Content-Type"), "Content-Type header should be set")
254+
req.NotEmpty(res.Header.Get("Etag"), "Etag header should be set")
255+
req.NotEmpty(res.Header.Get("X-Ipfs-Path"), "X-Ipfs-Path header should be set")
256+
req.Equal("Accept, Accept-Encoding", res.Header.Get("Vary"), "Vary header should be set")
257+
}
258+
259+
// Special check for probe CAR response
260+
if tc.path == "/ipfs/bafkqaaa" && tc.accept == trustlesshttp.MimeTypeCar && tc.method == http.MethodGet {
261+
// Parse the CAR to verify it's valid and has the probe CID as root
262+
reader, err := car.NewBlockReader(strings.NewReader(string(body)))
263+
req.NoError(err)
264+
req.Equal(1, len(reader.Roots))
265+
req.Equal("bafkqaaa", reader.Roots[0].String())
266+
}
110267
})
111268
}
112269
}

0 commit comments

Comments
 (0)