Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add search endpoint #12

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 19 additions & 18 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,22 @@ jobs:
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
# TODO build end-to-end test
# - name: Start gomagpie test instance
# run: |
# docker run \
# -v `pwd`/examples:/examples \
# --rm --detach -p 8080:8080 \
# --name gomagpie \
# gomagpie:local start-service some_index --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
10 changes: 10 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -152,6 +153,13 @@ func main() {
commonDBFlags[dbUsernameFlag],
commonDBFlags[dbPasswordFlag],
commonDBFlags[dbSslModeFlag],
&cli.PathFlag{
Name: searchIndexFlag,
EnvVars: []string{strcase.ToScreamingSnake(searchIndexFlag)},
Usage: "Name of search index to use",
Required: true,
Value: "search_index",
},
},
Action: func(c *cli.Context) error {
log.Println(c.Command.Usage)
Expand All @@ -172,6 +180,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)
},
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion internal/etl/etl.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,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)
Expand Down
12 changes: 6 additions & 6 deletions internal/etl/load/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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},
Expand All @@ -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 {
Expand Down
2 changes: 0 additions & 2 deletions internal/ogc/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Empty file removed internal/search/.keep
Empty file.
17 changes: 17 additions & 0 deletions internal/search/datasources/datasource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 {
Suggest(ctx context.Context, searchTerm string, collections map[string]map[string]string,
srid domain.SRID, limit int) ([]string, error)

// Close closes (connections to) the datasource gracefully
Close()
}
92 changes: 92 additions & 0 deletions internal/search/datasources/postgres/postgres.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package postgres

import (
"context"
"fmt"

"github.com/PDOK/gomagpie/internal/search/domain"
"github.com/jackc/pgx/v5"
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) Suggest(ctx context.Context, searchTerm string, _ map[string]map[string]string, _ domain.SRID, limit int) ([]string, error) {
queryCtx, cancel := context.WithTimeout(ctx, p.queryTimeout)
defer cancel()

// Prepare dynamic full-text search query
// Split terms by spaces and append :* to each term
terms := strings.Fields(searchTerm)
for i, term := range terms {
terms[i] = term + ":*"
}
searchTermForPostgres := strings.Join(terms, " & ")

sqlQuery := fmt.Sprintf(
`SELECT
r.display_name AS display_name,
max(r.rank) AS rank,
max(r.highlighted_text) AS highlighted_text
FROM (
SELECT display_name,
ts_rank_cd(ts, to_tsquery('%[1]s'), 1) AS rank,
ts_headline('dutch', suggest, to_tsquery('%[1]s')) AS highlighted_text
FROM %[2]s
WHERE ts @@ to_tsquery('%[1]s') LIMIT 500
) r
GROUP BY display_name
ORDER BY rank DESC, display_name ASC LIMIT $1`, searchTermForPostgres, p.searchIndex)

// Execute query
rows, err := p.db.Query(queryCtx, sqlQuery, limit)
if err != nil {
return nil, fmt.Errorf("query '%s' failed: %w", sqlQuery, err)
}
defer rows.Close()

if queryCtx.Err() != nil {
return nil, queryCtx.Err()
}

var suggestions []string
for rows.Next() {
var displayName, highlightedText string
var rank float64

// Scan all selected columns
if err := rows.Scan(&displayName, &rank, &highlightedText); err != nil {
return nil, err
}

suggestions = append(suggestions, highlightedText) // or displayName, whichever you want to return
}

return suggestions, nil
}
12 changes: 12 additions & 0 deletions internal/search/domain/spatialref.go
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions internal/search/json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package search

import (
stdjson "encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"

"github.com/PDOK/gomagpie/internal/engine"
perfjson "github.com/goccy/go-json"
)

var (
disableJSONPerfOptimization, _ = strconv.ParseBool(os.Getenv("DISABLE_JSON_PERF_OPTIMIZATION"))
)

// serveJSON serves JSON *WITHOUT* OpenAPI validation by writing directly to the response output stream
func 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
}
}

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")
}
Loading
Loading