Skip to content

Commit 0a92637

Browse files
committed
feat(go): add Go support with reverse geocoding functionality, including tests and CI integration
1 parent 6729a3a commit 0a92637

File tree

10 files changed

+582
-0
lines changed

10 files changed

+582
-0
lines changed

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ on:
66
paths:
77
- "libs/javascript/**"
88
- "libs/python/**"
9+
- "libs/go/**"
910
- ".github/workflows/**"
1011
pull_request:
1112
paths:
1213
- "libs/javascript/**"
1314
- "libs/python/**"
15+
- "libs/go/**"
1416
- ".github/workflows/**"
1517
workflow_dispatch:
1618

@@ -23,6 +25,7 @@ jobs:
2325
outputs:
2426
javascript: ${{ steps.filter.outputs.javascript }}
2527
python: ${{ steps.filter.outputs.python }}
28+
go: ${{ steps.filter.outputs.go }}
2629
steps:
2730
- name: Checkout repository
2831
uses: actions/checkout@v4
@@ -36,6 +39,8 @@ jobs:
3639
- 'libs/javascript/**'
3740
python:
3841
- 'libs/python/**'
42+
go:
43+
- 'libs/go/**'
3944
4045
javascript:
4146
name: JavaScript checks
@@ -91,3 +96,23 @@ jobs:
9196
- name: Run tests
9297
run: pytest
9398

99+
go:
100+
name: Go checks
101+
needs: changes
102+
if: needs.changes.outputs.go == 'true'
103+
runs-on: ubuntu-latest
104+
defaults:
105+
run:
106+
working-directory: libs/go
107+
steps:
108+
- name: Checkout repository
109+
uses: actions/checkout@v4
110+
111+
- name: Setup Go
112+
uses: actions/setup-go@v5
113+
with:
114+
go-version: "1.22"
115+
116+
- name: Run tests
117+
run: go test ./...
118+

libs/go/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# lakhua (Go)
2+
3+
Offline reverse geocoding for India, optimized for fast in-memory lookups using H3 indexes.
4+
5+
## Features
6+
7+
- Offline-first (no API keys, no network dependency)
8+
- In-memory lookup with singleton loader
9+
- Parent-resolution fallback (`5 -> 4`)
10+
- Debug timings for load and lookup paths
11+
12+
## Installation
13+
14+
```bash
15+
go get github.com/aialok/lakhua/libs/go
16+
```
17+
18+
## Quick Start
19+
20+
```go
21+
package main
22+
23+
import (
24+
"fmt"
25+
26+
lakhua "github.com/aialok/lakhua/libs/go"
27+
)
28+
29+
func main() {
30+
result := lakhua.Geocode(28.6139, 77.2090, nil)
31+
if result != nil {
32+
fmt.Println(result.City, result.State)
33+
}
34+
}
35+
```
36+
37+
## API
38+
39+
- `Geocode(lat, lon float64, options *GeocodeOptions) *GeocodeResult`
40+
- `GeocodeH3(h3Index string, options *GeocodeOptions) *GeocodeResult`
41+
- `GetGeocoder() *ReverseGeocoder`
42+
- `GetDataLoader() *ReverseGeoDataLoader`
43+
44+
## Options
45+
46+
```go
47+
type GeocodeOptions struct {
48+
Resolution int // default: 5
49+
Fallback *bool // default: true
50+
Debug bool // default: false
51+
}
52+
```
53+
54+
## Data files
55+
56+
The library expects the following files under `libs/go/data`:
57+
58+
- `reverse_geo_4.json`
59+
- `reverse_geo_5.json`
60+
61+
## Development
62+
63+
```bash
64+
go test ./...
65+
```
66+

libs/go/examples/basic/main.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
6+
lakhua "github.com/aialok/lakhua/libs/go"
7+
)
8+
9+
func main() {
10+
result := lakhua.Geocode(28.6139, 77.2090, &lakhua.GeocodeOptions{Debug: true})
11+
if result == nil {
12+
fmt.Println("no match")
13+
return
14+
}
15+
fmt.Printf("city=%s state=%s matched_h3=%s matched_resolution=%d\n", result.City, result.State, result.MatchedH3, result.MatchedResolution)
16+
}

libs/go/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/aialok/lakhua/libs/go
2+
3+
go 1.21
4+
5+
require github.com/uber/h3-go/v3 v3.7.1

libs/go/go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
2+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
6+
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
7+
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
8+
github.com/uber/h3-go/v3 v3.7.1 h1:qGAnkRKXHeuaGuLDktcouROiNDE1PgZTgiZGMBwVnSc=
9+
github.com/uber/h3-go/v3 v3.7.1/go.mod h1:XS+EMzW0EmjL/aioQsvLIYJRtC7/lodai5l8SNmlYIs=
10+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
11+
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
12+
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"runtime"
7+
)
8+
9+
const (
10+
MinResolution = 4
11+
MaxResolution = 5
12+
DefaultResolution = MaxResolution
13+
DataDirName = "data"
14+
DataFilePrefix = "reverse_geo_"
15+
)
16+
17+
var SupportedResolutions = []int{4, 5}
18+
19+
func ClampResolution(resolution int) int {
20+
if resolution < MinResolution {
21+
return MinResolution
22+
}
23+
if resolution > MaxResolution {
24+
return MaxResolution
25+
}
26+
return resolution
27+
}
28+
29+
func dataDirPath() string {
30+
_, currentFile, _, ok := runtime.Caller(0)
31+
if !ok {
32+
return filepath.Join(DataDirName)
33+
}
34+
// internal/config -> module root
35+
return filepath.Join(filepath.Dir(currentFile), "..", "..", DataDirName)
36+
}
37+
38+
func DataFilePath(resolution int) string {
39+
return filepath.Join(dataDirPath(), fmt.Sprintf("%s%d.json", DataFilePrefix, resolution))
40+
}

libs/go/internal/config/store.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
)
7+
8+
type LocationDetails struct {
9+
City string `json:"city"`
10+
State string `json:"state"`
11+
District *string `json:"district,omitempty"`
12+
Pincode *string `json:"pincode,omitempty"`
13+
}
14+
15+
type ReverseGeoStore map[string]LocationDetails
16+
17+
func ReadReverseGeoStore(resolution int) ReverseGeoStore {
18+
filePath := DataFilePath(resolution)
19+
if _, err := os.Stat(filePath); err != nil {
20+
return ReverseGeoStore{}
21+
}
22+
23+
raw, err := os.ReadFile(filePath)
24+
if err != nil {
25+
return ReverseGeoStore{}
26+
}
27+
28+
store := ReverseGeoStore{}
29+
if err := json.Unmarshal(raw, &store); err != nil {
30+
return ReverseGeoStore{}
31+
}
32+
return store
33+
}

libs/go/internal/loader/loader.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package loader
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
"time"
7+
8+
"github.com/aialok/lakhua/libs/go/internal/config"
9+
)
10+
11+
type DataLoader struct {
12+
once sync.Once
13+
stores map[int]config.ReverseGeoStore
14+
testOverride map[int]config.ReverseGeoStore
15+
mu sync.RWMutex
16+
}
17+
18+
var (
19+
defaultLoader *DataLoader
20+
defaultLoaderOnce sync.Once
21+
)
22+
23+
func GetDefault() *DataLoader {
24+
defaultLoaderOnce.Do(func() {
25+
defaultLoader = &DataLoader{
26+
stores: make(map[int]config.ReverseGeoStore),
27+
}
28+
})
29+
return defaultLoader
30+
}
31+
32+
func (l *DataLoader) loadAllStoresOnce(debug bool) {
33+
l.once.Do(func() {
34+
startedAt := time.Now()
35+
for _, resolution := range config.SupportedResolutions {
36+
l.stores[resolution] = config.ReadReverseGeoStore(resolution)
37+
}
38+
if debug {
39+
fmt.Printf("[lakhua][debug] loaded all stores into memory in %.3fms\n", time.Since(startedAt).Seconds()*1000)
40+
}
41+
})
42+
}
43+
44+
func (l *DataLoader) LoadResolutionStore(resolution int, debug bool) config.ReverseGeoStore {
45+
l.mu.RLock()
46+
override, ok := l.testOverride[resolution]
47+
l.mu.RUnlock()
48+
if ok {
49+
if debug {
50+
fmt.Printf("[lakhua][debug] using test override store for r%d\n", resolution)
51+
}
52+
return override
53+
}
54+
55+
l.loadAllStoresOnce(debug)
56+
57+
startedAt := time.Now()
58+
store := l.stores[resolution]
59+
if store == nil {
60+
store = config.ReverseGeoStore{}
61+
}
62+
if debug {
63+
fmt.Printf("[lakhua][debug] fetched in-memory store r%d in %.3fms\n", resolution, time.Since(startedAt).Seconds()*1000)
64+
}
65+
return store
66+
}
67+
68+
func (l *DataLoader) SetStoresForTesting(stores map[int]config.ReverseGeoStore) {
69+
l.mu.Lock()
70+
defer l.mu.Unlock()
71+
l.testOverride = stores
72+
}
73+
74+
func (l *DataLoader) ClearStoreCache() {
75+
l.mu.Lock()
76+
defer l.mu.Unlock()
77+
l.stores = make(map[int]config.ReverseGeoStore)
78+
l.once = sync.Once{}
79+
}

0 commit comments

Comments
 (0)