diff --git a/config/config.go b/config/config.go index 4f9fa229..b2c654f5 100644 --- a/config/config.go +++ b/config/config.go @@ -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 } diff --git a/config/config_test.go b/config/config_test.go index 720e3b5e..11f80583 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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) { diff --git a/internal/engine/problems.go b/internal/engine/problems.go index e3429bf6..7f9f121a 100644 --- a/internal/engine/problems.go +++ b/internal/engine/problems.go @@ -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...) +} diff --git a/internal/engine/testdata/config_invalid_geodatatiles.yaml b/internal/engine/testdata/config_invalid_geodatatiles.yaml new file mode 100644 index 00000000..e64d603a --- /dev/null +++ b/internal/engine/testdata/config_invalid_geodatatiles.yaml @@ -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 diff --git a/internal/ogc/tiles/main.go b/internal/ogc/tiles/main.go index f4bc0c4b..87de4229 100644 --- a/internal/ogc/tiles/main.go +++ b/internal/ogc/tiles/main.go @@ -1,7 +1,8 @@ package tiles import ( - "log" + "errors" + "fmt" "net/http" "net/url" "strings" @@ -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 @@ -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 } @@ -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, @@ -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 := "" diff --git a/internal/ogc/tiles/main_test.go b/internal/ogc/tiles/main_test.go index 82b1d9a3..0894492e 100644 --- a/internal/ogc/tiles/main_test.go +++ b/internal/ogc/tiles/main_test.go @@ -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, }, }, @@ -269,6 +269,7 @@ func TestTiles_TileForCollection(t *testing.T) { tileMatrix string tileRow string tileCol string + collection string } type want struct { body string @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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) } @@ -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) @@ -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)