locationcode/airports.go

271 lines
6.3 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path"
"strconv"
"strings"
"syscall"
"time"
"github.com/golang/geo/s2"
alphafoxtrot "github.com/grumpypixel/go-airport-finder"
"github.com/labstack/echo/v4"
slogecho "github.com/samber/slog-echo"
"go.ntppool.org/common/logger"
"go.ntppool.org/common/tracing"
"go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
"golang.org/x/sync/errgroup"
"go.askask.com/locationcode/types"
)
type Finder struct {
f *alphafoxtrot.AirportFinder
}
func main() {
log := logger.Setup()
dataDir := flag.String("data-dir", "./data", "Data cache directory")
flag.Parse()
err := validateData(*dataDir)
if err != nil {
log.Error("data error", "err", err)
os.Exit(2)
}
finder := &Finder{
f: alphafoxtrot.NewAirportFinder(),
}
// LoadOptions come with preset filepaths
options := alphafoxtrot.PresetLoadOptions(*dataDir)
// filter := alphafoxtrot.AirportTypeLarge | alphafoxtrot.AirportTypeMedium
filter := alphafoxtrot.AirportTypeRunways
// Load the data into memory
if err := finder.f.Load(options, filter); len(err) > 0 {
log.Error("finder load error", "err", err)
}
if args := flag.Args(); len(args) > 0 {
if len(args) != 4 {
fmt.Printf("[cc] [lat] [lng] [radius]\n")
os.Exit(2)
}
cc := strings.ToUpper(args[0])
latitude, err := strconv.ParseFloat(args[1], 64)
if err != nil {
fmt.Printf("could not parse %s as latitude: %s", args[1], err)
os.Exit(2)
}
longitude, err := strconv.ParseFloat(args[2], 64)
if err != nil {
fmt.Printf("could not parse %s as longitude: %s", args[2], err)
os.Exit(2)
}
radiusKM, err := strconv.ParseFloat(args[3], 64)
if err != nil {
fmt.Printf("could not parse %s as radius: %s", args[3], err)
os.Exit(2)
}
airports, err := finder.GetAirports(context.Background(), cc, radiusKM, latitude, longitude)
if err != nil {
fmt.Printf("GetAirports error: %s\n", err)
}
for _, a := range airports {
fmt.Printf("%s\t%s (%0f)\n", a.Code, a.Name, a.Distance)
}
os.Exit(0)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
tpShutdown, err := tracing.InitTracer(ctx, &tracing.TracerConfig{
ServiceName: "locationcode",
})
if err != nil {
log.Error("could not initialize tracer", "err", err)
}
e := echo.New()
e.Use(otelecho.Middleware("locationcode"))
e.Use(slogecho.NewWithConfig(log,
slogecho.Config{
WithTraceID: false, // done by logger already
DefaultLevel: slog.LevelInfo,
ClientErrorLevel: slog.LevelWarn,
ServerErrorLevel: slog.LevelError,
// WithRequestHeader: true,
Filters: []slogecho.Filter{
func(c echo.Context) bool {
return !(c.Request().URL.Path == "/" || c.Request().URL.Path == "/__healthz")
},
},
},
))
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "location code service")
})
e.GET("/v1/code", func(c echo.Context) error {
ctx := c.Request().Context()
countryISOCode := c.QueryParam("cc")
countryISOCode = strings.ToUpper(countryISOCode)
radiusString := c.QueryParam("radius")
var radiusKM float64
if len(radiusString) > 0 {
radiusKM, _ = strconv.ParseFloat(radiusString, 64)
}
if radiusKM < 150 {
radiusKM = 150
} else {
radiusKM = radiusKM * 1.5
}
latitude, longitude, err := getLatLng(c)
if err != nil {
return c.String(http.StatusBadRequest, fmt.Sprintf("invalid lat or lng: %s", err))
}
airports, err := finder.GetAirports(ctx, countryISOCode, radiusKM, latitude, longitude)
if err != nil {
return err
}
return c.JSON(http.StatusOK, airports)
})
errg, ctx := errgroup.WithContext(ctx)
errg.Go(func() error {
return e.Start(":8520")
})
errg.Go(func() error {
<-ctx.Done()
log.InfoContext(ctx, "shutting down server")
return e.Shutdown(ctx)
})
err = errg.Wait()
if err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.ErrorContext(ctx, "server error", "err", err)
}
}
shutdownCtx, shCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shCancel()
if err := tpShutdown(shutdownCtx); err != nil {
log.ErrorContext(shutdownCtx, "failed to shutdown tracer", "err", err)
}
}
func (f *Finder) GetAirports(ctx context.Context, cc string, radiusKM, latitude, longitude float64) ([]*types.Airport, error) {
log := logger.FromContext(ctx)
log.InfoContext(ctx, fmt.Sprintf("GetAirports(%s %.2f %.4f %.4f)", cc, radiusKM, latitude, longitude))
ipLocation := s2.LatLngFromDegrees(latitude, longitude)
maxResults := 500 // some countries have a lot of airports without local codes
radiusInMeters := radiusKM * 1000
airportsRaw := f.f.FindNearestAirportsByCountry(
cc,
latitude, longitude,
radiusInMeters,
maxResults,
alphafoxtrot.AirportTypeAll, // filtered at load time
)
airports := []*alphafoxtrot.Airport{}
for _, ap := range airportsRaw {
if len(ap.IATACode) == 0 {
continue
}
airports = append(airports, ap)
}
r := []*types.Airport{}
for _, airport := range airports {
// fmt.Printf("%d %s: %+v\n", i, airport.Name, airport)
a := types.NewAirport(airport)
ll := s2.LatLngFromDegrees(airport.LatitudeDeg, airport.LongitudeDeg)
a.Distance = float64(ipLocation.Distance(ll)) * 6371.01
r = append(r, a)
}
types.SortAirports(r)
maxReturns := 20
if len(r) > maxReturns {
r = r[0:maxReturns]
}
log.InfoContext(ctx, "got airports", "count", len(airportsRaw), "filtered", len(airports), "returning", len(r))
return r, nil
}
func validateData(dataDir string) error {
downloadFiles := false
for _, filename := range alphafoxtrot.OurAirportsFiles {
filepath := path.Join(dataDir, filename)
if _, err := os.Stat(filepath); os.IsNotExist(err) {
downloadFiles = true
break
}
}
if downloadFiles {
fmt.Println("Downloading CSV files from OurAirports.com...")
err := DownloadDatabase(dataDir)
if err != nil {
return err
}
}
return nil
}
func getLatLng(c echo.Context) (float64, float64, error) {
latitudeStr := c.QueryParam("lat") // 37.3793
longitudeStr := c.QueryParam("lng") // -122.12
latitude, err := strconv.ParseFloat(latitudeStr, 64)
if err != nil {
return 0, 0, err
}
longitude, err := strconv.ParseFloat(longitudeStr, 64)
if err != nil {
return 0, 0, err
}
return latitude, longitude, nil
}