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

fix(tiles): fix proxying of collection-level tiles #227

Merged
merged 4 commits into from
Sep 6, 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
7 changes: 7 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ func validate(config *Config) error {
if config.OgcAPI.Features != nil {
return validateFeatureCollections(config.OgcAPI.Features.Collections)
}
if config.OgcAPI.Tiles != nil && len(config.OgcAPI.Tiles.Collections) > 0 {
for _, coll := range config.OgcAPI.Tiles.Collections {
if coll.Tiles == nil {
return errors.New("invalid tiles config provided: no tileserver(s) configured for collection-level tiles")
}
}
}
return nil
}

Expand Down
8 changes: 8 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ func TestNewConfig(t *testing.T) {
wantErr: true,
wantErrMsg: "validation for 'Version' failed on the 'semver' tag",
},
{
name: "fail on invalid config file for geodata tiles (collection-level tiles)",
args: args{
configFile: "internal/engine/testdata/config_invalid_geodatatiles.yaml",
},
wantErr: true,
wantErrMsg: "invalid tiles config provided: no tileserver(s) configured for collection-level tiles",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
6 changes: 6 additions & 0 deletions internal/engine/problems.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,9 @@ func RenderProblem(kind ProblemKind, w http.ResponseWriter, details ...string) {
log.Printf("failed to write response: %v", err)
}
}

// RenderProblemAndLog writes RFC 7807 (https://tools.ietf.org/html/rfc7807) problem to client + logs message to stdout.
func RenderProblemAndLog(kind ProblemKind, w http.ResponseWriter, err error, details ...string) {
log.Printf("%v", err.Error())
RenderProblem(kind, w, details...)
}
27 changes: 27 additions & 0 deletions internal/engine/testdata/config_invalid_geodatatiles.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
version: 2.0.0
title: 'Foobar'
serviceIdentifier: FB
abstract: foo bar baz
license:
name: CC0 1.0
url: http://creativecommons.org/publicdomain/zero/1.0/deed.nl
baseUrl: http://localhost:8080
availableLanguages:
- en
ogcApi:
tiles:
# collection-level tiles without tileserver config is invalid
collections:
- id: foo
metadata:
title: Foo
- id: bar
metadata:
title: Bar
- id: baz
metadata:
title: Baz
keywords:
- foo
- bar
105 changes: 78 additions & 27 deletions internal/ogc/tiles/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package tiles

import (
"log"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -90,6 +91,7 @@ func NewTiles(e *engine.Engine) *Tiles {
}

// Collection-level tiles (geodata tiles in OGC spec)
geoDataTiles := map[string]config.Tiles{}
for _, coll := range e.Config.OgcAPI.Tiles.Collections {
if coll.Tiles == nil {
continue
Expand All @@ -99,11 +101,15 @@ func NewTiles(e *engine.Engine) *Tiles {
e.Config.BaseURL.String() + g.CollectionsPath + "/" + coll.ID,
util.Cast(AllProjections),
})
geoDataTiles[coll.ID] = coll.Tiles.GeoDataTiles
}
if len(geoDataTiles) != 0 {
e.Router.Get(g.CollectionsPath+"/{collectionId}"+tilesPath, tiles.TilesetsListForCollection())
e.Router.Get(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}", tiles.TilesetForCollection())
e.Router.Head(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}", tiles.Tile(coll.Tiles.GeoDataTiles))
e.Router.Get(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}", tiles.Tile(coll.Tiles.GeoDataTiles))
e.Router.Head(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}", tiles.TileForCollection(geoDataTiles))
e.Router.Get(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}", tiles.TileForCollection(geoDataTiles))
}

return tiles
}

Expand Down Expand Up @@ -154,44 +160,90 @@ func (t *Tiles) TilesetForCollection() http.HandlerFunc {
}
}

// Tile reverse proxy to configured tileserver/object storage. Assumes the backing resources is publicly accessible.
func (t *Tiles) Tile(tileConfig config.Tiles) http.HandlerFunc {
// Tile reverse proxy to configured tileserver/object storage. Assumes the backing resource is publicly accessible.
func (t *Tiles) Tile(tilesConfig config.Tiles) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tileMatrixSetID := chi.URLParam(r, "tileMatrixSetId")
tileMatrix := chi.URLParam(r, "tileMatrix")
tileRow := chi.URLParam(r, "tileRow")
tileCol := chi.URLParam(r, "tileCol")

// We support content negotiation using Accept header and ?f= param, but also
// using the .pbf extension. This is for backwards compatibility.
if !strings.HasSuffix(tileCol, "."+engine.FormatMVTAlternative) {
// if no format is specified, default to mvt
if format := strings.Replace(t.engine.CN.NegotiateFormat(r), engine.FormatJSON, engine.FormatMVT, 1); format != engine.FormatMVT && format != engine.FormatMVTAlternative {
engine.RenderProblem(engine.ProblemBadRequest, w, "Specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported")
return
}
} else {
tileCol = tileCol[:len(tileCol)-4] // remove .pbf extension
tileCol, err := getTileColumn(r, t.engine.CN.NegotiateFormat(r))
if err != nil {
engine.RenderProblemAndLog(engine.ProblemBadRequest, w, err, err.Error())
return
}

// ogc spec is (default) z/row/col but tileserver is z/col/row (z/x/y)
replacer := strings.NewReplacer("{tms}", tileMatrixSetID, "{z}", tileMatrix, "{x}", tileCol, "{y}", tileRow)
tilesTmpl := defaultTilesTmpl
if tileConfig.URITemplateTiles != nil {
tilesTmpl = *tileConfig.URITemplateTiles
target, err := createTilesURL(tileMatrixSetID, tileMatrix, tileCol, tileRow, tilesConfig)
if err != nil {
engine.RenderProblemAndLog(engine.ProblemServerError, w, err)
return
}
path, _ := url.JoinPath("/", replacer.Replace(tilesTmpl))
t.engine.ReverseProxy(w, r, target, true, engine.MediaTypeMVT)
}
}

target, err := url.Parse(tileConfig.TileServer.String() + path)
// TileForCollection reverse proxy to configured tileserver/object storage for tiles within a given collection.
// Assumes the backing resource is publicly accessible.
func (t *Tiles) TileForCollection(tilesConfigByCollection map[string]config.Tiles) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
collectionID := chi.URLParam(r, "collectionId")
tileMatrixSetID := chi.URLParam(r, "tileMatrixSetId")
tileMatrix := chi.URLParam(r, "tileMatrix")
tileRow := chi.URLParam(r, "tileRow")
tileCol, err := getTileColumn(r, t.engine.CN.NegotiateFormat(r))
if err != nil {
log.Printf("invalid target url, can't proxy tiles: %v", err)
engine.RenderProblem(engine.ProblemServerError, w)
engine.RenderProblemAndLog(engine.ProblemBadRequest, w, err, err.Error())
return
}

tilesConfig, ok := tilesConfigByCollection[collectionID]
if !ok {
err = fmt.Errorf("no tiles available for collection: %s", collectionID)
engine.RenderProblemAndLog(engine.ProblemNotFound, w, err, err.Error())
return
}
target, err := createTilesURL(tileMatrixSetID, tileMatrix, tileCol, tileRow, tilesConfig)
if err != nil {
engine.RenderProblemAndLog(engine.ProblemServerError, w, err)
return
}
t.engine.ReverseProxy(w, r, target, true, engine.MediaTypeMVT)
}
}

func getTileColumn(r *http.Request, format string) (string, error) {
tileCol := chi.URLParam(r, "tileCol")

// We support content negotiation using Accept header and ?f= param, but also
// using the .pbf extension. This is for backwards compatibility.
if !strings.HasSuffix(tileCol, "."+engine.FormatMVTAlternative) {
// if no format is specified, default to mvt
if f := strings.Replace(format, engine.FormatJSON, engine.FormatMVT, 1); f != engine.FormatMVT && f != engine.FormatMVTAlternative {
return "", errors.New("specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported")
}
} else {
tileCol = tileCol[:len(tileCol)-4] // remove .pbf extension
}
return tileCol, nil
}

func createTilesURL(tileMatrixSetID string, tileMatrix string, tileCol string,
tileRow string, tilesCfg config.Tiles) (*url.URL, error) {

tilesTmpl := defaultTilesTmpl
if tilesCfg.URITemplateTiles != nil {
tilesTmpl = *tilesCfg.URITemplateTiles
}
// OGC spec is (default) z/row/col but tileserver is z/col/row (z/x/y)
replacer := strings.NewReplacer("{tms}", tileMatrixSetID, "{z}", tileMatrix, "{x}", tileCol, "{y}", tileRow)
path, _ := url.JoinPath("/", replacer.Replace(tilesTmpl))

target, err := url.Parse(tilesCfg.TileServer.String() + path)
if err != nil {
return nil, fmt.Errorf("invalid target url, can't proxy tiles: %w", err)
}
return target, nil
}

func renderTileMatrixTemplates(e *engine.Engine) {
e.RenderTemplates(tileMatrixSetsPath,
tileMatrixSetsBreadcrumbs,
Expand All @@ -215,7 +267,6 @@ func renderTileMatrixTemplates(e *engine.Engine) {
}

func renderTilesTemplates(e *engine.Engine, collection *config.GeoSpatialCollection, data templateData) {

var breadcrumbs []engine.Breadcrumb
path := tilesPath
collectionID := ""
Expand Down
38 changes: 33 additions & 5 deletions internal/ogc/tiles/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ func TestTiles_Tile(t *testing.T) {
tileCol: "15",
},
want: want{
body: "Specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported",
body: "specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported",
statusCode: http.StatusBadRequest,
},
},
Expand Down Expand Up @@ -269,6 +269,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix string
tileRow string
tileCol string
collection string
}
type want struct {
body string
Expand All @@ -288,6 +289,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "0",
tileRow: "0",
tileCol: "0",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/0/0/0.pbf",
Expand All @@ -303,6 +305,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/5/15/10.pbf",
Expand All @@ -318,6 +321,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/5/15/10.pbf",
Expand All @@ -333,6 +337,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/5/15/10.pbf",
Expand All @@ -348,6 +353,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15.pbf",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/5/15/10.pbf",
Expand All @@ -363,16 +369,33 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "example",
},
want: want{
body: "Specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported",
body: "specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported",
statusCode: http.StatusBadRequest,
},
},
{
name: "invalid/NetherlandsRDNewQuad/5/10/15?=pbf",
fields: fields{
configFile: "internal/ogc/tiles/testdata/config_tiles_collectionlevel.yaml",
url: "http://localhost:8080/invalid/tiles/:tileMatrixSetId/:tileMatrix/:tileRow/:tileCol?f=pbf",
tileMatrixSetID: "NetherlandsRDNewQuad",
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "invalid",
},
want: want{
body: "no tiles available for collection: invalid",
statusCode: http.StatusNotFound,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := createTileRequest(tt.fields.url, tt.fields.tileMatrixSetID, tt.fields.tileMatrix, tt.fields.tileRow, tt.fields.tileCol)
req, err := createTileRequest(tt.fields.url, tt.fields.tileMatrixSetID, tt.fields.tileMatrix, tt.fields.tileRow, tt.fields.tileCol, tt.fields.collection)
if err != nil {
log.Fatal(err)
}
Expand All @@ -382,7 +405,8 @@ func TestTiles_TileForCollection(t *testing.T) {
newEngine, err := engine.NewEngine(tt.fields.configFile, "", false, true)
assert.NoError(t, err)
tiles := NewTiles(newEngine)
handler := tiles.Tile(newEngine.Config.OgcAPI.Tiles.Collections[0].Tiles.GeoDataTiles)
geoDataTiles := map[string]config.Tiles{newEngine.Config.OgcAPI.Tiles.Collections[0].ID: newEngine.Config.OgcAPI.Tiles.Collections[0].Tiles.GeoDataTiles}
handler := tiles.TileForCollection(geoDataTiles)
handler.ServeHTTP(rr, req)

assert.Equal(t, tt.want.statusCode, rr.Code)
Expand Down Expand Up @@ -842,9 +866,13 @@ func createMockServer() (*httptest.ResponseRecorder, *httptest.Server) {
return rr, ts
}

func createTileRequest(url string, tileMatrixSetID string, tileMatrix string, tileRow string, tileCol string) (*http.Request, error) {
func createTileRequest(url string, tileMatrixSetID string, tileMatrix string, tileRow string, tileCol string, collectionID ...string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
rctx := chi.NewRouteContext()
for _, id := range collectionID {
rctx.URLParams.Add("collectionId", id)
}

rctx.URLParams.Add("tileMatrixSetId", tileMatrixSetID)
rctx.URLParams.Add("tileMatrix", tileMatrix)
rctx.URLParams.Add("tileRow", tileRow)
Expand Down
Loading