From 9b4f72687e449090a2929506bee996b09818b30b Mon Sep 17 00:00:00 2001 From: Rub21 Date: Mon, 12 Jan 2026 20:36:37 -0500 Subject: [PATCH 01/16] Upgrade to TileJSON 3.0.0 specification and add fields json schema --- mapbox/tilejson/tilejson.go | 5 ++- provider/postgis/postgis.go | 65 +++++++++++++++++++++++++++++++ provider/provider.go | 8 ++++ server/handle_map_capabilities.go | 14 ++++++- 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/mapbox/tilejson/tilejson.go b/mapbox/tilejson/tilejson.go index 12098219b..0fce00325 100644 --- a/mapbox/tilejson/tilejson.go +++ b/mapbox/tilejson/tilejson.go @@ -2,7 +2,7 @@ // https://github.com/mapbox/tilejson-spec package tilejson -const Version = "2.1.0" +const Version = "3.0.0" type GeomType string @@ -135,4 +135,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]interface{} `json:"fields"` } diff --git a/provider/postgis/postgis.go b/provider/postgis/postgis.go index a81d6596a..04859a03f 100644 --- a/provider/postgis/postgis.go +++ b/provider/postgis/postgis.go @@ -1069,6 +1069,71 @@ 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]interface{}, 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 (%v): %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 (%v): %w", layerName, err) + } + defer rows.Close() + + fields := make(map[string]interface{}) + fdescs := rows.FieldDescriptions() + + for _, desc := range fdescs { + fieldName := string(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 + // Reference: https://github.com/postgres/postgres/blob/master/src/include/catalog/pg_type.dat + switch oid { + case 16: // bool + return "Boolean" + case 20, 21, 23, 26: // int8, int2, int4, oid + return "Number" + case 700, 701, 1700: // float4, float8, numeric + return "Number" + case 18, 19, 25, 1042, 1043: // char, name, text, bpchar, varchar + return "String" + case 1114, 1184: // timestamp, timestamptz + return "String" + case 114, 3802: // json, jsonb + 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..ca3e84e13 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -132,6 +132,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]interface{}, error) +} + // TilerUnion represents either a Std Tiler or and MVTTiler; only one should be not nil. type TilerUnion struct { Std Tiler diff --git a/server/handle_map_capabilities.go b/server/handle_map_capabilities.go index d9cf17cbd..15dcd0b1b 100644 --- a/server/handle_map_capabilities.go +++ b/server/handle_map_capabilities.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "net/http" "net/url" @@ -12,6 +13,7 @@ import ( "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" ) type HandleMapCapabilities struct { @@ -22,7 +24,7 @@ type HandleMapCapabilities struct { } // 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 @@ -134,6 +136,16 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques Query: debugQuery, }.String(), }, + Fields: make(map[string]interface{}), + } + + // Try to get field information from the provider if it supports it + if fielder, ok := m.Layers[i].Provider.(provider.LayerFielder); ok { + 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", m.Layers[i].MVTName(), err) + } } switch m.Layers[i].GeomType.(type) { From 9585817a819df71c9e1aacbd5982a2461af2898f Mon Sep 17 00:00:00 2001 From: Rub21 Date: Mon, 12 Jan 2026 21:49:29 -0500 Subject: [PATCH 02/16] Add in-memory cache for capabilities endpoint to reduce DB queries --- server/handle_map_capabilities.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/server/handle_map_capabilities.go b/server/handle_map_capabilities.go index 15dcd0b1b..b34794de4 100644 --- a/server/handle_map_capabilities.go +++ b/server/handle_map_capabilities.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strings" + "sync" "github.com/dimfeld/httptreemux" @@ -16,6 +17,11 @@ import ( "github.com/go-spatial/tegola/provider" ) +var ( + capabilitiesCache = make(map[string]tilejson.TileJSON) + capabilitiesMux sync.RWMutex +) + type HandleMapCapabilities struct { // required mapName string @@ -44,6 +50,16 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques req.extension = "json" } + // Check cache + capabilitiesMux.RLock() + if cached, ok := capabilitiesCache[req.mapName]; ok { + capabilitiesMux.RUnlock() + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(cached) + return + } + capabilitiesMux.RUnlock() + // lookup our Map m, err := atlas.GetMap(req.mapName) if err != nil { @@ -175,6 +191,11 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques // build our URL scheme for the tile grid tileJSON.Tiles = append(tileJSON.Tiles, tileURL) + // Store in cache + capabilitiesMux.Lock() + capabilitiesCache[req.mapName] = tileJSON + capabilitiesMux.Unlock() + // content type w.Header().Add("Content-Type", "application/json") From 652376eda866afdc0a392f2debb6bf0079ecfa65 Mon Sep 17 00:00:00 2001 From: "Ruben L. Mendoza" Date: Fri, 23 Jan 2026 11:30:24 -0500 Subject: [PATCH 03/16] Set layerName as string Co-authored-by: Ben --- provider/postgis/postgis.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/postgis/postgis.go b/provider/postgis/postgis.go index 04859a03f..7cf097deb 100644 --- a/provider/postgis/postgis.go +++ b/provider/postgis/postgis.go @@ -1081,7 +1081,7 @@ func (p Provider) LayerFields(ctx context.Context, layerName string) (map[string 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 (%v): %w", layerName, err) + return nil, fmt.Errorf("error replacing layer tokens for layer (%s): %w", layerName, err) } // Wrap the query to get column information without fetching rows From 0e4e22822d06d23a6affc6626b98702bfd72dd61 Mon Sep 17 00:00:00 2001 From: "Ruben L. Mendoza" Date: Fri, 23 Jan 2026 11:31:18 -0500 Subject: [PATCH 04/16] Update provider/postgis/postgis.go Co-authored-by: Ben --- provider/postgis/postgis.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/postgis/postgis.go b/provider/postgis/postgis.go index 7cf097deb..c365e57b9 100644 --- a/provider/postgis/postgis.go +++ b/provider/postgis/postgis.go @@ -1097,7 +1097,7 @@ func (p Provider) LayerFields(ctx context.Context, layerName string) (map[string fdescs := rows.FieldDescriptions() for _, desc := range fdescs { - fieldName := string(desc.Name) + fieldName := desc.Name // Skip geometry and ID fields as they're not attributes if fieldName == plyr.GeomFieldName() || fieldName == plyr.IDFieldName() { From 423e95cc1b68c9b5f0f454d695af88ea1cbc70de Mon Sep 17 00:00:00 2001 From: "Ruben L. Mendoza" Date: Fri, 23 Jan 2026 12:11:34 -0500 Subject: [PATCH 05/16] Update provider/postgis/postgis.go Co-authored-by: Ben --- provider/postgis/postgis.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/postgis/postgis.go b/provider/postgis/postgis.go index c365e57b9..e9de93a33 100644 --- a/provider/postgis/postgis.go +++ b/provider/postgis/postgis.go @@ -1089,7 +1089,7 @@ func (p Provider) LayerFields(ctx context.Context, layerName string) (map[string rows, err := p.pool.Query(ctx, sql) if err != nil { - return nil, fmt.Errorf("error querying fields for layer (%v): %w", layerName, err) + return nil, fmt.Errorf("error querying fields for layer (%s): %w", layerName, err) } defer rows.Close() From 70bfc557873f135de58faf191fbbf234c2590df8 Mon Sep 17 00:00:00 2001 From: "Ruben L. Mendoza" Date: Fri, 23 Jan 2026 12:11:44 -0500 Subject: [PATCH 06/16] Update provider/postgis/postgis.go Co-authored-by: Ben --- provider/postgis/postgis.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/postgis/postgis.go b/provider/postgis/postgis.go index e9de93a33..e26577c89 100644 --- a/provider/postgis/postgis.go +++ b/provider/postgis/postgis.go @@ -1093,7 +1093,7 @@ func (p Provider) LayerFields(ctx context.Context, layerName string) (map[string } defer rows.Close() - fields := make(map[string]interface{}) + fields := make(map[string]any) fdescs := rows.FieldDescriptions() for _, desc := range fdescs { From 439aa9a997a5903fd376dfeb95a7b33aade40844 Mon Sep 17 00:00:00 2001 From: "Ruben L. Mendoza" Date: Fri, 23 Jan 2026 12:12:01 -0500 Subject: [PATCH 07/16] Update provider/provider.go Co-authored-by: Ben --- provider/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/provider.go b/provider/provider.go index ca3e84e13..8c2075b64 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -137,7 +137,7 @@ type Tiler interface { // 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]interface{}, error) + 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. From bc671f459a2eb186c232bdfacc178e7b6367a926 Mon Sep 17 00:00:00 2001 From: "Ruben L. Mendoza" Date: Fri, 23 Jan 2026 12:12:17 -0500 Subject: [PATCH 08/16] Update server/handle_map_capabilities.go Co-authored-by: Ben --- server/handle_map_capabilities.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/handle_map_capabilities.go b/server/handle_map_capabilities.go index b34794de4..f5336fb47 100644 --- a/server/handle_map_capabilities.go +++ b/server/handle_map_capabilities.go @@ -157,7 +157,7 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques // Try to get field information from the provider if it supports it if fielder, ok := m.Layers[i].Provider.(provider.LayerFielder); ok { - if fields, err := fielder.LayerFields(context.Background(), m.Layers[i].ProviderLayerName); err == nil { + if fields, err := fielder.LayerFields(r.Context(), m.Layers[i].ProviderLayerName); err == nil { layer.Fields = fields } else { log.Debugf("error getting fields for layer (%v): %v", m.Layers[i].MVTName(), err) From b029e48c8cb0a656bf9b9bea0bf57ed9b8171fea Mon Sep 17 00:00:00 2001 From: Rub21 Date: Fri, 23 Jan 2026 12:42:50 -0500 Subject: [PATCH 09/16] Using pgtype for postgresTypeToString --- provider/postgis/postgis.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/provider/postgis/postgis.go b/provider/postgis/postgis.go index e26577c89..4fd4723a9 100644 --- a/provider/postgis/postgis.go +++ b/provider/postgis/postgis.go @@ -1114,20 +1114,19 @@ func (p Provider) LayerFields(ctx context.Context, layerName string) (map[string // postgresTypeToString converts PostgreSQL OID types to simple type strings func postgresTypeToString(oid uint32) string { - // Common PostgreSQL type OIDs - // Reference: https://github.com/postgres/postgres/blob/master/src/include/catalog/pg_type.dat + // Common PostgreSQL type OIDs using pgtype constants switch oid { - case 16: // bool + case pgtype.BoolOID: return "Boolean" - case 20, 21, 23, 26: // int8, int2, int4, oid + case pgtype.Int8OID, pgtype.Int2OID, pgtype.Int4OID, pgtype.OIDOID: return "Number" - case 700, 701, 1700: // float4, float8, numeric + case pgtype.Float4OID, pgtype.Float8OID, pgtype.NumericOID: return "Number" - case 18, 19, 25, 1042, 1043: // char, name, text, bpchar, varchar + case pgtype.QCharOID, pgtype.NameOID, pgtype.TextOID, pgtype.BPCharOID, pgtype.VarcharOID: return "String" - case 1114, 1184: // timestamp, timestamptz + case pgtype.TimestampOID, pgtype.TimestamptzOID: return "String" - case 114, 3802: // json, jsonb + case pgtype.JSONOID, pgtype.JSONBOID: return "String" default: return "String" From 72ad388f96c72267403d8c9a9ac5ee006d3fa698 Mon Sep 17 00:00:00 2001 From: Rub21 Date: Fri, 23 Jan 2026 13:03:22 -0500 Subject: [PATCH 10/16] Use sync.Once() --- server/handle_map_capabilities.go | 59 +++++++++++++++++++------------ 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/server/handle_map_capabilities.go b/server/handle_map_capabilities.go index f5336fb47..dbedb9c9d 100644 --- a/server/handle_map_capabilities.go +++ b/server/handle_map_capabilities.go @@ -1,7 +1,6 @@ package server import ( - "context" "encoding/json" "net/http" "net/url" @@ -19,6 +18,7 @@ import ( var ( capabilitiesCache = make(map[string]tilejson.TileJSON) + capabilitiesOnce = make(map[string]*sync.Once) capabilitiesMux sync.RWMutex ) @@ -58,17 +58,24 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques json.NewEncoder(w).Encode(cached) return } + // Get or create sync.Once for this map to avoid duplicate work + once, exists := capabilitiesOnce[req.mapName] + if !exists { + once = &sync.Once{} + capabilitiesOnce[req.mapName] = once + } capabilitiesMux.RUnlock() - // 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) - return - } + // Build TileJSON only once per map (idempotent operation) + once.Do(func() { + // lookup our Map + m, err := atlas.GetMap(req.mapName) + if err != nil { + log.Errorf("map (%v) not configured. check your config file", req.mapName) + return + } - tileJSON := tilejson.TileJSON{ + tileJSON := tilejson.TileJSON{ Attribution: &m.Attribution, Bounds: m.Bounds.Extent(), Center: m.Center, @@ -180,21 +187,27 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques tileJSON.VectorLayers = append(tileJSON.VectorLayers, layer) } - tileURL := TileURLTemplate{ - Scheme: scheme(r), - Host: hostName(r).Host, - PathPrefix: URIPrefix, - MapName: req.mapName, - Query: debugQuery, - }.String() + tileURL := TileURLTemplate{ + Scheme: scheme(r), + Host: hostName(r).Host, + PathPrefix: URIPrefix, + MapName: req.mapName, + Query: debugQuery, + }.String() - // build our URL scheme for the tile grid - tileJSON.Tiles = append(tileJSON.Tiles, tileURL) + // build our URL scheme for the tile grid + tileJSON.Tiles = append(tileJSON.Tiles, tileURL) - // Store in cache - capabilitiesMux.Lock() - capabilitiesCache[req.mapName] = tileJSON - capabilitiesMux.Unlock() + // Store in cache + capabilitiesMux.Lock() + capabilitiesCache[req.mapName] = tileJSON + capabilitiesMux.Unlock() + }) + + // Read from cache after construction (all goroutines, including the one that built it) + capabilitiesMux.RLock() + cached := capabilitiesCache[req.mapName] + capabilitiesMux.RUnlock() // content type w.Header().Add("Content-Type", "application/json") @@ -204,7 +217,7 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques w.Header().Add("Pragma", "no-cache") w.Header().Add("Expires", "0") - if err = json.NewEncoder(w).Encode(tileJSON); err != nil { + if err := json.NewEncoder(w).Encode(cached); err != nil { log.Errorf("error encoding tileJSON for map (%v)", req.mapName) } } From 4242e323f493e2947a4802b44490fe76b28b559f Mon Sep 17 00:00:00 2001 From: Rub21 Date: Fri, 23 Jan 2026 13:05:18 -0500 Subject: [PATCH 11/16] Update interface to any --- mapbox/tilejson/tilejson.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mapbox/tilejson/tilejson.go b/mapbox/tilejson/tilejson.go index 0fce00325..4f0b5a763 100644 --- a/mapbox/tilejson/tilejson.go +++ b/mapbox/tilejson/tilejson.go @@ -137,5 +137,5 @@ type VectorLayer struct { 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]interface{} `json:"fields"` + Fields map[string]any{} `json:"fields"` } From 6325b105ffdb435c1fca22d6185f96cdaabebac0 Mon Sep 17 00:00:00 2001 From: "Ruben L. Mendoza" Date: Wed, 28 Jan 2026 14:46:32 -0500 Subject: [PATCH 12/16] Update tilejson spec test (#1) * Add testing for TileJSON 3.0.0 * Updates for testing --- mapbox/tilejson/tilejson.go | 7 +- server/handle_map_capabilities.go | 164 ++++++++++++---------- server/handle_map_capabilities_test.go | 181 ++++++++++++++++++++++++- server/server.go | 2 +- 4 files changed, 280 insertions(+), 74 deletions(-) diff --git a/mapbox/tilejson/tilejson.go b/mapbox/tilejson/tilejson.go index 4f0b5a763..8020ab306 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 = "3.0.0" +const ( + Version = "3.0.0" + Version2 = "2.0.0" +) type GeomType string @@ -137,5 +140,5 @@ type VectorLayer struct { 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"` + Fields map[string]any `json:"fields,omitempty"` } diff --git a/server/handle_map_capabilities.go b/server/handle_map_capabilities.go index dbedb9c9d..1956e9cfe 100644 --- a/server/handle_map_capabilities.go +++ b/server/handle_map_capabilities.go @@ -23,6 +23,7 @@ var ( ) type HandleMapCapabilities struct { + Atlas *atlas.Atlas // required mapName string // the requests extension defaults to "json" @@ -50,57 +51,81 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques req.extension = "json" } + cacheKey := req.mapName + "|" + URLRoot(r).String() + "|" + r.URL.Query().Encode() + // Check cache capabilitiesMux.RLock() - if cached, ok := capabilitiesCache[req.mapName]; ok { + if cached, ok := capabilitiesCache[cacheKey]; ok { capabilitiesMux.RUnlock() w.Header().Add("Content-Type", "application/json") json.NewEncoder(w).Encode(cached) return } // Get or create sync.Once for this map to avoid duplicate work - once, exists := capabilitiesOnce[req.mapName] + once, exists := capabilitiesOnce[cacheKey] if !exists { once = &sync.Once{} - capabilitiesOnce[req.mapName] = once + capabilitiesOnce[cacheKey] = once } capabilitiesMux.RUnlock() // Build TileJSON only once per map (idempotent operation) once.Do(func() { // lookup our Map - m, err := atlas.GetMap(req.mapName) + var ( + m atlas.Map + err error + ) + if req.Atlas != nil { + m, err = req.Atlas.Map(req.mapName) + } else { + m, err = atlas.GetMap(req.mapName) + } if err != nil { log.Errorf("map (%v) not configured. check your config file", req.mapName) return } + // Determine TileJSON version based on whether any provider implements LayerFielder + hasLayerFielder := false + for i := range m.Layers { + if _, ok := m.Layers[i].Provider.(provider.LayerFielder); ok { + hasLayerFielder = true + break + } + } + + tileJSONVersion := tilejson.Version2 + if hasLayerFielder { + tileJSONVersion = tilejson.Version + } + 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), - } + 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), + } - // parse our query string - var query = r.URL.Query() + // parse our query string + var 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") + 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() - } + // update our map to include the debug layers + m = m.AddDebugLayers() + } - for i := range m.Layers { + 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 @@ -141,52 +166,55 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques continue } - // build our vector layer details - layer := tilejson.VectorLayer{ - Version: 2, - Extent: 4096, - ID: m.Layers[i].MVTName(), - Name: m.Layers[i].MVTName(), - MinZoom: m.Layers[i].MinZoom, - MaxZoom: m.Layers[i].MaxZoom, - Tiles: []string{ - TileURLTemplate{ - Scheme: scheme(r), - Host: hostName(r).Host, - PathPrefix: URIPrefix, - MapName: req.mapName, - LayerName: m.Layers[i].MVTName(), - Query: debugQuery, - }.String(), - }, - Fields: make(map[string]interface{}), - } + // build our vector layer details + layer := tilejson.VectorLayer{ + Version: 2, + Extent: 4096, + ID: m.Layers[i].MVTName(), + Name: m.Layers[i].MVTName(), + MinZoom: m.Layers[i].MinZoom, + MaxZoom: m.Layers[i].MaxZoom, + Tiles: []string{ + TileURLTemplate{ + Scheme: scheme(r), + Host: hostName(r).Host, + PathPrefix: URIPrefix, + MapName: req.mapName, + LayerName: m.Layers[i].MVTName(), + Query: debugQuery, + }.String(), + }, + } + + // Try to get field information from the provider if it supports it + // Only populate Fields if using TileJSON 3.0.0 + if hasLayerFielder { + layer.Fields = make(map[string]interface{}) + if fielder, ok := m.Layers[i].Provider.(provider.LayerFielder); ok { + if fields, err := fielder.LayerFields(r.Context(), m.Layers[i].ProviderLayerName); err == nil { + layer.Fields = fields + } else { + log.Debugf("error getting fields for layer (%v): %v", m.Layers[i].MVTName(), err) + } + } + } - // Try to get field information from the provider if it supports it - if fielder, ok := m.Layers[i].Provider.(provider.LayerFielder); ok { - if fields, err := fielder.LayerFields(r.Context(), m.Layers[i].ProviderLayerName); err == nil { - layer.Fields = fields - } else { - log.Debugf("error getting fields for layer (%v): %v", m.Layers[i].MVTName(), err) + switch m.Layers[i].GeomType.(type) { + case geom.Point, geom.MultiPoint: + layer.GeometryType = tilejson.GeomTypePoint + case geom.Line, geom.LineString, geom.MultiLineString: + layer.GeometryType = tilejson.GeomTypeLine + case geom.Polygon, geom.MultiPolygon: + layer.GeometryType = tilejson.GeomTypePolygon + default: + layer.GeometryType = tilejson.GeomTypeUnknown + // TODO: debug log } - } - switch m.Layers[i].GeomType.(type) { - case geom.Point, geom.MultiPoint: - layer.GeometryType = tilejson.GeomTypePoint - case geom.Line, geom.LineString, geom.MultiLineString: - layer.GeometryType = tilejson.GeomTypeLine - case geom.Polygon, geom.MultiPolygon: - 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) } - // add our layer to our tile layer response - tileJSON.VectorLayers = append(tileJSON.VectorLayers, layer) - } - tileURL := TileURLTemplate{ Scheme: scheme(r), Host: hostName(r).Host, @@ -200,13 +228,13 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques // Store in cache capabilitiesMux.Lock() - capabilitiesCache[req.mapName] = tileJSON + capabilitiesCache[cacheKey] = tileJSON capabilitiesMux.Unlock() }) // Read from cache after construction (all goroutines, including the one that built it) capabilitiesMux.RLock() - cached := capabilitiesCache[req.mapName] + cached := capabilitiesCache[cacheKey] capabilitiesMux.RUnlock() // content type diff --git a/server/handle_map_capabilities_test.go b/server/handle_map_capabilities_test.go index b68a4f3ce..70a636b53 100644 --- a/server/handle_map_capabilities_test.go +++ b/server/handle_map_capabilities_test.go @@ -1,6 +1,7 @@ package server_test import ( + "context" "encoding/json" "io" "net/http" @@ -9,11 +10,26 @@ import ( "reflect" "testing" + "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]interface{} // layerName -> fields +} + +func (p *layerFielderProvider) LayerFields(ctx context.Context, layerName string) (map[string]interface{}, error) { + if fields, ok := p.fields[layerName]; ok { + return fields, nil + } + return make(map[string]interface{}), nil +} + func TestHandleMapCapabilities(t *testing.T) { type tcase struct { handler http.Handler @@ -84,7 +100,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 +169,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 +302,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 +430,162 @@ func TestHandleMapCapabilitiesCORS(t *testing.T) { t.Run(name, CORSTest(tc)) } } + +func TestHandleMapCapabilitiesTileJSONVersion(t *testing.T) { + // Test v2.0.0: provider without LayerFielder (existing test-map) + t.Run("TileJSON v2.0.0 without LayerFielder", func(t *testing.T) { + server.HostName = nil + server.Port = "" + + router := server.NewRouter(nil) + r, err := http.NewRequest(http.MethodGet, "http://localhost:8080/capabilities/test-map.json", nil) + if err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + router.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 + } + + // Should be v2.0.0 since test.TileProvider doesn't implement LayerFielder + if tileJSON.TileJSON != tilejson.Version2 { + t.Errorf("TileJSON version mismatch: got (%v) expected (%v)", tileJSON.TileJSON, tilejson.Version2) + return + } + + // v2.0.0 should not have Fields (or empty Fields should be omitted) + 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) + } + } + }) + + // Test v3.0.0: provider with LayerFielder + t.Run("TileJSON v3.0.0 with LayerFielder", func(t *testing.T) { + // Create a new map with LayerFielder provider + testMapV3Name := "test-map-v3" + layerFielderLayer1 := atlas.Layer{ + Name: "test-layer", + ProviderLayerName: "test-layer-1", + MinZoom: 4, + MaxZoom: 9, + Provider: &layerFielderProvider{ + TileProvider: &test.TileProvider{}, + fields: map[string]map[string]interface{}{ + "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]interface{}{ + "test-layer-2-provider-layer-name": { + "description": "String", + "count": "Number", + }, + }, + }, + GeomType: geom.Line{}, + } + + testMapV3 := atlas.NewWebMercatorMap(testMapV3Name) + testMapV3.Attribution = testMapAttribution + testMapV3.Center = testMapCenter + testMapV3.Layers = append(testMapV3.Layers, layerFielderLayer1, layerFielderLayer2) + + a := &atlas.Atlas{} + a.AddMap(testMapV3) + + server.HostName = nil + server.Port = "" + + router := server.NewRouter(a) + r, err := http.NewRequest(http.MethodGet, "http://localhost:8080/capabilities/test-map-v3.json", nil) + if err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + router.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 + } + + // Should be v3.0.0 since provider implements LayerFielder + if tileJSON.TileJSON != tilejson.Version { + t.Errorf("TileJSON version mismatch: got (%v) expected (%v)", tileJSON.TileJSON, tilejson.Version) + return + } + + // v3.0.0 should have Fields + if len(tileJSON.VectorLayers) == 0 { + t.Errorf("expected VectorLayers, got none") + return + } + + expectedFields1 := map[string]interface{}{ + "name": "String", + "age": "Number", + "is_active": "Boolean", + } + + expectedFields2 := map[string]interface{}{ + "description": "String", + "count": "Number", + } + + // Check Fields for first layer + if !reflect.DeepEqual(tileJSON.VectorLayers[0].Fields, expectedFields1) { + t.Errorf("Fields mismatch for layer %v: got %v expected %v", tileJSON.VectorLayers[0].ID, tileJSON.VectorLayers[0].Fields, expectedFields1) + } + + // Check Fields for second layer + if len(tileJSON.VectorLayers) > 1 { + if !reflect.DeepEqual(tileJSON.VectorLayers[1].Fields, expectedFields2) { + t.Errorf("Fields mismatch for layer %v: got %v expected %v", tileJSON.VectorLayers[1].ID, tileJSON.VectorLayers[1].Fields, expectedFields2) + } + } + }) +} diff --git a/server/server.go b/server/server.go index 6cd04037c..31a5f8a01 100644 --- a/server/server.go +++ b/server/server.go @@ -88,7 +88,7 @@ func NewRouter(a *atlas.Atlas) *httptreemux.TreeMux { group.UsingContext(). Handler(observability.InstrumentAPIHandler(http.MethodGet, "/capabilities", o, HeadersHandler(HandleCapabilities{}))) group.UsingContext(). - Handler(observability.InstrumentAPIHandler(http.MethodGet, "/capabilities/:map_name", o, HeadersHandler(HandleMapCapabilities{}))) + Handler(observability.InstrumentAPIHandler(http.MethodGet, "/capabilities/:map_name", o, HeadersHandler(HandleMapCapabilities{Atlas: a}))) // map tiles hMapLayerZXY := HandleMapLayerZXY{Atlas: a} From e35d804182984f75bcb710899f8ea8678b14d5c7 Mon Sep 17 00:00:00 2001 From: Benjamin Ramser Date: Tue, 10 Feb 2026 09:09:44 +0100 Subject: [PATCH 13/16] refactor: simplify TileJSON caching and fix version logic --- mapbox/tilejson/tilejson.go | 2 +- server/handle_map_capabilities.go | 352 ++++++++++++------------ server/handle_map_capabilities_test.go | 357 +++++++++++++++++-------- server/server.go | 2 +- 4 files changed, 423 insertions(+), 290 deletions(-) diff --git a/mapbox/tilejson/tilejson.go b/mapbox/tilejson/tilejson.go index 8020ab306..94946ff50 100644 --- a/mapbox/tilejson/tilejson.go +++ b/mapbox/tilejson/tilejson.go @@ -3,8 +3,8 @@ package tilejson const ( - Version = "3.0.0" Version2 = "2.0.0" + Version3 = "3.0.0" ) type GeomType string diff --git a/server/handle_map_capabilities.go b/server/handle_map_capabilities.go index 1956e9cfe..2bc336ab4 100644 --- a/server/handle_map_capabilities.go +++ b/server/handle_map_capabilities.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "net/http" "net/url" @@ -10,20 +11,25 @@ import ( "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 = make(map[string]tilejson.TileJSON) - capabilitiesOnce = make(map[string]*sync.Once) - capabilitiesMux sync.RWMutex -) +var capabilitiesCache sync.Map + +type cacheEntry struct { + once sync.Once + tileJSON tilejson.TileJSON + err error +} type HandleMapCapabilities struct { - Atlas *atlas.Atlas + // 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" @@ -36,14 +42,11 @@ type HandleMapCapabilities struct { // 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] @@ -51,201 +54,204 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques req.extension = "json" } - cacheKey := req.mapName + "|" + URLRoot(r).String() + "|" + r.URL.Query().Encode() - - // Check cache - capabilitiesMux.RLock() - if cached, ok := capabilitiesCache[cacheKey]; ok { - capabilitiesMux.RUnlock() - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(cached) + cacheKey := req.mapName + ":" + URLRoot(r).String() + ":" + r.URL.Query().Encode() + 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) + } + 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 } - // Get or create sync.Once for this map to avoid duplicate work - once, exists := capabilitiesOnce[cacheKey] - if !exists { - once = &sync.Once{} - capabilitiesOnce[cacheKey] = once + + 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) } - capabilitiesMux.RUnlock() - - // Build TileJSON only once per map (idempotent operation) - once.Do(func() { - // lookup our Map - var ( - m atlas.Map - err error - ) - if req.Atlas != nil { - m, err = req.Atlas.Map(req.mapName) - } else { - m, err = atlas.GetMap(req.mapName) - } - if err != nil { - log.Errorf("map (%v) not configured. check your config file", req.mapName) - return - } +} - // Determine TileJSON version based on whether any provider implements LayerFielder - hasLayerFielder := false - for i := range m.Layers { - if _, ok := m.Layers[i].Provider.(provider.LayerFielder); ok { - hasLayerFielder = true - break - } - } +// 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 + } - tileJSONVersion := tilejson.Version2 - if hasLayerFielder { - tileJSONVersion = tilejson.Version - } + m, err := getMap(req.mapName) + if err != nil { + return tilejson.TileJSON{}, err + } - 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), + // 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. + // NOTE: to be discussed + hasLayerFielder := true + for i := range m.Layers { + if _, ok := m.Layers[i].Provider.(provider.LayerFielder); !ok { + hasLayerFielder = false + break } + } - // parse our query string - var query = r.URL.Query() + tileJSONVersion := tilejson.Version2 + if hasLayerFielder { + tileJSONVersion = tilejson.Version3 + } - debugQuery := url.Values{} - // if we have a debug param add it to our URLs - if query.Get(QueryKeyDebug) == "true" { - debugQuery.Set(QueryKeyDebug, "true") + 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{}, + } - // update our map to include the debug layers - m = m.AddDebugLayers() - } + query := r.URL.Query() + debugQuery := url.Values{} + if query.Get(QueryKeyDebug) == "true" { + debugQuery.Set(QueryKeyDebug, "true") + 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 - } + for i := range m.Layers { + 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 + } + + continue } - // the first layer sets the initial min / max otherwise they default to 0/0 + // first layer sets the initial map zoom level if len(tileJSON.VectorLayers) == 0 { tileJSON.MinZoom = m.Layers[i].MinZoom 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 + } } - // check if we have a min zoom lower then our current min - if tileJSON.MinZoom > m.Layers[i].MinZoom { - 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 + layer := tilejson.VectorLayer{ + Version: 2, + Extent: tegola.DefaultExtent, + ID: mvtName, + Name: mvtName, + MinZoom: m.Layers[i].MinZoom, + MaxZoom: m.Layers[i].MaxZoom, + Tiles: []string{ + TileURLTemplate{ + Scheme: scheme(r), + Host: hostName(r).Host, + PathPrefix: URIPrefix, + MapName: req.mapName, + LayerName: mvtName, + Query: debugQuery, + }.String(), + }, } - // entry for layer already exists. move on - if skip { - continue - } + // 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]interface{}) - // build our vector layer details - layer := tilejson.VectorLayer{ - Version: 2, - Extent: 4096, - ID: m.Layers[i].MVTName(), - Name: m.Layers[i].MVTName(), - MinZoom: m.Layers[i].MinZoom, - MaxZoom: m.Layers[i].MaxZoom, - Tiles: []string{ - TileURLTemplate{ - Scheme: scheme(r), - Host: hostName(r).Host, - PathPrefix: URIPrefix, - MapName: req.mapName, - LayerName: m.Layers[i].MVTName(), - Query: debugQuery, - }.String(), - }, - } - - // Try to get field information from the provider if it supports it - // Only populate Fields if using TileJSON 3.0.0 - if hasLayerFielder { - layer.Fields = make(map[string]interface{}) - if fielder, ok := m.Layers[i].Provider.(provider.LayerFielder); ok { - if fields, err := fielder.LayerFields(r.Context(), m.Layers[i].ProviderLayerName); err == nil { - layer.Fields = fields - } else { - log.Debugf("error getting fields for layer (%v): %v", m.Layers[i].MVTName(), err) - } + // 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) } } + } - switch m.Layers[i].GeomType.(type) { - case geom.Point, geom.MultiPoint: - layer.GeometryType = tilejson.GeomTypePoint - case geom.Line, geom.LineString, geom.MultiLineString: - layer.GeometryType = tilejson.GeomTypeLine - case geom.Polygon, geom.MultiPolygon: - 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) + // set geometry type + switch m.Layers[i].GeomType.(type) { + case geom.Point, geom.MultiPoint: + layer.GeometryType = tilejson.GeomTypePoint + case geom.Line, geom.LineString, geom.MultiLineString: + layer.GeometryType = tilejson.GeomTypeLine + case geom.Polygon, geom.MultiPolygon: + layer.GeometryType = tilejson.GeomTypePolygon + default: + layer.GeometryType = tilejson.GeomTypeUnknown } - tileURL := TileURLTemplate{ - Scheme: scheme(r), - Host: hostName(r).Host, - PathPrefix: URIPrefix, - MapName: req.mapName, - Query: debugQuery, - }.String() - - // build our URL scheme for the tile grid - tileJSON.Tiles = append(tileJSON.Tiles, tileURL) - - // Store in cache - capabilitiesMux.Lock() - capabilitiesCache[cacheKey] = tileJSON - capabilitiesMux.Unlock() - }) + tileJSON.VectorLayers = append(tileJSON.VectorLayers, layer) + } - // Read from cache after construction (all goroutines, including the one that built it) - capabilitiesMux.RLock() - cached := capabilitiesCache[cacheKey] - capabilitiesMux.RUnlock() + tileURL := TileURLTemplate{ + Scheme: scheme(r), + Host: hostName(r).Host, + PathPrefix: URIPrefix, + MapName: req.mapName, + Query: debugQuery, + }.String() - // content type - w.Header().Add("Content-Type", "application/json") + // build our URL scheme for the tile grid + tileJSON.Tiles = append(tileJSON.Tiles, tileURL) - // 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(cached); 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 70a636b53..dcfa0a8e3 100644 --- a/server/handle_map_capabilities_test.go +++ b/server/handle_map_capabilities_test.go @@ -3,6 +3,7 @@ package server_test import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -10,6 +11,7 @@ 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" @@ -20,14 +22,14 @@ import ( // layerFielderProvider is a mock provider that implements LayerFielder for testing TileJSON v3.0.0 type layerFielderProvider struct { *test.TileProvider - fields map[string]map[string]interface{} // layerName -> fields + fields map[string]map[string]any // layerName -> fields } -func (p *layerFielderProvider) LayerFields(ctx context.Context, layerName string) (map[string]interface{}, error) { +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]interface{}), nil + return make(map[string]any), nil } func TestHandleMapCapabilities(t *testing.T) { @@ -431,20 +433,167 @@ func TestHandleMapCapabilitiesCORS(t *testing.T) { } } +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) { - // Test v2.0.0: provider without LayerFielder (existing test-map) - t.Run("TileJSON v2.0.0 without LayerFielder", func(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 = "" - router := server.NewRouter(nil) - r, err := http.NewRequest(http.MethodGet, "http://localhost:8080/capabilities/test-map.json", nil) + // 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() - router.ServeHTTP(w, r) + handler.ServeHTTP(w, r) if w.Code != http.StatusOK { t.Errorf("handler returned wrong status code: got (%v) expected (%v)", w.Code, http.StatusOK) @@ -463,129 +612,107 @@ func TestHandleMapCapabilitiesTileJSONVersion(t *testing.T) { return } - // Should be v2.0.0 since test.TileProvider doesn't implement LayerFielder - if tileJSON.TileJSON != tilejson.Version2 { - t.Errorf("TileJSON version mismatch: got (%v) expected (%v)", tileJSON.TileJSON, tilejson.Version2) + // verify TileJSON version + if tileJSON.TileJSON != tc.expectedVersion { + t.Errorf("TileJSON version mismatch: got (%v) expected (%v)", tileJSON.TileJSON, tc.expectedVersion) return } - // v2.0.0 should not have Fields (or empty Fields should be omitted) - 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) + // verify Fields presence/absence + if tc.shouldHaveFields { + if len(tileJSON.VectorLayers) == 0 { + t.Errorf("expected VectorLayers, got none") + return } - } - }) - // Test v3.0.0: provider with LayerFielder - t.Run("TileJSON v3.0.0 with LayerFielder", func(t *testing.T) { - // Create a new map with LayerFielder provider - testMapV3Name := "test-map-v3" - layerFielderLayer1 := atlas.Layer{ - Name: "test-layer", - ProviderLayerName: "test-layer-1", - MinZoom: 4, - MaxZoom: 9, - Provider: &layerFielderProvider{ - TileProvider: &test.TileProvider{}, - fields: map[string]map[string]interface{}{ - "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]interface{}{ - "test-layer-2-provider-layer-name": { - "description": "String", - "count": "Number", - }, - }, - }, - GeomType: geom.Line{}, + // 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) + } + } } + } - testMapV3 := atlas.NewWebMercatorMap(testMapV3Name) - testMapV3.Attribution = testMapAttribution - testMapV3.Center = testMapCenter - testMapV3.Layers = append(testMapV3.Layers, layerFielderLayer1, layerFielderLayer2) - - a := &atlas.Atlas{} - a.AddMap(testMapV3) - - server.HostName = nil - server.Port = "" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fn(t, tc) + }) + } +} - router := server.NewRouter(a) - r, err := http.NewRequest(http.MethodGet, "http://localhost:8080/capabilities/test-map-v3.json", nil) - if err != nil { - t.Fatal(err) - } +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{}, + }) - w := httptest.NewRecorder() - router.ServeHTTP(w, r) + testAtlas := &atlas.Atlas{} + testAtlas.AddMap(testMap) - if w.Code != http.StatusOK { - t.Errorf("handler returned wrong status code: got (%v) expected (%v)", w.Code, http.StatusOK) - return - } + // 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 + }, + } - bytes, err := io.ReadAll(w.Body) - if err != nil { - t.Errorf("err reading response body: %v", err) - return - } + uri := "http://localhost:8080/capabilities/test-map-flaky.json" - var tileJSON tilejson.TileJSON - if err := json.Unmarshal(bytes, &tileJSON); err != nil { - t.Errorf("unable to unmarshal JSON response body: %v", err) - return - } + // 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) - // Should be v3.0.0 since provider implements LayerFielder - if tileJSON.TileJSON != tilejson.Version { - t.Errorf("TileJSON version mismatch: got (%v) expected (%v)", tileJSON.TileJSON, tilejson.Version) - return - } + w1 := httptest.NewRecorder() + handler.ServeHTTP(w1, r1) - // v3.0.0 should have Fields - if len(tileJSON.VectorLayers) == 0 { - t.Errorf("expected VectorLayers, got none") - return - } + if w1.Code != http.StatusInternalServerError { + t.Errorf("first request should fail: got %v expected %v", w1.Code, http.StatusInternalServerError) + } - expectedFields1 := map[string]interface{}{ - "name": "String", - "age": "Number", - "is_active": "Boolean", - } + // 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)) - expectedFields2 := map[string]interface{}{ - "description": "String", - "count": "Number", - } + w2 := httptest.NewRecorder() + handler.ServeHTTP(w2, r2) - // Check Fields for first layer - if !reflect.DeepEqual(tileJSON.VectorLayers[0].Fields, expectedFields1) { - t.Errorf("Fields mismatch for layer %v: got %v expected %v", tileJSON.VectorLayers[0].ID, tileJSON.VectorLayers[0].Fields, expectedFields1) - } + if w2.Code != http.StatusOK { + t.Errorf("second request should succeed: got %v expected %v", w2.Code, http.StatusOK) + } - // Check Fields for second layer - if len(tileJSON.VectorLayers) > 1 { - if !reflect.DeepEqual(tileJSON.VectorLayers[1].Fields, expectedFields2) { - t.Errorf("Fields mismatch for layer %v: got %v expected %v", tileJSON.VectorLayers[1].ID, tileJSON.VectorLayers[1].Fields, expectedFields2) - } - } - }) + if callCount != 2 { + t.Errorf("GetMap should be called twice (no error caching), was called %d times", callCount) + } } diff --git a/server/server.go b/server/server.go index 31a5f8a01..6cd04037c 100644 --- a/server/server.go +++ b/server/server.go @@ -88,7 +88,7 @@ func NewRouter(a *atlas.Atlas) *httptreemux.TreeMux { group.UsingContext(). Handler(observability.InstrumentAPIHandler(http.MethodGet, "/capabilities", o, HeadersHandler(HandleCapabilities{}))) group.UsingContext(). - Handler(observability.InstrumentAPIHandler(http.MethodGet, "/capabilities/:map_name", o, HeadersHandler(HandleMapCapabilities{Atlas: a}))) + Handler(observability.InstrumentAPIHandler(http.MethodGet, "/capabilities/:map_name", o, HeadersHandler(HandleMapCapabilities{}))) // map tiles hMapLayerZXY := HandleMapLayerZXY{Atlas: a} From ff80e14a122aca2ab283a09f11f1d3ee99c97170 Mon Sep 17 00:00:00 2001 From: Benjamin Ramser Date: Sat, 7 Mar 2026 12:21:22 +0100 Subject: [PATCH 14/16] fix: raise when map requires tilejson spec v3 but provider does not support --- atlas/map.go | 5 --- cmd/internal/register/maps.go | 27 ++++++++++------ cmd/internal/register/maps_test.go | 22 ++++++++++++- config/config.go | 14 +++++++-- config/config_test.go | 50 +++++++++++++++++++++++++++--- config/errors.go | 4 +++ provider/errors.go | 8 +++-- provider/map.go | 15 ++++----- provider/provider.go | 28 ++++++++++++----- server/handle_map_capabilities.go | 1 - 10 files changed, 132 insertions(+), 42 deletions(-) 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/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/provider.go b/provider/provider.go index 8c2075b64..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 @@ -158,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) @@ -245,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: @@ -261,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 2bc336ab4..7a3d2607f 100644 --- a/server/handle_map_capabilities.go +++ b/server/handle_map_capabilities.go @@ -101,7 +101,6 @@ func (req HandleMapCapabilities) buildTileJSON(r *http.Request) (tilejson.TileJS // 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. - // NOTE: to be discussed hasLayerFielder := true for i := range m.Layers { if _, ok := m.Layers[i].Provider.(provider.LayerFielder); !ok { From b506bc7e69b9b8871094ce214d97fa932b33457a Mon Sep 17 00:00:00 2001 From: Benjamin Ramser Date: Sat, 7 Mar 2026 12:23:05 +0100 Subject: [PATCH 15/16] chore: interface to any --- provider/postgis/postgis.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/postgis/postgis.go b/provider/postgis/postgis.go index 4fd4723a9..8be9798e9 100644 --- a/provider/postgis/postgis.go +++ b/provider/postgis/postgis.go @@ -1071,7 +1071,7 @@ func (p Provider) TileFeatures( // 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]interface{}, error) { +func (p Provider) LayerFields(ctx context.Context, layerName string) (map[string]any, error) { plyr, ok := p.Layer(layerName) if !ok { return nil, ErrLayerNotFound{layerName} From b37037bbc11c3f2ccd2312ce13952f15587fdcc2 Mon Sep 17 00:00:00 2001 From: Benjamin Ramser Date: Tue, 4 Aug 2026 09:36:35 +0200 Subject: [PATCH 16/16] chore: resolve comments --- provider/postgis/postgis.go | 8 +++++--- server/handle_map_capabilities.go | 9 +++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/provider/postgis/postgis.go b/provider/postgis/postgis.go index 39575bf9b..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" diff --git a/server/handle_map_capabilities.go b/server/handle_map_capabilities.go index 7a3d2607f..f93cb8c75 100644 --- a/server/handle_map_capabilities.go +++ b/server/handle_map_capabilities.go @@ -36,6 +36,10 @@ type HandleMapCapabilities struct { 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/3.0.0) // @@ -54,12 +58,13 @@ func (req HandleMapCapabilities) ServeHTTP(w http.ResponseWriter, r *http.Reques req.extension = "json" } - cacheKey := req.mapName + ":" + URLRoot(r).String() + ":" + r.URL.Query().Encode() + 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) @@ -196,7 +201,7 @@ func (req HandleMapCapabilities) buildTileJSON(r *http.Request) (tilejson.TileJS // 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]interface{}) + 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