diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml deleted file mode 100644 index 0f89eae..0000000 --- a/.github/workflows/e2e-test.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: e2e-test -on: - pull_request: -jobs: - end-to-end-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - # Build a local test image for (potential) re-use across end-to-end tests - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver: docker - - name: Build test image - uses: docker/build-push-action@v5 - with: - push: false - tags: gomagpie:local - - - name: Start gomagpie test instance - run: | - docker run \ - -v `pwd`/examples:/examples \ - --rm --detach -p 8080:8080 \ - --name gomagpie \ - gomagpie:local start-service --config-file /examples/config.yaml - - # E2E Test - - name: E2E Test => Cypress - uses: cypress-io/github-action@v6 - with: - working-directory: ./tests - browser: chrome - - - name: Stop gomagpie test instance - run: | - docker stop gomagpie diff --git a/cmd/main.go b/cmd/main.go index 704936d..1ae74d2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,6 +8,7 @@ import ( "strconv" "github.com/PDOK/gomagpie/config" + "github.com/PDOK/gomagpie/internal/search" "github.com/iancoleman/strcase" eng "github.com/PDOK/gomagpie/internal/engine" @@ -117,9 +118,11 @@ var ( EnvVars: []string{strcase.ToScreamingSnake(dbPortFlag)}, }, dbNameFlag: &cli.StringFlag{ - Name: dbNameFlag, - Usage: "Connect to this database", - EnvVars: []string{strcase.ToScreamingSnake(dbNameFlag)}, + Name: dbNameFlag, + Usage: "Connect to this database", + Value: "postgres", + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(dbNameFlag)}, }, dbSslModeFlag: &cli.StringFlag{ Name: dbSslModeFlag, @@ -166,6 +169,12 @@ func main() { commonDBFlags[dbUsernameFlag], commonDBFlags[dbPasswordFlag], commonDBFlags[dbSslModeFlag], + &cli.PathFlag{ + Name: searchIndexFlag, + EnvVars: []string{strcase.ToScreamingSnake(searchIndexFlag)}, + Usage: "Name of search index to use", + Value: "search_index", + }, }, Action: func(c *cli.Context) error { log.Println(c.Command.Usage) @@ -186,6 +195,8 @@ func main() { } // Each OGC API building block makes use of said Engine ogc.SetupBuildingBlocks(engine, dbConn) + // Create search endpoint + search.NewSearch(engine, dbConn, c.String(searchIndexFlag)) return engine.Start(address, debugPort, shutdownDelay) }, diff --git a/config/collections.go b/config/collections.go index f025d94..396b8e4 100644 --- a/config/collections.go +++ b/config/collections.go @@ -123,7 +123,7 @@ func (c *Config) HasCollections() bool { return c.AllCollections() != nil } -// AllCollections get all collections - with for example features, tiles, 3d tiles - offered through this OGC API. +// AllCollections get all collections - with for example features, tiles, 3d tiles - offered through this OGC API. // Results are returned in alphabetic or literal order. func (c *Config) AllCollections() GeoSpatialCollections { if len(c.CollectionOrder) > 0 { @@ -134,6 +134,16 @@ func (c *Config) AllCollections() GeoSpatialCollections { return c.Collections } +func (g GeoSpatialCollections) WithSearch() GeoSpatialCollections { + result := make([]GeoSpatialCollection, 0, len(g)) + for _, collection := range g { + if collection.Search != nil { + result = append(result, collection) + } + } + return result +} + // Unique lists all unique GeoSpatialCollections (no duplicate IDs). // Don't use in hot path (creates a map on every invocation). func (g GeoSpatialCollections) Unique() []GeoSpatialCollection { diff --git a/go.mod b/go.mod index e2be442..811bffb 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/go-playground/validator/v10 v10.22.1 github.com/go-spatial/geom v0.1.0 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 + github.com/goccy/go-json v0.10.3 github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8 github.com/iancoleman/strcase v0.3.0 github.com/jackc/pgx/v5 v5.7.1 diff --git a/go.sum b/go.sum index d19c9ef..e4bccba 100644 --- a/go.sum +++ b/go.sum @@ -87,6 +87,8 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8 h1:4txT5G2kqVAKMjzidIabL/8KqjIK71yj30YOeuxLn10= diff --git a/internal/engine/openapi.go b/internal/engine/openapi.go index 6a4d3fb..faacf6e 100644 --- a/internal/engine/openapi.go +++ b/internal/engine/openapi.go @@ -26,12 +26,13 @@ import ( ) const ( - specPath = templatesDir + "openapi/" - preamble = specPath + "preamble.go.json" - problems = specPath + "problems.go.json" - commonCollections = specPath + "common-collections.go.json" - commonSpec = specPath + "common.go.json" - HTMLRegex = `<[/]?([a-zA-Z]+).*?>` + specPath = templatesDir + "openapi/" + preamble = specPath + "preamble.go.json" + problems = specPath + "problems.go.json" + commonCollections = specPath + "common-collections.go.json" + commonSpec = specPath + "common.go.json" + featuresSearchSpec = specPath + "features-search.go.json" + HTMLRegex = `<[/]?([a-zA-Z]+).*?>` ) type OpenAPI struct { @@ -51,6 +52,9 @@ func newOpenAPI(config *gomagpieconfig.Config) *OpenAPI { if config.AllCollections() != nil { defaultOpenAPIFiles = append(defaultOpenAPIFiles, commonCollections) } + if len(config.Collections.WithSearch()) > 0 { + defaultOpenAPIFiles = append(defaultOpenAPIFiles, featuresSearchSpec) + } // add preamble first openAPIFiles := []string{preamble} diff --git a/internal/engine/templates/openapi/features-search.go.json b/internal/engine/templates/openapi/features-search.go.json new file mode 100644 index 0000000..a86e85c --- /dev/null +++ b/internal/engine/templates/openapi/features-search.go.json @@ -0,0 +1,710 @@ +{{- /*gotype: github.com/PDOK/gokoala/internal/engine.TemplateData*/ -}} +{{ $cfg := .Config }} +{ + "openapi": "3.0.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/search": { + "get": { + "tags" : [ "Features" ], + "summary": "search features in one or more collections across datasets.", + "description": "This endpoint allows one to implement autocomplete functionality for location search. The `q` parameter accepts a partial location name and will return all matching locations up to the specified `limit`. The list of search results are offered as features (in GeoJSON, JSON-FG) but contain only minimal information; like a feature ID, highlighted text and a bounding box. When you want to get the full feature you must follow the included link (`href`) in the search result. This allows one to retrieve all properties of the feature and the full geometry from the corresponding OGC API.", + "operationId": "search", + "parameters": [ + { + "$ref": "#/components/parameters/q" + }, + {{- range $index, $coll := .Config.Collections.WithSearch -}} + {{- if $index -}},{{- end -}} + { + "$ref": "#/components/parameters/{{ $coll.ID }}-collection-search" + } + {{- end -}} + , + { + "$ref": "#/components/parameters/limit-search" + }, + { + "$ref": "#/components/parameters/crs" + } + ], + "responses": { + "200": { + "description": "The response is a document consisting of features in the collection.\nThe features contain only minimal information but include a link (href) to the actual feature in another OGC API. Follow that link to get the full feature data.", + "headers": { + "Content-Crs": { + "description": "a URI, in angular brackets, identifying the coordinate reference system used in the content / payload", + "schema": { + "type": "string" + }, + "example": "" + } + }, + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/featureCollectionGeoJSON" + }, + "example": { + "type": "FeatureCollection", + "links": [ + { + "href": "http://data.example.com/collections/buildings/items.json", + "rel": "self", + "type": "application/geo+json", + "title": "this document" + }, + { + "href": "http://data.example.com/collections/buildings/items?f=html", + "rel": "alternate", + "type": "text/html", + "title": "this document as HTML" + } + ], + "timeStamp": "2018-04-03T14:52:23Z", +{{/* "numberMatched": 123,*/}} + "numberReturned": 2, + "features": [ + { + "type": "Feature", + "id": "123", + "geometry": { + "type": "Polygon", + "coordinates": [ + "..." + ] + }, + "properties": { + "function": "residential", + "floors": "2", + "lastUpdate": "2015-08-01T12:34:56Z" + } + }, + { + "type": "Feature", + "id": "132", + "geometry": { + "type": "Polygon", + "coordinates": [ + "..." + ] + }, + "properties": { + "function": "public use", + "floors": "10", + "lastUpdate": "2013-12-03T10:15:37Z" + } + } + ] + } + }, + "application/vnd.ogc.fg+json": { + "schema": { + "$ref": "#/components/schemas/featureCollectionJSONFG" + }, + "example": { + "conformsTo": [ + "http://www.opengis.net/spec/json-fg-1/0.2/conf/core" + ], + "type": "FeatureCollection", + "links": [ + { + "href": "http://data.example.com/collections/buildings/items.json", + "rel": "self", + "type": "application/geo+json", + "title": "this document" + }, + { + "href": "http://data.example.com/collections/buildings/items?f=html", + "rel": "alternate", + "type": "text/html", + "title": "this document as HTML" + } + ], + "timeStamp": "2018-04-03T14:52:23Z", + {{/* "numberMatched": 123,*/}} + "numberReturned": 2, + "features": [ + { + "type": "Feature", + "id": "123", + "place": { + "type": "Polygon", + "coordinates": [ + "..." + ] + }, + "geometry": null, + "time": null, + "properties": { + "function": "residential", + "floors": "2", + "lastUpdate": "2015-08-01T12:34:56Z" + } + }, + { + "type": "Feature", + "id": "132", + "place": { + "type": "Polygon", + "coordinates": [ + "..." + ] + }, + "geometry": null, + "time": null, + "properties": { + "function": "public use", + "floors": "10", + "lastUpdate": "2013-12-03T10:15:37Z" + } + } + ] + } + }, + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + {{block "problems" . }}{{end}} + } + } + } + }, + "components": { + "schemas": { + "featureCollectionJSONFG": { + "required": [ + "features", + "type" + ], + "type": "object", + "properties": { + "conformsTo": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "format": "uri" + } + }, + "coordRefSys": { + "type": "string", + "format": "uri" + }, + "features": { + "type": "array", + "items": { + "$ref": "#/components/schemas/featureJSONFG" + } + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/link" + } + }, + "timeStamp": { + "$ref": "#/components/schemas/timeStamp" + }, +{{/* "numberMatched": {*/}} +{{/* "$ref": "#/components/schemas/numberMatched"*/}} +{{/* },*/}} + "numberReturned": { + "$ref": "#/components/schemas/numberReturned" + } + } + }, + "featureCollectionGeoJSON": { + "required": [ + "features", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "features": { + "type": "array", + "items": { + "$ref": "#/components/schemas/featureGeoJSON" + } + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/link" + } + }, + "timeStamp": { + "$ref": "#/components/schemas/timeStamp" + }, +{{/* "numberMatched": {*/}} +{{/* "$ref": "#/components/schemas/numberMatched"*/}} +{{/* },*/}} + "numberReturned": { + "$ref": "#/components/schemas/numberReturned" + } + } + }, + "featureJSONFG": { + "required": [ + "time", + "place", + "geometry", + "properties", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "conformsTo": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "format": "uri" + } + }, + "coordRefSys": { + "type": "string", + "format": "uri" + }, + "time": { + {{/* not implemented yet, since we don't yet support temporal data */}} + "nullable": true + }, + "place": { + "nullable": true, + "allOf": [ + {{/* 3D conformance class not implemented, so just delegate to GeoJSON compatible geometries */}} + { + "$ref": "#/components/schemas/geometryGeoJSON" + } + ] + }, + "geometry": { + "nullable": true, + "allOf": [ + {{/* 3D conformance class not implemented, so just delegate to GeoJSON compatible geometries */}} + { + "$ref": "#/components/schemas/geometryGeoJSON" + } + ] + }, + "properties": { + "type": "object", + "nullable": true + }, + "id": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/link" + } + } + } + }, + "featureGeoJSON": { + "required": [ + "geometry", + "properties", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/components/schemas/geometryGeoJSON" + }, + "properties": { + "type": "object", + "nullable": true + }, + "id": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/link" + } + } + } + }, + "geometryGeoJSON": { + "oneOf": [ + { + "$ref": "#/components/schemas/pointGeoJSON" + }, + { + "$ref": "#/components/schemas/multipointGeoJSON" + }, + { + "$ref": "#/components/schemas/linestringGeoJSON" + }, + { + "$ref": "#/components/schemas/multilinestringGeoJSON" + }, + { + "$ref": "#/components/schemas/polygonGeoJSON" + }, + { + "$ref": "#/components/schemas/multipolygonGeoJSON" + }, + { + "$ref": "#/components/schemas/geometrycollectionGeoJSON" + } + ] + }, + "geometrycollectionGeoJSON": { + "required": [ + "geometries", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "GeometryCollection" + ] + }, + "geometries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/geometryGeoJSON" + } + } + } + }, + "linestringGeoJSON": { + "required": [ + "coordinates", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "LineString" + ] + }, + "coordinates": { + "minItems": 2, + "type": "array", + "items": { + "minItems": 2, + "type": "array", + "items": { + "type": "number" + } + } + } + } + }, + "link": { + "required": [ + "href" + ], + "type": "object", + "properties": { + "href": { + "type": "string", + "example": "http://data.example.com/buildings/123" + }, + "rel": { + "type": "string", + "example": "alternate" + }, + "type": { + "type": "string", + "example": "application/geo+json" + }, + "hreflang": { + "type": "string", + "example": "en" + }, + "title": { + "type": "string", + "example": "Trierer Strasse 70, 53115 Bonn" + }, + "length": { + "type": "integer" + } + } + }, + "multilinestringGeoJSON": { + "required": [ + "coordinates", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiLineString" + ] + }, + "coordinates": { + "type": "array", + "items": { + "minItems": 2, + "type": "array", + "items": { + "minItems": 2, + "type": "array", + "items": { + "type": "number" + } + } + } + } + } + }, + "multipointGeoJSON": { + "required": [ + "coordinates", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPoint" + ] + }, + "coordinates": { + "type": "array", + "items": { + "minItems": 2, + "type": "array", + "items": { + "type": "number" + } + } + } + } + }, + "multipolygonGeoJSON": { + "required": [ + "coordinates", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "minItems": 4, + "type": "array", + "items": { + "minItems": 2, + "type": "array", + "items": { + "type": "number" + } + } + } + } + } + } + }, +{{/* "numberMatched": {*/}} +{{/* "minimum": 0,*/}} +{{/* "type": "integer",*/}} +{{/* "description": "The number of features of the feature type that match the selection\nparameters like `bbox`.",*/}} +{{/* "example": 127*/}} +{{/* },*/}} + "numberReturned": { + "minimum": 0, + "type": "integer", + "description": "The number of features in the feature collection.\n\nA server may omit this information in a response, if the information\nabout the number of features is not known or difficult to compute.\n\nIf the value is provided, the value shall be identical to the number\nof items in the \"features\" array.", + "example": 10 + }, + "pointGeoJSON": { + "required": [ + "coordinates", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Point" + ] + }, + "coordinates": { + "minItems": 2, + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "polygonGeoJSON": { + "required": [ + "coordinates", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { + "type": "array", + "items": { + "minItems": 4, + "type": "array", + "items": { + "minItems": 2, + "type": "array", + "items": { + "type": "number" + } + } + } + } + } + }, + "timeStamp": { + "type": "string", + "description": "This property indicates the time and date when the response was generated.", + "format": "date-time", + "example": "2017-08-17T08:05:32Z" + } + }, + "parameters": { + "q": { + "name": "q", + "in": "query", + "description": "The search term(s)", + "required": true, + "style": "form", + "explode": false, + "schema": { + "type": "string", + "minLength": 2, + "maxLength": 200 + } + }, + {{- range $index, $coll := .Config.Collections.WithSearch -}} + {{- if $index -}},{{- end -}} + "{{ $coll.ID }}-collection-search": { + "name": "{{ $coll.ID }}", + "in": "query", + "description": "When provided the {{ $coll.ID }} collection is included in the search. This parameter should be provided as a [deep object](https://swagger.io/docs/specification/v3_0/serialization/#query-parameters) containing the version and relevance of the {{ $coll.ID }} collection, for example `q=foo&{{ $coll.ID }}[version]=1&{{ $coll.ID }}[relevance]=0.5`", + "required": false, + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "number", + "description": "The version of the {{ $coll.ID }} collection.", + "example": "1" + }, + "relevance": { + "type": "number", + "format": "float", + "description": "The relevance score of the {{ $coll.ID }} collection.", + "example": 0.50 + } + } + } + } + {{- end -}} + , + "crs": { + "name": "crs", + "in": "query", + "description": "The coordinate reference system of the geometries in the response. Default is WGS84 longitude/latitude", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "default": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "enum": [ + {{/* TODO make configurable */}} + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/0/28992" + ] + }, + "style": "form", + "explode": false + }, + "limit-search": { + "name": "limit", + "in": "query", + "description": "The optional limit parameter limits the number of items that are presented in the response document.\n\nOnly items are counted that are on the first level of the collection in the response document.\nNested objects contained within the explicitly requested items shall not be counted.\n\nMinimum = 1. Maximum = 50. Default = 10.", + "required": false, + "style": "form", + "explode": false, + "schema": { + "maximum": 50, + "minimum": 1, + "type": "integer", + "default": 10 + } + } + } + } +} diff --git a/internal/etl/etl.go b/internal/etl/etl.go index e7613fd..e308b46 100644 --- a/internal/etl/etl.go +++ b/internal/etl/etl.go @@ -110,7 +110,7 @@ func newSourceToExtract(filePath string) (Extract, error) { func newTargetToLoad(dbConn string) (Load, error) { if strings.HasPrefix(dbConn, "postgres:") { - return load.NewPostgis(dbConn) + return load.NewPostgres(dbConn) } // add new targets here (elasticsearch, solr, etc) return nil, fmt.Errorf("unsupported target database connection: %s", dbConn) diff --git a/internal/etl/extract/geopackage.go b/internal/etl/extract/geopackage.go index 24811b5..51e7f23 100644 --- a/internal/etl/extract/geopackage.go +++ b/internal/etl/extract/geopackage.go @@ -93,23 +93,35 @@ func (g *GeoPackage) Extract(table config.FeatureTable, fields []string, where s if len(row) != len(fields)+nrOfStandardFieldsInQuery { return nil, fmt.Errorf("unexpected row length (%v)", len(row)) } - result = append(result, mapRowToRawRecord(row, fields)) + record, err := mapRowToRawRecord(row, fields) + if err != nil { + return nil, err + } + result = append(result, record) } return result, nil } -func mapRowToRawRecord(row []any, fields []string) t.RawRecord { +func mapRowToRawRecord(row []any, fields []string) (t.RawRecord, error) { bbox := row[1:5] + fid := row[0].(int64) + if fid <= 0 { + return t.RawRecord{}, errors.New("encountered negative fid") + } + geomType := row[5].(string) + if geomType == "" { + return t.RawRecord{}, fmt.Errorf("encountered empty geometry type for fid %d", fid) + } return t.RawRecord{ - FeatureID: row[0].(int64), + FeatureID: fid, Bbox: &geom.Extent{ bbox[0].(float64), bbox[1].(float64), bbox[2].(float64), bbox[3].(float64), }, - GeometryType: row[5].(string), + GeometryType: geomType, FieldValues: row[nrOfStandardFieldsInQuery : nrOfStandardFieldsInQuery+len(fields)], - } + }, nil } diff --git a/internal/etl/load/postgres.go b/internal/etl/load/postgres.go index 65dfd10..ac29070 100644 --- a/internal/etl/load/postgres.go +++ b/internal/etl/load/postgres.go @@ -9,12 +9,12 @@ import ( pgxgeom "github.com/twpayne/pgx-geom" ) -type Postgis struct { +type Postgres struct { db *pgx.Conn ctx context.Context } -func NewPostgis(dbConn string) (*Postgis, error) { +func NewPostgres(dbConn string) (*Postgres, error) { ctx := context.Background() db, err := pgx.Connect(ctx, dbConn) if err != nil { @@ -24,14 +24,14 @@ func NewPostgis(dbConn string) (*Postgis, error) { if err := pgxgeom.Register(ctx, db); err != nil { return nil, err } - return &Postgis{db: db, ctx: ctx}, nil + return &Postgres{db: db, ctx: ctx}, nil } -func (p *Postgis) Close() { +func (p *Postgres) Close() { _ = p.db.Close(p.ctx) } -func (p *Postgis) Load(records []t.SearchIndexRecord, index string) (int64, error) { +func (p *Postgres) Load(records []t.SearchIndexRecord, index string) (int64, error) { loaded, err := p.db.CopyFrom( p.ctx, pgx.Identifier{index}, @@ -48,7 +48,7 @@ func (p *Postgis) Load(records []t.SearchIndexRecord, index string) (int64, erro } // Init initialize search index -func (p *Postgis) Init(index string) error { +func (p *Postgres) Init(index string) error { geometryType := `create type geometry_type as enum ('POINT', 'MULTIPOINT', 'LINESTRING', 'MULTILINESTRING', 'POLYGON', 'MULTIPOLYGON');` _, err := p.db.Exec(p.ctx, geometryType) if err != nil { @@ -58,7 +58,7 @@ func (p *Postgis) Init(index string) error { searchIndexTable := fmt.Sprintf(` create table if not exists %[1]s ( id serial, - feature_id varchar (8) not null , + feature_id text not null , collection_id text not null, collection_version int not null, display_name text not null, diff --git a/internal/etl/transform/extend_values.go b/internal/etl/transform/extend_values.go index 05e0d0a..e9c3355 100644 --- a/internal/etl/transform/extend_values.go +++ b/internal/etl/transform/extend_values.go @@ -9,7 +9,7 @@ import ( ) // Return slice of fieldValuesByName -func extendFieldValues(fieldValuesByName map[string]any, substitutionsFile, synonymsFile string) ([]map[string]any, error) { +func extendFieldValues(fieldValuesByName map[string]string, substitutionsFile, synonymsFile string) ([]map[string]string, error) { substitutions, err := readCsvFile(substitutionsFile) if err != nil { return nil, err @@ -21,7 +21,7 @@ func extendFieldValues(fieldValuesByName map[string]any, substitutionsFile, syno var fieldValuesByNameWithAllValues = make(map[string][]string) for key, value := range fieldValuesByName { - valueLower := strings.ToLower(value.(string)) + valueLower := strings.ToLower(value) // Get all substitutions substitutedValues, err := extendValues([]string{valueLower}, substitutions) @@ -49,7 +49,7 @@ func extendFieldValues(fieldValuesByName map[string]any, substitutionsFile, syno // Transform a map[string][]string into a []map[string]string using the cartesian product, i.e. // - both maps have the same keys // - values exist for all possible combinations -func generateAllFieldValuesByName(input map[string][]string) []map[string]any { +func generateAllFieldValuesByName(input map[string][]string) []map[string]string { keys := []string{} values := [][]string{} @@ -61,16 +61,16 @@ func generateAllFieldValuesByName(input map[string][]string) []map[string]any { return generateCombinations(keys, values) } -func generateCombinations(keys []string, values [][]string) []map[string]any { +func generateCombinations(keys []string, values [][]string) []map[string]string { if len(keys) == 0 || len(values) == 0 { return nil } - result := []map[string]any{{}} // contains empty map so the first iteration works + result := []map[string]string{{}} // contains empty map so the first iteration works for keyDepth := 0; keyDepth < len(keys); keyDepth++ { - var newResult []map[string]any + var newResult []map[string]string for _, entry := range result { for _, val := range values[keyDepth] { - newEntry := make(map[string]any) + newEntry := make(map[string]string) for k, v := range entry { newEntry[k] = v } diff --git a/internal/etl/transform/extend_values_test.go b/internal/etl/transform/extend_values_test.go index 043d1aa..1f2d2e9 100644 --- a/internal/etl/transform/extend_values_test.go +++ b/internal/etl/transform/extend_values_test.go @@ -9,20 +9,20 @@ import ( func Test_generateAllFieldValues(t *testing.T) { type args struct { - fieldValuesByName map[string]any + fieldValuesByName map[string]string substitutionsFile string synonymsFile string } tests := []struct { name string args args - want []map[string]any + want []map[string]string wantErr assert.ErrorAssertionFunc }{ - {"simple record", args{map[string]any{"component_thoroughfarename": "foo", "component_postaldescriptor": "1234AB", "component_addressareaname": "bar"}, "../testdata/substitutions.csv", "../testdata/synonyms.csv"}, []map[string]any{{"component_thoroughfarename": "foo", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}}, assert.NoError}, - {"single synonym record", args{map[string]any{"component_thoroughfarename": "eerste", "component_postaldescriptor": "1234AB", "component_addressareaname": "bar"}, "../testdata/substitutions.csv", "../testdata/synonyms.csv"}, []map[string]any{{"component_thoroughfarename": "eerste", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "1ste", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}}, assert.NoError}, - {"single synonym with capital", args{map[string]any{"component_thoroughfarename": "Eerste", "component_postaldescriptor": "1234AB", "component_addressareaname": "bar"}, "../testdata/substitutions.csv", "../testdata/synonyms.csv"}, []map[string]any{{"component_thoroughfarename": "eerste", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "1ste", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}}, assert.NoError}, - {"two-way synonym record", args{map[string]any{"component_thoroughfarename": "eerste 2de", "component_postaldescriptor": "1234AB", "component_addressareaname": "bar"}, "../testdata/substitutions.csv", "../testdata/synonyms.csv"}, []map[string]any{{"component_thoroughfarename": "eerste 2de", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "1ste 2de", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "eerste tweede", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "1ste tweede", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}}, assert.NoError}, + {"simple record", args{map[string]string{"component_thoroughfarename": "foo", "component_postaldescriptor": "1234AB", "component_addressareaname": "bar"}, "../testdata/substitutions.csv", "../testdata/synonyms.csv"}, []map[string]string{{"component_thoroughfarename": "foo", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}}, assert.NoError}, + {"single synonym record", args{map[string]string{"component_thoroughfarename": "eerste", "component_postaldescriptor": "1234AB", "component_addressareaname": "bar"}, "../testdata/substitutions.csv", "../testdata/synonyms.csv"}, []map[string]string{{"component_thoroughfarename": "eerste", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "1ste", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}}, assert.NoError}, + {"single synonym with capital", args{map[string]string{"component_thoroughfarename": "Eerste", "component_postaldescriptor": "1234AB", "component_addressareaname": "bar"}, "../testdata/substitutions.csv", "../testdata/synonyms.csv"}, []map[string]string{{"component_thoroughfarename": "eerste", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "1ste", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}}, assert.NoError}, + {"two-way synonym record", args{map[string]string{"component_thoroughfarename": "eerste 2de", "component_postaldescriptor": "1234AB", "component_addressareaname": "bar"}, "../testdata/substitutions.csv", "../testdata/synonyms.csv"}, []map[string]string{{"component_thoroughfarename": "eerste 2de", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "1ste 2de", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "eerste tweede", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}, {"component_thoroughfarename": "1ste tweede", "component_postaldescriptor": "1234ab", "component_addressareaname": "bar"}}, assert.NoError}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -43,13 +43,13 @@ func Test_generateCombinations(t *testing.T) { tests := []struct { name string args args - want []map[string]any + want []map[string]string }{ - {"Single key, single value", args{[]string{"key1"}, [][]string{{"value1"}}}, []map[string]any{{"key1": "value1"}}}, - {"Single key, slice of values", args{[]string{"key1"}, [][]string{{"value1", "value2"}}}, []map[string]any{{"key1": "value1"}, {"key1": "value2"}}}, - {"Two keys, two single values", args{[]string{"key1", "key2"}, [][]string{{"value1"}, {"value2"}}}, []map[string]any{{"key1": "value1", "key2": "value2"}}}, - {"Two keys, slice + single value", args{[]string{"key1", "key2"}, [][]string{{"value1", "value2"}, {"value3"}}}, []map[string]any{{"key1": "value1", "key2": "value3"}, {"key1": "value2", "key2": "value3"}}}, - {"Two keys, two slices values", args{[]string{"key1", "key2"}, [][]string{{"value1", "value2"}, {"value3", "value4"}}}, []map[string]any{{"key1": "value1", "key2": "value3"}, {"key1": "value1", "key2": "value4"}, {"key1": "value2", "key2": "value3"}, {"key1": "value2", "key2": "value4"}}}, + {"Single key, single value", args{[]string{"key1"}, [][]string{{"value1"}}}, []map[string]string{{"key1": "value1"}}}, + {"Single key, slice of values", args{[]string{"key1"}, [][]string{{"value1", "value2"}}}, []map[string]string{{"key1": "value1"}, {"key1": "value2"}}}, + {"Two keys, two single values", args{[]string{"key1", "key2"}, [][]string{{"value1"}, {"value2"}}}, []map[string]string{{"key1": "value1", "key2": "value2"}}}, + {"Two keys, slice + single value", args{[]string{"key1", "key2"}, [][]string{{"value1", "value2"}, {"value3"}}}, []map[string]string{{"key1": "value1", "key2": "value3"}, {"key1": "value2", "key2": "value3"}}}, + {"Two keys, two slices values", args{[]string{"key1", "key2"}, [][]string{{"value1", "value2"}, {"value3", "value4"}}}, []map[string]string{{"key1": "value1", "key2": "value3"}, {"key1": "value1", "key2": "value4"}, {"key1": "value2", "key2": "value3"}, {"key1": "value2", "key2": "value4"}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/etl/transform/transform.go b/internal/etl/transform/transform.go index 8fdded0..3013f05 100644 --- a/internal/etl/transform/transform.go +++ b/internal/etl/transform/transform.go @@ -38,7 +38,7 @@ type Transformer struct{} func (t Transformer) Transform(records []RawRecord, collection config.GeoSpatialCollection, substitutionsFile string, synonymsFile string) ([]SearchIndexRecord, error) { result := make([]SearchIndexRecord, 0, len(records)) for _, r := range records { - fieldValuesByName, err := slicesToMap(collection.Search.Fields, r.FieldValues) + fieldValuesByName, err := slicesToStringMap(collection.Search.Fields, r.FieldValues) if err != nil { return nil, err } @@ -82,8 +82,11 @@ func (t Transformer) Transform(records []RawRecord, collection config.GeoSpatial return result, nil } -func (t Transformer) renderTemplate(templateFromConfig string, fieldValuesByName map[string]any) (string, error) { - parsedTemplate, err := template.New("").Funcs(engine.GlobalTemplateFuncs).Parse(templateFromConfig) +func (t Transformer) renderTemplate(templateFromConfig string, fieldValuesByName map[string]string) (string, error) { + parsedTemplate, err := template.New(""). + Funcs(engine.GlobalTemplateFuncs). + Option("missingkey=zero"). + Parse(templateFromConfig) if err != nil { return "", err } @@ -91,7 +94,7 @@ func (t Transformer) renderTemplate(templateFromConfig string, fieldValuesByName if err = parsedTemplate.Execute(&b, fieldValuesByName); err != nil { return "", err } - return b.String(), err + return strings.TrimSpace(b.String()), err } func (r RawRecord) transformBbox() (*pggeom.Polygon, error) { @@ -119,13 +122,17 @@ func (r RawRecord) transformBbox() (*pggeom.Polygon, error) { return polygon, nil } -func slicesToMap(keys []string, values []any) (map[string]any, error) { +func slicesToStringMap(keys []string, values []any) (map[string]string, error) { if len(keys) != len(values) { return nil, fmt.Errorf("slices must be of the same length, got %d keys and %d values", len(keys), len(values)) } - result := make(map[string]any, len(keys)) + result := make(map[string]string, len(keys)) for i := range keys { - result[keys[i]] = values[i] + value := values[i] + if value != nil { + stringValue := fmt.Sprintf("%v", value) + result[keys[i]] = stringValue + } } return result, nil } diff --git a/internal/ogc/setup.go b/internal/ogc/setup.go index d7386e4..f91e92d 100644 --- a/internal/ogc/setup.go +++ b/internal/ogc/setup.go @@ -14,6 +14,4 @@ func SetupBuildingBlocks(engine *engine.Engine, _ string) { if engine.Config.HasCollections() { geospatial.NewCollections(engine) } - - // TODO Something with the dbConnString param in PDOK-17118 } diff --git a/internal/search/.keep b/internal/search/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/search/datasources/datasource.go b/internal/search/datasources/datasource.go new file mode 100644 index 0000000..7aeb918 --- /dev/null +++ b/internal/search/datasources/datasource.go @@ -0,0 +1,19 @@ +package datasources + +import ( + "context" + + "github.com/PDOK/gomagpie/internal/search/domain" +) + +// Datasource knows how make different kinds of queries/actions on the underlying actual datastore. +// This abstraction allows the rest of the system to stay datastore agnostic. +type Datasource interface { + // SearchFeaturesAcrossCollections search features in one or more collections. Collections can be located + // in this dataset or in other datasets. + SearchFeaturesAcrossCollections(ctx context.Context, searchTerm string, collections domain.CollectionsWithParams, + srid domain.SRID, limit int) (*domain.FeatureCollection, error) + + // Close closes (connections to) the datasource gracefully + Close() +} diff --git a/internal/search/datasources/postgres/postgres.go b/internal/search/datasources/postgres/postgres.go new file mode 100644 index 0000000..f68b38a --- /dev/null +++ b/internal/search/datasources/postgres/postgres.go @@ -0,0 +1,131 @@ +package postgres + +import ( + "context" + "fmt" + + d "github.com/PDOK/gomagpie/internal/search/domain" + "github.com/jackc/pgx/v5" + pggeom "github.com/twpayne/go-geom" + "github.com/twpayne/go-geom/encoding/geojson" + pgxgeom "github.com/twpayne/pgx-geom" + + "strings" + "time" +) + +type Postgres struct { + db *pgx.Conn + ctx context.Context + + queryTimeout time.Duration + searchIndex string +} + +func NewPostgres(dbConn string, queryTimeout time.Duration, searchIndex string) (*Postgres, error) { + ctx := context.Background() + db, err := pgx.Connect(ctx, dbConn) + if err != nil { + return nil, fmt.Errorf("unable to connect to database: %w", err) + } + // add support for Go <-> PostGIS conversions + if err := pgxgeom.Register(ctx, db); err != nil { + return nil, err + } + return &Postgres{db, ctx, queryTimeout, searchIndex}, nil +} + +func (p *Postgres) Close() { + _ = p.db.Close(p.ctx) +} + +func (p *Postgres) SearchFeaturesAcrossCollections(ctx context.Context, searchTerm string, collections d.CollectionsWithParams, + srid d.SRID, limit int) (*d.FeatureCollection, error) { + + queryCtx, cancel := context.WithTimeout(ctx, p.queryTimeout) + defer cancel() + + // Split terms by spaces and append :* to each term + terms := strings.Fields(searchTerm) + for i, term := range terms { + terms[i] = term + ":*" + } + termsConcat := strings.Join(terms, " & ") + query := makeSearchQuery(p.searchIndex, srid) + + // Execute search query + names, ints := collections.NamesAndVersions() + rows, err := p.db.Query(queryCtx, query, limit, termsConcat, names, ints) + if err != nil { + return nil, fmt.Errorf("query '%s' failed: %w", query, err) + } + defer rows.Close() + + // Turn rows into FeatureCollection + return mapRowsToFeatures(queryCtx, rows) +} + +func makeSearchQuery(index string, srid d.SRID) string { + // language=postgresql + query := fmt.Sprintf(` + with query as ( + select to_tsquery('dutch', $2) query + ) + select r.display_name as display_name, + max(r.feature_id) as feature_id, + max(r.collection_id) as collection_id, + max(r.collection_version) as collection_version, + max(r.geometry_type) as geometry_type, + st_transform(max(r.bbox), %[2]d)::geometry as bbox, + max(r.rank) as rank, + max(r.highlighted_text) as highlighted_text + from ( + select display_name, feature_id, collection_id, collection_version, geometry_type, bbox, + ts_rank_cd(ts, (select query from query), 1) as rank, + ts_headline('dutch', display_name, (select query from query)) as highlighted_text + from %[1]s + where ts @@ (select query from query) and (collection_id, collection_version) in ( + -- make a virtual table by creating tuples from the provided arrays. + select * from unnest($3::text[], $4::int[]) + ) + order by rank desc, display_name asc -- keep the same as outer 'order by' clause + limit 500 + ) r + group by r.display_name, r.collection_id, r.collection_version, r.feature_id + order by rank desc, display_name asc + limit $1`, index, srid) // don't add user input here, use $X params for user input! + + return query +} + +func mapRowsToFeatures(queryCtx context.Context, rows pgx.Rows) (*d.FeatureCollection, error) { + fc := d.FeatureCollection{Features: make([]*d.Feature, 0)} + for rows.Next() { + var displayName, highlightedText, featureID, collectionID, collectionVersion, geomType string + var rank float64 + var bbox pggeom.T + + if err := rows.Scan(&displayName, &featureID, &collectionID, &collectionVersion, &geomType, + &bbox, &rank, &highlightedText); err != nil { + return nil, err + } + geojsonGeom, err := geojson.Encode(bbox) + if err != nil { + return nil, err + } + fc.Features = append(fc.Features, &d.Feature{ + ID: featureID, + Geometry: *geojsonGeom, + Properties: map[string]any{ + d.PropCollectionID: collectionID, + d.PropCollectionVersion: collectionVersion, + d.PropGeomType: geomType, + d.PropDisplayName: displayName, + d.PropHighlight: highlightedText, + d.PropScore: rank, + }, + }) + fc.NumberReturned = len(fc.Features) + } + return &fc, queryCtx.Err() +} diff --git a/internal/search/domain/geojson.go b/internal/search/domain/geojson.go new file mode 100644 index 0000000..e72d032 --- /dev/null +++ b/internal/search/domain/geojson.go @@ -0,0 +1,53 @@ +package domain + +import ( + "github.com/twpayne/go-geom/encoding/geojson" +) + +// featureCollectionType allows the GeoJSON type to be automatically set during json marshalling +type featureCollectionType struct{} + +func (fc *featureCollectionType) MarshalJSON() ([]byte, error) { + return []byte(`"FeatureCollection"`), nil +} + +// featureType allows the type for Feature to be automatically set during json Marshalling +type featureType struct{} + +func (ft *featureType) MarshalJSON() ([]byte, error) { + return []byte(`"Feature"`), nil +} + +// FeatureCollection is a GeoJSON FeatureCollection with extras such as links +// Note: fields in this struct are sorted for optimal memory usage (field alignment) +type FeatureCollection struct { + Type featureCollectionType `json:"type"` + Timestamp string `json:"timeStamp,omitempty"` + Links []Link `json:"links,omitempty"` + Features []*Feature `json:"features"` + NumberReturned int `json:"numberReturned"` +} + +// Feature is a GeoJSON Feature with extras such as links +// Note: fields in this struct are sorted for optimal memory usage (field alignment) +type Feature struct { + Type featureType `json:"type"` + Properties map[string]any `json:"properties"` + Geometry geojson.Geometry `json:"geometry"` + // We expect feature ids to be auto-incrementing integers (which is the default in geopackages) + // since we use it for cursor-based pagination. + ID string `json:"id"` + Links []Link `json:"links,omitempty"` +} + +// Link according to RFC 8288, https://datatracker.ietf.org/doc/html/rfc8288 +// Note: fields in this struct are sorted for optimal memory usage (field alignment) +type Link struct { + Rel string `json:"rel"` + Title string `json:"title,omitempty"` + Type string `json:"type,omitempty"` + Href string `json:"href"` + Hreflang string `json:"hreflang,omitempty"` + Length int64 `json:"length,omitempty"` + Templated bool `json:"templated,omitempty"` +} diff --git a/internal/search/domain/search.go b/internal/search/domain/search.go new file mode 100644 index 0000000..f99b01f --- /dev/null +++ b/internal/search/domain/search.go @@ -0,0 +1,41 @@ +package domain + +import "strconv" + +const ( + VersionParam = "version" +) + +// GeoJSON properties in search response +const ( + PropCollectionID = "collectionId" + PropCollectionVersion = "collectionVersion" + PropGeomType = "collectionGeometryType" + PropDisplayName = "displayName" + PropHighlight = "highlight" + PropScore = "score" + PropHref = "href" +) + +// CollectionsWithParams collection name with associated CollectionParams +// These are provided though a URL query string as "deep object" params, e.g. paramName[prop1]=value1¶mName[prop2]=value2&.... +type CollectionsWithParams map[string]CollectionParams + +// CollectionParams parameter key with associated value +type CollectionParams map[string]string + +func (cp CollectionsWithParams) NamesAndVersions() (names []string, versions []int) { + for name := range cp { + version, ok := cp[name][VersionParam] + if !ok { + continue + } + versionNr, err := strconv.Atoi(version) + if err != nil { + continue + } + versions = append(versions, versionNr) + names = append(names, name) + } + return names, versions +} diff --git a/internal/search/domain/spatialref.go b/internal/search/domain/spatialref.go new file mode 100644 index 0000000..5a666a6 --- /dev/null +++ b/internal/search/domain/spatialref.go @@ -0,0 +1,12 @@ +package domain + +const ( + CrsURIPrefix = "http://www.opengis.net/def/crs/" + UndefinedSRID = 0 + WGS84SRIDPostgis = 4326 // Use the same SRID as used during ETL + WGS84CodeOGC = "CRS84" +) + +// SRID Spatial Reference System Identifier: a unique value to unambiguously identify a spatial coordinate system. +// For example '28992' in https://www.opengis.net/def/crs/EPSG/0/28992 +type SRID int diff --git a/internal/search/error.go b/internal/search/error.go new file mode 100644 index 0000000..1505671 --- /dev/null +++ b/internal/search/error.go @@ -0,0 +1,21 @@ +package search + +import ( + "context" + "errors" + "log" + "net/http" + + "github.com/PDOK/gomagpie/internal/engine" +) + +// log error, but send generic message to client to prevent possible information leakage from datasource +func handleQueryError(w http.ResponseWriter, err error) { + msg := "failed to fulfill search request" + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // provide more context when user hits the query timeout + msg += ": querying took too long (timeout encountered). Simplify your request and try again, or contact support" + } + log.Printf("%s, error: %v\n", msg, err) + engine.RenderProblem(engine.ProblemServerError, w, msg) // don't include sensitive information in details msg +} diff --git a/internal/search/json.go b/internal/search/json.go new file mode 100644 index 0000000..c6a1d90 --- /dev/null +++ b/internal/search/json.go @@ -0,0 +1,119 @@ +package search + +import ( + "bytes" + stdjson "encoding/json" + "io" + "log" + "net/http" + "net/url" + "os" + "strconv" + "time" + + "github.com/PDOK/gomagpie/internal/engine" + "github.com/PDOK/gomagpie/internal/search/domain" + perfjson "github.com/goccy/go-json" +) + +var ( + now = time.Now // allow mocking + disableJSONPerfOptimization, _ = strconv.ParseBool(os.Getenv("DISABLE_JSON_PERF_OPTIMIZATION")) +) + +type jsonFeatures struct { + engine *engine.Engine + validateResponse bool +} + +func newJSONFeatures(e *engine.Engine) *jsonFeatures { + return &jsonFeatures{ + engine: e, + validateResponse: true, // TODO make configurable + } +} + +func (jf *jsonFeatures) featuresAsGeoJSON(w http.ResponseWriter, r *http.Request, baseURL url.URL, fc *domain.FeatureCollection) { + fc.Timestamp = now().Format(time.RFC3339) + fc.Links = createFeatureCollectionLinks(baseURL) // TODO add links + + if jf.validateResponse { + jf.serveAndValidateJSON(&fc, engine.MediaTypeGeoJSON, r, w) + } else { + jf.serveJSON(&fc, engine.MediaTypeGeoJSON, w) + } +} + +// serveAndValidateJSON serves JSON after performing OpenAPI response validation. +func (jf *jsonFeatures) serveAndValidateJSON(input any, contentType string, r *http.Request, w http.ResponseWriter) { + json := &bytes.Buffer{} + if err := getEncoder(json).Encode(input); err != nil { + handleJSONEncodingFailure(err, w) + return + } + jf.engine.Serve(w, r, false /* performed earlier */, jf.validateResponse, contentType, json.Bytes()) +} + +// serveJSON serves JSON *WITHOUT* OpenAPI validation by writing directly to the response output stream +func (jf *jsonFeatures) serveJSON(input any, contentType string, w http.ResponseWriter) { + w.Header().Set(engine.HeaderContentType, contentType) + + if err := getEncoder(w).Encode(input); err != nil { + handleJSONEncodingFailure(err, w) + return + } +} + +func createFeatureCollectionLinks(baseURL url.URL) []domain.Link { + links := make([]domain.Link, 0) + + href := baseURL.JoinPath("search") + query := href.Query() + query.Set(engine.FormatParam, engine.FormatJSON) + href.RawQuery = query.Encode() + + links = append(links, domain.Link{ + Rel: "self", + Title: "This document as GeoJSON", + Type: engine.MediaTypeGeoJSON, + Href: href.String(), + }) + // TODO: support HTML and JSON-FG output in location API + // links = append(links, domain.Link{ + // Rel: "alternate", + // Title: "This document as JSON-FG", + // Type: engine.MediaTypeJSONFG, + // Href: featuresURL.toSelfURL(collectionID, engine.FormatJSONFG), + // }) + // links = append(links, domain.Link{ + // Rel: "alternate", + // Title: "This document as HTML", + // Type: engine.MediaTypeHTML, + // Href: featuresURL.toSelfURL(collectionID, engine.FormatHTML), + // }) + return links +} + +type jsonEncoder interface { + Encode(input any) error +} + +// Create JSONEncoder. Note escaping of '<', '>' and '&' is disabled (HTMLEscape is false). +// Especially the '&' is important since we use this character in the next/prev links. +func getEncoder(w io.Writer) jsonEncoder { + if disableJSONPerfOptimization { + // use Go stdlib JSON encoder + encoder := stdjson.NewEncoder(w) + encoder.SetEscapeHTML(false) + return encoder + } + // use ~7% overall faster 3rd party JSON encoder (in case of issues switch back to stdlib using env variable) + encoder := perfjson.NewEncoder(w) + encoder.SetEscapeHTML(false) + return encoder +} + +func handleJSONEncodingFailure(err error, w http.ResponseWriter) { + log.Printf("JSON encoding failed: %v", err) + engine.RenderProblem(engine.ProblemServerError, w, "Failed to write JSON response") +} diff --git a/internal/search/main.go b/internal/search/main.go new file mode 100644 index 0000000..a61124a --- /dev/null +++ b/internal/search/main.go @@ -0,0 +1,116 @@ +package search + +import ( + "fmt" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/PDOK/gomagpie/config" + "github.com/PDOK/gomagpie/internal/engine" + ds "github.com/PDOK/gomagpie/internal/search/datasources" + "github.com/PDOK/gomagpie/internal/search/datasources/postgres" + "github.com/PDOK/gomagpie/internal/search/domain" +) + +const ( + timeout = time.Second * 15 +) + +type Search struct { + engine *engine.Engine + datasource ds.Datasource + + json *jsonFeatures +} + +func NewSearch(e *engine.Engine, dbConn string, searchIndex string) *Search { + s := &Search{ + engine: e, + datasource: newDatasource(e, dbConn, searchIndex), + json: newJSONFeatures(e), + } + e.Router.Get("/search", s.Search()) + return s +} + +// Search autosuggest locations based on user input +func (s *Search) Search() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := s.engine.OpenAPI.ValidateRequest(r); err != nil { + engine.RenderProblem(engine.ProblemBadRequest, w, err.Error()) + return + } + collections, searchTerm, outputSRID, limit, err := parseQueryParams(r.URL.Query()) + if err != nil { + engine.RenderProblem(engine.ProblemBadRequest, w, err.Error()) + return + } + fc, err := s.datasource.SearchFeaturesAcrossCollections(r.Context(), searchTerm, collections, outputSRID, limit) + if err != nil { + handleQueryError(w, err) + return + } + if err = s.enrichFeaturesWithHref(fc); err != nil { + engine.RenderProblem(engine.ProblemServerError, w, err.Error()) + return + } + + format := s.engine.CN.NegotiateFormat(r) + switch format { + case engine.FormatGeoJSON, engine.FormatJSON: + s.json.featuresAsGeoJSON(w, r, *s.engine.Config.BaseURL.URL, fc) + default: + engine.RenderProblem(engine.ProblemNotAcceptable, w, fmt.Sprintf("format '%s' is not supported", format)) + return + } + } +} + +func (s *Search) enrichFeaturesWithHref(fc *domain.FeatureCollection) error { + for _, feat := range fc.Features { + collectionID, ok := feat.Properties[domain.PropCollectionID] + if !ok || collectionID == "" { + return fmt.Errorf("collection reference not found in feature %s", feat.ID) + } + collection := config.CollectionByID(s.engine.Config, collectionID.(string)) + if collection.Search != nil { + for _, ogcColl := range collection.Search.OGCCollections { + geomType, ok := feat.Properties[domain.PropGeomType] + if !ok || geomType == "" { + return fmt.Errorf("geometry type not found in feature %s", feat.ID) + } + if strings.EqualFold(ogcColl.GeometryType, geomType.(string)) { + href, err := url.JoinPath(ogcColl.APIBaseURL.String(), "collections", ogcColl.CollectionID, "items", feat.ID) + if err != nil { + return fmt.Errorf("failed to construct API url %w", err) + } + href += "?f=json" + + // add href to feature both in GeoJSON properties (for broad compatibility and in line with OGC API Features part 5) and as a Link. + feat.Properties[domain.PropHref] = href + feat.Links = []domain.Link{ + { + Rel: "canonical", + Title: "The actual feature in the corresponding OGC API", + Type: "application/geo+json", + Href: href, + }, + } + } + } + } + } + return nil +} + +func newDatasource(e *engine.Engine, dbConn string, searchIndex string) ds.Datasource { + datasource, err := postgres.NewPostgres(dbConn, timeout, searchIndex) + if err != nil { + log.Fatalf("failed to create datasource: %v", err) + } + e.RegisterShutdownHook(datasource.Close) + return datasource +} diff --git a/internal/search/main_test.go b/internal/search/main_test.go new file mode 100644 index 0000000..ac50973 --- /dev/null +++ b/internal/search/main_test.go @@ -0,0 +1,287 @@ +package search + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "net/http/httptest" + "os" + "path" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/PDOK/gomagpie/config" + "github.com/PDOK/gomagpie/internal/engine" + "github.com/PDOK/gomagpie/internal/etl" + "github.com/docker/go-connections/nat" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const testSearchIndex = "search_index" +const configFile = "internal/search/testdata/config.yaml" + +func init() { + // change working dir to root + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../") + err := os.Chdir(dir) + if err != nil { + panic(err) + } +} + +func TestSearch(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + ctx := context.Background() + + // given available postgres + dbPort, postgisContainer, err := setupPostgis(ctx, t) + if err != nil { + t.Error(err) + } + defer terminateContainer(ctx, t, postgisContainer) + + dbConn := fmt.Sprintf("postgres://postgres:postgres@127.0.0.1:%d/%s?sslmode=disable", dbPort.Int(), "test_db") + + // given available engine + eng, err := engine.NewEngine(configFile, false, false) + assert.NoError(t, err) + + // given search endpoint + searchEndpoint := NewSearch(eng, dbConn, testSearchIndex) + + // given empty search index + err = etl.CreateSearchIndex(dbConn, testSearchIndex) + assert.NoError(t, err) + + // given imported geopackage (creates two collections in search_index with identical data) + err = importAddressesGpkg("addresses", dbConn) + assert.NoError(t, err) + err = importAddressesGpkg("buildings", dbConn) + assert.NoError(t, err) + + // run test cases + type fields struct { + url string + } + type want struct { + body string + statusCode int + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "Fail on search without collection parameter(s)", + fields: fields{ + url: "http://localhost:8080/search?q=\"Oudeschild\"&limit=50", + }, + want: want{ + body: "internal/search/testdata/expected-search-no-collection.json", + statusCode: http.StatusBadRequest, + }, + }, + { + name: "Fail on search with collection without version (first variant)", + fields: fields{ + url: "http://localhost:8080/search?q=\"Oudeschild\"&addresses", + }, + want: want{ + body: "internal/search/testdata/expected-search-no-version-1.json", + statusCode: http.StatusBadRequest, + }, + }, + { + name: "Fail on search with collection without version (second variant)", + fields: fields{ + url: "http://localhost:8080/search?q=\"Oudeschild\"&addresses=1", + }, + want: want{ + body: "internal/search/testdata/expected-search-no-version-2.json", + statusCode: http.StatusBadRequest, + }, + }, + { + name: "Fail on search with collection without version (third variant)", + fields: fields{ + url: "http://localhost:8080/search?q=\"Oudeschild\"&addresses[foo]=1", + }, + want: want{ + body: "internal/search/testdata/expected-search-no-version-3.json", + statusCode: http.StatusBadRequest, + }, + }, + { + name: "Search: 'Den' for a single collection in WGS84 (default)", + fields: fields{ + url: "http://localhost:8080/search?q=\"Den\"&addresses[version]=1&addresses[relevance]=0.8&limit=10&f=json", + }, + want: want{ + body: "internal/search/testdata/expected-search-den-single-collection-wgs84.json", + statusCode: http.StatusOK, + }, + }, + { + name: "Search: 'Den' for a single collection in RD", + fields: fields{ + url: "http://localhost:8080/search?q=\"Den\"&addresses[version]=1&addresses[relevance]=0.8&limit=10&f=json&crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992", + }, + want: want{ + body: "internal/search/testdata/expected-search-den-single-collection-rd.json", + statusCode: http.StatusOK, + }, + }, + { + name: "Search: 'Den' in another collection in RD", + fields: fields{ + url: "http://localhost:8080/search?q=\"Den\"&buildings[version]=1&limit=10&f=json", + }, + want: want{ + body: "internal/search/testdata/expected-search-den-building-collection-wgs84.json", + statusCode: http.StatusOK, + }, + }, + { + name: "Search: 'Den' in multiple collections: with one non-existing collection, so same output as single collection) in RD", + fields: fields{ + url: "http://localhost:8080/search?q=\"Den\"&addresses[version]=1&addresses[relevance]=0.8&foo[version]=2&foo[relevance]=0.8&limit=10&f=json&crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992", + }, + want: want{ + body: "internal/search/testdata/expected-search-den-single-collection-rd.json", + statusCode: http.StatusOK, + }, + }, + { + name: "Search: 'Den' in multiple collections: collection addresses + collection buildings, but addresses with non-existing version", + fields: fields{ + url: "http://localhost:8080/search?q=\"Den\"&addresses[version]=2&buildings[version]=1&limit=20&f=json", + }, + want: want{ + body: "internal/search/testdata/expected-search-den-multiple-collection-single-output-wgs84.json", // only expect building results since addresses version doesn't exist. + statusCode: http.StatusOK, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given mock time + now = func() time.Time { return time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) } + engine.Now = now + + // given available server + rr, ts := createMockServer() + defer ts.Close() + + // when + handler := searchEndpoint.Search() + req, err := createRequest(tt.fields.url) + assert.NoError(t, err) + handler.ServeHTTP(rr, req) + + // then + assert.Equal(t, tt.want.statusCode, rr.Code) + + log.Printf("============ ACTUAL:\n %s", rr.Body.String()) + expectedBody, err := os.ReadFile(tt.want.body) + if err != nil { + assert.NoError(t, err) + } + assert.JSONEq(t, string(expectedBody), rr.Body.String()) + }) + } +} + +func importAddressesGpkg(collectionName string, dbConn string) error { + conf, err := config.NewConfig(configFile) + if err != nil { + return err + } + collection := config.CollectionByID(conf, collectionName) + table := config.FeatureTable{Name: "addresses", FID: "fid", Geom: "geom"} + return etl.ImportFile(*collection, testSearchIndex, + "internal/etl/testdata/addresses-crs84.gpkg", + "internal/etl/testdata/substitutions.csv", + "internal/etl/testdata/synonyms.csv", table, 5000, dbConn) +} + +func setupPostgis(ctx context.Context, t *testing.T) (nat.Port, testcontainers.Container, error) { + req := testcontainers.ContainerRequest{ + Image: "docker.io/postgis/postgis:16-3.5-alpine", + Env: map[string]string{ + "POSTGRES_USER": "postgres", + "POSTGRES_PASSWORD": "postgres", + "POSTGRES_DB": "postgres", + }, + ExposedPorts: []string{"5432/tcp"}, + Cmd: []string{"postgres", "-c", "fsync=off"}, + WaitingFor: wait.ForLog("PostgreSQL init process complete; ready for start up."), + Files: []testcontainers.ContainerFile{ + { + HostFilePath: "tests/testdata/sql/init-db.sql", + ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base("testdata/init-db.sql"), + FileMode: 0755, + }, + }, + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Error(err) + } + port, err := container.MappedPort(ctx, "5432/tcp") + if err != nil { + t.Error(err) + } + + log.Println("Giving postgres a few extra seconds to fully start") + time.Sleep(2 * time.Second) + + return port, container, err +} + +func terminateContainer(ctx context.Context, t *testing.T, container testcontainers.Container) { + if err := container.Terminate(ctx); err != nil { + t.Fatalf("Failed to terminate container: %s", err.Error()) + } +} + +func createMockServer() (*httptest.ResponseRecorder, *httptest.Server) { + rr := httptest.NewRecorder() + l, err := net.Listen("tcp", "localhost:9095") + if err != nil { + log.Fatal(err) + } + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + engine.SafeWrite(w.Write, []byte(r.URL.String())) + })) + err = ts.Listener.Close() + if err != nil { + log.Fatal(err) + } + ts.Listener = l + ts.Start() + return rr, ts +} + +func createRequest(url string) (*http.Request, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if req == nil || err != nil { + return req, err + } + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext())) + return req, err +} diff --git a/internal/search/testdata/config.yaml b/internal/search/testdata/config.yaml new file mode 100644 index 0000000..996e439 --- /dev/null +++ b/internal/search/testdata/config.yaml @@ -0,0 +1,62 @@ +--- +version: 1.0.0 +lastUpdated: "2024-10-22T12:00:00Z" +baseUrl: http://localhost:8080 +availableLanguages: + - nl + - en +collections: + - id: addresses + metadata: + title: Addresses + description: These are example addresses + extent: + bbox: + - 50.2129 + - 2.52713 + - 55.7212 + - 7.37403 + search: + fields: + - component_thoroughfarename + - component_postaldescriptor + - component_addressareaname + - locator_designator_addressnumber + - locator_designator_addressnumberextension + - locator_designator_addressnumber2ndextension + displayNameTemplate: "{{ .component_thoroughfarename }} - {{ .component_addressareaname | firstupper }} {{ .locator_designator_addressnumber }} {{ .locator_designator_addressnumberextension }} {{ .locator_designator_addressnumber2ndextension }}" + etl: + suggestTemplates: + - "{{ .component_thoroughfarename }} {{ .component_addressareaname }}" + - "{{ .component_thoroughfarename }}, {{ .component_postaldescriptor }} {{ .component_addressareaname }}" + ogcCollections: + - api: https://example.com/ogc/v1 + collection: addresses + geometryType: point + - id: buildings + metadata: + title: Buildings + description: These are example buildings + extent: + bbox: + - 50.2129 + - 2.52713 + - 55.7212 + - 7.37403 + search: + fields: + - component_thoroughfarename + - component_postaldescriptor + - component_addressareaname + - locator_designator_addressnumber + - locator_designator_addressnumberextension + - locator_designator_addressnumber2ndextension + displayNameTemplate: "{{ .component_thoroughfarename }} - {{ .component_addressareaname | firstupper }} {{ .locator_designator_addressnumber }} {{ .locator_designator_addressnumberextension }} {{ .locator_designator_addressnumber2ndextension }}" + etl: + suggestTemplates: + - "{{ .component_thoroughfarename }} {{ .component_addressareaname }}" + - "{{ .component_thoroughfarename }}, {{ .component_postaldescriptor }} {{ .component_addressareaname }}" + ogcCollections: + - api: https://example.com/ogc/v1 + collection: buildings + geometryType: point \ No newline at end of file diff --git a/internal/search/testdata/expected-search-den-building-collection-wgs84.json b/internal/search/testdata/expected-search-den-building-collection-wgs84.json new file mode 100644 index 0000000..7092af7 --- /dev/null +++ b/internal/search/testdata/expected-search-den-building-collection-wgs84.json @@ -0,0 +1,495 @@ +{ + "type": "FeatureCollection", + "timeStamp": "2000-01-01T00:00:00Z", + "links": [ + { + "rel": "self", + "title": "This document as GeoJSON", + "type": "application/geo+json", + "href": "http://localhost:8080/search?f=json" + } + ], + "features": [ + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 1", + "highlight": "Abbewaal - Den Burg 1", + "href": "https://example.com/ogc/v1/collections/buildings/items/51?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.701583684040178, + 52.96172161329086 + ], + [ + 4.901583684040177, + 52.96172161329086 + ], + [ + 4.901583684040177, + 53.161721613290865 + ], + [ + 4.701583684040178, + 53.161721613290865 + ], + [ + 4.701583684040178, + 52.96172161329086 + ] + ] + ] + }, + "id": "51", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/51?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 1", + "highlight": "Abbewaal - Den Burg 1", + "href": "https://example.com/ogc/v1/collections/buildings/items/52?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.701583684040178, + 52.96172161329086 + ], + [ + 4.901583684040177, + 52.96172161329086 + ], + [ + 4.901583684040177, + 53.161721613290865 + ], + [ + 4.701583684040178, + 53.161721613290865 + ], + [ + 4.701583684040178, + 52.96172161329086 + ] + ] + ] + }, + "id": "52", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/52?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/buildings/items/32183?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703248382838079, + 52.96279808509741 + ], + [ + 4.903248382838078, + 52.96279808509741 + ], + [ + 4.903248382838078, + 53.162798085097414 + ], + [ + 4.703248382838079, + 53.162798085097414 + ], + [ + 4.703248382838079, + 52.96279808509741 + ] + ] + ] + }, + "id": "32183", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/32183?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/buildings/items/53?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703248382838079, + 52.96279808509741 + ], + [ + 4.903248382838078, + 52.96279808509741 + ], + [ + 4.903248382838078, + 53.162798085097414 + ], + [ + 4.703248382838079, + 53.162798085097414 + ], + [ + 4.703248382838079, + 52.96279808509741 + ] + ] + ] + }, + "id": "53", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/53?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/buildings/items/32184?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703246926093469, + 52.9627011349704 + ], + [ + 4.903246926093468, + 52.9627011349704 + ], + [ + 4.903246926093468, + 53.162701134970405 + ], + [ + 4.703246926093469, + 53.162701134970405 + ], + [ + 4.703246926093469, + 52.9627011349704 + ] + ] + ] + }, + "id": "32184", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/32184?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/buildings/items/54?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703248382838079, + 52.96279808509741 + ], + [ + 4.903248382838078, + 52.96279808509741 + ], + [ + 4.903248382838078, + 53.162798085097414 + ], + [ + 4.703248382838079, + 53.162798085097414 + ], + [ + 4.703248382838079, + 52.96279808509741 + ] + ] + ] + }, + "id": "54", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/54?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 11", + "highlight": "Abbewaal - Den Burg 11", + "href": "https://example.com/ogc/v1/collections/buildings/items/22549?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.701889188504978, + 52.962632527818215 + ], + [ + 4.901889188504978, + 52.962632527818215 + ], + [ + 4.901889188504978, + 53.16263252781822 + ], + [ + 4.701889188504978, + 53.16263252781822 + ], + [ + 4.701889188504978, + 52.962632527818215 + ] + ] + ] + }, + "id": "22549", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/22549?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 13", + "highlight": "Abbewaal - Den Burg 13", + "href": "https://example.com/ogc/v1/collections/buildings/items/56?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702154512142386, + 52.96275394293894 + ], + [ + 4.902154512142385, + 52.96275394293894 + ], + [ + 4.902154512142385, + 53.16275394293894 + ], + [ + 4.702154512142386, + 53.16275394293894 + ], + [ + 4.702154512142386, + 52.96275394293894 + ] + ] + ] + }, + "id": "56", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/56?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 13", + "highlight": "Abbewaal - Den Burg 13", + "href": "https://example.com/ogc/v1/collections/buildings/items/55?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702154512142386, + 52.96275394293894 + ], + [ + 4.902154512142385, + 52.96275394293894 + ], + [ + 4.902154512142385, + 53.16275394293894 + ], + [ + 4.702154512142386, + 53.16275394293894 + ], + [ + 4.702154512142386, + 52.96275394293894 + ] + ] + ] + }, + "id": "55", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/55?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 15", + "highlight": "Abbewaal - Den Burg 15", + "href": "https://example.com/ogc/v1/collections/buildings/items/57?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702695564526969, + 52.962965478349716 + ], + [ + 4.902695564526968, + 52.962965478349716 + ], + [ + 4.902695564526968, + 53.16296547834972 + ], + [ + 4.702695564526969, + 53.16296547834972 + ], + [ + 4.702695564526969, + 52.962965478349716 + ] + ] + ] + }, + "id": "57", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/57?f=json" + } + ] + } + ], + "numberReturned": 10 +} diff --git a/internal/search/testdata/expected-search-den-multiple-collection-single-output-wgs84.json b/internal/search/testdata/expected-search-den-multiple-collection-single-output-wgs84.json new file mode 100644 index 0000000..adfbbe0 --- /dev/null +++ b/internal/search/testdata/expected-search-den-multiple-collection-single-output-wgs84.json @@ -0,0 +1,975 @@ +{ + "type": "FeatureCollection", + "timeStamp": "2000-01-01T00:00:00Z", + "links": [ + { + "rel": "self", + "title": "This document as GeoJSON", + "type": "application/geo+json", + "href": "http://localhost:8080/search?f=json" + } + ], + "features": [ + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 1", + "highlight": "Abbewaal - Den Burg 1", + "href": "https://example.com/ogc/v1/collections/buildings/items/51?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.701583684040178, + 52.96172161329086 + ], + [ + 4.901583684040177, + 52.96172161329086 + ], + [ + 4.901583684040177, + 53.161721613290865 + ], + [ + 4.701583684040178, + 53.161721613290865 + ], + [ + 4.701583684040178, + 52.96172161329086 + ] + ] + ] + }, + "id": "51", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/51?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 1", + "highlight": "Abbewaal - Den Burg 1", + "href": "https://example.com/ogc/v1/collections/buildings/items/52?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.701583684040178, + 52.96172161329086 + ], + [ + 4.901583684040177, + 52.96172161329086 + ], + [ + 4.901583684040177, + 53.161721613290865 + ], + [ + 4.701583684040178, + 53.161721613290865 + ], + [ + 4.701583684040178, + 52.96172161329086 + ] + ] + ] + }, + "id": "52", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/52?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/buildings/items/32183?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703248382838079, + 52.96279808509741 + ], + [ + 4.903248382838078, + 52.96279808509741 + ], + [ + 4.903248382838078, + 53.162798085097414 + ], + [ + 4.703248382838079, + 53.162798085097414 + ], + [ + 4.703248382838079, + 52.96279808509741 + ] + ] + ] + }, + "id": "32183", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/32183?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/buildings/items/53?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703248382838079, + 52.96279808509741 + ], + [ + 4.903248382838078, + 52.96279808509741 + ], + [ + 4.903248382838078, + 53.162798085097414 + ], + [ + 4.703248382838079, + 53.162798085097414 + ], + [ + 4.703248382838079, + 52.96279808509741 + ] + ] + ] + }, + "id": "53", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/53?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/buildings/items/32184?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703246926093469, + 52.9627011349704 + ], + [ + 4.903246926093468, + 52.9627011349704 + ], + [ + 4.903246926093468, + 53.162701134970405 + ], + [ + 4.703246926093469, + 53.162701134970405 + ], + [ + 4.703246926093469, + 52.9627011349704 + ] + ] + ] + }, + "id": "32184", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/32184?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/buildings/items/54?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703248382838079, + 52.96279808509741 + ], + [ + 4.903248382838078, + 52.96279808509741 + ], + [ + 4.903248382838078, + 53.162798085097414 + ], + [ + 4.703248382838079, + 53.162798085097414 + ], + [ + 4.703248382838079, + 52.96279808509741 + ] + ] + ] + }, + "id": "54", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/54?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 11", + "highlight": "Abbewaal - Den Burg 11", + "href": "https://example.com/ogc/v1/collections/buildings/items/22549?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.701889188504978, + 52.962632527818215 + ], + [ + 4.901889188504978, + 52.962632527818215 + ], + [ + 4.901889188504978, + 53.16263252781822 + ], + [ + 4.701889188504978, + 53.16263252781822 + ], + [ + 4.701889188504978, + 52.962632527818215 + ] + ] + ] + }, + "id": "22549", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/22549?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 13", + "highlight": "Abbewaal - Den Burg 13", + "href": "https://example.com/ogc/v1/collections/buildings/items/56?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702154512142386, + 52.96275394293894 + ], + [ + 4.902154512142385, + 52.96275394293894 + ], + [ + 4.902154512142385, + 53.16275394293894 + ], + [ + 4.702154512142386, + 53.16275394293894 + ], + [ + 4.702154512142386, + 52.96275394293894 + ] + ] + ] + }, + "id": "56", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/56?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 13", + "highlight": "Abbewaal - Den Burg 13", + "href": "https://example.com/ogc/v1/collections/buildings/items/55?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702154512142386, + 52.96275394293894 + ], + [ + 4.902154512142385, + 52.96275394293894 + ], + [ + 4.902154512142385, + 53.16275394293894 + ], + [ + 4.702154512142386, + 53.16275394293894 + ], + [ + 4.702154512142386, + 52.96275394293894 + ] + ] + ] + }, + "id": "55", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/55?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 15", + "highlight": "Abbewaal - Den Burg 15", + "href": "https://example.com/ogc/v1/collections/buildings/items/57?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702695564526969, + 52.962965478349716 + ], + [ + 4.902695564526968, + 52.962965478349716 + ], + [ + 4.902695564526968, + 53.16296547834972 + ], + [ + 4.702695564526969, + 53.16296547834972 + ], + [ + 4.702695564526969, + 52.962965478349716 + ] + ] + ] + }, + "id": "57", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/57?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 15", + "highlight": "Abbewaal - Den Burg 15", + "href": "https://example.com/ogc/v1/collections/buildings/items/58?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702695564526969, + 52.962965478349716 + ], + [ + 4.902695564526968, + 52.962965478349716 + ], + [ + 4.902695564526968, + 53.16296547834972 + ], + [ + 4.702695564526969, + 53.16296547834972 + ], + [ + 4.702695564526969, + 52.962965478349716 + ] + ] + ] + }, + "id": "58", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/58?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 17", + "highlight": "Abbewaal - Den Burg 17", + "href": "https://example.com/ogc/v1/collections/buildings/items/16128?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702643767605495, + 52.96301123478128 + ], + [ + 4.902643767605494, + 52.96301123478128 + ], + [ + 4.902643767605494, + 53.16301123478128 + ], + [ + 4.702643767605495, + 53.16301123478128 + ], + [ + 4.702643767605495, + 52.96301123478128 + ] + ] + ] + }, + "id": "16128", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/16128?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 19", + "highlight": "Abbewaal - Den Burg 19", + "href": "https://example.com/ogc/v1/collections/buildings/items/59?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702569893786273, + 52.963164466064796 + ], + [ + 4.902569893786272, + 52.963164466064796 + ], + [ + 4.902569893786272, + 53.1631644660648 + ], + [ + 4.702569893786273, + 53.1631644660648 + ], + [ + 4.702569893786273, + 52.963164466064796 + ] + ] + ] + }, + "id": "59", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/59?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 2", + "highlight": "Abbewaal - Den Burg 2", + "href": "https://example.com/ogc/v1/collections/buildings/items/16130?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702320309835308, + 52.96208847172587 + ], + [ + 4.902320309835307, + 52.96208847172587 + ], + [ + 4.902320309835307, + 53.16208847172587 + ], + [ + 4.702320309835308, + 53.16208847172587 + ], + [ + 4.702320309835308, + 52.96208847172587 + ] + ] + ] + }, + "id": "16130", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/16130?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 2", + "highlight": "Abbewaal - Den Burg 2", + "href": "https://example.com/ogc/v1/collections/buildings/items/16129?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702320309835308, + 52.96208847172587 + ], + [ + 4.902320309835307, + 52.96208847172587 + ], + [ + 4.902320309835307, + 53.16208847172587 + ], + [ + 4.702320309835308, + 53.16208847172587 + ], + [ + 4.702320309835308, + 52.96208847172587 + ] + ] + ] + }, + "id": "16129", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/16129?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 21", + "highlight": "Abbewaal - Den Burg 21", + "href": "https://example.com/ogc/v1/collections/buildings/items/60?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702700429994286, + 52.963319045764216 + ], + [ + 4.902700429994285, + 52.963319045764216 + ], + [ + 4.902700429994285, + 53.16331904576422 + ], + [ + 4.702700429994286, + 53.16331904576422 + ], + [ + 4.702700429994286, + 52.963319045764216 + ] + ] + ] + }, + "id": "60", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/60?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 23", + "highlight": "Abbewaal - Den Burg 23", + "href": "https://example.com/ogc/v1/collections/buildings/items/16131?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702907537974755, + 52.963443724518335 + ], + [ + 4.902907537974754, + 52.963443724518335 + ], + [ + 4.902907537974754, + 53.16344372451834 + ], + [ + 4.702907537974755, + 53.16344372451834 + ], + [ + 4.702907537974755, + 52.963443724518335 + ] + ] + ] + }, + "id": "16131", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/16131?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 23", + "highlight": "Abbewaal - Den Burg 23", + "href": "https://example.com/ogc/v1/collections/buildings/items/16132?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702907537974755, + 52.963443724518335 + ], + [ + 4.902907537974754, + 52.963443724518335 + ], + [ + 4.902907537974754, + 53.16344372451834 + ], + [ + 4.702907537974755, + 53.16344372451834 + ], + [ + 4.702907537974755, + 52.963443724518335 + ] + ] + ] + }, + "id": "16132", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/16132?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 25", + "highlight": "Abbewaal - Den Burg 25", + "href": "https://example.com/ogc/v1/collections/buildings/items/63?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703028657055511, + 52.963494467409724 + ], + [ + 4.90302865705551, + 52.963494467409724 + ], + [ + 4.90302865705551, + 53.16349446740973 + ], + [ + 4.703028657055511, + 53.16349446740973 + ], + [ + 4.703028657055511, + 52.963494467409724 + ] + ] + ] + }, + "id": "63", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/63?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "buildings", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 25", + "highlight": "Abbewaal - Den Burg 25", + "href": "https://example.com/ogc/v1/collections/buildings/items/62?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703028657055511, + 52.963494467409724 + ], + [ + 4.90302865705551, + 52.963494467409724 + ], + [ + 4.90302865705551, + 53.16349446740973 + ], + [ + 4.703028657055511, + 53.16349446740973 + ], + [ + 4.703028657055511, + 52.963494467409724 + ] + ] + ] + }, + "id": "62", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/buildings/items/62?f=json" + } + ] + } + ], + "numberReturned": 20 +} diff --git a/internal/search/testdata/expected-search-den-single-collection-rd.json b/internal/search/testdata/expected-search-den-single-collection-rd.json new file mode 100644 index 0000000..0461836 --- /dev/null +++ b/internal/search/testdata/expected-search-den-single-collection-rd.json @@ -0,0 +1,495 @@ +{ + "type": "FeatureCollection", + "timeStamp": "2000-01-01T00:00:00Z", + "links": [ + { + "rel": "self", + "title": "This document as GeoJSON", + "type": "application/geo+json", + "href": "http://localhost:8080/search?f=json" + } + ], + "features": [ + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 1", + "highlight": "Abbewaal - Den Burg 1", + "href": "https://example.com/ogc/v1/collections/addresses/items/51?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 108930.82374757381, + 552963.3442540341 + ], + [ + 122369.28607266696, + 552854.3120668632 + ], + [ + 122519.11914567353, + 575110.8422034585 + ], + [ + 109142.385825345, + 575219.5200152525 + ], + [ + 108930.82374757381, + 552963.3442540341 + ] + ] + ] + }, + "id": "51", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/51?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 1", + "highlight": "Abbewaal - Den Burg 1", + "href": "https://example.com/ogc/v1/collections/addresses/items/52?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 108930.82374757381, + 552963.3442540341 + ], + [ + 122369.28607266696, + 552854.3120668632 + ], + [ + 122519.11914567353, + 575110.8422034585 + ], + [ + 109142.385825345, + 575219.5200152525 + ], + [ + 108930.82374757381, + 552963.3442540341 + ] + ] + ] + }, + "id": "52", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/52?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/addresses/items/32183?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 109043.81291995465, + 553082.0701673088 + ], + [ + 122481.94500341009, + 552973.3499022151 + ], + [ + 122631.26540948273, + 575229.8898158654 + ], + [ + 109254.86279694513, + 575338.2567024586 + ], + [ + 109043.81291995465, + 553082.0701673088 + ] + ] + ] + }, + "id": "32183", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/32183?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/addresses/items/53?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 109043.81291995465, + 553082.0701673088 + ], + [ + 122481.94500341009, + 552973.3499022151 + ], + [ + 122631.26540948273, + 575229.8898158654 + ], + [ + 109254.86279694513, + 575338.2567024586 + ], + [ + 109043.81291995465, + 553082.0701673088 + ] + ] + ] + }, + "id": "53", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/53?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/addresses/items/32184?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 109043.61280645092, + 553071.2827189546 + ], + [ + 122481.77479068137, + 552962.5620117659 + ], + [ + 122631.09554431966, + 575219.1012624607 + ], + [ + 109254.66298884692, + 575327.4685912606 + ], + [ + 109043.61280645092, + 553071.2827189546 + ] + ] + ] + }, + "id": "32184", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/32184?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/addresses/items/54?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 109043.81291995465, + 553082.0701673088 + ], + [ + 122481.94500341009, + 552973.3499022151 + ], + [ + 122631.26540948273, + 575229.8898158654 + ], + [ + 109254.86279694513, + 575338.2567024586 + ], + [ + 109043.81291995465, + 553082.0701673088 + ] + ] + ] + }, + "id": "54", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/54?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 11", + "highlight": "Abbewaal - Den Burg 11", + "href": "https://example.com/ogc/v1/collections/addresses/items/22549?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 108952.31359697209, + 553064.5136385753 + ], + [ + 122390.49529567861, + 552955.5399556488 + ], + [ + 122540.2350379685, + 575212.0767479953 + ], + [ + 109163.78273979851, + 575320.6962311822 + ], + [ + 108952.31359697209, + 553064.5136385753 + ] + ] + ] + }, + "id": "22549", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/22549?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 13", + "highlight": "Abbewaal - Den Burg 13", + "href": "https://example.com/ogc/v1/collections/addresses/items/56?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 108970.26902163994, + 553077.8551786089 + ], + [ + 122408.41355513701, + 552968.9311218729 + ], + [ + 122558.07153432549, + 575225.4691312271 + ], + [ + 109181.65645385033, + 575334.0391476178 + ], + [ + 108970.26902163994, + 553077.8551786089 + ] + ] + ] + }, + "id": "56", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/56?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 13", + "highlight": "Abbewaal - Den Burg 13", + "href": "https://example.com/ogc/v1/collections/addresses/items/55?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 108970.26902163994, + 553077.8551786089 + ], + [ + 122408.41355513701, + 552968.9311218729 + ], + [ + 122558.07153432549, + 575225.4691312271 + ], + [ + 109181.65645385033, + 575334.0391476178 + ], + [ + 108970.26902163994, + 553077.8551786089 + ] + ] + ] + }, + "id": "55", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/55?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 15", + "highlight": "Abbewaal - Den Burg 15", + "href": "https://example.com/ogc/v1/collections/addresses/items/57?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 109006.84566435352, + 553101.0494150533 + ], + [ + 122444.9255303106, + 552992.226492696 + ], + [ + 122594.41673838987, + 575248.7667374964 + ], + [ + 109218.0664168045, + 575357.2359449365 + ], + [ + 109006.84566435352, + 553101.0494150533 + ] + ] + ] + }, + "id": "57", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/57?f=json" + } + ] + } + ], + "numberReturned": 10 +} diff --git a/internal/search/testdata/expected-search-den-single-collection-wgs84.json b/internal/search/testdata/expected-search-den-single-collection-wgs84.json new file mode 100644 index 0000000..04ae83c --- /dev/null +++ b/internal/search/testdata/expected-search-den-single-collection-wgs84.json @@ -0,0 +1,495 @@ +{ + "type": "FeatureCollection", + "timeStamp": "2000-01-01T00:00:00Z", + "links": [ + { + "rel": "self", + "title": "This document as GeoJSON", + "type": "application/geo+json", + "href": "http://localhost:8080/search?f=json" + } + ], + "features": [ + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 1", + "highlight": "Abbewaal - Den Burg 1", + "href": "https://example.com/ogc/v1/collections/addresses/items/51?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.701583684040178, + 52.96172161329086 + ], + [ + 4.901583684040177, + 52.96172161329086 + ], + [ + 4.901583684040177, + 53.161721613290865 + ], + [ + 4.701583684040178, + 53.161721613290865 + ], + [ + 4.701583684040178, + 52.96172161329086 + ] + ] + ] + }, + "id": "51", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/51?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 1", + "highlight": "Abbewaal - Den Burg 1", + "href": "https://example.com/ogc/v1/collections/addresses/items/52?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.701583684040178, + 52.96172161329086 + ], + [ + 4.901583684040177, + 52.96172161329086 + ], + [ + 4.901583684040177, + 53.161721613290865 + ], + [ + 4.701583684040178, + 53.161721613290865 + ], + [ + 4.701583684040178, + 52.96172161329086 + ] + ] + ] + }, + "id": "52", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/52?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/addresses/items/32183?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703248382838079, + 52.96279808509741 + ], + [ + 4.903248382838078, + 52.96279808509741 + ], + [ + 4.903248382838078, + 53.162798085097414 + ], + [ + 4.703248382838079, + 53.162798085097414 + ], + [ + 4.703248382838079, + 52.96279808509741 + ] + ] + ] + }, + "id": "32183", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/32183?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/addresses/items/53?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703248382838079, + 52.96279808509741 + ], + [ + 4.903248382838078, + 52.96279808509741 + ], + [ + 4.903248382838078, + 53.162798085097414 + ], + [ + 4.703248382838079, + 53.162798085097414 + ], + [ + 4.703248382838079, + 52.96279808509741 + ] + ] + ] + }, + "id": "53", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/53?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/addresses/items/32184?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703246926093469, + 52.9627011349704 + ], + [ + 4.903246926093468, + 52.9627011349704 + ], + [ + 4.903246926093468, + 53.162701134970405 + ], + [ + 4.703246926093469, + 53.162701134970405 + ], + [ + 4.703246926093469, + 52.9627011349704 + ] + ] + ] + }, + "id": "32184", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/32184?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 10", + "highlight": "Abbewaal - Den Burg 10", + "href": "https://example.com/ogc/v1/collections/addresses/items/54?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.703248382838079, + 52.96279808509741 + ], + [ + 4.903248382838078, + 52.96279808509741 + ], + [ + 4.903248382838078, + 53.162798085097414 + ], + [ + 4.703248382838079, + 53.162798085097414 + ], + [ + 4.703248382838079, + 52.96279808509741 + ] + ] + ] + }, + "id": "54", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/54?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 11", + "highlight": "Abbewaal - Den Burg 11", + "href": "https://example.com/ogc/v1/collections/addresses/items/22549?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.701889188504978, + 52.962632527818215 + ], + [ + 4.901889188504978, + 52.962632527818215 + ], + [ + 4.901889188504978, + 53.16263252781822 + ], + [ + 4.701889188504978, + 53.16263252781822 + ], + [ + 4.701889188504978, + 52.962632527818215 + ] + ] + ] + }, + "id": "22549", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/22549?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 13", + "highlight": "Abbewaal - Den Burg 13", + "href": "https://example.com/ogc/v1/collections/addresses/items/56?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702154512142386, + 52.96275394293894 + ], + [ + 4.902154512142385, + 52.96275394293894 + ], + [ + 4.902154512142385, + 53.16275394293894 + ], + [ + 4.702154512142386, + 53.16275394293894 + ], + [ + 4.702154512142386, + 52.96275394293894 + ] + ] + ] + }, + "id": "56", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/56?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 13", + "highlight": "Abbewaal - Den Burg 13", + "href": "https://example.com/ogc/v1/collections/addresses/items/55?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702154512142386, + 52.96275394293894 + ], + [ + 4.902154512142385, + 52.96275394293894 + ], + [ + 4.902154512142385, + 53.16275394293894 + ], + [ + 4.702154512142386, + 53.16275394293894 + ], + [ + 4.702154512142386, + 52.96275394293894 + ] + ] + ] + }, + "id": "55", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/55?f=json" + } + ] + }, + { + "type": "Feature", + "properties": { + "collectionGeometryType": "POINT", + "collectionId": "addresses", + "collectionVersion": "1", + "displayName": "Abbewaal - Den Burg 15", + "highlight": "Abbewaal - Den Burg 15", + "href": "https://example.com/ogc/v1/collections/addresses/items/57?f=json", + "score": 0.10277967154979706 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.702695564526969, + 52.962965478349716 + ], + [ + 4.902695564526968, + 52.962965478349716 + ], + [ + 4.902695564526968, + 53.16296547834972 + ], + [ + 4.702695564526969, + 53.16296547834972 + ], + [ + 4.702695564526969, + 52.962965478349716 + ] + ] + ] + }, + "id": "57", + "links": [ + { + "rel": "canonical", + "title": "The actual feature in the corresponding OGC API", + "type": "application/geo+json", + "href": "https://example.com/ogc/v1/collections/addresses/items/57?f=json" + } + ] + } + ], + "numberReturned": 10 +} diff --git a/internal/search/testdata/expected-search-no-collection.json b/internal/search/testdata/expected-search-no-collection.json new file mode 100644 index 0000000..68cf186 --- /dev/null +++ b/internal/search/testdata/expected-search-no-collection.json @@ -0,0 +1,6 @@ +{ + "detail": "no collection(s) specified in request, specify at least one collection and version. For example: 'foo[version]=1' where 'foo' is the collection and '1' the version", + "status": 400, + "timeStamp": "2000-01-01T00:00:00Z", + "title": "Bad Request" +} diff --git a/internal/search/testdata/expected-search-no-version-1.json b/internal/search/testdata/expected-search-no-version-1.json new file mode 100644 index 0000000..1c2a224 --- /dev/null +++ b/internal/search/testdata/expected-search-no-version-1.json @@ -0,0 +1,6 @@ +{ + "detail": "unknown query parameter(s) found: addresses=", + "status": 400, + "timeStamp": "2000-01-01T00:00:00Z", + "title": "Bad Request" +} \ No newline at end of file diff --git a/internal/search/testdata/expected-search-no-version-2.json b/internal/search/testdata/expected-search-no-version-2.json new file mode 100644 index 0000000..4306f23 --- /dev/null +++ b/internal/search/testdata/expected-search-no-version-2.json @@ -0,0 +1,6 @@ +{ + "detail": "unknown query parameter(s) found: addresses=1", + "status": 400, + "timeStamp": "2000-01-01T00:00:00Z", + "title": "Bad Request" +} \ No newline at end of file diff --git a/internal/search/testdata/expected-search-no-version-3.json b/internal/search/testdata/expected-search-no-version-3.json new file mode 100644 index 0000000..2afd740 --- /dev/null +++ b/internal/search/testdata/expected-search-no-version-3.json @@ -0,0 +1,6 @@ +{ + "detail": "request doesn't conform to OpenAPI spec: parameter \"addresses\" in query has an error: property \"version\" is missing", + "status": 400, + "timeStamp": "2000-01-01T00:00:00Z", + "title": "Bad Request" +} diff --git a/internal/search/url.go b/internal/search/url.go new file mode 100644 index 0000000..daf46ef --- /dev/null +++ b/internal/search/url.go @@ -0,0 +1,146 @@ +package search + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/PDOK/gomagpie/internal/engine" + d "github.com/PDOK/gomagpie/internal/search/domain" +) + +const ( + queryParam = "q" + limitParam = "limit" + crsParam = "crs" + + limitDefault = 10 + limitMax = 50 +) + +var ( + deepObjectParamRegex = regexp.MustCompile(`\w+\[\w+]`) +) + +func parseQueryParams(query url.Values) (collections d.CollectionsWithParams, searchTerm string, outputSRID d.SRID, limit int, err error) { + err = validateNoUnknownParams(query) + if err != nil { + return + } + searchTerm, searchTermErr := parseSearchTerm(query) + collections, collErr := parseCollections(query) + outputSRID, outputSRIDErr := parseCrsToPostgisSRID(query, crsParam) + limit, limitErr := parseLimit(query) + err = errors.Join(collErr, searchTermErr, limitErr, outputSRIDErr) + return +} + +// Parse collections as "deep object" params, e.g. collectionName[prop1]=value1&collectionName[prop2]=value2&.... +func parseCollections(query url.Values) (d.CollectionsWithParams, error) { + deepObjectParams := make(d.CollectionsWithParams, len(query)) + for key, values := range query { + if strings.Contains(key, "[") { + // Extract deepObject parameters + parts := strings.SplitN(key, "[", 2) + mainKey := parts[0] + subKey := strings.TrimSuffix(parts[1], "]") + + if _, exists := deepObjectParams[mainKey]; !exists { + deepObjectParams[mainKey] = make(map[string]string) + } + deepObjectParams[mainKey][subKey] = values[0] + } + } + errMsg := "specify at least one collection and version. For example: 'foo[version]=1' where 'foo' is the collection and '1' the version" + if len(deepObjectParams) == 0 { + return nil, fmt.Errorf("no collection(s) specified in request, %s", errMsg) + } + for name := range deepObjectParams { + if version, ok := deepObjectParams[name][d.VersionParam]; !ok || version == "" { + return nil, fmt.Errorf("no version specified in request for collection %s, %s", name, errMsg) + } + } + return deepObjectParams, nil +} + +func parseSearchTerm(query url.Values) (searchTerm string, err error) { + searchTerm = query.Get(queryParam) + if searchTerm == "" { + err = fmt.Errorf("no search term provided, '%s' query parameter is required", queryParam) + } + return +} + +// implements req 7.6 (https://docs.ogc.org/is/17-069r4/17-069r4.html#query_parameters) +func validateNoUnknownParams(query url.Values) error { + copyParams := clone(query) + copyParams.Del(engine.FormatParam) + copyParams.Del(queryParam) + copyParams.Del(limitParam) + copyParams.Del(crsParam) + for key := range query { + if deepObjectParamRegex.MatchString(key) { + copyParams.Del(key) + } + } + if len(copyParams) > 0 { + return fmt.Errorf("unknown query parameter(s) found: %v", copyParams.Encode()) + } + return nil +} + +func clone(params url.Values) url.Values { + copyParams := url.Values{} + for k, v := range params { + copyParams[k] = v + } + return copyParams +} + +func parseCrsToPostgisSRID(params url.Values, paramName string) (d.SRID, error) { + param := params.Get(paramName) + if param == "" { + return d.WGS84SRIDPostgis, nil // default to WGS84 + } + param = strings.TrimSpace(param) + if !strings.HasPrefix(param, d.CrsURIPrefix) { + return d.UndefinedSRID, fmt.Errorf("%s param should start with %s, got: %s", paramName, d.CrsURIPrefix, param) + } + var srid d.SRID + lastIndex := strings.LastIndex(param, "/") + if lastIndex != -1 { + crsCode := param[lastIndex+1:] + if crsCode == d.WGS84CodeOGC { + return d.WGS84SRIDPostgis, nil // CRS84 is WGS84, we use EPSG:4326 for Postgres TODO: check if correct since axis order differs between CRS84 and EPSG:4326 + } + val, err := strconv.Atoi(crsCode) + if err != nil { + return 0, fmt.Errorf("expected numerical CRS code, received: %s", crsCode) + } + srid = d.SRID(val) + } + return srid, nil +} + +func parseLimit(params url.Values) (int, error) { + limit := limitDefault + var err error + if params.Get(limitParam) != "" { + limit, err = strconv.Atoi(params.Get(limitParam)) + if err != nil { + err = errors.New("limit must be numeric") + } + // "If the value of the limit parameter is larger than the maximum value, this SHALL NOT result + // in an error (instead use the maximum as the parameter value)." + if limit > limitMax { + limit = limitMax + } + } + if limit < 0 { + err = errors.New("limit can't be negative") + } + return limit, err +}