├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── Dockerfile ├── bin └── test.geojson ├── cmd └── detectr-server │ └── main.go ├── database ├── database.go ├── errors.go └── memory │ ├── memory.go │ └── memory_test.go ├── errors └── errors.go ├── go.mod ├── go.sum ├── handlers ├── geofences │ ├── controller.go │ ├── create.go │ └── create_test.go └── location │ ├── controller.go │ ├── post.go │ └── post_test.go ├── logger ├── errors.go └── logger.go ├── models ├── location.go └── responses.go ├── readme.md └── validation └── validation.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: monthly 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | workflow_dispatch: 8 | jobs: 9 | ci: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: setup go 14 | uses: actions/setup-go@v5 15 | - name: build 16 | run: go build ./cmd/detectr-server 17 | - name: lint 18 | uses: golangci/golangci-lint-action@v6.1.1 19 | with: 20 | version: latest 21 | - name: test 22 | run: go test -v ./... 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | name: build 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: setup 19 | uses: actions/setup-go@v5 20 | - name: release 21 | uses: goreleaser/goreleaser-action@master 22 | with: 23 | version: latest 24 | args: release --rm-dist --config ./.goreleaser.yaml 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/berlin.geojson 2 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: detectr 2 | builds: 3 | - env: 4 | - CGO_ENABLED=0 5 | main: ./cmd/detectr-server 6 | binary: detectr-server 7 | goos: 8 | - linux 9 | - windows 10 | - darwin 11 | archives: 12 | - replacements: 13 | darwin: Darwin 14 | linux: Linux 15 | windows: Windows 16 | 386: i386 17 | amd64: x86_64 18 | checksum: 19 | name_template: "checksums.txt" 20 | snapshot: 21 | name_template: "{{ incpatch .Version }}-next" 22 | changelog: 23 | sort: asc 24 | filters: 25 | exclude: 26 | - "^docs:" 27 | - "^test:" 28 | - "^chore:" 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/TekWizely/pre-commit-golang 3 | rev: v1.0.0-rc.1 4 | hooks: 5 | - id: go-fmt 6 | args: [-w] 7 | - id: go-vet-mod 8 | - id: golangci-lint-mod 9 | - id: go-imports 10 | - id: go-mod-tidy 11 | - repo: https://github.com/jorisroovers/gitlint 12 | rev: v0.19.1 13 | hooks: 14 | - id: gitlint 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 AS build 2 | 3 | # `boilerplate` should be replaced with your project name 4 | WORKDIR /go/src/detectr 5 | 6 | COPY . . 7 | 8 | RUN go mod download 9 | 10 | # Builds the application as a staticly linked one, to allow it to run on alpine 11 | RUN cd cmd/detectr/ && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o detectr . 12 | 13 | FROM alpine:latest 14 | 15 | WORKDIR /app 16 | 17 | RUN mkdir ./bin 18 | COPY ./bin ./bin 19 | 20 | COPY --from=build /go/src/detectr/cmd/detectr . 21 | 22 | EXPOSE 3000 23 | 24 | CMD ["./detectr"] 25 | -------------------------------------------------------------------------------- /bin/test.geojson: -------------------------------------------------------------------------------- 1 | {"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"dahlem"},"geometry":{"type":"Polygon","coordinates":[[[13.24951171875,52.45056957810956],[13.316802978515625,52.45056957810956],[13.316802978515625,52.481107522268495],[13.24951171875,52.481107522268495],[13.24951171875,52.45056957810956]]]}},{"type":"Feature","properties":{"name":"tempelhofer feld"},"geometry":{"type":"Polygon","coordinates":[[[13.382720947265625,52.461866903001194],[13.429412841796875,52.461866903001194],[13.429412841796875,52.487797939920966],[13.382720947265625,52.487797939920966],[13.382720947265625,52.461866903001194]]]}},{"type":"Feature","properties":{"name":"mueggelsee"},"geometry":{"type":"Polygon","coordinates":[[[13.607254028320312,52.42294169179911],[13.693771362304686,52.42294169179911],[13.693771362304686,52.45642781208071],[13.607254028320312,52.45642781208071],[13.607254028320312,52.42294169179911]]]}},{"type":"Feature","properties":{"name":"lidl"},"geometry":{"type":"Polygon","coordinates":[[[13.380231857299805,52.467253032741084],[13.385896682739258,52.467253032741084],[13.385896682739258,52.46918769516145],[13.380231857299805,52.46918769516145],[13.380231857299805,52.467253032741084]]]}},{"type":"Feature","properties":{"name":"random"},"geometry":{"type":"Polygon","coordinates":[[[13.429412841796875,52.56591677100683],[13.473358154296875,52.56591677100683],[13.473358154296875,52.58469468354708],[13.429412841796875,52.58469468354708],[13.429412841796875,52.56591677100683]]]}},{"type":"Feature","properties":{"name":"random"},"geometry":{"type":"Polygon","coordinates":[[[13.410186767578123,52.56591677100683],[13.512496948242186,52.56591677100683],[13.512496948242186,52.57468079766565],[13.410186767578123,52.57468079766565],[13.410186767578123,52.56591677100683]]]}},{"type":"Feature","properties":{"name":"rehberge"},"geometry":{"type":"Polygon","coordinates":[[[13.325042724609375,52.552141190185246],[13.339462280273438,52.552141190185246],[13.339462280273438,52.56049054341193],[13.325042724609375,52.56049054341193],[13.325042724609375,52.552141190185246]]]}},{"type":"Feature","properties":{"name":"scheunenviertel"},"geometry":{"type":"Polygon","coordinates":[[[13.359375,52.512042174642346],[13.423233032226562,52.512042174642346],[13.423233032226562,52.54379024803229],[13.359375,52.54379024803229],[13.359375,52.512042174642346]]]}},{"type":"Feature","properties":{"name":"stadtpark"},"geometry":{"type":"Polygon","coordinates":[[[13.176727294921875,52.55965567958739],[13.191146850585938,52.55965567958739],[13.191146850585938,52.565499392715374],[13.176727294921875,52.565499392715374],[13.176727294921875,52.55965567958739]]]}},{"type":"Feature","properties":{"name":"random"},"geometry":{"type":"Polygon","coordinates":[[[13.360061645507812,52.451406516388964],[13.423919677734375,52.451406516388964],[13.423919677734375,52.48528915254611],[13.360061645507812,52.48528915254611],[13.360061645507812,52.451406516388964]]]}},{"type":"Feature","properties":{"name":"malchsee"},"geometry":{"type":"Polygon","coordinates":[[[13.253631591796875,52.584277483971114],[13.267364501953125,52.584277483971114],[13.267364501953125,52.594706282077965],[13.253631591796875,52.594706282077965],[13.253631591796875,52.584277483971114]]]}},{"type":"Feature","properties":{"name":"random"},"geometry":{"type":"Polygon","coordinates":[[[13.436965942382812,52.51663871100423],[13.491897583007812,52.51663871100423],[13.491897583007812,52.5341847000842],[13.436965942382812,52.5341847000842],[13.436965942382812,52.51663871100423]]]}},{"type":"Feature","properties":{"name":"technopark"},"geometry":{"type":"Polygon","coordinates":[[[13.250198364257812,52.52248815280757],[13.29071044921875,52.52248815280757],[13.29071044921875,52.5417022642097],[13.250198364257812,52.5417022642097],[13.250198364257812,52.52248815280757]]]}},{"type":"Feature","properties":{"name":"wittenau"},"geometry":{"type":"Polygon","coordinates":[[[13.32435607910156,52.59136933670434],[13.3538818359375,52.59136933670434],[13.3538818359375,52.61013634893439],[13.32435607910156,52.61013634893439],[13.32435607910156,52.59136933670434]]]}},{"type":"Feature","properties":{"name":"karlshorst"},"geometry":{"type":"Polygon","coordinates":[[[13.504257202148438,52.47441608702583],[13.53515625,52.47441608702583],[13.53515625,52.49657756892365],[13.504257202148438,52.49657756892365],[13.504257202148438,52.47441608702583]]]}},{"type":"Feature","properties":{"name":"havel"},"geometry":{"type":"Polygon","coordinates":[[[13.173980712890625,52.487797939920966],[13.210372924804686,52.487797939920966],[13.210372924804686,52.51078849036716],[13.173980712890625,52.51078849036716],[13.173980712890625,52.487797939920966]]]}},{"type":"Feature","properties":{"name":"uhlandstr"},"geometry":{"type":"Polygon","coordinates":[[[13.313369750976562,52.49657756892365],[13.34014892578125,52.49657756892365],[13.34014892578125,52.51371369804256],[13.313369750976562,52.51371369804256],[13.313369750976562,52.49657756892365]]]}},{"type":"Feature","properties":{"name":"deutsche oper"},"geometry":{"type":"Polygon","coordinates":[[[13.303756713867188,52.506191342034576],[13.325042724609375,52.506191342034576],[13.325042724609375,52.521234766555494],[13.303756713867188,52.521234766555494],[13.303756713867188,52.506191342034576]]]}},{"type":"Feature","properties":{"name":"kamenzer damm"},"geometry":{"type":"Polygon","coordinates":[[[13.333969116210938,52.42503531994297],[13.373107910156248,52.42503531994297],[13.373107910156248,52.44471056482437],[13.333969116210938,52.44471056482437],[13.333969116210938,52.42503531994297]]]}},{"type":"Feature","properties":{"name":"test"},"geometry":{"type":"Polygon","coordinates":[[[13.010559082031248,52.6351465262243],[13.01605224609375,52.6351465262243],[13.01605224609375,52.63639665997182],[13.010559082031248,52.63639665997182],[13.010559082031248,52.6351465262243]]]}},{"type":"Feature","properties":{"name":"business area"},"geometry":{"type":"Polygon","coordinates":[[[13.107376098632812,52.37769505233968],[13.699264526367188,52.37769505233968],[13.699264526367188,52.63973017532399],[13.107376098632812,52.63973017532399],[13.107376098632812,52.37769505233968]]]}}]} -------------------------------------------------------------------------------- /cmd/detectr-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/signal" 7 | "time" 8 | 9 | "fmt" 10 | 11 | "github.com/urfave/cli/v2" 12 | 13 | "github.com/iwpnd/detectr/database/memory" 14 | "github.com/iwpnd/detectr/errors" 15 | "github.com/iwpnd/detectr/handlers/geofences" 16 | "github.com/iwpnd/detectr/handlers/location" 17 | "github.com/iwpnd/detectr/logger" 18 | 19 | "github.com/gofiber/fiber/v2" 20 | keyauth "github.com/iwpnd/fiber-key-auth" 21 | ) 22 | 23 | func startDetectr(ctx *cli.Context) error { 24 | port := ctx.Int64("port") 25 | datapath := ctx.String("data") 26 | loglevel := ctx.String("log-level") 27 | requirekey := ctx.Bool("require-key") 28 | 29 | if loglevel != "" { 30 | err := logger.SetLogLevel(loglevel) 31 | if err != nil { 32 | log.Fatal(err) 33 | return err 34 | } 35 | } 36 | 37 | l, err := logger.New() 38 | 39 | if err != nil { 40 | fmt.Println(err) 41 | } 42 | 43 | db := memory.New() 44 | 45 | c := fiber.Config{ 46 | AppName: "detectr", 47 | DisableStartupMessage: true, 48 | ErrorHandler: func(ctx *fiber.Ctx, err error) error { 49 | return ctx.Status( 50 | fiber.StatusInternalServerError).JSON( 51 | errors.NewRequestError( 52 | &errors.ErrRequestError{ 53 | Status: fiber.StatusInternalServerError, 54 | Detail: err.Error()})) 55 | }, 56 | } 57 | 58 | app := fiber.New(c) 59 | app.Get("/healthz", func(c *fiber.Ctx) error { 60 | return c.SendStatus(200) 61 | }) 62 | 63 | l.Info("Starting detectr...") 64 | 65 | if port == 0 { 66 | port = 3000 67 | } 68 | 69 | if datapath != "" { 70 | start := time.Now() 71 | l.Info(fmt.Sprintf("Ingesting data from local file path: %v", datapath)) 72 | err := db.LoadFromPath(datapath) 73 | if err != nil { 74 | log.Fatal(err) 75 | return err 76 | } 77 | elapsed := fmt.Sprint(time.Since(start)) 78 | l.Info(fmt.Sprintf("Done ingesting data from local file path: %v. Took: %v", datapath, elapsed)) 79 | } 80 | 81 | if requirekey { 82 | app.Use(keyauth.New(keyauth.WithStructuredErrorMsg())) 83 | } 84 | 85 | location.RegisterRoutes(app, db, l) 86 | geofences.RegisterRoutes(app, db, l) 87 | 88 | go func() { 89 | l.Info(fmt.Sprintf("Detectr listening on port :%v", port)) 90 | if err := app.Listen(fmt.Sprintf(":%v", port)); err != nil { 91 | log.Panic(err) 92 | } 93 | }() 94 | 95 | ch := make(chan os.Signal, 1) 96 | signal.Notify(ch, os.Interrupt) 97 | <-ch 98 | l.Info("Shutting down detectr...") 99 | _ = app.Shutdown() 100 | 101 | return nil 102 | } 103 | 104 | var withPort cli.Int64Flag 105 | var withKeyAuth cli.BoolFlag 106 | var withLogLevel cli.StringFlag 107 | var withDataset cli.StringFlag 108 | 109 | func init() { 110 | withPort = cli.Int64Flag{ 111 | Name: "port", 112 | Usage: "define port", 113 | Value: 3000, 114 | Required: true, 115 | } 116 | withKeyAuth = cli.BoolFlag{ 117 | Name: "require-key", 118 | Usage: "use keyauth", 119 | Value: false, 120 | Required: false, 121 | } 122 | withLogLevel = cli.StringFlag{ 123 | Name: "log-level", 124 | Usage: "set loglevel", 125 | Value: "error", 126 | Required: false, 127 | } 128 | withDataset = cli.StringFlag{ 129 | Name: "data", 130 | Usage: "path to dataset to load with app", 131 | Required: false, 132 | } 133 | } 134 | 135 | func main() { 136 | app := &cli.App{ 137 | Name: "detectr", 138 | Usage: "geofence application", 139 | Action: startDetectr, 140 | Flags: []cli.Flag{ 141 | &withPort, 142 | &withKeyAuth, 143 | &withLogLevel, 144 | &withDataset, 145 | }, 146 | } 147 | 148 | if err := app.Run(os.Args); err != nil { 149 | log.Fatal(err) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "math" 5 | 6 | geojson "github.com/paulmach/go.geojson" 7 | ) 8 | 9 | type Searcher interface { 10 | Intersects(p []float64) []*geojson.Feature 11 | } 12 | 13 | type Creater interface { 14 | Create(*geojson.Feature) error 15 | } 16 | 17 | type Deleter interface { 18 | Delete(*geojson.Feature) 19 | Truncate() 20 | } 21 | 22 | type Datastore interface { 23 | Searcher 24 | Creater 25 | Deleter 26 | } 27 | 28 | type Extent []float64 29 | 30 | func (ex Extent) Center() []float64 { 31 | w := ex[0] 32 | s := ex[1] 33 | e := ex[2] 34 | n := ex[3] 35 | 36 | lat := n - math.Abs(s-n)/2 37 | lng := e - math.Abs(w-e)/2 38 | 39 | return []float64{lng, lat} 40 | } 41 | 42 | type OuterRing [][]float64 43 | 44 | func (r OuterRing) ToExtent() Extent { 45 | w := r[0][0] 46 | s := r[0][1] 47 | e := r[0][0] 48 | n := r[0][1] 49 | 50 | for _, p := range r { 51 | if w > p[0] { 52 | w = p[0] 53 | } 54 | 55 | if s > p[1] { 56 | s = p[1] 57 | } 58 | 59 | if e < p[0] { 60 | e = p[0] 61 | } 62 | 63 | if n < p[1] { 64 | n = p[1] 65 | } 66 | } 67 | 68 | return []float64{w, s, e, n} 69 | } 70 | -------------------------------------------------------------------------------- /database/errors.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | 6 | geojson "github.com/paulmach/go.geojson" 7 | ) 8 | 9 | // ErrInvalidGeometry ... 10 | type ErrInvalidGeometryType struct { 11 | Type geojson.GeometryType 12 | } 13 | 14 | // Error ... 15 | func (err ErrInvalidGeometryType) Error() string { 16 | return fmt.Sprintf("unsupported geometry type: %s", err.Type) 17 | } 18 | 19 | // ErrEmptyGeometry ... 20 | type ErrEmptyGeometry struct{} 21 | 22 | // Error ... 23 | func (err ErrEmptyGeometry) Error() string { 24 | return "empty geometry" 25 | } 26 | -------------------------------------------------------------------------------- /database/memory/memory.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/google/uuid" 8 | "github.com/iwpnd/detectr/database" 9 | "github.com/iwpnd/piper" 10 | geojson "github.com/paulmach/go.geojson" 11 | 12 | "github.com/tidwall/geoindex" 13 | 14 | "github.com/tidwall/rtree" 15 | ) 16 | 17 | // Memory ... 18 | type Memory struct { 19 | tree *geoindex.Index 20 | } 21 | 22 | // New to create a new database 23 | func New() *Memory { 24 | db := &Memory{ 25 | tree: geoindex.Wrap(&rtree.RTree{}), 26 | } 27 | return db 28 | } 29 | 30 | // Truncate to create a new database 31 | func (db *Memory) Truncate() { 32 | db.tree = geoindex.Wrap(&rtree.RTree{}) 33 | } 34 | 35 | // Create to create a new entry into the database 36 | func (db *Memory) Create(g *geojson.Feature) error { 37 | if g.Geometry == nil { 38 | return &database.ErrEmptyGeometry{} 39 | } 40 | 41 | if !g.Geometry.IsPolygon() { 42 | return &database.ErrInvalidGeometryType{Type: g.Geometry.Type} 43 | } 44 | 45 | if g.ID == nil { 46 | id := uuid.Must(uuid.NewRandom()).String() 47 | g.ID = id 48 | } 49 | 50 | var or database.OuterRing = g.Geometry.Polygon[0] 51 | rect := or.ToExtent() 52 | db.tree.Insert( 53 | [2]float64{rect[0], rect[1]}, 54 | [2]float64{rect[2], rect[3]}, 55 | g, 56 | ) 57 | return nil 58 | } 59 | 60 | // Delete to delete an entry from the database 61 | func (db *Memory) Delete(g *geojson.Feature) { 62 | var or database.OuterRing = g.Geometry.Polygon[0] 63 | rect := or.ToExtent() 64 | 65 | db.tree.Delete( 66 | [2]float64{rect[0], rect[1]}, 67 | [2]float64{rect[2], rect[3]}, 68 | g, 69 | ) 70 | } 71 | 72 | // Count to get the current amount of entries in the database 73 | func (db *Memory) Count() int { 74 | return db.tree.Len() 75 | } 76 | 77 | func (db *Memory) search( 78 | p []float64, 79 | iter func(object *geojson.Feature) bool, 80 | ) bool { 81 | alive := true 82 | db.tree.Search( 83 | [2]float64{p[0], p[1]}, 84 | [2]float64{p[0], p[1]}, 85 | func(_, _ [2]float64, value interface{}) bool { 86 | item := value.(*geojson.Feature) 87 | alive = iter(item) 88 | return alive 89 | }, 90 | ) 91 | return alive 92 | } 93 | 94 | // Intersects to find entries intersecting the requested point 95 | func (db *Memory) Intersects(p []float64) []*geojson.Feature { 96 | var matches []*geojson.Feature 97 | 98 | db.search(p, func(o *geojson.Feature) bool { 99 | if o.Geometry.IsPolygon() { 100 | if piper.Pip(p, o.Geometry.Polygon) { 101 | matches = append(matches, o) 102 | } 103 | return true 104 | } 105 | return true 106 | }) 107 | 108 | return matches 109 | } 110 | 111 | // LoadFromPath to load a FeatureCollection from file 112 | func (db *Memory) LoadFromPath(path string) error { 113 | file, err := os.ReadFile(path) 114 | 115 | if err != nil { 116 | return err 117 | } 118 | 119 | fc, err := geojson.UnmarshalFeatureCollection(file) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | for _, f := range fc.Features { 125 | err := db.Create(f) 126 | if err != nil { 127 | fmt.Print("skipping geometry: ", err.Error()) 128 | } 129 | } 130 | 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /database/memory/memory_test.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "testing" 5 | 6 | geojson "github.com/paulmach/go.geojson" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func setupDatabase() *Memory { 11 | db := New() 12 | 13 | return db 14 | } 15 | 16 | func TestCreate(t *testing.T) { 17 | db := setupDatabase() 18 | defer db.Truncate() 19 | 20 | expected := []byte(`{"id":"foobar","type":"Feature","geometry":{"type":"Polygon","coordinates":[[[13.3967096231641,52.47425410999395],[13.3967096231641,52.4680479999262],[13.413318577304466,52.4680479999262],[13.413318577304466,52.47425410999395],[13.3967096231641,52.47425410999395]]]},"properties":{"id":"foobar"}}`) 21 | 22 | f, err := geojson.UnmarshalFeature(expected) 23 | if err != nil { 24 | t.Fatal("failed to unmarshal feature: ", err) 25 | } 26 | 27 | err = db.Create(f) 28 | if err != nil { 29 | t.Fatal("failed to create feature") 30 | } 31 | 32 | p := []float64{13.40532627661105, 52.471361312503575} 33 | 34 | matches := db.Intersects(p) 35 | got, _ := matches[0].MarshalJSON() 36 | 37 | assert.Equal(t, string(expected), string(got)) 38 | } 39 | 40 | func TestCreateGenerateID(t *testing.T) { 41 | db := setupDatabase() 42 | defer db.Truncate() 43 | 44 | data := []byte(`{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[13.3967096231641,52.47425410999395],[13.3967096231641,52.4680479999262],[13.413318577304466,52.4680479999262],[13.413318577304466,52.47425410999395],[13.3967096231641,52.47425410999395]]]}}`) 45 | 46 | f, err := geojson.UnmarshalFeature(data) 47 | if err != nil { 48 | t.Fatal("failed to unmarshal feature: ", err) 49 | } 50 | 51 | assert.Nil(t, f.ID) 52 | 53 | err = db.Create(f) 54 | if err != nil { 55 | t.Fatal("failed to create feature") 56 | } 57 | 58 | p := []float64{13.40532627661105, 52.471361312503575} 59 | 60 | matches := db.Intersects(p) 61 | 62 | assert.NotNil(t, matches[0].ID) 63 | } 64 | 65 | func TestCreateFailed(t *testing.T) { 66 | db := setupDatabase() 67 | defer db.Truncate() 68 | 69 | type tcase struct { 70 | Data []byte 71 | expectedError string 72 | } 73 | 74 | tests := map[string]tcase{ 75 | "test invalid geometry type": { 76 | Data: []byte(`{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[1,1]}}`), 77 | expectedError: "unsupported geometry type: Point", 78 | }, 79 | "test faulty geofence": { 80 | Data: []byte(`{"foo":"bar"}`), 81 | expectedError: "empty geometry", 82 | }, 83 | } 84 | 85 | for _, test := range tests { 86 | f, err := geojson.UnmarshalFeature(test.Data) 87 | if err != nil { 88 | t.Fatal("failed to unmarshal feature: ", err) 89 | } 90 | err = db.Create(f) 91 | if test.expectedError != "" && err != nil { 92 | assert.Equal(t, test.expectedError, err.Error()) 93 | } 94 | } 95 | } 96 | 97 | func TestTruncate(t *testing.T) { 98 | db := setupDatabase() 99 | 100 | data := []byte(`{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[13.3967096231641,52.47425410999395],[13.3967096231641,52.4680479999262],[13.413318577304466,52.4680479999262],[13.413318577304466,52.47425410999395],[13.3967096231641,52.47425410999395]]]}}`) 101 | 102 | f, err := geojson.UnmarshalFeature(data) 103 | if err != nil { 104 | t.Fatal("failed to unmarshal feature: ", err) 105 | } 106 | err = db.Create(f) 107 | if err != nil { 108 | t.Fatal("failed to create feature") 109 | } 110 | 111 | p := []float64{13.40532627661105, 52.471361312503575} 112 | 113 | assert.Equal(t, 1, len(db.Intersects(p))) 114 | 115 | db.Truncate() 116 | assert.Equal(t, 0, db.Count()) 117 | assert.Equal(t, 0, len(db.Intersects(p))) 118 | } 119 | 120 | func TestDelete(t *testing.T) { 121 | db := setupDatabase() 122 | defer db.Truncate() 123 | 124 | data := []byte(`{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[13.3967096231641,52.47425410999395],[13.3967096231641,52.4680479999262],[13.413318577304466,52.4680479999262],[13.413318577304466,52.47425410999395],[13.3967096231641,52.47425410999395]]]}}`) 125 | 126 | f, err := geojson.UnmarshalFeature(data) 127 | if err != nil { 128 | t.Fatal("failed to unmarshal feature: ", err) 129 | } 130 | err = db.Create(f) 131 | if err != nil { 132 | t.Fatal("failed to create feature") 133 | } 134 | 135 | p := []float64{13.40532627661105, 52.471361312503575} 136 | 137 | assert.Equal(t, 1, db.Count()) 138 | assert.Equal(t, 1, len(db.Intersects(p))) 139 | 140 | db.Delete(f) 141 | 142 | assert.Equal(t, 0, db.Count()) 143 | assert.Equal(t, 0, len(db.Intersects(p))) 144 | } 145 | -------------------------------------------------------------------------------- /errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | // ErrRequestError ... 4 | type ErrRequestError struct { 5 | Status int `json:"status"` 6 | Source string `json:"source,omitempty"` 7 | Title string `json:"title,omitempty"` 8 | Detail string `json:"details,omitempty"` 9 | } 10 | 11 | // ErrorResponse ... 12 | type ErrorResponse struct { 13 | Errors []*ErrRequestError `json:"errors"` 14 | } 15 | 16 | // NewRequestError ... 17 | func NewRequestError(e ...*ErrRequestError) ErrorResponse { 18 | error := ErrorResponse{} 19 | 20 | error.Errors = append(error.Errors, e...) 21 | 22 | return error 23 | } 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/iwpnd/detectr 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/go-playground/validator/v10 v10.20.0 7 | github.com/gofiber/fiber/v2 v2.52.4 8 | github.com/google/uuid v1.6.0 9 | github.com/iwpnd/fiber-key-auth v0.3.0 10 | github.com/iwpnd/piper v0.1.0 11 | github.com/paulmach/go.geojson v1.5.0 12 | github.com/stretchr/testify v1.9.0 13 | github.com/tidwall/geoindex v1.7.0 14 | github.com/tidwall/rtree v1.10.0 15 | github.com/urfave/cli/v2 v2.27.2 16 | go.uber.org/zap v1.27.0 17 | ) 18 | 19 | require ( 20 | github.com/andybalholm/brotli v1.1.0 // indirect 21 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 24 | github.com/go-playground/locales v0.14.1 // indirect 25 | github.com/go-playground/universal-translator v0.18.1 // indirect 26 | github.com/klauspost/compress v1.17.8 // indirect 27 | github.com/leodido/go-urn v1.4.0 // indirect 28 | github.com/mattn/go-colorable v0.1.13 // indirect 29 | github.com/mattn/go-isatty v0.0.20 // indirect 30 | github.com/mattn/go-runewidth v0.0.15 // indirect 31 | github.com/pmezard/go-difflib v1.0.0 // indirect 32 | github.com/rivo/uniseg v0.4.7 // indirect 33 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 34 | github.com/tidwall/cities v0.1.0 // indirect 35 | github.com/tidwall/lotsa v1.0.3 // indirect 36 | github.com/valyala/bytebufferpool v1.0.0 // indirect 37 | github.com/valyala/fasthttp v1.52.0 // indirect 38 | github.com/valyala/tcplisten v1.0.0 // indirect 39 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect 40 | go.uber.org/multierr v1.11.0 // indirect 41 | golang.org/x/crypto v0.22.0 // indirect 42 | golang.org/x/net v0.24.0 // indirect 43 | golang.org/x/sys v0.19.0 // indirect 44 | golang.org/x/text v0.14.0 // indirect 45 | gopkg.in/yaml.v3 v3.0.1 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 2 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 8 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 9 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 10 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 11 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 12 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 13 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 14 | github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= 15 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 16 | github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM= 17 | github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= 18 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 19 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 20 | github.com/iwpnd/fiber-key-auth v0.3.0 h1:hxrb8sJfKtk/vP2rZCiG10J7A7UE/JjXzCbBWTup4V8= 21 | github.com/iwpnd/fiber-key-auth v0.3.0/go.mod h1:uKQ0AQwKiSRmLJLlC5X3e86AKK+iyBGcghnMFCvYsA4= 22 | github.com/iwpnd/piper v0.1.0 h1:o0C4GHdae5NgYYgYen5n1jOnc/ybeIhlUIv0ICmLtfk= 23 | github.com/iwpnd/piper v0.1.0/go.mod h1:3XpO0nodbDJdLQnnFzTRH6SliqXdNqMDcKPeOp1tMkU= 24 | github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= 25 | github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 26 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 27 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 28 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 29 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 30 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 31 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 32 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 33 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 34 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 35 | github.com/paulmach/go.geojson v1.5.0 h1:7mhpMK89SQdHFcEGomT7/LuJhwhEgfmpWYVlVmLEdQw= 36 | github.com/paulmach/go.geojson v1.5.0/go.mod h1:DgdUy2rRVDDVgKqrjMe2vZAHMfhDTrjVKt3LmHIXGbU= 37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 40 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 41 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 42 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 43 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 44 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 45 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 46 | github.com/tidwall/cities v0.1.0 h1:CVNkmMf7NEC9Bvokf5GoSsArHCKRMTgLuubRTHnH0mE= 47 | github.com/tidwall/cities v0.1.0/go.mod h1:lV/HDp2gCcRcHJWqgt6Di54GiDrTZwh1aG2ZUPNbqa4= 48 | github.com/tidwall/geoindex v1.7.0 h1:jtk41sfgwIt8MEDyC3xyKSj75iXXf6rjReJGDNPtR5o= 49 | github.com/tidwall/geoindex v1.7.0/go.mod h1:rvVVNEFfkJVWGUdEfU8QaoOg/9zFX0h9ofWzA60mz1I= 50 | github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8= 51 | github.com/tidwall/lotsa v1.0.3 h1:lFAp3PIsS58FPmz+LzhE1mcZ67tBBCRPv5j66g6y7sg= 52 | github.com/tidwall/lotsa v1.0.3/go.mod h1:cPF+z88hamDNDjvE+u3suxCtRMVw24Gvze9eeWGYook= 53 | github.com/tidwall/rtree v1.10.0 h1:+EcI8fboEaW1L3/9oW/6AMoQ8HiEIHyR7bQOGnmz4Mg= 54 | github.com/tidwall/rtree v1.10.0/go.mod h1:iDJQ9NBRtbfKkzZu02za+mIlaP+bjYPnunbSNidpbCQ= 55 | github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= 56 | github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= 57 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 58 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 59 | github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0= 60 | github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ= 61 | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 62 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 63 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= 64 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= 65 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 66 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 67 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 68 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 69 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 70 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 71 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 72 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 73 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 74 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 76 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 77 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 78 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 79 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 80 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 81 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 82 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 83 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 84 | -------------------------------------------------------------------------------- /handlers/geofences/controller.go: -------------------------------------------------------------------------------- 1 | package geofences 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/iwpnd/detectr/database" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | type handler struct { 10 | DB database.Datastore 11 | Logger *zap.Logger 12 | } 13 | 14 | // RegisterRoutes to register geofence routes with the fiber app 15 | func RegisterRoutes(app *fiber.App, db database.Datastore, logger *zap.Logger) { 16 | h := &handler{ 17 | DB: db, 18 | Logger: logger, 19 | } 20 | 21 | routes := app.Group("geofence") 22 | routes.Post("/", h.CreateFence) 23 | } 24 | -------------------------------------------------------------------------------- /handlers/geofences/create.go: -------------------------------------------------------------------------------- 1 | package geofences 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/iwpnd/detectr/errors" 9 | "github.com/iwpnd/detectr/models" 10 | geojson "github.com/paulmach/go.geojson" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // CreateFence ... 15 | func (h *handler) CreateFence(c *fiber.Ctx) error { 16 | start := time.Now() 17 | 18 | if string(c.Request().Header.ContentType()) != "application/json" { 19 | return c.Status(fiber.StatusUnprocessableEntity).JSON(errors.NewRequestError(&errors.ErrRequestError{ 20 | Status: fiber.StatusUnprocessableEntity, 21 | Detail: "Content-Type must be 'application/json'", 22 | })) 23 | } 24 | 25 | d, err := geojson.UnmarshalFeature(c.Body()) 26 | if err != nil { 27 | return c.Status(fiber.StatusUnprocessableEntity).JSON(errors.NewRequestError(&errors.ErrRequestError{Status: fiber.StatusUnprocessableEntity, 28 | Detail: err.Error(), 29 | })) 30 | } 31 | 32 | err = h.DB.Create(d) 33 | if err != nil { 34 | return c.Status(fiber.StatusUnprocessableEntity).JSON(errors.NewRequestError(&errors.ErrRequestError{ 35 | Status: fiber.StatusUnprocessableEntity, 36 | Detail: err.Error(), 37 | })) 38 | } 39 | 40 | r := &models.Response{Data: d} 41 | 42 | elapsed := time.Since(start) 43 | h.Logger.Debug("Created geofence", zap.Any("data", &d), zap.String("elapsed", fmt.Sprint(elapsed))) 44 | 45 | return c.Status(201).JSON(&r) 46 | } 47 | -------------------------------------------------------------------------------- /handlers/geofences/create_test.go: -------------------------------------------------------------------------------- 1 | package geofences 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/gofiber/fiber/v2" 11 | "github.com/iwpnd/detectr/database/memory" 12 | "github.com/iwpnd/detectr/errors" 13 | "github.com/iwpnd/detectr/logger" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func setupApp() *fiber.App { 18 | app := fiber.New() 19 | 20 | _ = logger.SetLogLevel("warn") 21 | lg, _ := logger.New() 22 | db := memory.New() 23 | RegisterRoutes(app, db, lg) 24 | 25 | return app 26 | } 27 | 28 | func TestCreate(t *testing.T) { 29 | app := setupApp() 30 | 31 | data := []byte(`{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[13.3967096231641,52.47425410999395],[13.3967096231641,52.4680479999262],[13.413318577304466,52.4680479999262],[13.413318577304466,52.47425410999395],[13.3967096231641,52.47425410999395]]]}}`) 32 | 33 | type tcase struct { 34 | Body []byte 35 | ContentType string 36 | ExpectedCode int 37 | ExpectedError string 38 | } 39 | 40 | tests := map[string]tcase{ 41 | "test application/json": { 42 | Body: data, 43 | ContentType: "application/json", 44 | ExpectedCode: 201, 45 | }, 46 | "test application/geo+json": { 47 | Body: data, 48 | ContentType: "application/geo+json", 49 | ExpectedCode: 422, 50 | }, 51 | "test invalid geometry type": { 52 | Body: []byte(`{"type":"Feature","properties":{},"geometry":{"type:"Point","coorddinates":[1,1]}}`), 53 | ContentType: "application/json", 54 | ExpectedCode: 422, 55 | ExpectedError: "unsupported geometry type: Point", 56 | }, 57 | "test empty geofence": { 58 | Body: []byte(``), 59 | ContentType: "application/json", 60 | ExpectedCode: 422, 61 | ExpectedError: "empty geometry", 62 | }, 63 | "test faulty geofence": { 64 | Body: []byte(`{"foo":"bar"}`), 65 | ContentType: "application/json", 66 | ExpectedCode: 422, 67 | ExpectedError: "empty geometry", 68 | }, 69 | } 70 | 71 | for _, test := range tests { 72 | 73 | r := httptest.NewRequest( 74 | "POST", 75 | "/geofence", 76 | bytes.NewBuffer(test.Body), 77 | ) 78 | r.Header.Add("Content-Type", test.ContentType) 79 | 80 | resp, _ := app.Test(r, -1) 81 | defer resp.Body.Close() 82 | 83 | assert.Equal(t, test.ExpectedCode, resp.StatusCode) 84 | 85 | if test.ExpectedError != "" { 86 | body, err := io.ReadAll(resp.Body) 87 | if err != nil { 88 | t.Fatal("cannot read body") 89 | } 90 | 91 | e := &errors.ErrRequestError{} 92 | err = json.Unmarshal(body, e) 93 | if err != nil { 94 | t.Fatal("cannot unmarshal response") 95 | } 96 | 97 | e.Detail = test.ExpectedError 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /handlers/location/controller.go: -------------------------------------------------------------------------------- 1 | package location 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/iwpnd/detectr/database" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | type handler struct { 10 | DB database.Datastore 11 | Logger *zap.Logger 12 | } 13 | 14 | // RegisterRoutes to register routes with the fiber app 15 | func RegisterRoutes(app *fiber.App, db database.Datastore, logger *zap.Logger) { 16 | h := &handler{ 17 | DB: db, 18 | Logger: logger, 19 | } 20 | 21 | routes := app.Group("location") 22 | routes.Post("/", h.PostLocation) 23 | } 24 | -------------------------------------------------------------------------------- /handlers/location/post.go: -------------------------------------------------------------------------------- 1 | package location 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/iwpnd/detectr/errors" 9 | "github.com/iwpnd/detectr/models" 10 | "github.com/iwpnd/detectr/validation" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | func (h *handler) PostLocation(c *fiber.Ctx) error { 15 | start := time.Now() 16 | l := new(models.Location) 17 | 18 | if string(c.Request().Header.ContentType()) != "application/json" { 19 | return c.Status(fiber.StatusUnprocessableEntity).JSON(errors.NewRequestError(&errors.ErrRequestError{ 20 | Status: fiber.StatusUnprocessableEntity, 21 | Detail: "Content-type must be 'application/json'", 22 | })) 23 | } 24 | 25 | if err := c.BodyParser(l); err != nil { 26 | return c.Status(fiber.StatusUnprocessableEntity).JSON( 27 | errors.NewRequestError(&errors.ErrRequestError{Detail: err.Error()}), 28 | ) 29 | } 30 | 31 | h.Logger.Debug("Received Location", 32 | zap.Float64("latitude", l.Lat), 33 | zap.Float64("longitude", l.Lng), 34 | ) 35 | 36 | errs := validation.ValidateStruct(*l) 37 | if errs != nil { 38 | return c.Status(400).JSON(errors.NewRequestError(errs...)) 39 | } 40 | 41 | p := []float64{l.Lng, l.Lat} 42 | 43 | matches := h.DB.Intersects(p) 44 | elapsed := fmt.Sprint(time.Since(start)) 45 | 46 | r := &models.LocationResponse{ 47 | Data: models.LocationResponsePayload{ 48 | Elapsed: elapsed, 49 | Request: *l, 50 | Matches: matches, 51 | }, 52 | } 53 | 54 | h.Logger.Debug("Processed Location", 55 | zap.Float64("latitude", l.Lat), 56 | zap.Float64("longitude", l.Lng), 57 | zap.String("elapsed", elapsed), 58 | zap.Any("matches", &matches), 59 | ) 60 | 61 | return c.JSON(&r) 62 | } 63 | -------------------------------------------------------------------------------- /handlers/location/post_test.go: -------------------------------------------------------------------------------- 1 | package location 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/gofiber/fiber/v2" 12 | "github.com/iwpnd/detectr/database/memory" 13 | "github.com/iwpnd/detectr/logger" 14 | "github.com/iwpnd/detectr/models" 15 | 16 | geojson "github.com/paulmach/go.geojson" 17 | "github.com/stretchr/testify/assert" 18 | ) 19 | 20 | var data = []byte(`{"id":"foobar","type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[-3.0988311767578125,40.837710162420045],[-3.121490478515625,40.820045086716505],[-3.0978012084960938,40.80237530523985],[-3.0754852294921875,40.8210843390845],[-3.0988311767578125,40.837710162420045]],[[-3.0988311767578125,40.82783908257347],[-3.1098175048828125,40.820045086716505],[-3.0988311767578125,40.81147063339219],[-3.086471557617187,40.820304901335035],[-3.0988311767578125,40.82783908257347]]]}}`) 21 | 22 | func locationRequest(lat float64, lng float64) []byte { 23 | return []byte(fmt.Sprintf(`{"lat":%v,"lng":%v}`, lat, lng)) 24 | } 25 | 26 | func setupApp() (*fiber.App, error) { 27 | app := fiber.New() 28 | 29 | data, _ := geojson.UnmarshalFeature(data) 30 | 31 | _ = logger.SetLogLevel("warn") 32 | lg, _ := logger.New() 33 | 34 | db := memory.New() 35 | 36 | err := db.Create(data) 37 | if err != nil { 38 | fmt.Println(err.Error()) 39 | return nil, err 40 | } 41 | 42 | RegisterRoutes(app, db, lg) 43 | 44 | return app, nil 45 | } 46 | 47 | func TestLocation(t *testing.T) { 48 | app, err := setupApp() 49 | if err != nil { 50 | t.Fatal(err.Error()) 51 | } 52 | 53 | type tcase struct { 54 | Body []byte 55 | ContentType string 56 | ExpectedCode int 57 | ExpectedMatchCount int 58 | ExpectedMatches string 59 | } 60 | 61 | d, _ := geojson.UnmarshalFeature(data) 62 | f, _ := d.MarshalJSON() 63 | 64 | tests := map[string]tcase{ 65 | "south-east outside polygon, in bbox": { 66 | Body: locationRequest(40.80809251416925, -3.0816650390625), 67 | ContentType: "application/json", 68 | ExpectedCode: 200, 69 | ExpectedMatchCount: 0, 70 | }, 71 | "south-east inside polygon, inside bbox": { 72 | Body: locationRequest(40.81497849824719, -3.0878448486328125), 73 | ContentType: "application/json", 74 | ExpectedCode: 200, 75 | ExpectedMatchCount: 1, 76 | }, 77 | "south-east outside polygon, outside bbox": { 78 | Body: locationRequest(40.800945926051526, -3.0713653564453125), 79 | ContentType: "application/json", 80 | ExpectedCode: 200, 81 | ExpectedMatchCount: 0, 82 | }, 83 | "south outside polygon, outside bbox": { 84 | Body: locationRequest(40.79769722250925, -3.0978012084960938), 85 | ContentType: "application/json", 86 | ExpectedCode: 200, 87 | ExpectedMatchCount: 0, 88 | }, 89 | "south inside polygon, inside bbox": { 90 | Body: locationRequest(40.8067931917519, -3.098316192626953), 91 | ContentType: "application/json", 92 | ExpectedCode: 200, 93 | ExpectedMatchCount: 1, 94 | ExpectedMatches: string(f), 95 | }, 96 | "south-west outside polygon, inside bbox": { 97 | Body: locationRequest(40.807702720115294, -3.116168975830078), 98 | ContentType: "application/json", 99 | ExpectedCode: 200, 100 | ExpectedMatchCount: 0, 101 | }, 102 | "south-west outside polygon, outside bbox": { 103 | Body: locationRequest(40.80068603561921, -3.1250953674316406), 104 | ContentType: "application/json", 105 | ExpectedCode: 200, 106 | ExpectedMatchCount: 0, 107 | }, 108 | "south-west inside polygon, inside bbox": { 109 | Body: locationRequest(40.814198988751876, -3.10810089111328), 110 | ContentType: "application/json", 111 | ExpectedCode: 200, 112 | ExpectedMatchCount: 1, 113 | ExpectedMatches: string(f), 114 | }, 115 | "west outside polygon, outside bbox": { 116 | Body: locationRequest(40.8197852710803, -3.1266403198242188), 117 | ContentType: "application/json", 118 | ExpectedCode: 200, 119 | ExpectedMatchCount: 0, 120 | }, 121 | "west inside polygon, inside bbox": { 122 | Body: locationRequest(40.82017499415298, -3.1141090393066406), 123 | ContentType: "application/json", 124 | ExpectedCode: 200, 125 | ExpectedMatchCount: 1, 126 | ExpectedMatches: string(f), 127 | }, 128 | "north-west inside polygon, inside bbox": { 129 | Body: locationRequest(40.82667004158603, -3.1070709228515625), 130 | ContentType: "application/json", 131 | ExpectedCode: 200, 132 | ExpectedMatchCount: 1, 133 | ExpectedMatches: string(f), 134 | }, 135 | "north-west outside polygon, inside bbox": { 136 | Body: locationRequest(40.83199550584334, -3.1141090393066406), 137 | ContentType: "application/json", 138 | ExpectedCode: 200, 139 | ExpectedMatchCount: 0, 140 | }, 141 | "north inside polygon, inside bbox": { 142 | Body: locationRequest(40.83264492344398, -3.0988311767578125), 143 | ContentType: "application/json", 144 | ExpectedCode: 200, 145 | ExpectedMatchCount: 1, 146 | ExpectedMatches: string(f), 147 | }, 148 | "north outside polygon, outside bbox": { 149 | Body: locationRequest(40.8425152878029, -3.0988311767578125), 150 | ContentType: "application/json", 151 | ExpectedCode: 200, 152 | ExpectedMatchCount: 0, 153 | }, 154 | "north-east inside polygon, inside bbox": { 155 | Body: locationRequest(40.826799936046804, -3.0895614624023438), 156 | ContentType: "application/json", 157 | ExpectedCode: 200, 158 | ExpectedMatchCount: 1, 159 | ExpectedMatches: string(f), 160 | }, 161 | "north-east outside polygon, inside bbox": { 162 | Body: locationRequest(40.83160585222969, -3.0816650390625), 163 | ContentType: "application/json", 164 | ExpectedCode: 200, 165 | ExpectedMatchCount: 0, 166 | }, 167 | "north-east outside polygon, outside bbox": { 168 | Body: locationRequest(40.84147637129013, -3.07016372680664), 169 | ContentType: "application/json", 170 | ExpectedCode: 200, 171 | }, 172 | "east inside polygon, inside bbox": { 173 | Body: locationRequest(40.82056471493589, -3.080635070800781), 174 | ContentType: "application/json", 175 | ExpectedCode: 200, 176 | ExpectedMatchCount: 1, 177 | ExpectedMatches: string(f), 178 | }, 179 | "east outside polygon, outside bbox": { 180 | Body: locationRequest(40.8210843390845, -3.069477081298828), 181 | ContentType: "application/json", 182 | ExpectedCode: 200, 183 | ExpectedMatchCount: 0, 184 | }, 185 | "east outside polygon but in hole, inside bbox": { 186 | Body: locationRequest(40.81874599835864, -3.098487854003906), 187 | ContentType: "application/json", 188 | ExpectedCode: 200, 189 | ExpectedMatchCount: 0, 190 | }, 191 | "fails because wrong content type": { 192 | Body: locationRequest(40.81874599835864, -3.098487854003906), 193 | ContentType: "application/geo+json", 194 | ExpectedCode: 422, 195 | }, 196 | "fails because bad input": { 197 | Body: locationRequest(1140.81874599835864, -3.098487854003906), 198 | ContentType: "application/json", 199 | ExpectedCode: 400, 200 | }, 201 | } 202 | 203 | for _, test := range tests { 204 | req := httptest.NewRequest( 205 | "POST", 206 | "/location", 207 | bytes.NewBuffer(test.Body), 208 | ) 209 | req.Header.Add("Content-Type", test.ContentType) 210 | 211 | resp, _ := app.Test(req, -1) 212 | defer resp.Body.Close() 213 | 214 | b, _ := io.ReadAll(resp.Body) 215 | 216 | r := &models.LocationResponse{} 217 | err := json.Unmarshal(b, &r) 218 | if err != nil { 219 | t.Fatal("failed to unmarshal response body") 220 | } 221 | 222 | if test.ExpectedMatches != "" { 223 | rf, _ := r.Data.Matches[0].MarshalJSON() 224 | assert.Equal(t, string(rf), test.ExpectedMatches) 225 | } 226 | 227 | assert.Equal(t, len(r.Data.Matches), test.ExpectedMatchCount) 228 | assert.Equal(t, test.ExpectedCode, resp.StatusCode) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /logger/errors.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import "fmt" 4 | 5 | // ErrInvalidLogLevel ... 6 | type ErrInvalidLogLevel struct { 7 | Level string 8 | } 9 | 10 | // Error ... 11 | func (err ErrInvalidLogLevel) Error() string { 12 | return fmt.Sprintf("%s is an invalid log level", err.Level) 13 | } 14 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "strings" 5 | 6 | "go.uber.org/zap" 7 | "go.uber.org/zap/zapcore" 8 | ) 9 | 10 | var defaultConfig = zap.Config{ 11 | Encoding: "json", 12 | Level: zap.NewAtomicLevelAt(zapcore.DebugLevel), 13 | OutputPaths: []string{"stdout"}, 14 | ErrorOutputPaths: []string{"stderr"}, 15 | EncoderConfig: zapcore.EncoderConfig{ 16 | MessageKey: "message", 17 | 18 | LevelKey: "level", 19 | EncodeLevel: zapcore.CapitalLevelEncoder, 20 | 21 | TimeKey: "time", 22 | EncodeTime: zapcore.ISO8601TimeEncoder, 23 | }, 24 | } 25 | 26 | func getLogLevelFromString(level string) (zapcore.Level, error) { 27 | l := strings.ToLower(level) 28 | 29 | switch l { 30 | case "debug": 31 | return zapcore.DebugLevel, nil 32 | case "info": 33 | return zapcore.InfoLevel, nil 34 | case "warn": 35 | return zapcore.WarnLevel, nil 36 | case "error": 37 | return zapcore.ErrorLevel, nil 38 | default: 39 | return 0, &ErrInvalidLogLevel{Level: level} 40 | } 41 | } 42 | 43 | func SetLogLevel(level string) error { 44 | l, err := getLogLevelFromString(level) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | defaultConfig.Level = zap.NewAtomicLevelAt(l) 50 | return nil 51 | } 52 | 53 | func New() (*zap.Logger, error) { 54 | logger, err := defaultConfig.Build() 55 | 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return logger, nil 61 | } 62 | -------------------------------------------------------------------------------- /models/location.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Location ... 4 | type Location struct { 5 | Lat float64 `json:"lat" validate:"required,gte=-90,lte=90"` 6 | Lng float64 `json:"lng" validate:"required,gte=-180,lte=180"` 7 | } 8 | -------------------------------------------------------------------------------- /models/responses.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import geojson "github.com/paulmach/go.geojson" 4 | 5 | // Response ... 6 | type Response struct { 7 | Data interface{} `json:"data"` 8 | } 9 | 10 | // LocationResponsePayload ... 11 | type LocationResponsePayload struct { 12 | Elapsed string `json:"elapsed"` 13 | Request Location `json:"request"` 14 | Matches []*geojson.Feature `json:"matches"` 15 | } 16 | 17 | // LocationResponse ... 18 | type LocationResponse struct { 19 | Data LocationResponsePayload `json:"data"` 20 | } 21 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # detectr 2 | 3 | A minimal geofencing application build with [gofiber](https://github.com/gofiber/fiber), using an embedded 4 | [rtree](https://github.com/tidwall/rtree) and [geoindex](https://github.com/tidwall/geoindex) for fast 5 | geospatial lookups. 6 | 7 | Bring your own data, pass a location and receive the geofences that your location 8 | is currently in. 9 | 10 | ## Installation 11 | 12 | ### cli 13 | 14 | ```bash 15 | go install github.com/iwpnd/detectr/cmd/detectr-server@latest 16 | ``` 17 | 18 | ```bash 19 | ➜ detectr --help 20 | NAME: 21 | detectr - geofence application 22 | 23 | USAGE: 24 | detectr [global options] command [command options] [arguments...] 25 | 26 | COMMANDS: 27 | help, h Shows a list of commands or help for one command 28 | 29 | GLOBAL OPTIONS: 30 | --port value define port (default: 3000) 31 | --require-key use keyauth (default: false) 32 | --log-level value set loglevel (default: "error") 33 | --data value path to dataset to load with app 34 | --help, -h show help (default: false) 35 | ``` 36 | 37 | ## Usage 38 | 39 | ```bash 40 | # start up 41 | 42 | detectr --data my_featurecollection.geojson 43 | ``` 44 | 45 | ```bash 46 | # send a location 47 | 48 | curl -X POST -H "Content-Type: application/json" -d '{"lng":13.4042367,"lat":52.473091}' http://localhost:3000/location 49 | 50 | >> { 51 | "data": { 52 | "elapsed": "150.75µs", 53 | "request": { 54 | "lat": 52.473091, 55 | "lng": 13.4042367 56 | }, 57 | "matches": [ 58 | { 59 | "type": "Feature", 60 | "properties": {"id":"my geofence"}, 61 | "geometry": { 62 | "coordinates": [ 63 | [ 64 | [ 65 | 13.41493887975497, 66 | 52.47961028115867 67 | ], 68 | [ 69 | 13.393534522441712, 70 | 52.47961028115867 71 | ], 72 | [ 73 | 13.393534522441712, 74 | 52.466572160399664 75 | ], 76 | [ 77 | 13.41493887975497, 78 | 52.466572160399664 79 | ], 80 | [ 81 | 13.41493887975497, 82 | 52.47961028115867 83 | ] 84 | ] 85 | ], 86 | "type": "Polygon" 87 | } 88 | } 89 | ] 90 | } 91 | } 92 | ``` 93 | 94 | ## License 95 | 96 | MIT 97 | 98 | ## Acknowledgement 99 | 100 | - Triggered by this article on the UBER engineering blog 101 | about [UBERs highest query per second service](https://www.uber.com/en-DE/blog/go-geofence-highest-query-per-second-service/). 102 | - Heavily inspired by [Tile38](https://github.com/tidwall/tile38) and build because 103 | I wanted to understand how Tile38 works under the hood. 104 | 105 | ## Maintainer 106 | 107 | Benjamin Ramser - [@iwpnd](https://github.com/iwpnd) 108 | 109 | Project Link: [https://github.com/iwpnd/detectr](https://github.com/iwpnd/detectr) 110 | -------------------------------------------------------------------------------- /validation/validation.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "github.com/go-playground/validator/v10" 5 | "github.com/iwpnd/detectr/errors" 6 | ) 7 | 8 | // ValidateStruct ... 9 | func ValidateStruct(s interface{}) []*errors.ErrRequestError { 10 | var errs []*errors.ErrRequestError 11 | validate := validator.New() 12 | err := validate.Struct(s) 13 | if err != nil { 14 | for _, err := range err.(validator.ValidationErrors) { 15 | var element errors.ErrRequestError 16 | element.Source = err.Field() 17 | element.Title = "Invalid Attribute" 18 | element.Detail = err.Tag() 19 | element.Status = 400 20 | errs = append(errs, &element) 21 | } 22 | } 23 | return errs 24 | } 25 | --------------------------------------------------------------------------------