diff --git a/atlas/map.go b/atlas/map.go index 190559489..e6cb51629 100644 --- a/atlas/map.go +++ b/atlas/map.go @@ -64,8 +64,6 @@ type Map struct { mvtProviderName string mvtProvider provider.MVTTiler - - observer observability.Interface } // HasMVTProvider indicates if map is a mvt provider based map @@ -194,13 +192,11 @@ func (m Map) encodeMVTProviderTile(ctx context.Context, tile slippy.Tile, params } } return m.mvtProvider.MVTForLayers(ctx, ptile, params, layers) - } // encodeMVTTile will encode the given tile into mvt format // TODO (arolek): support for max zoom func (m Map) encodeMVTTile(ctx context.Context, tile slippy.Tile, params provider.Params) ([]byte, error) { - // tile container var mvtTile mvt.Tile // wait group for concurrent layer fetching @@ -214,7 +210,6 @@ func (m Map) encodeMVTTile(ctx context.Context, tile slippy.Tile, params provide // iterate our layers for i, layer := range m.Layers { - // go routine for fetching the layer concurrently go func(i int, l Layer) { mvtLayer := mvt.Layer{ diff --git a/cmd/internal/register/maps.go b/cmd/internal/register/maps.go index bb4e62c9f..070c47c57 100644 --- a/cmd/internal/register/maps.go +++ b/cmd/internal/register/maps.go @@ -1,6 +1,7 @@ package register import ( + "fmt" "html" "regexp" "strings" @@ -8,6 +9,7 @@ import ( "github.com/go-spatial/geom" "github.com/go-spatial/tegola/atlas" "github.com/go-spatial/tegola/config" + "github.com/go-spatial/tegola/mapbox/tilejson" "github.com/go-spatial/tegola/provider" ) @@ -32,7 +34,6 @@ func webMercatorMapFromConfigMap(cfg provider.Map) (newMap atlas.Map) { newMap.TileBuffer = uint64(*cfg.TileBuffer) } return newMap - } func layerInfosFindByName(infos []provider.LayerInfo, name string) provider.LayerInfo { @@ -48,10 +49,8 @@ func layerInfosFindByName(infos []provider.LayerInfo, name string) provider.Laye } func atlasLayerFromConfigLayer(cfg *provider.MapLayer, mapName string, layerProvider provider.Layerer) (layer atlas.Layer, err error) { - var ( - // providerLayer is primary used for error reporting. - providerLayer = string(cfg.ProviderLayer) - ) + // providerLayer is primary used for error reporting. + providerLayer := string(cfg.ProviderLayer) // read the provider's layer names // don't care about the error. providerName, layerName, _ := cfg.ProviderLayerName() @@ -123,12 +122,9 @@ func selectProvider(name string, mapName string, newMap *atlas.Map, providers ma return nil, ErrProviderNotFound{name} } -// Maps registers maps with with atlas +// Maps registers maps with with atlas. func Maps(a *atlas.Atlas, maps []provider.Map, providers map[string]provider.TilerUnion) error { - - var ( - layerer provider.Layerer - ) + var layerer provider.Layerer // iterate our maps for _, m := range maps { @@ -144,6 +140,17 @@ func Maps(a *atlas.Atlas, maps []provider.Map, providers map[string]provider.Til } } + // if our map requests TileJSON v3 specifically + // we have to validate that the provider supports it. + // if m.TileJSONVersion is not being provided tegola determines + // the TileJSON version for the user. + if m.TileJSONVersion == tilejson.Version3 { + p := providers[providerName] + if ok, err := p.IsTileJSONV3Compatible(); !ok { + return fmt.Errorf("%w: %s", err, providerName) + } + } + // find our layer provider layerer, err = selectProvider(providerName, string(m.Name), &newMap, providers) if err != nil { diff --git a/cmd/internal/register/maps_test.go b/cmd/internal/register/maps_test.go index 5573bb8c4..c06d382ed 100644 --- a/cmd/internal/register/maps_test.go +++ b/cmd/internal/register/maps_test.go @@ -8,6 +8,7 @@ import ( "github.com/go-spatial/tegola/cmd/internal/register" "github.com/go-spatial/tegola/dict" "github.com/go-spatial/tegola/internal/env" + "github.com/go-spatial/tegola/mapbox/tilejson" "github.com/go-spatial/tegola/provider" ) @@ -39,7 +40,6 @@ func TestMaps(t *testing.T) { if !errors.Is(err, tc.expectedErr) { t.Errorf("invalid error, expected %v got %v", tc.expectedErr, err) } - return } } @@ -125,6 +125,26 @@ func TestMaps(t *testing.T) { }, }, }, + "fails when map requires TileJSON v3 but provider does not implement": { + maps: []provider.Map{ + { + Name: "test", + TileJSONVersion: tilejson.Version3, + Layers: []provider.MapLayer{ + { + ProviderLayer: "foo.bar", + }, + }, + }, + }, + providers: []dict.Dict{ + { + "name": "foo", + "type": "debug", + }, + }, + expectedErr: provider.ErrNotTileJSONV3Compatible, + }, "success": { maps: []provider.Map{}, providers: []dict.Dict{ diff --git a/config/config.go b/config/config.go index feaccbc63..fe85887cb 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "os" + "slices" "strings" "time" @@ -13,6 +14,7 @@ import ( "github.com/go-spatial/tegola" "github.com/go-spatial/tegola/internal/env" "github.com/go-spatial/tegola/internal/log" + "github.com/go-spatial/tegola/mapbox/tilejson" "github.com/go-spatial/tegola/provider" ) @@ -157,7 +159,6 @@ func ValidateAndRegisterParams(mapName string, params []provider.QueryParameter) // Validate checks the config for issues func (c *Config) Validate() error { - var knownTypes []string drivers := make(map[string]int) for _, name := range provider.Drivers(provider.TypeStd) { @@ -200,7 +201,6 @@ func (c *Config) Validate() error { // maps with configured parameters for logging mapsWithCustomParams := []string{} for mapKey, m := range c.Maps { - // validate any declared query parameters if err := ValidateAndRegisterParams(string(m.Name), m.Parameters); err != nil { return err @@ -214,6 +214,14 @@ func (c *Config) Validate() error { mapLayers[string(m.Name)] = map[string]provider.MapLayer{} } + if m.TileJSONVersion != "" && + !slices.Contains( + []string{tilejson.Version3, tilejson.Version2}, + m.TileJSONVersion, + ) { + return ErrUnknownTileJSONVersion + } + // Set current provider to empty, for MVT providers // we can only have the same provider for all layers. // This allow us to track what the first found provider @@ -387,7 +395,7 @@ func Load(location string) (conf Config, err error) { log.Infof("loading remote config (%v)", location) // setup http client with a timeout - var httpClient = &http.Client{ + httpClient := &http.Client{ Timeout: time.Second * 10, } diff --git a/config/config_test.go b/config/config_test.go index 5af0c4bde..e61670cf1 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-spatial/tegola/config" "github.com/go-spatial/tegola/internal/env" + "github.com/go-spatial/tegola/mapbox/tilejson" "github.com/go-spatial/tegola/provider" _ "github.com/go-spatial/tegola/provider/debug" _ "github.com/go-spatial/tegola/provider/postgis" @@ -61,7 +62,6 @@ func TestParse(t *testing.T) { fn := func(tc tcase) func(*testing.T) { return func(t *testing.T) { - f, err := os.Open(tc.configPath) if err != nil { t.Fatalf("unexpected error: %s", err) @@ -388,7 +388,6 @@ func TestParse(t *testing.T) { } func TestValidateMutateZoom(t *testing.T) { - type tcase struct { config *config.Config expectedMinZoom int @@ -511,7 +510,6 @@ func TestValidateMutateZoom(t *testing.T) { for name, tc := range tests { t.Run(name, fn(tc)) } - } func TestValidate(t *testing.T) { @@ -1318,12 +1316,56 @@ func TestValidate(t *testing.T) { }, }, }, + "invalid tilejson version": { + expectedErr: config.ErrUnknownTileJSONVersion, + config: config.Config{ + Providers: []env.Dict{ + { + "name": "some_provider", + "type": "mvt_test", + }, + }, + Maps: []provider.Map{ + { + Name: "some_map", + Attribution: "Test Attribution", + TileJSONVersion: "foobar", + Layers: []provider.MapLayer{ + { + ProviderLayer: "some_provider.some_layer", + }, + }, + }, + }, + }, + }, + "valid tilejson version": { + config: config.Config{ + Providers: []env.Dict{ + { + "name": "some_provider", + "type": "mvt_test", + }, + }, + Maps: []provider.Map{ + { + Name: "some_map", + Attribution: "Test Attribution", + TileJSONVersion: tilejson.Version3, + Layers: []provider.MapLayer{ + { + ProviderLayer: "some_provider.some_layer", + }, + }, + }, + }, + }, + }, } for name, tc := range tests { t.Run(name, fn(tc)) } - } func TestConfigureTileBuffers(t *testing.T) { diff --git a/config/errors.go b/config/errors.go index 8475127ed..232a9414d 100644 --- a/config/errors.go +++ b/config/errors.go @@ -1,12 +1,16 @@ package config import ( + "errors" "fmt" "strings" + "github.com/go-spatial/tegola/mapbox/tilejson" "github.com/go-spatial/tegola/provider" ) +var ErrUnknownTileJSONVersion = errors.New("tilejson_version invalid. can either be " + tilejson.Version2 + " or " + tilejson.Version3) + type ErrMapNotFound struct { MapName string } diff --git a/mapbox/tilejson/tilejson.go b/mapbox/tilejson/tilejson.go index 12098219b..94946ff50 100644 --- a/mapbox/tilejson/tilejson.go +++ b/mapbox/tilejson/tilejson.go @@ -2,7 +2,10 @@ // https://github.com/mapbox/tilejson-spec package tilejson -const Version = "2.1.0" +const ( + Version2 = "2.0.0" + Version3 = "3.0.0" +) type GeomType string @@ -135,4 +138,7 @@ type VectorLayer struct { MaxZoom uint `json:"maxzoom"` // Tegola supports individual layer tiles. Tiles []string `json:"tiles"` + // REQUIRED in TileJSON 3.0.0. An object whose keys and values are the names + // and descriptions of attributes available in this layer. + Fields map[string]any `json:"fields,omitempty"` } diff --git a/provider/errors.go b/provider/errors.go index 0524da319..590c36da7 100644 --- a/provider/errors.go +++ b/provider/errors.go @@ -9,9 +9,11 @@ import ( var ( // ErrCanceled is returned when cancel was requested - ErrCanceled = fmt.Errorf("provider: %v", context.Canceled) - ErrUnsupported = errors.New("provider: unsupported") - ErrNilInitFunc = errors.New("init function can not be nil") + ErrCanceled = fmt.Errorf("provider: %v", context.Canceled) + ErrUnsupported = errors.New("provider: unsupported") + ErrNilInitFunc = errors.New("init function can not be nil") + ErrNotTileJSONV3Compatible = errors.New("provider is not TileJSON version 3 compatible") + ErrNoProvider = errors.New("no provider provided") ) type ErrUnableToConvertFeatureID struct { diff --git a/provider/map.go b/provider/map.go index d59ccd0b9..74894aa87 100644 --- a/provider/map.go +++ b/provider/map.go @@ -4,11 +4,12 @@ import "github.com/go-spatial/tegola/internal/env" // A Map represents a map in the Tegola Config file. type Map struct { - Name env.String `toml:"name"` - Attribution env.String `toml:"attribution"` - Bounds []env.Float `toml:"bounds"` - Center [3]env.Float `toml:"center"` - Layers []MapLayer `toml:"layers"` - Parameters []QueryParameter `toml:"params"` - TileBuffer *env.Int `toml:"tile_buffer"` + Name env.String `toml:"name"` + Attribution env.String `toml:"attribution"` + Bounds []env.Float `toml:"bounds"` + Center [3]env.Float `toml:"center"` + Layers []MapLayer `toml:"layers"` + Parameters []QueryParameter `toml:"params"` + TileBuffer *env.Int `toml:"tile_buffer"` + TileJSONVersion string `toml:"tilejson_version"` } diff --git a/provider/postgis/postgis.go b/provider/postgis/postgis.go index 321878bc2..67175236e 100644 --- a/provider/postgis/postgis.go +++ b/provider/postgis/postgis.go @@ -10,15 +10,17 @@ import ( "github.com/go-spatial/geom" "github.com/go-spatial/geom/encoding/wkb" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" + "github.com/go-spatial/tegola" conf "github.com/go-spatial/tegola/config" "github.com/go-spatial/tegola/dict" "github.com/go-spatial/tegola/internal/log" "github.com/go-spatial/tegola/observability" "github.com/go-spatial/tegola/provider" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/prometheus/client_golang/prometheus" ) const Name = "postgis" @@ -331,6 +333,70 @@ func (p Provider) TileFeatures( return rows.Err() } +// LayerFields returns a map of field names to their types for a given layer. +// It executes a sample query (LIMIT 0) to get column information without fetching data. +func (p Provider) LayerFields(ctx context.Context, layerName string) (map[string]any, error) { + plyr, ok := p.Layer(layerName) + if !ok { + return nil, ErrLayerNotFound{layerName} + } + + // Use a dummy tile to replace tokens in the SQL + dummyTile := provider.NewTile(0, 0, 0, 256, 3857) + sql, err := replaceTokens(plyr.sql, &plyr, dummyTile, true) + if err != nil { + return nil, fmt.Errorf("error replacing layer tokens for layer (%s): %w", layerName, err) + } + + // Wrap the query to get column information without fetching rows + sql = fmt.Sprintf("SELECT * FROM (%s) AS subquery LIMIT 0", sql) + + rows, err := p.pool.Query(ctx, sql) + if err != nil { + return nil, fmt.Errorf("error querying fields for layer (%s): %w", layerName, err) + } + defer rows.Close() + + fields := make(map[string]any) + fdescs := rows.FieldDescriptions() + + for _, desc := range fdescs { + fieldName := desc.Name + + // Skip geometry and ID fields as they're not attributes + if fieldName == plyr.GeomFieldName() || fieldName == plyr.IDFieldName() { + continue + } + + // Map PostgreSQL OID types to simple type names + fieldType := postgresTypeToString(desc.DataTypeOID) + fields[fieldName] = fieldType + } + + return fields, nil +} + +// postgresTypeToString converts PostgreSQL OID types to simple type strings +func postgresTypeToString(oid uint32) string { + // Common PostgreSQL type OIDs using pgtype constants + switch oid { + case pgtype.BoolOID: + return "Boolean" + case pgtype.Int8OID, pgtype.Int2OID, pgtype.Int4OID, pgtype.OIDOID: + return "Number" + case pgtype.Float4OID, pgtype.Float8OID, pgtype.NumericOID: + return "Number" + case pgtype.QCharOID, pgtype.NameOID, pgtype.TextOID, pgtype.BPCharOID, pgtype.VarcharOID: + return "String" + case pgtype.TimestampOID, pgtype.TimestamptzOID: + return "String" + case pgtype.JSONOID, pgtype.JSONBOID: + return "String" + default: + return "String" + } +} + func (p Provider) MVTForLayers( ctx context.Context, tile provider.Tile, diff --git a/provider/provider.go b/provider/provider.go index d66d7c53a..d5a1d7e16 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -29,9 +29,7 @@ const ( TypeAll = TypeStd & TypeMvt ) -var ( - webmercatorGrid = slippy.NewGrid(3857, 0) -) +var webmercatorGrid = slippy.NewGrid(3857, 0) func (pt providerType) Prefix() string { if pt == TypeMvt { @@ -100,7 +98,6 @@ func (tile *tile_t) Extent() (ext *geom.Extent, srid uint64) { return &geom.Extent{}, 3857 } return ext, 3857 - } // BufferedExtent returns an extent of the tile, with the define buffer @@ -132,6 +129,14 @@ type Tiler interface { TileFeatures(ctx context.Context, layer string, t Tile, params Params, fn func(f *Feature) error) error } +// LayerFielder is an optional interface that providers can implement to expose +// field information for their layers. This is used to populate the "fields" +// property in TileJSON 3.0.0 capabilities. +type LayerFielder interface { + // LayerFields returns a map of field names to their type descriptions for the given layer + LayerFields(ctx context.Context, layerName string) (map[string]any, error) +} + // TilerUnion represents either a Std Tiler or and MVTTiler; only one should be not nil. type TilerUnion struct { Std Tiler @@ -150,6 +155,23 @@ func (tu TilerUnion) Layers() ([]LayerInfo, error) { return nil, ErrNilInitFunc } +func (tu TilerUnion) IsTileJSONV3Compatible() (bool, error) { + if tu.Std != nil { + if _, ok := tu.Std.(LayerFielder); !ok { + return false, ErrNotTileJSONV3Compatible + } + return true, nil + } + if tu.Mvt != nil { + if _, ok := tu.Std.(LayerFielder); !ok { + return false, ErrNotTileJSONV3Compatible + } + return true, nil + } + + return false, ErrNoProvider +} + // InitFunc initialize a provider given a config map. The init function should validate the config map, and report any errors. This is called by the For function. type InitFunc func(dicter dict.Dicter, maps []Map) (Tiler, error) @@ -237,7 +259,7 @@ func Drivers(types ...providerType) (l []string) { continue } case std: - if v.init == nil { //not of type std + if v.init == nil { // not of type std continue } default: @@ -253,9 +275,7 @@ func Drivers(types ...providerType) (l []string) { // a std provider. The correct entry in TilerUnion will not be nil. If there is an error both entries // will be nil. func For(name string, config dict.Dicter, maps []Map) (val TilerUnion, err error) { - var ( - driversList = Drivers() - ) + driversList := Drivers() if providers == nil { return val, ErrUnknownProvider{KnownProviders: driversList} } diff --git a/server/handle_map_capabilities.go b/server/handle_map_capabilities.go index d9cf17cbd..f93cb8c75 100644 --- a/server/handle_map_capabilities.go +++ b/server/handle_map_capabilities.go @@ -1,40 +1,56 @@ package server import ( + "context" "encoding/json" "net/http" "net/url" "strings" + "sync" "github.com/dimfeld/httptreemux" "github.com/go-spatial/geom" + "github.com/go-spatial/tegola" "github.com/go-spatial/tegola/atlas" "github.com/go-spatial/tegola/internal/log" "github.com/go-spatial/tegola/mapbox/tilejson" + "github.com/go-spatial/tegola/provider" ) +var capabilitiesCache sync.Map + +type cacheEntry struct { + once sync.Once + tileJSON tilejson.TileJSON + err error +} + type HandleMapCapabilities struct { + // function to retrieve a map, defaults to atlas.GetMap if nil + // required because of our defaultAtlas + GetMap func(string) (atlas.Map, error) // required mapName string // the requests extension defaults to "json" extension string } +func cacheKey(mapName string, r *http.Request) string { + return mapName + ":" + URLRoot(r).String() + ":" + r.URL.Query().Encode() +} + // ServeHTTP returns details about a map according to the -// tileJSON spec (https://github.com/mapbox/tilejson-spec/tree/master/2.1.0) +// tileJSON spec (https://github.com/mapbox/tilejson-spec/tree/master/3.0.0) // // URI scheme: /capabilities/:map_name.json // map_name - map name in the config file func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Request) { - params := httptreemux.ContextParams(r.Context()) - - // read the map_name value from the request mapName := params["map_name"] mapNameParts := strings.Split(mapName, ".") - req.mapName = mapNameParts[0] + // check if we have a provided extension if len(mapNameParts) > 2 { req.extension = mapNameParts[len(mapNameParts)-1] @@ -42,86 +58,131 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques req.extension = "json" } - // lookup our Map - m, err := atlas.GetMap(req.mapName) - if err != nil { - log.Errorf("map (%v) not configured. check your config file", req.mapName) - http.Error(w, "map ("+req.mapName+") not configured. check your config file", http.StatusBadRequest) + cacheKey := cacheKey(req.mapName, r) + value, _ := capabilitiesCache.LoadOrStore(cacheKey, &cacheEntry{}) + entry, ok := value.(*cacheEntry) + if !ok || entry == nil { + http.Error(w, "internal cache error", http.StatusInternalServerError) + log.Errorf("cache entry for map (%v) is invalid", req.mapName) + return + } + entry.once.Do(func() { + entry.tileJSON, entry.err = req.buildTileJSON(r) + }) + if entry.err != nil { + capabilitiesCache.Delete(cacheKey) // remove failed entry to enable a retry + log.Errorf("error building tilejson: %v", entry.err) + http.Error(w, "error building map capabilities", http.StatusInternalServerError) return } - tileJSON := tilejson.TileJSON{ - Attribution: &m.Attribution, - Bounds: m.Bounds.Extent(), - Center: m.Center, - Format: TileURLFileFormat, - Name: &m.Name, - Scheme: tilejson.SchemeXYZ, - TileJSON: tilejson.Version, - Version: "1.0.0", - Grids: make([]string, 0), - Data: make([]string, 0), + w.Header().Add("Content-Type", "application/json") + + // cache control headers (no-cache) + w.Header().Add("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Add("Pragma", "no-cache") + w.Header().Add("Expires", "0") + + if err := json.NewEncoder(w).Encode(entry.tileJSON); err != nil { + log.Errorf("error encoding tileJSON for map (%v)", req.mapName) } +} - // parse our query string - var query = r.URL.Query() +// buildTileJSON is a helper to declutter the ServeHTTP method. +func (req HandleMapCapabilities) buildTileJSON(r *http.Request) (tilejson.TileJSON, error) { + getMap := req.GetMap + if getMap == nil { + getMap = atlas.GetMap + } + + m, err := getMap(req.mapName) + if err != nil { + return tilejson.TileJSON{}, err + } + + // determine TileJSON version based on whether ALL providers implement LayerFielder. + // we only use TileJSON 3.0.0 if every provider can supply field metadata to ensure + // consistent capabilities across all layers. If a provider lacks LayerFielder support, + // we fall back to TileJSON 2.0.0 to maintain predictable behavior for clients. + // this prevents mixed scenarios where some layers have field metadata and others don't, + // which would make it difficult for clients to reliably work with field information. + hasLayerFielder := true + for i := range m.Layers { + if _, ok := m.Layers[i].Provider.(provider.LayerFielder); !ok { + hasLayerFielder = false + break + } + } + + tileJSONVersion := tilejson.Version2 + if hasLayerFielder { + tileJSONVersion = tilejson.Version3 + } + + tileJSON := tilejson.TileJSON{ + Attribution: &m.Attribution, + Bounds: m.Bounds.Extent(), + Center: m.Center, + Format: TileURLFileFormat, + Name: &m.Name, + Scheme: tilejson.SchemeXYZ, + TileJSON: tileJSONVersion, + Version: "1.0.0", + Grids: make([]string, 0), + Data: make([]string, 0), + VectorLayers: []tilejson.VectorLayer{}, + } + query := r.URL.Query() debugQuery := url.Values{} - // if we have a debug param add it to our URLs if query.Get(QueryKeyDebug) == "true" { debugQuery.Set(QueryKeyDebug, "true") - - // update our map to include the debug layers m = m.AddDebugLayers() } for i := range m.Layers { - // check if the layer already exists in our slice. this can happen if the config - // is using the "name" param for a layer to override the providerLayerName - var skip bool - for j := range tileJSON.VectorLayers { - if tileJSON.VectorLayers[j].ID == m.Layers[i].MVTName() { - // we need to use the min and max of all layers with this name - if tileJSON.VectorLayers[j].MinZoom > m.Layers[i].MinZoom { - tileJSON.VectorLayers[j].MinZoom = m.Layers[i].MinZoom - } + mvtName := m.Layers[i].MVTName() - if tileJSON.VectorLayers[j].MaxZoom < m.Layers[i].MaxZoom { - tileJSON.VectorLayers[j].MaxZoom = m.Layers[i].MaxZoom - } + // now check if we already have a VectorLayer with this ID as + // multple config layers can map to the same mvt layer name + if idx, found := findVectorLayerByID(tileJSON.VectorLayers, mvtName); found { + if tileJSON.VectorLayers[idx].MinZoom > m.Layers[i].MinZoom { + tileJSON.VectorLayers[idx].MinZoom = m.Layers[i].MinZoom + } + if tileJSON.VectorLayers[idx].MaxZoom < m.Layers[i].MaxZoom { + tileJSON.VectorLayers[idx].MaxZoom = m.Layers[i].MaxZoom + } - skip = true - break + // update map level zoom range + if tileJSON.MinZoom > m.Layers[i].MinZoom { + tileJSON.MinZoom = m.Layers[i].MinZoom + } + if tileJSON.MaxZoom < m.Layers[i].MaxZoom { + tileJSON.MaxZoom = m.Layers[i].MaxZoom } - } - // the first layer sets the initial min / max otherwise they default to 0/0 - if len(tileJSON.VectorLayers) == 0 { - tileJSON.MinZoom = m.Layers[i].MinZoom - tileJSON.MaxZoom = m.Layers[i].MaxZoom + continue } - // check if we have a min zoom lower then our current min - if tileJSON.MinZoom > m.Layers[i].MinZoom { + // first layer sets the initial map zoom level + if len(tileJSON.VectorLayers) == 0 { tileJSON.MinZoom = m.Layers[i].MinZoom - } - - // check if we have a max zoom higher then our current max - if tileJSON.MaxZoom < m.Layers[i].MaxZoom { tileJSON.MaxZoom = m.Layers[i].MaxZoom + } else { + // update map zoom level for subsequent layer + if tileJSON.MinZoom > m.Layers[i].MinZoom { + tileJSON.MinZoom = m.Layers[i].MinZoom + } + if tileJSON.MaxZoom < m.Layers[i].MaxZoom { + tileJSON.MaxZoom = m.Layers[i].MaxZoom + } } - // entry for layer already exists. move on - if skip { - continue - } - - // build our vector layer details layer := tilejson.VectorLayer{ Version: 2, - Extent: 4096, - ID: m.Layers[i].MVTName(), - Name: m.Layers[i].MVTName(), + Extent: tegola.DefaultExtent, + ID: mvtName, + Name: mvtName, MinZoom: m.Layers[i].MinZoom, MaxZoom: m.Layers[i].MaxZoom, Tiles: []string{ @@ -130,12 +191,36 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques Host: hostName(r).Host, PathPrefix: URIPrefix, MapName: req.mapName, - LayerName: m.Layers[i].MVTName(), + LayerName: mvtName, Query: debugQuery, }.String(), }, } + // always initialize Fields for all layers regardless of TileJSON version. + // in TileJSON 3.0.0, fields is REQUIRED (must be present, even if empty). + // in TileJSON 2.0.0, fields was already optional and harmless to include. + // this ensures spec compliance in both cases. + layer.Fields = make(map[string]any) + + // try to populate field information from the provider if it supports LayerFielder + // ony providers that implement LayerFielder will have their fields populated + if hasLayerFielder { + if fielder, ok := m.Layers[i].Provider.(provider.LayerFielder); ok { + // NOTE: we explicitly use a new context here to avoid a cancelled request context + // to result in an initiallized, but emtpy TileJSON cache entry - think sync.Once. + // even if request is cancelled we finish the work for the next requesting client. + if fields, err := fielder.LayerFields( + context.Background(), m.Layers[i].ProviderLayerName, + ); err == nil { + layer.Fields = fields + } else { + log.Debugf("error getting fields for layer (%v): %v", mvtName, err) + } + } + } + + // set geometry type switch m.Layers[i].GeomType.(type) { case geom.Point, geom.MultiPoint: layer.GeometryType = tilejson.GeomTypePoint @@ -145,10 +230,8 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques layer.GeometryType = tilejson.GeomTypePolygon default: layer.GeometryType = tilejson.GeomTypeUnknown - // TODO: debug log } - // add our layer to our tile layer response tileJSON.VectorLayers = append(tileJSON.VectorLayers, layer) } @@ -163,15 +246,16 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques // build our URL scheme for the tile grid tileJSON.Tiles = append(tileJSON.Tiles, tileURL) - // content type - w.Header().Add("Content-Type", "application/json") - - // cache control headers (no-cache) - w.Header().Add("Cache-Control", "no-cache, no-store, must-revalidate") - w.Header().Add("Pragma", "no-cache") - w.Header().Add("Expires", "0") + return tileJSON, nil +} - if err = json.NewEncoder(w).Encode(tileJSON); err != nil { - log.Errorf("error encoding tileJSON for map (%v)", req.mapName) +// findVectorLayerByID searches for a VectorLayer with the given ID +// Returns the index and true if found, -1 and false otherwise +func findVectorLayerByID(layers []tilejson.VectorLayer, id string) (int, bool) { + for i := range layers { + if layers[i].ID == id { + return i, true + } } + return -1, false } diff --git a/server/handle_map_capabilities_test.go b/server/handle_map_capabilities_test.go index b68a4f3ce..dcfa0a8e3 100644 --- a/server/handle_map_capabilities_test.go +++ b/server/handle_map_capabilities_test.go @@ -1,7 +1,9 @@ package server_test import ( + "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -9,11 +11,27 @@ import ( "reflect" "testing" + "github.com/dimfeld/httptreemux" + "github.com/go-spatial/geom" "github.com/go-spatial/tegola/atlas" "github.com/go-spatial/tegola/mapbox/tilejson" + "github.com/go-spatial/tegola/provider/test" "github.com/go-spatial/tegola/server" ) +// layerFielderProvider is a mock provider that implements LayerFielder for testing TileJSON v3.0.0 +type layerFielderProvider struct { + *test.TileProvider + fields map[string]map[string]any // layerName -> fields +} + +func (p *layerFielderProvider) LayerFields(ctx context.Context, layerName string) (map[string]any, error) { + if fields, ok := p.fields[layerName]; ok { + return fields, nil + } + return make(map[string]any), nil +} + func TestHandleMapCapabilities(t *testing.T) { type tcase struct { handler http.Handler @@ -84,7 +102,7 @@ func TestHandleMapCapabilities(t *testing.T) { Name: &testMapName, Description: nil, Scheme: tilejson.SchemeXYZ, - TileJSON: tilejson.Version, + TileJSON: tilejson.Version2, Tiles: []string{ server.TileURLTemplate{ Scheme: "http", @@ -153,7 +171,7 @@ func TestHandleMapCapabilities(t *testing.T) { Name: &testMapName, Description: nil, Scheme: tilejson.SchemeXYZ, - TileJSON: tilejson.Version, + TileJSON: tilejson.Version2, Tiles: []string{ server.TileURLTemplate{ Scheme: "http", @@ -286,7 +304,7 @@ func TestHandleMapCapabilities(t *testing.T) { Name: &testMapName, Description: nil, Scheme: tilejson.SchemeXYZ, - TileJSON: tilejson.Version, + TileJSON: tilejson.Version2, Tiles: []string{ server.TileURLTemplate{ Scheme: "http", @@ -414,3 +432,287 @@ func TestHandleMapCapabilitiesCORS(t *testing.T) { t.Run(name, CORSTest(tc)) } } + +type TileJSONVersionTestCase struct { + mapName string + setupMap func() atlas.Map + expectedVersion string + expectedFields map[string]map[string]any // layer ID -> expected fields + shouldHaveFields bool +} + +func TestHandleMapCapabilitiesTileJSONVersion(t *testing.T) { + tests := map[string]TileJSONVersionTestCase{ + "TileJSON v2.0.0 without LayerFielder": { + mapName: "test-map-v2", + setupMap: func() atlas.Map { + // return default test map without LayerFielder + testMap := atlas.NewWebMercatorMap("test-map-v2") + testMap.Attribution = testMapAttribution + testMap.Center = testMapCenter + testMap.Layers = append(testMap.Layers, atlas.Layer{ + Name: "test-layer", + ProviderLayerName: "test-layer-provider", + MinZoom: 4, + MaxZoom: 9, + Provider: &test.TileProvider{}, // No LayerFielder + GeomType: geom.Point{}, + }) + return testMap + }, + expectedVersion: tilejson.Version2, + shouldHaveFields: false, + }, + "TileJSON v3.0.0 with LayerFielder": { + mapName: "test-map-v3", + setupMap: func() atlas.Map { + layerFielderLayer1 := atlas.Layer{ + Name: "test-layer", + ProviderLayerName: "test-layer-1", + MinZoom: 4, + MaxZoom: 9, + Provider: &layerFielderProvider{ + TileProvider: &test.TileProvider{}, + fields: map[string]map[string]any{ + "test-layer-1": { + "name": "String", + "age": "Number", + "is_active": "Boolean", + }, + }, + }, + GeomType: geom.Point{}, + } + + layerFielderLayer2 := atlas.Layer{ + Name: "test-layer-2-name", + ProviderLayerName: "test-layer-2-provider-layer-name", + MinZoom: 10, + MaxZoom: 15, + Provider: &layerFielderProvider{ + TileProvider: &test.TileProvider{}, + fields: map[string]map[string]any{ + "test-layer-2-provider-layer-name": { + "description": "String", + "count": "Number", + }, + }, + }, + GeomType: geom.Line{}, + } + + testMapV3 := atlas.NewWebMercatorMap("test-map-v3") + testMapV3.Attribution = testMapAttribution + testMapV3.Center = testMapCenter + testMapV3.Layers = append(testMapV3.Layers, layerFielderLayer1, layerFielderLayer2) + return testMapV3 + }, + expectedVersion: tilejson.Version3, + shouldHaveFields: true, + expectedFields: map[string]map[string]any{ + "test-layer": { + "name": "String", + "age": "Number", + "is_active": "Boolean", + }, + "test-layer-2-name": { + "description": "String", + "count": "Number", + }, + }, + }, + "TileJSON v2.0.0 with mixed providers (one without LayerFielder)": { + mapName: "test-map-mixed", + setupMap: func() atlas.Map { + // one layer with LayerFielder support + layerWithFielder := atlas.Layer{ + Name: "layer-with-fielder", + ProviderLayerName: "layer-with-fielder-provider", + MinZoom: 4, + MaxZoom: 9, + Provider: &layerFielderProvider{ + TileProvider: &test.TileProvider{}, + fields: map[string]map[string]any{ + "layer-with-fielder-provider": { + "name": "String", + "type": "String", + }, + }, + }, + GeomType: geom.Point{}, + } + + // and another layer without LayerFielder support + layerWithoutFielder := atlas.Layer{ + Name: "layer-without-fielder", + ProviderLayerName: "layer-without-fielder-provider", + MinZoom: 10, + MaxZoom: 15, + Provider: &test.TileProvider{}, // No LayerFielder + GeomType: geom.Polygon{}, + } + + testMapMixed := atlas.NewWebMercatorMap("test-map-mixed") + testMapMixed.Attribution = testMapAttribution + testMapMixed.Center = testMapCenter + testMapMixed.Layers = append(testMapMixed.Layers, layerWithFielder, layerWithoutFielder) + return testMapMixed + }, + expectedVersion: tilejson.Version2, + shouldHaveFields: false, // we want this to fall back to v2.0.0, so no fields should be populated + }, + } + + fn := func(t *testing.T, tc TileJSONVersionTestCase) { + server.HostName = nil + server.Port = "" + + // create handler with injected GetMap function + // to avoid requiring an Atlas passed to HandleMapCapabitilies struct + testMap := tc.setupMap() + handler := server.HandleMapCapabilities{ + GetMap: func(mapName string) (atlas.Map, error) { + if mapName == tc.mapName { + return testMap, nil + } + return atlas.Map{}, fmt.Errorf("map not found: %s", mapName) + }, + } + + uri := fmt.Sprintf("http://localhost:8080/capabilities/%s.json", tc.mapName) + r, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + t.Fatal(err) + } + + // set the context params that the handler expects + params := map[string]string{ + "map_name": fmt.Sprintf("%s.json", tc.mapName), + } + ctx := httptreemux.AddParamsToContext(r.Context(), params) + r = r.WithContext(ctx) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("handler returned wrong status code: got (%v) expected (%v)", w.Code, http.StatusOK) + return + } + + bytes, err := io.ReadAll(w.Body) + if err != nil { + t.Errorf("err reading response body: %v", err) + return + } + + var tileJSON tilejson.TileJSON + if err := json.Unmarshal(bytes, &tileJSON); err != nil { + t.Errorf("unable to unmarshal JSON response body: %v", err) + return + } + + // verify TileJSON version + if tileJSON.TileJSON != tc.expectedVersion { + t.Errorf("TileJSON version mismatch: got (%v) expected (%v)", tileJSON.TileJSON, tc.expectedVersion) + return + } + + // verify Fields presence/absence + if tc.shouldHaveFields { + if len(tileJSON.VectorLayers) == 0 { + t.Errorf("expected VectorLayers, got none") + return + } + + // ckeck Fields for each layer + for _, layer := range tileJSON.VectorLayers { + expectedFields, ok := tc.expectedFields[layer.ID] + if !ok { + t.Errorf("unexpected layer ID: %v", layer.ID) + continue + } + + if !reflect.DeepEqual(layer.Fields, expectedFields) { + t.Errorf("Fields mismatch for layer %v: got %v expected %v", layer.ID, layer.Fields, expectedFields) + } + } + } else { + // v2.0.0 should not have populated Fields + for _, layer := range tileJSON.VectorLayers { + if len(layer.Fields) > 0 { + t.Errorf("TileJSON v2.0.0 layer %v should not have Fields, got %v", layer.ID, layer.Fields) + } + } + } + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fn(t, tc) + }) + } +} + +func TestHandleMapCapabilitiesErrorNotCached(t *testing.T) { + server.HostName = nil + server.Port = "" + + callCount := 0 + + testMap := atlas.NewWebMercatorMap("test-map-flaky") + testMap.Attribution = testMapAttribution + testMap.Center = testMapCenter + testMap.Layers = append(testMap.Layers, atlas.Layer{ + Name: "test-layer", + ProviderLayerName: "test-layer-provider", + MinZoom: 4, + MaxZoom: 9, + Provider: &test.TileProvider{}, + GeomType: geom.Point{}, + }) + + testAtlas := &atlas.Atlas{} + testAtlas.AddMap(testMap) + + // mock a GetMap that fails first time, but succeeds the second time + handler := &server.HandleMapCapabilities{ + GetMap: func(mapName string) (atlas.Map, error) { + callCount++ + if callCount == 1 { + return atlas.Map{}, fmt.Errorf("temporary database error") + } + return testMap, nil + }, + } + + uri := "http://localhost:8080/capabilities/test-map-flaky.json" + + // first request will fail + r1, _ := http.NewRequest(http.MethodGet, uri, nil) + params := map[string]string{"map_name": "test-map-flaky.json"} + ctx := httptreemux.AddParamsToContext(r1.Context(), params) + r1 = r1.WithContext(ctx) + + w1 := httptest.NewRecorder() + handler.ServeHTTP(w1, r1) + + if w1.Code != http.StatusInternalServerError { + t.Errorf("first request should fail: got %v expected %v", w1.Code, http.StatusInternalServerError) + } + + // second request - should succeed because errors are not being cached but retried + r2, _ := http.NewRequest(http.MethodGet, uri, nil) + r2 = r2.WithContext(httptreemux.AddParamsToContext(r2.Context(), params)) + + w2 := httptest.NewRecorder() + handler.ServeHTTP(w2, r2) + + if w2.Code != http.StatusOK { + t.Errorf("second request should succeed: got %v expected %v", w2.Code, http.StatusOK) + } + + if callCount != 2 { + t.Errorf("GetMap should be called twice (no error caching), was called %d times", callCount) + } +}