Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set etag on pmtiles serve responses #137

Merged
merged 9 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions caddy/pmtiles_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@

import (
"fmt"
"io"
"log"
"net/http"
"strconv"
"time"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/protomaps/go-pmtiles/pmtiles"
"go.uber.org/zap"
_ "gocloud.dev/blob/azureblob"

Check warning on line 17 in caddy/pmtiles_proxy.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

a blank import should be only in a main or test package, or have a comment justifying it
_ "gocloud.dev/blob/fileblob"
_ "gocloud.dev/blob/gcsblob"
_ "gocloud.dev/blob/s3blob"
"io"
"log"
"net/http"
"strconv"
"time"
)

func init() {
Expand All @@ -41,7 +42,7 @@
}
}

func (m *Middleware) Provision(ctx caddy.Context) error {

Check warning on line 45 in caddy/pmtiles_proxy.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

exported method Middleware.Provision should have comment or be unexported
m.logger = ctx.Logger()
logger := log.New(io.Discard, "", log.Ldate)
prefix := "." // serve only the root of the bucket for now, at the root route of Caddyfile
Expand All @@ -54,7 +55,7 @@
return nil
}

func (m *Middleware) Validate() error {

Check warning on line 58 in caddy/pmtiles_proxy.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

exported method Middleware.Validate should have comment or be unexported
if m.Bucket == "" {
return fmt.Errorf("no bucket")
}
Expand All @@ -66,18 +67,13 @@

func (m Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
start := time.Now()
statusCode, headers, body := m.server.Get(r.Context(), r.URL.Path)
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(statusCode)
w.Write(body)
statusCode := m.server.ServeHTTP(w, r)
m.logger.Info("response", zap.Int("status", statusCode), zap.String("path", r.URL.Path), zap.Duration("duration", time.Since(start)))

return next.ServeHTTP(w, r)
}

func (m *Middleware) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {

Check warning on line 76 in caddy/pmtiles_proxy.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

exported method Middleware.UnmarshalCaddyfile should have comment or be unexported
for d.Next() {
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/alecthomas/kong v0.8.0
github.com/aws/aws-sdk-go v1.45.12
github.com/caddyserver/caddy/v2 v2.7.5
github.com/cespare/xxhash/v2 v2.2.0
github.com/dustin/go-humanize v1.0.1
github.com/paulmach/orb v0.10.0
github.com/prometheus/client_golang v1.18.0
Expand Down Expand Up @@ -62,7 +63,6 @@ require (
github.com/bits-and-blooms/bitset v1.2.0 // indirect
github.com/caddyserver/certmagic v0.19.2 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
Expand Down
7 changes: 1 addition & 6 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main

Check warning on line 1 in main.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

should have a package comment

import (
"fmt"
Expand Down Expand Up @@ -140,12 +140,7 @@

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
statusCode, headers, body := server.Get(r.Context(), r.URL.Path)
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(statusCode)
w.Write(body)
statusCode := server.ServeHTTP(w, r)
logger.Printf("served %d %s in %s", statusCode, r.URL.Path, time.Since(start))
})

Expand Down
35 changes: 29 additions & 6 deletions pmtiles/bucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import (
"bytes"
"context"
"crypto/md5"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
Expand All @@ -17,6 +17,7 @@

"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/cespare/xxhash/v2"
"gocloud.dev/blob"
)

Expand Down Expand Up @@ -55,8 +56,7 @@
return nil, "", fmt.Errorf("Not found %s", key)
}

hash := md5.Sum(bs)
resultEtag := hex.EncodeToString(hash[:])
resultEtag := generateEtag(bs)
if len(etag) > 0 && resultEtag != etag {
return nil, "", &RefreshRequiredError{}
}
Expand All @@ -72,12 +72,37 @@
path string
}

func (b FileBucket) NewRangeReader(ctx context.Context, key string, offset, length int64) (io.ReadCloser, error) {

Check warning on line 75 in pmtiles/bucket.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

exported method FileBucket.NewRangeReader should have comment or be unexported
body, _, err := b.NewRangeReaderEtag(ctx, key, offset, length, "")
return body, err
}

func uintToBytes(n uint64) []byte {
bs := make([]byte, 8)
binary.LittleEndian.PutUint64(bs, n)
return bs
}

func hasherToEtag(hasher *xxhash.Digest) string {
sum := uintToBytes(hasher.Sum64())
return fmt.Sprintf(`"%s"`, hex.EncodeToString(sum))
}

func generateEtag(data []byte) string {
hasher := xxhash.New()
hasher.Write(data)
return hasherToEtag(hasher)
}

func generateEtagFromInts(ns ...int64) string {
hasher := xxhash.New()
for _, n := range ns {
hasher.Write(uintToBytes(uint64(n)))
}
return hasherToEtag(hasher)
}

func (b FileBucket) NewRangeReaderEtag(_ context.Context, key string, offset, length int64, etag string) (io.ReadCloser, string, error) {

Check warning on line 105 in pmtiles/bucket.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

exported method FileBucket.NewRangeReaderEtag should have comment or be unexported
name := filepath.Join(b.path, key)
file, err := os.Open(name)
defer file.Close()
Expand All @@ -88,9 +113,7 @@
if err != nil {
return nil, "", err
}
modInfo := fmt.Sprintf("%d %d", info.ModTime().UnixNano(), info.Size())
hash := md5.Sum([]byte(modInfo))
newEtag := fmt.Sprintf(`"%s"`, hex.EncodeToString(hash[:]))
newEtag := generateEtagFromInts(info.ModTime().UnixNano(), info.Size())
if len(etag) > 0 && etag != newEtag {
return nil, "", &RefreshRequiredError{}
}
Expand All @@ -105,7 +128,7 @@
return io.NopCloser(bytes.NewReader(result)), newEtag, nil
}

func (b FileBucket) Close() error {

Check warning on line 131 in pmtiles/bucket.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

exported method FileBucket.Close should have comment or be unexported
return nil
}

Expand All @@ -114,7 +137,7 @@
Do(req *http.Request) (*http.Response, error)
}

type HTTPBucket struct {

Check warning on line 140 in pmtiles/bucket.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

exported type HTTPBucket should have comment or be unexported
baseURL string
client HTTPClient
}
Expand All @@ -124,7 +147,7 @@
return body, err
}

func (b HTTPBucket) NewRangeReaderEtag(ctx context.Context, key string, offset, length int64, etag string) (io.ReadCloser, string, error) {

Check warning on line 150 in pmtiles/bucket.go

View workflow job for this annotation

GitHub Actions / fmt_vet_lint

exported method HTTPBucket.NewRangeReaderEtag should have comment or be unexported
reqURL := b.baseURL + "/" + key

req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
Expand Down
28 changes: 28 additions & 0 deletions pmtiles/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import (
"errors"
"io"
"log"
"net/http"
"regexp"
"strconv"
"time"

"github.com/prometheus/client_golang/prometheus"
)
Expand Down Expand Up @@ -294,6 +296,7 @@ func (server *Server) getTileJSON(ctx context.Context, httpHeaders map[string]st
}

httpHeaders["Content-Type"] = "application/json"
httpHeaders["Etag"] = generateEtag(tilejsonBytes)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the end result is case-insensitive but the capitalization convention seems to be ETag with a capital T, any objections to changing it to that?


return 200, httpHeaders, tilejsonBytes
}
Expand All @@ -310,6 +313,7 @@ func (server *Server) getMetadata(ctx context.Context, httpHeaders map[string]st
}

httpHeaders["Content-Type"] = "application/json"
httpHeaders["Etag"] = generateEtag(metadataBytes)
return 200, httpHeaders, metadataBytes
}
func (server *Server) getTile(ctx context.Context, httpHeaders map[string]string, name string, z uint8, x uint32, y uint32, ext string) (int, map[string]string, []byte) {
Expand All @@ -320,6 +324,7 @@ func (server *Server) getTile(ctx context.Context, httpHeaders map[string]string
}
return status, headers, data
}

func (server *Server) getTileAttempt(ctx context.Context, httpHeaders map[string]string, name string, z uint8, x uint32, y uint32, ext string, purgeEtag string) (int, map[string]string, []byte, string) {
rootReq := request{key: cacheKey{name: name, offset: 0, length: 0}, value: make(chan cachedValue, 1), purgeEtag: purgeEtag}
bdon marked this conversation as resolved.
Show resolved Hide resolved
server.reqs <- rootReq
Expand Down Expand Up @@ -390,6 +395,8 @@ func (server *Server) getTileAttempt(ctx context.Context, httpHeaders map[string
if err != nil {
return 500, httpHeaders, []byte("I/O error"), ""
}

httpHeaders["Etag"] = generateEtag(b)
if headerVal, ok := headerContentType(header); ok {
httpHeaders["Content-Type"] = headerVal
}
Expand Down Expand Up @@ -465,3 +472,24 @@ func (server *Server) Get(ctx context.Context, path string) (int, map[string]str

return 404, httpHeaders, []byte("Path not found")
}

// Serve an HTTP response from the archive
func (server *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) int {
statusCode, headers, body := server.Get(r.Context(), r.URL.Path)
for k, v := range headers {
w.Header().Set(k, v)
}
if statusCode == 200 {
// handle if-match, if-none-match request headers based on response etag
http.ServeContent(
w, r,
"", // name used to infer content-type, but we've already set that
time.UnixMilli(0), // ignore setting last-modified time and handling if-modified-since headers
bytes.NewReader(body),
)
} else {
w.WriteHeader(statusCode)
w.Write(body)
}
return statusCode
}
44 changes: 44 additions & 0 deletions pmtiles/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,47 @@ func TestInvalidateCacheOnMetadataRequest(t *testing.T) {
"meta": "data2"
}`, string(data))
}

func TestEtagResponsesFromTile(t *testing.T) {
mockBucket, server := newServer(t)
header := HeaderV3{
TileType: Mvt,
}
mockBucket.items["archive.pmtiles"] = fakeArchive(t, header, map[string]interface{}{}, map[Zxy][]byte{
{0, 0, 0}: {0, 1, 2, 3},
{4, 1, 2}: {1, 2, 3},
}, false)

statusCode, headers000v1, _ := server.Get(context.Background(), "/archive/0/0/0.mvt")
assert.Equal(t, 200, statusCode)
statusCode, headers412v1, _ := server.Get(context.Background(), "/archive/4/1/2.mvt")
assert.Equal(t, 200, statusCode)
statusCode, headers311v1, _ := server.Get(context.Background(), "/archive/3/1/1.mvt")
assert.Equal(t, 204, statusCode)

mockBucket.items["archive.pmtiles"] = fakeArchive(t, header, map[string]interface{}{}, map[Zxy][]byte{
{0, 0, 0}: {0, 1, 2, 3},
{4, 1, 2}: {1, 2, 3, 4}, // different
}, false)

statusCode, headers000v2, _ := server.Get(context.Background(), "/archive/0/0/0.mvt")
assert.Equal(t, 200, statusCode)
statusCode, headers412v2, _ := server.Get(context.Background(), "/archive/4/1/2.mvt")
assert.Equal(t, 200, statusCode)
statusCode, headers311v2, _ := server.Get(context.Background(), "/archive/3/1/1.mvt")
assert.Equal(t, 204, statusCode)

// 204's have no etag
assert.Equal(t, "", headers311v1["Etag"])
assert.Equal(t, "", headers311v2["Etag"])

// 000 and 311 didn't change
assert.Equal(t, headers000v1["Etag"], headers000v2["Etag"])

// 412 did change
assert.NotEqual(t, headers412v1["Etag"], headers412v2["Etag"])

// all are different
assert.NotEqual(t, headers000v1["Etag"], headers311v1["Etag"])
assert.NotEqual(t, headers000v1["Etag"], headers412v1["Etag"])
}
Loading