Skip to content

Commit 8a5fae2

Browse files
fix(api): apply Range and preconditions to HEAD as GET does
1 parent 9c9b79a commit 8a5fae2

4 files changed

Lines changed: 176 additions & 19 deletions

File tree

openapi/Swarm.yaml

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ paths:
246246
description: Default response
247247
head:
248248
summary: Retrieve headers containing the content type and length for the reference
249+
description: >
250+
Identical to GET on the same reference except that no content is sent,
251+
so Range and conditional request headers are honored the same way.
249252
tags:
250253
- Bytes
251254
parameters:
@@ -286,15 +289,44 @@ paths:
286289
description: The reference, as an entity tag.
287290
schema:
288291
type: string
292+
Last-Modified:
293+
description: The time the response was generated.
294+
schema:
295+
type: string
289296
Access-Control-Expose-Headers:
290297
description: Headers exposed for CORS.
291298
schema:
292299
type: string
293-
example: Content-Disposition, Accept-Ranges
300+
example: Content-Disposition
301+
"206":
302+
description: Headers for the range requested via the Range header.
303+
headers:
304+
Content-Range:
305+
description: The range covered, and the total size of the content.
306+
schema:
307+
type: string
308+
example: bytes 0-1023/4096
309+
Content-Length:
310+
description: The size of the requested range in bytes.
311+
schema:
312+
type: integer
313+
example: 1024
314+
"304":
315+
description: The entity tag in If-None-Match matches the reference.
294316
"400":
295317
$ref: "SwarmCommon.yaml#/components/responses/400"
296318
"404":
297319
$ref: "SwarmCommon.yaml#/components/responses/404"
320+
"412":
321+
description: A conditional request header was not satisfied.
322+
"416":
323+
description: The requested range cannot be satisfied.
324+
headers:
325+
Content-Range:
326+
description: The total size of the content.
327+
schema:
328+
type: string
329+
example: bytes */4096
298330
"500":
299331
$ref: "SwarmCommon.yaml#/components/responses/500"
300332
default:
@@ -485,6 +517,9 @@ paths:
485517
description: Default response
486518
head:
487519
summary: Retrieve headers with content type and length for the reference
520+
description: >
521+
Identical to GET on the same reference except that no content is sent,
522+
so Range and conditional request headers are honored the same way.
488523
tags:
489524
- BZZ
490525
parameters:
@@ -525,15 +560,44 @@ paths:
525560
description: The reference, as an entity tag.
526561
schema:
527562
type: string
563+
Last-Modified:
564+
description: The time the response was generated.
565+
schema:
566+
type: string
528567
Access-Control-Expose-Headers:
529568
description: Headers exposed for CORS.
530569
schema:
531570
type: string
532-
example: Content-Disposition, Accept-Ranges
571+
example: Content-Disposition
572+
"206":
573+
description: Headers for the range requested via the Range header.
574+
headers:
575+
Content-Range:
576+
description: The range covered, and the total size of the content.
577+
schema:
578+
type: string
579+
example: bytes 0-1023/4096
580+
Content-Length:
581+
description: The size of the requested range in bytes.
582+
schema:
583+
type: integer
584+
example: 1024
585+
"304":
586+
description: The entity tag in If-None-Match matches the reference.
533587
"400":
534588
$ref: "SwarmCommon.yaml#/components/responses/400"
535589
"404":
536590
$ref: "SwarmCommon.yaml#/components/responses/404"
591+
"412":
592+
description: A conditional request header was not satisfied.
593+
"416":
594+
description: The requested range cannot be satisfied.
595+
headers:
596+
Content-Range:
597+
description: The total size of the content.
598+
schema:
599+
type: string
600+
example: bytes */4096
537601
"500":
538602
$ref: "SwarmCommon.yaml#/components/responses/500"
539603
default:

pkg/api/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ const (
107107
ContentTypeHeader = "Content-Type"
108108
ContentDispositionHeader = "Content-Disposition"
109109
ContentLengthHeader = "Content-Length"
110+
ContentRangeHeader = "Content-Range"
110111
AcceptRangesHeader = "Accept-Ranges"
111112
RangeHeader = "Range"
112113
OriginHeader = "Origin"

pkg/api/bytes_test.go

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -477,21 +477,112 @@ func TestBytesHead(t *testing.T) {
477477

478478
resource := "/bytes/" + resp.Reference.String()
479479

480-
jsonhttptest.Request(t, client, http.MethodHead, resource, http.StatusOK,
481-
jsonhttptest.WithExpectedContentLength(len(content)),
482-
jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"),
483-
jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"),
484-
)
485-
jsonhttptest.Request(t, client, http.MethodGet, resource, http.StatusOK,
486-
jsonhttptest.WithExpectedContentLength(len(content)),
487-
jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"),
488-
jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"),
489-
)
480+
for _, method := range []string{http.MethodHead, http.MethodGet} {
481+
jsonhttptest.Request(t, client, method, resource, http.StatusOK,
482+
jsonhttptest.WithExpectedContentLength(len(content)),
483+
jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"),
484+
jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"),
485+
jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", resp.Reference)),
486+
jsonhttptest.WithNonEmptyResponseHeader("Last-Modified"),
487+
)
488+
}
490489
})
491490
}
492491
}
493492
}
494493

494+
// TestBytesHeadRangeAndConditional tests that HEAD applies Range and precondition
495+
// headers exactly as GET does, since HEAD differs from GET only in sending no body.
496+
func TestBytesHeadRangeAndConditional(t *testing.T) {
497+
t.Parallel()
498+
499+
g := mockbytes.New(0, mockbytes.MockTypeStandard).WithModulus(255)
500+
content, err := g.SequentialBytes(swarm.ChunkSize * 10)
501+
if err != nil {
502+
t.Fatal(err)
503+
}
504+
505+
client, _, _, _ := newTestServer(t, testServerOptions{
506+
Storer: mockstorer.New(),
507+
Post: mockpost.New(mockpost.WithAcceptAll()),
508+
})
509+
510+
var resp struct {
511+
Reference swarm.Address `json:"reference"`
512+
}
513+
jsonhttptest.Request(t, client, http.MethodPost, "/bytes", http.StatusCreated,
514+
jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"),
515+
jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr),
516+
jsonhttptest.WithRequestBody(bytes.NewReader(content)),
517+
jsonhttptest.WithUnmarshalJSONResponse(&resp),
518+
)
519+
520+
resource := "/bytes/" + resp.Reference.String()
521+
etag := fmt.Sprintf("%q", resp.Reference)
522+
523+
tests := []struct {
524+
name string
525+
header [2]string
526+
want int
527+
headers []jsonhttptest.Option
528+
}{
529+
{
530+
name: "satisfiable range",
531+
header: [2]string{api.RangeHeader, "bytes=0-99"},
532+
want: http.StatusPartialContent,
533+
headers: []jsonhttptest.Option{
534+
jsonhttptest.WithExpectedContentLength(100),
535+
jsonhttptest.WithExpectedResponseHeader(api.ContentRangeHeader, fmt.Sprintf("bytes 0-99/%d", len(content))),
536+
},
537+
},
538+
{
539+
name: "unsatisfiable range",
540+
header: [2]string{api.RangeHeader, "bytes=99999999-"},
541+
want: http.StatusRequestedRangeNotSatisfiable,
542+
headers: []jsonhttptest.Option{
543+
jsonhttptest.WithExpectedResponseHeader(api.ContentRangeHeader, fmt.Sprintf("bytes */%d", len(content))),
544+
},
545+
},
546+
{
547+
name: "matching if-none-match",
548+
header: [2]string{"If-None-Match", etag},
549+
want: http.StatusNotModified,
550+
headers: []jsonhttptest.Option{
551+
jsonhttptest.WithNoResponseBody(),
552+
},
553+
},
554+
{
555+
name: "non-matching if-none-match",
556+
header: [2]string{"If-None-Match", `"0000000000000000000000000000000000000000000000000000000000000001"`},
557+
want: http.StatusOK,
558+
headers: []jsonhttptest.Option{
559+
jsonhttptest.WithExpectedContentLength(len(content)),
560+
},
561+
},
562+
{
563+
// A body-less response must not inherit the full content length, or the
564+
// server truncates the connection and the client sees an unexpected EOF.
565+
name: "non-matching if-match",
566+
header: [2]string{"If-Match", `"0000000000000000000000000000000000000000000000000000000000000001"`},
567+
want: http.StatusPreconditionFailed,
568+
headers: []jsonhttptest.Option{
569+
jsonhttptest.WithNoResponseBody(),
570+
},
571+
},
572+
}
573+
574+
for _, tt := range tests {
575+
t.Run(tt.name, func(t *testing.T) {
576+
for _, method := range []string{http.MethodHead, http.MethodGet} {
577+
opts := append([]jsonhttptest.Option{
578+
jsonhttptest.WithRequestHeader(tt.header[0], tt.header[1]),
579+
}, tt.headers...)
580+
jsonhttptest.Request(t, client, method, resource, tt.want, opts...)
581+
}
582+
})
583+
}
584+
}
585+
495586
// TestBytesHeadErrorsMatchGet tests that HEAD reports the same status as GET for
496587
// references that cannot be served.
497588
func TestBytesHeadErrorsMatchGet(t *testing.T) {

pkg/api/bzz.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"net/http"
1616
"path"
1717
"path/filepath"
18-
"strconv"
1918
"strings"
2019
"time"
2120

@@ -790,15 +789,17 @@ func (s *Service) downloadHandler(logger log.Logger, w http.ResponseWriter, r *h
790789
if etag {
791790
w.Header().Set(ETagHeader, fmt.Sprintf("%q", reference))
792791
}
793-
w.Header().Set(ContentLengthHeader, strconv.FormatInt(l, 10))
792+
// Content-Length is left to http.ServeContent, which knows how many bytes the
793+
// response actually carries. Setting it here would survive into the responses
794+
// that carry no content, notably the 412 from a failed precondition.
794795
w.Header().Add(AccessControlExposeHeaders, ContentDispositionHeader)
795796

797+
// http.ServeContent writes no body for HEAD, so header-only responses take the
798+
// same path as GET and inherit its preconditions, Range handling and headers.
799+
// The reader is passed unbuffered: a response without a body has nothing to
800+
// read ahead for.
796801
if headersOnly {
797-
// http.ServeContent would set this, but the GET path is not reached here.
798-
// "bytes" is the range unit, not the endpoint.
799-
w.Header().Set(AcceptRangesHeader, "bytes")
800-
w.Header().Add(AccessControlExposeHeaders, AcceptRangesHeader)
801-
w.WriteHeader(http.StatusOK)
802+
http.ServeContent(w, r, "", time.Now(), reader)
802803
return
803804
}
804805

0 commit comments

Comments
 (0)