diff --git a/assets/i18n/active.en.toml b/assets/i18n/active.en.toml index 45036a1e..a42656ec 100644 --- a/assets/i18n/active.en.toml +++ b/assets/i18n/active.en.toml @@ -92,7 +92,8 @@ Format = "Format" # Collections/Collection page ViewCollectionAs = "View collection as" -Extent = "Geographic extent" +GeographicExtent = "Geographic extent" +TemporalExtent = "Temporal extent" GoTo = "Go to the" ViewIn = "View in the" Browse = "Browse through the" @@ -103,3 +104,4 @@ Geometry = "geometry" Prev = "Previous" Next = "Next" Items = "items" +ReferenceDate = "Date" diff --git a/assets/i18n/active.nl.toml b/assets/i18n/active.nl.toml index 434a948a..4a4a3ab2 100644 --- a/assets/i18n/active.nl.toml +++ b/assets/i18n/active.nl.toml @@ -98,7 +98,8 @@ Format = "Formaat" # Collections/Collection page ViewCollectionAs = "Bekijk collectie als" -Extent = "Geografische begrenzing" +GeographicExtent = "Geografische begrenzing" +TemporalExtent = "Temporele begrenzing" GoTo = "Ga naar de" ViewIn = "Bekijk in de" Browse = "Blader door de" @@ -109,3 +110,4 @@ Geometry = "geometrie" Prev = "Vorige" Next = "Volgende" Items = "items" +ReferenceDate = "Peildatum" diff --git a/engine/config.go b/engine/config.go index 3678ae37..def140e0 100644 --- a/engine/config.go +++ b/engine/config.go @@ -83,7 +83,24 @@ func validate(config *Config) error { errMessages = append(errMessages, valErr.Error()+"\n") } } - return fmt.Errorf("invalid config provided:\n %v", errMessages) + return fmt.Errorf("invalid config provided:\n%v", errMessages) + } + // custom validations + if config.OgcAPI.Features != nil { + return validateCollectionsTemporalConfig(config.OgcAPI.Features.Collections) + } + return nil +} + +func validateCollectionsTemporalConfig(collections GeoSpatialCollections) error { + var errMessages []string + for _, collection := range collections { + if collection.Metadata != nil && collection.Metadata.TemporalProperties != nil && collection.Metadata.Extent.Interval == nil { + errMessages = append(errMessages, fmt.Sprintf("validation failed for collection '%s'; field 'Extent.Interval' is required with field 'TemporalProperties'\n", collection.ID)) + } + } + if len(errMessages) > 0 { + return fmt.Errorf("invalid config provided:\n%v", errMessages) } return nil } @@ -215,13 +232,14 @@ type GeoSpatialCollection struct { } type GeoSpatialCollectionMetadata struct { - Title *string `yaml:"title"` - Description *string `yaml:"description"` - Thumbnail *string `yaml:"thumbnail"` - Keywords []string `yaml:"keywords"` - LastUpdated *string `yaml:"lastUpdated"` - LastUpdatedBy string `yaml:"lastUpdatedBy"` - Extent *Extent `yaml:"extent"` + Title *string `yaml:"title"` + Description *string `yaml:"description" validate:"required"` + Thumbnail *string `yaml:"thumbnail"` + Keywords []string `yaml:"keywords"` + LastUpdated *string `yaml:"lastUpdated"` + LastUpdatedBy string `yaml:"lastUpdatedBy"` + TemporalProperties *TemporalProperties `yaml:"temporalProperties" validate:"omitempty,required_with=Extent.Interval"` + Extent *Extent `yaml:"extent"` } type CollectionEntry3dGeoVolumes struct { @@ -294,7 +312,7 @@ type OgcAPIFeatures struct { Limit Limit `yaml:"limit"` Datasources *Datasources `yaml:"datasources"` // optional since you can also define datasources at the collection level Basemap string `yaml:"basemap" default:"OSM"` - Collections GeoSpatialCollections `yaml:"collections" validate:"required"` + Collections GeoSpatialCollections `yaml:"collections" validate:"required,dive"` // Whether GeoJSON/JSON-FG responses will be validated against the OpenAPI spec // since it has significant performance impact when dealing with large JSON payloads. @@ -464,8 +482,14 @@ type ZoomLevelRange struct { } type Extent struct { - Srs string `yaml:"srs" validate:"required,startswith=EPSG:"` - Bbox []string `yaml:"bbox"` + Srs string `yaml:"srs" validate:"required,startswith=EPSG:"` + Bbox []string `yaml:"bbox"` + Interval []string `yaml:"interval" validate:"omitempty,len=2"` +} + +type TemporalProperties struct { + StartDate string `yaml:"startDate" validate:"required"` + EndDate string `yaml:"endDate" validate:"required"` } type License struct { diff --git a/engine/templates/openapi/features.go.json b/engine/templates/openapi/features.go.json index eeb8d39b..eb45f136 100644 --- a/engine/templates/openapi/features.go.json +++ b/engine/templates/openapi/features.go.json @@ -1036,7 +1036,7 @@ "datetime": { "name": "datetime", "in": "query", - "description": "Either a date-time or an interval. Date and time expressions adhere to RFC 3339.\nIntervals may be bounded or half-bounded (double-dots at start or end).\n\nExamples:\n\n* A date-time: \"2018-02-12T23:20:50Z\"\n* A bounded interval: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\"\n* Half-bounded intervals: \"2018-02-12T00:00:00Z/..\" or \"../2018-03-18T12:31:12Z\"\n\nOnly features that have a temporal property that intersects the value of\n`datetime` are selected.\n\nIf a feature has multiple temporal properties, it is the decision of the\nserver whether only a single temporal property is used to determine\nthe extent or all relevant temporal properties.", + "description": "A date-time (intervals are currently not supported). Date and time expressions adhere to RFC 3339.\n\nExamples:\n\n* A date-time: \"2018-02-12T23:20:50Z\"\n\nOnly features that have a temporal property that intersects the value of\n`datetime` are selected.\n\nIf a feature has multiple temporal properties, it is the decision of the\nserver whether only a single temporal property is used to determine\nthe extent or all relevant temporal properties.", "required": false, "style": "form", "explode": false, diff --git a/examples/config_features_local.yaml b/examples/config_features_local.yaml index e7b5c7ed..4a7f8813 100644 --- a/examples/config_features_local.yaml +++ b/examples/config_features_local.yaml @@ -67,6 +67,10 @@ ogcApi: - Address thumbnail: old.png lastUpdated: "2030-01-02T12:00:00Z" + temporalProperties: + startDate: validfrom + endDate: validto extent: srs: EPSG:4326 bbox: [ "50.2129", "2.52713", "55.7212", "7.37403" ] + interval: [ "\"1970-01-01T00:00:00Z\"", "null" ] diff --git a/examples/resources/addresses-crs84.gpkg b/examples/resources/addresses-crs84.gpkg index bda24cfc..c57219aa 100644 Binary files a/examples/resources/addresses-crs84.gpkg and b/examples/resources/addresses-crs84.gpkg differ diff --git a/examples/resources/addresses-etrs89.gpkg b/examples/resources/addresses-etrs89.gpkg index cb15df2f..9cdd06c2 100644 Binary files a/examples/resources/addresses-etrs89.gpkg and b/examples/resources/addresses-etrs89.gpkg differ diff --git a/examples/resources/addresses-rd.gpkg b/examples/resources/addresses-rd.gpkg index d13c53c8..2370bf6b 100644 Binary files a/examples/resources/addresses-rd.gpkg and b/examples/resources/addresses-rd.gpkg differ diff --git a/ogc/common/geospatial/templates/collection.go.html b/ogc/common/geospatial/templates/collection.go.html index 70cc873c..d47e0efb 100644 --- a/ogc/common/geospatial/templates/collection.go.html +++ b/ogc/common/geospatial/templates/collection.go.html @@ -93,11 +93,19 @@
Features
{{ end }} {{ if and .Params.Metadata .Params.Metadata.Extent }}
  • - {{ i18n "Extent" }} + {{ i18n "GeographicExtent" }} ({{ .Params.Metadata.Extent.Srs }}): {{ .Params.Metadata.Extent.Bbox | join ", " }}
  • {{ end }} + {{ if and .Params.Metadata .Params.Metadata.Extent.Interval }} +
  • + {{ i18n "TemporalExtent" }} + (ISO-8601): + {{ toDate "2006-01-02T15:04:05Z" ((first .Params.Metadata.Extent.Interval) | replace "\"" "") | date "2006-01-02" }} / + {{ if not (contains "null" (last .Params.Metadata.Extent.Interval)) }}{{ toDate "2006-01-02T15:04:05Z" ((last .Params.Metadata.Extent.Interval) | replace "\"" "") | date "2006-01-02" }}{{ else }}..{{ end }} +
  • + {{ end }} diff --git a/ogc/common/geospatial/templates/collection.go.json b/ogc/common/geospatial/templates/collection.go.json index a50e772d..072f33fb 100644 --- a/ogc/common/geospatial/templates/collection.go.json +++ b/ogc/common/geospatial/templates/collection.go.json @@ -17,7 +17,11 @@ "spatial": { "bbox": [ [ {{ .Params.Metadata.Extent.Bbox | join "," }} ] ], "crs" : "http://www.opengis.net/def/crs/EPSG/0/{{ trimPrefix "EPSG:" .Params.Metadata.Extent.Srs }}" - } + }{{ if and .Params.Metadata .Params.Metadata.Extent.Interval }}, + "temporal": { + "interval": [ [ {{ .Params.Metadata.Extent.Interval | join ", " }} ] ], + "trs" : "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian" + }{{ end }} }, {{ end }} {{ if and .Config.OgcAPI.Features .Config.OgcAPI.Features.Collections }} diff --git a/ogc/common/geospatial/templates/collections.go.html b/ogc/common/geospatial/templates/collections.go.html index 2beb1c9b..b87347b1 100644 --- a/ogc/common/geospatial/templates/collections.go.html +++ b/ogc/common/geospatial/templates/collections.go.html @@ -55,11 +55,19 @@
    {{ end }} {{ if and $coll.Metadata $coll.Metadata.Extent }}
  • - {{ i18n "Extent" }} + {{ i18n "GeographicExtent" }} ({{ $coll.Metadata.Extent.Srs }}): {{ $coll.Metadata.Extent.Bbox | join ", " }}
  • {{ end }} + {{ if and $coll.Metadata $coll.Metadata.Extent.Interval }} +
  • + {{ i18n "TemporalExtent" }} + (ISO-8601): + {{ toDate "2006-01-02T15:04:05Z" ((first $coll.Metadata.Extent.Interval) | replace "\"" "") | date "2006-01-02" }} / + {{ if not (contains "null" (last $coll.Metadata.Extent.Interval)) }}{{ toDate "2006-01-02T15:04:05Z" ((last $coll.Metadata.Extent.Interval) | replace "\"" "") | date "2006-01-02" }}{{ else }}..{{ end }} +
  • + {{ end }} {{ if and $coll.Metadata $coll.Metadata.Thumbnail }} Tumbnail of collection {{ $coll.ID }} diff --git a/ogc/common/geospatial/templates/collections.go.json b/ogc/common/geospatial/templates/collections.go.json index 298e96cd..e5108637 100644 --- a/ogc/common/geospatial/templates/collections.go.json +++ b/ogc/common/geospatial/templates/collections.go.json @@ -40,7 +40,11 @@ "spatial": { "bbox": [ [ {{ $coll.Metadata.Extent.Bbox | join "," }} ] ], "crs" : "http://www.opengis.net/def/crs/EPSG/0/{{ trimPrefix "EPSG:" $coll.Metadata.Extent.Srs }}" - } + }{{ if and $coll.Metadata $coll.Metadata.Extent.Interval }}, + "temporal": { + "interval": [ [ {{ $coll.Metadata.Extent.Interval | join ", " }} ] ], + "trs" : "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian" + }{{ end }} } {{ end }} {{ if and $cfg.OgcAPI.Features $cfg.OgcAPI.Features.Collections }} diff --git a/ogc/features/datasources/datasource.go b/ogc/features/datasources/datasource.go index db53bb71..1303fcf7 100644 --- a/ogc/features/datasources/datasource.go +++ b/ogc/features/datasources/datasource.go @@ -2,6 +2,7 @@ package datasources import ( "context" + "time" "github.com/PDOK/gokoala/ogc/features/domain" "github.com/go-spatial/geom" @@ -43,6 +44,9 @@ type FeaturesCriteria struct { // filtering by bounding box Bbox *geom.Extent + // filtering by reference date + TemporalCriteria TemporalCriteria + // filtering by properties PropertyFilters map[string]string @@ -51,6 +55,15 @@ type FeaturesCriteria struct { FilterLang string } +type TemporalCriteria struct { + // reference date + ReferenceDate time.Time + + // startDate and endDate properties + StartDateProperty string + EndDateProperty string +} + // FeatureTableMetadata abstraction to access metadata of a feature table (aka attribute table) type FeatureTableMetadata interface { diff --git a/ogc/features/datasources/geopackage/asserts.go b/ogc/features/datasources/geopackage/asserts.go index c913cf57..be55e8c9 100644 --- a/ogc/features/datasources/geopackage/asserts.go +++ b/ogc/features/datasources/geopackage/asserts.go @@ -29,6 +29,14 @@ func assertIndexesExist( return err } + // assert temporal columns are indexed if configured + if coll.Metadata != nil && coll.Metadata.TemporalProperties != nil { + temporalBtreeColumns := strings.Join([]string{coll.Metadata.TemporalProperties.StartDate, coll.Metadata.TemporalProperties.EndDate}, ",") + if err := assertIndexExists(table.TableName, db, temporalBtreeColumns); err != nil { + return err + } + } + // assert the column for each property filter is indexed. for _, propertyFilter := range coll.Features.Filters.Properties { if err := assertIndexExists(table.TableName, db, propertyFilter.Name); err != nil { diff --git a/ogc/features/datasources/geopackage/geopackage.go b/ogc/features/datasources/geopackage/geopackage.go index 5a68682d..acd8244d 100644 --- a/ogc/features/datasources/geopackage/geopackage.go +++ b/ogc/features/datasources/geopackage/geopackage.go @@ -281,21 +281,23 @@ func (g *GeoPackage) makeFeaturesQuery(ctx context.Context, table *featureTable, func (g *GeoPackage) makeDefaultQuery(table *featureTable, criteria datasources.FeaturesCriteria) (string, map[string]any) { pfClause, pfNamedParams := propertyFiltersToSQL(criteria.PropertyFilters) + temporalClause, temporalNamedParams := temporalCriteriaToSQL(criteria.TemporalCriteria) defaultQuery := fmt.Sprintf(` with - next as (select * from %[1]s where %[2]s >= :fid %[3]s order by %[2]s asc limit :limit + 1), - prev as (select * from %[1]s where %[2]s < :fid %[3]s order by %[2]s desc limit :limit), + next as (select * from %[1]s where %[2]s >= :fid %[3]s %[4]s order by %[2]s asc limit :limit + 1), + prev as (select * from %[1]s where %[2]s < :fid %[3]s %[4]s order by %[2]s desc limit :limit), nextprev as (select * from next union all select * from prev), nextprevfeat as (select *, lag(%[2]s, :limit) over (order by %[2]s) as prevfid, lead(%[2]s, :limit) over (order by %[2]s) as nextfid from nextprev) -select * from nextprevfeat where %[2]s >= :fid %[3]s limit :limit -`, table.TableName, g.fidColumn, pfClause) // don't add user input here, use named params for user input! +select * from nextprevfeat where %[2]s >= :fid %[3]s %[4]s limit :limit +`, table.TableName, g.fidColumn, temporalClause, pfClause) // don't add user input here, use named params for user input! namedParams := map[string]any{ "fid": criteria.Cursor.FID, "limit": criteria.Limit, } maps.Copy(namedParams, pfNamedParams) + maps.Copy(namedParams, temporalNamedParams) return defaultQuery, namedParams } @@ -313,6 +315,7 @@ func (g *GeoPackage) makeBboxQuery(table *featureTable, onlyFIDs bool, criteria // whether to use the BTree index or the property filter index btreeIndexHint = "" } + temporalClause, temporalNamedParams := temporalCriteriaToSQL(criteria.TemporalCriteria) bboxQuery := fmt.Sprintf(` with @@ -325,14 +328,14 @@ with from %[1]s f inner join rtree_%[1]s_%[4]s rf on f.%[2]s = rf.id where rf.minx <= :maxx and rf.maxx >= :minx and rf.miny <= :maxy and rf.maxy >= :miny and st_intersects((select * from given_bbox), castautomagic(f.%[4]s)) = 1 - and f.%[2]s >= :fid %[6]s + and f.%[2]s >= :fid %[6]s %[7]s order by f.%[2]s asc limit (select iif(bbox_size == 'small', :limit + 1, 0) from bbox_size)), next_bbox_btree as (select f.* - from %[1]s f %[7]s + from %[1]s f %[8]s where f.minx <= :maxx and f.maxx >= :minx and f.miny <= :maxy and f.maxy >= :miny and st_intersects((select * from given_bbox), castautomagic(f.%[4]s)) = 1 - and f.%[2]s >= :fid %[6]s + and f.%[2]s >= :fid %[6]s %[7]s order by f.%[2]s asc limit (select iif(bbox_size == 'big', :limit + 1, 0) from bbox_size)), next as (select * from next_bbox_rtree union all select * from next_bbox_btree), @@ -340,22 +343,22 @@ with from %[1]s f inner join rtree_%[1]s_%[4]s rf on f.%[2]s = rf.id where rf.minx <= :maxx and rf.maxx >= :minx and rf.miny <= :maxy and rf.maxy >= :miny and st_intersects((select * from given_bbox), castautomagic(f.%[4]s)) = 1 - and f.%[2]s < :fid %[6]s + and f.%[2]s < :fid %[6]s %[7]s order by f.%[2]s desc limit (select iif(bbox_size == 'small', :limit, 0) from bbox_size)), prev_bbox_btree as (select f.* - from %[1]s f %[7]s + from %[1]s f %[8]s where f.minx <= :maxx and f.maxx >= :minx and f.miny <= :maxy and f.maxy >= :miny and st_intersects((select * from given_bbox), castautomagic(f.%[4]s)) = 1 - and f.%[2]s < :fid %[6]s + and f.%[2]s < :fid %[6]s %[7]s order by f.%[2]s desc limit (select iif(bbox_size == 'big', :limit, 0) from bbox_size)), prev as (select * from prev_bbox_rtree union all select * from prev_bbox_btree), nextprev as (select * from next union all select * from prev), nextprevfeat as (select *, lag(%[2]s, :limit) over (order by %[2]s) as prevfid, lead(%[2]s, :limit) over (order by %[2]s) as nextfid from nextprev) -select %[5]s from nextprevfeat where %[2]s >= :fid %[6]s limit :limit +select %[5]s from nextprevfeat where %[2]s >= :fid %[6]s %[7]s limit :limit `, table.TableName, g.fidColumn, g.maxBBoxSizeToUseWithRTree, table.GeometryColumnName, - selectClause, pfClause, btreeIndexHint) // don't add user input here, use named params for user input! + selectClause, temporalClause, pfClause, btreeIndexHint) // don't add user input here, use named params for user input! bboxAsWKT, err := wkt.EncodeString(criteria.Bbox) if err != nil { @@ -371,6 +374,7 @@ select %[5]s from nextprevfeat where %[2]s >= :fid %[6]s limit :limit "miny": criteria.Bbox.MinY(), "bboxSrid": criteria.InputSRID} maps.Copy(namedParams, pfNamedParams) + maps.Copy(namedParams, temporalNamedParams) return bboxQuery, namedParams, nil } @@ -406,3 +410,14 @@ func propertyFiltersToSQL(pf map[string]string) (sql string, namedParams map[str } return sql, namedParams } + +func temporalCriteriaToSQL(temporalCriteria datasources.TemporalCriteria) (sql string, namedParams map[string]any) { + namedParams = make(map[string]any) + if !temporalCriteria.ReferenceDate.IsZero() { + namedParams["referenceDate"] = temporalCriteria.ReferenceDate + startDate := temporalCriteria.StartDateProperty + endDate := temporalCriteria.EndDateProperty + sql = fmt.Sprintf(" and \"%[1]s\" <= :referenceDate and (\"%[2]s\" >= :referenceDate or \"%[2]s\" is null)", startDate, endDate) + } + return sql, namedParams +} diff --git a/ogc/features/html.go b/ogc/features/html.go index 49c4fdc1..6d94993a 100644 --- a/ogc/features/html.go +++ b/ogc/features/html.go @@ -3,6 +3,7 @@ package features import ( "net/http" "strconv" + "time" "github.com/PDOK/gokoala/engine" "github.com/PDOK/gokoala/ogc/features/domain" @@ -46,6 +47,7 @@ type featureCollectionPage struct { PrevLink string NextLink string Limit int + ReferenceDate *time.Time PropertyFilters map[string]string } @@ -59,8 +61,8 @@ type featurePage struct { } func (hf *htmlFeatures) features(w http.ResponseWriter, r *http.Request, collectionID string, - cursor domain.Cursors, featuresURL featureCollectionURL, limit int, propertyFilters map[string]string, - fc *domain.FeatureCollection) { + cursor domain.Cursors, featuresURL featureCollectionURL, limit int, referenceDate *time.Time, + propertyFilters map[string]string, fc *domain.FeatureCollection) { collectionMetadata := collections[collectionID] @@ -76,6 +78,10 @@ func (hf *htmlFeatures) features(w http.ResponseWriter, r *http.Request, collect }, }...) + if referenceDate.IsZero() { + referenceDate = nil + } + pageContent := &featureCollectionPage{ *fc, collectionID, @@ -84,6 +90,7 @@ func (hf *htmlFeatures) features(w http.ResponseWriter, r *http.Request, collect featuresURL.toPrevNextURL(collectionID, cursor.Prev, engine.FormatHTML), featuresURL.toPrevNextURL(collectionID, cursor.Next, engine.FormatHTML), limit, + referenceDate, propertyFilters, } diff --git a/ogc/features/main.go b/ogc/features/main.go index 7fd8d32b..5c705b59 100644 --- a/ogc/features/main.go +++ b/ogc/features/main.go @@ -63,6 +63,8 @@ func NewFeatures(e *engine.Engine) *Features { } // Features serve a FeatureCollection with the given collectionId +// +//nolint:cyclop func (f *Features) Features() http.HandlerFunc { cfg := f.engine.Config @@ -80,7 +82,14 @@ func (f *Features) Features() http.HandlerFunc { } url := featureCollectionURL{*cfg.BaseURL.URL, r.URL.Query(), cfg.OgcAPI.Features.Limit, cfg.OgcAPI.Features.PropertyFiltersForCollection(collectionID)} - encodedCursor, limit, inputSRID, outputSRID, contentCrs, bbox, propertyFilters, err := url.parse() + encodedCursor, limit, inputSRID, outputSRID, contentCrs, bbox, referenceDate, propertyFilters, err := url.parse() + var temporalCriteria ds.TemporalCriteria + if collection := collections[collectionID]; collection != nil && collection.TemporalProperties != nil { + temporalCriteria = ds.TemporalCriteria{ + ReferenceDate: referenceDate, + StartDateProperty: collection.TemporalProperties.StartDate, + EndDateProperty: collection.TemporalProperties.EndDate} + } if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -93,12 +102,13 @@ func (f *Features) Features() http.HandlerFunc { // fast path datasource := f.datasources[DatasourceKey{srid: outputSRID.GetOrDefault(), collectionID: collectionID}] fc, newCursor, err = datasource.GetFeatures(r.Context(), collectionID, ds.FeaturesCriteria{ - Cursor: encodedCursor.Decode(url.checksum()), - Limit: limit, - InputSRID: inputSRID.GetOrDefault(), - OutputSRID: outputSRID.GetOrDefault(), - Bbox: bbox, - PropertyFilters: propertyFilters, + Cursor: encodedCursor.Decode(url.checksum()), + Limit: limit, + InputSRID: inputSRID.GetOrDefault(), + OutputSRID: outputSRID.GetOrDefault(), + Bbox: bbox, + TemporalCriteria: temporalCriteria, + PropertyFilters: propertyFilters, // Add filter, filter-lang }) if err != nil { @@ -110,12 +120,13 @@ func (f *Features) Features() http.HandlerFunc { var fids []int64 datasource := f.datasources[DatasourceKey{srid: inputSRID.GetOrDefault(), collectionID: collectionID}] fids, newCursor, err = datasource.GetFeatureIDs(r.Context(), collectionID, ds.FeaturesCriteria{ - Cursor: encodedCursor.Decode(url.checksum()), - Limit: limit, - InputSRID: inputSRID.GetOrDefault(), - OutputSRID: outputSRID.GetOrDefault(), - Bbox: bbox, - PropertyFilters: propertyFilters, + Cursor: encodedCursor.Decode(url.checksum()), + Limit: limit, + InputSRID: inputSRID.GetOrDefault(), + OutputSRID: outputSRID.GetOrDefault(), + Bbox: bbox, + TemporalCriteria: temporalCriteria, + PropertyFilters: propertyFilters, // Add filter, filter-lang }) if err == nil && fids != nil { @@ -133,7 +144,7 @@ func (f *Features) Features() http.HandlerFunc { switch f.engine.CN.NegotiateFormat(r) { case engine.FormatHTML: - f.html.features(w, r, collectionID, newCursor, url, limit, propertyFilters, fc) + f.html.features(w, r, collectionID, newCursor, url, limit, &referenceDate, propertyFilters, fc) case engine.FormatGeoJSON, engine.FormatJSON: f.json.featuresAsGeoJSON(w, r, collectionID, newCursor, url, fc) case engine.FormatJSONFG: diff --git a/ogc/features/templates/features.go.html b/ogc/features/templates/features.go.html index d5127940..68170eb0 100644 --- a/ogc/features/templates/features.go.html +++ b/ogc/features/templates/features.go.html @@ -9,7 +9,11 @@ const url = new URL(window.location.href); url.searchParams.delete('cursor'); // when filters change, we can't continue pagination. if (value) { - url.searchParams.set(name, value); + if (name === 'datetime') { + url.searchParams.set(name, new Date(value).toISOString()); // input is %Y-%m-%d, but parameter value should be RFC3339 + } else { + url.searchParams.set(name, value); + } } else { url.searchParams.delete(name); } @@ -54,6 +58,16 @@
    +
    +
    + +
    + +
    +
    +
    diff --git a/ogc/features/testdata/config_features_bag.yaml b/ogc/features/testdata/config_features_bag.yaml index 6b8f2abe..d3fb7da9 100644 --- a/ogc/features/testdata/config_features_bag.yaml +++ b/ogc/features/testdata/config_features_bag.yaml @@ -25,9 +25,11 @@ ogcApi: - name: postcode metadata: title: Foooo + description: Foooo - id: bar tableName: ligplaatsen metadata: title: Barrr + description: Barrr tableName: ligplaatsen - id: baz diff --git a/ogc/features/testdata/config_features_bag_invalid_filters.yaml b/ogc/features/testdata/config_features_bag_invalid_filters.yaml index 5275a7f9..4b97b433 100644 --- a/ogc/features/testdata/config_features_bag_invalid_filters.yaml +++ b/ogc/features/testdata/config_features_bag_invalid_filters.yaml @@ -26,3 +26,4 @@ ogcApi: - name: postcode metadata: title: Foooo + description: Foooo diff --git a/ogc/features/url.go b/ogc/features/url.go index 6549de03..a5fad78c 100644 --- a/ogc/features/url.go +++ b/ogc/features/url.go @@ -10,6 +10,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/PDOK/gokoala/engine" "github.com/PDOK/gokoala/ogc/features/domain" @@ -68,7 +69,7 @@ type featureCollectionURL struct { // parse the given URL to values required to delivery a set of Features func (fc featureCollectionURL) parse() (encodedCursor domain.EncodedCursor, limit int, inputSRID SRID, outputSRID SRID, - contentCrs ContentCrs, bbox *geom.Extent, propertyFilters map[string]string, err error) { + contentCrs ContentCrs, bbox *geom.Extent, referenceDate time.Time, propertyFilters map[string]string, err error) { err = fc.validateNoUnknownParams() if err != nil { @@ -80,7 +81,7 @@ func (fc featureCollectionURL) parse() (encodedCursor domain.EncodedCursor, limi contentCrs = parseCrsToContentCrs(fc.params) propertyFilters, pfErr := parsePropertyFilters(fc.configuredPropertyFilters, fc.params) bbox, bboxSRID, bboxErr := parseBbox(fc.params) - dateTimeErr := parseDateTime(fc.params) + referenceDate, dateTimeErr := parseDateTime(fc.params) _, filterSRID, filterErr := parseFilter(fc.params) inputSRID, inputSRIDErr := consolidateSRIDs(bboxSRID, filterSRID) @@ -330,11 +331,16 @@ func parsePropertyFilters(configuredPropertyFilters []engine.PropertyFilter, par return propertyFilters, nil } -func parseDateTime(params url.Values) error { - if params.Get(dateTimeParam) != "" { - return errors.New("datetime param is currently not supported") +// Support filtering on datetime: https://docs.ogc.org/is/17-069r4/17-069r4.html#_parameter_datetime +func parseDateTime(params url.Values) (time.Time, error) { + datetime := params.Get(dateTimeParam) + if strings.Contains(datetime, "/") { + return time.Time{}, fmt.Errorf("datetime param '%s' represents an interval, intervals are currently not supported", datetime) } - return nil + if datetime != "" { + return time.Parse(time.RFC3339, datetime) + } + return time.Time{}, nil } func parseFilter(params url.Values) (filter string, filterSRID SRID, err error) { diff --git a/ogc/features/url_test.go b/ogc/features/url_test.go index a5f203ad..c9bc4fab 100644 --- a/ogc/features/url_test.go +++ b/ogc/features/url_test.go @@ -27,6 +27,7 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { wantOutputCrs int wantBbox *geom.Extent wantInputCrs int + wantRefDate *time.Time wantPropFilters map[string]string wantErr assert.ErrorAssertionFunc }{ @@ -44,6 +45,7 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { wantLimit: 10, wantOutputCrs: 100000, wantBbox: nil, + wantRefDate: nil, wantInputCrs: 100000, wantErr: success(), }, @@ -67,6 +69,7 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { wantLimit: 20, // use max instead of supplied limit wantOutputCrs: 28992, wantBbox: (*geom.Extent)([]float64{1, 2, 3, 4}), + wantRefDate: nil, wantInputCrs: 28992, wantErr: success(), }, @@ -89,6 +92,7 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { wantLimit: 20, // use max instead of supplied limit wantOutputCrs: 100000, wantBbox: (*geom.Extent)([]float64{1, 2, 3, 4}), + wantRefDate: nil, wantInputCrs: 28992, wantErr: success(), }, @@ -111,6 +115,7 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { wantLimit: 20, // use max instead of supplied limit wantOutputCrs: 28992, wantBbox: (*geom.Extent)([]float64{1, 2, 3, 4}), + wantRefDate: nil, wantInputCrs: 100000, wantErr: success(), }, @@ -134,9 +139,28 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { wantLimit: 20, // use max instead of supplied limit wantOutputCrs: 100000, wantBbox: (*geom.Extent)([]float64{1, 2, 3, 4}), + wantRefDate: nil, wantInputCrs: 28992, wantErr: success(), }, + { + name: "Parse datetime", + fields: fields{ + baseURL: *host, + params: url.Values{ + "datetime": []string{time.Time{}.Format(time.RFC3339)}, + }, + limit: engine.Limit{ + Default: 1, + Max: 2, + }, + }, + wantLimit: 1, + wantOutputCrs: 100000, + wantInputCrs: 100000, + wantRefDate: &time.Time{}, + wantErr: success(), + }, { name: "Parse property filters", fields: fields{ @@ -152,6 +176,7 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { wantLimit: 10, wantOutputCrs: 100000, wantInputCrs: 100000, + wantRefDate: nil, wantPropFilters: map[string]string{"foo": "baz"}, wantErr: success(), }, @@ -171,6 +196,7 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { wantLimit: 10, wantOutputCrs: 100000, wantInputCrs: 100000, + wantRefDate: nil, wantPropFilters: map[string]string{"foo": "baz", "bar": "bazz"}, wantErr: success(), }, @@ -299,11 +325,11 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { }, }, { - name: "Fail on unimplemented datetime", + name: "Fail on unimplemented datetime interval", fields: fields{ baseURL: *host, params: url.Values{ - "datetime": []string{time.Now().String()}, + "datetime": []string{"2023-11-10T23:00:00Z/2023-11-15T23:00:00Z"}, }, limit: engine.Limit{ Default: 1, @@ -311,7 +337,7 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { }, }, wantErr: func(t assert.TestingT, err error, _ ...any) bool { - assert.Equalf(t, "datetime param is currently not supported", err.Error(), "parse()") + assert.Equalf(t, "datetime param '2023-11-10T23:00:00Z/2023-11-15T23:00:00Z' represents an interval, intervals are currently not supported", err.Error(), "parse()") return false }, }, @@ -367,7 +393,7 @@ func Test_featureCollectionURL_parseParams(t *testing.T) { }, }, } - gotEncodedCursor, gotLimit, gotInputCrs, gotOutputCrs, _, gotBbox, gotPF, err := fc.parse() + gotEncodedCursor, gotLimit, gotInputCrs, gotOutputCrs, _, gotBbox, _, gotPF, err := fc.parse() if !tt.wantErr(t, err, "parse()") { return }