Skip to content

Commit

Permalink
add inclusion/exclusion filters for aircraft
Browse files Browse the repository at this point in the history
  • Loading branch information
its-felix committed May 12, 2024
1 parent 5877e32 commit 7b0d020
Show file tree
Hide file tree
Showing 12 changed files with 540 additions and 221 deletions.
1 change: 1 addition & 0 deletions cdk/lib/constructs/api-lambda-construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class ApiLambdaConstruct extends Construct {
props.dataBucket.grantRead(lambda, 'raw/ourairports_data/airports.csv');
props.dataBucket.grantRead(lambda, 'raw/ourairports_data/countries.csv');
props.dataBucket.grantRead(lambda, 'raw/ourairports_data/regions.csv');
props.dataBucket.grantRead(lambda, 'raw/LH_Public_Data/aircraft.json');

this.functionURL = new FunctionUrl(this, 'ApiLambdaFunctionUrl', {
function: lambda,
Expand Down
79 changes: 79 additions & 0 deletions go/api/data/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"cmp"
"context"
"encoding/csv"
"encoding/json"
"errors"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/explore-flights/monorepo/go/common/lufthansa"
"io"
"slices"
"strings"
)

Expand Down Expand Up @@ -138,6 +141,12 @@ type Airport struct {
Name string `json:"name"`
}

type Aircraft struct {
Code string `json:"code"`
EquipCode string `json:"equipCode"`
Name string `json:"name"`
}

type MinimalS3Client interface {
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
}
Expand Down Expand Up @@ -211,6 +220,42 @@ func (h *Handler) Airports(ctx context.Context) (AirportsResponse, error) {
return resp, nil
}

func (h *Handler) Aircraft(ctx context.Context) ([]Aircraft, error) {
excludeNames := []string{
"freighter",
"gulfstream",
"cessna",
"hawker",
"fokker",
}

aircraft, err := loadJson[[]lufthansa.Aircraft](ctx, h, "raw/LH_Public_Data/aircraft.json")
if err != nil {
return nil, err
}

result := make([]Aircraft, 0, len(aircraft))
for _, a := range aircraft {
if a.AirlineEquipCode == "" || a.AirlineEquipCode == "*" || len(a.Names.Name) < 1 {
continue
}

name := findName(a.Names, "EN")
lName := strings.ToLower(name)
if slices.ContainsFunc(excludeNames, func(s string) bool { return strings.Contains(lName, s) }) {
continue
}

result = append(result, Aircraft{
Code: a.AircraftCode,
EquipCode: a.AirlineEquipCode,
Name: name,
})
}

return result, err
}

func (h *Handler) loadCsv(ctx context.Context, name string) (*csvReader, error) {
resp, err := h.s3c.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(h.bucket),
Expand All @@ -227,6 +272,40 @@ func (h *Handler) loadCsv(ctx context.Context, name string) (*csvReader, error)
}, nil
}

func loadJson[T any](ctx context.Context, h *Handler, key string) (T, error) {
resp, err := h.s3c.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(h.bucket),
Key: aws.String(key),
})

if err != nil {
var def T
return def, err
}

defer resp.Body.Close()

var r T
return r, json.NewDecoder(resp.Body).Decode(&r)
}

func findName(names lufthansa.Names, lang string) string {
if len(names.Name) < 1 {
return ""
}

r := names.Name[0].Name
for _, n := range names.Name {
if n.LanguageCode == lang {
return n.Name
} else if n.LanguageCode == "EN" {
r = n.Name
}
}

return r
}

type csvReader struct {
r *csv.Reader
c io.Closer
Expand Down
1 change: 1 addition & 0 deletions go/api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ require (
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
)

replace github.com/explore-flights/monorepo/go/common v0.0.0 => ../common
2 changes: 2 additions & 0 deletions go/api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,7 @@ golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
3 changes: 2 additions & 1 deletion go/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ func main() {
dataHandler := data.NewHandler(s3c, bucket)

e := echo.New()
e.GET("/api/connections/:export", web.NewConnectionsEndpoint(connHandler))
e.POST("/api/connections/:export", web.NewConnectionsEndpoint(connHandler))
e.GET("/data/airports.json", web.NewAirportsHandler(dataHandler))
e.GET("/data/aircraft.json", web.NewAircraftHandler(dataHandler))

if err := run(ctx, e); err != nil {
panic(err)
Expand Down
34 changes: 31 additions & 3 deletions go/api/search/connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ type Connection struct {
Outgoing []Connection
}

type ConnectionOption interface {
Matches(f *common.Flight) bool
}

type WithIncludeAircraft []string

func (a WithIncludeAircraft) Matches(f *common.Flight) bool {
return slices.Contains(a, f.AircraftType)
}

type WithExcludeAircraft []string

func (a WithExcludeAircraft) Matches(f *common.Flight) bool {
return !slices.Contains(a, f.AircraftType)
}

type ConnectionsHandler struct {
fr *FlightRepo
}
Expand All @@ -20,7 +36,7 @@ func NewConnectionsHandler(fr *FlightRepo) *ConnectionsHandler {
return &ConnectionsHandler{fr}
}

func (ch *ConnectionsHandler) FindConnections(ctx context.Context, origins, destinations []string, minDeparture, maxDeparture time.Time, maxFlights int, minLayover, maxLayover, maxDuration time.Duration) ([]Connection, error) {
func (ch *ConnectionsHandler) FindConnections(ctx context.Context, origins, destinations []string, minDeparture, maxDeparture time.Time, maxFlights int, minLayover, maxLayover, maxDuration time.Duration, options ...ConnectionOption) ([]Connection, error) {
minDate := common.NewLocalDate(minDeparture.UTC())
maxDate := common.NewLocalDate(maxDeparture.Add(maxDuration).UTC())

Expand All @@ -45,11 +61,12 @@ func (ch *ConnectionsHandler) FindConnections(ctx context.Context, origins, dest
minLayover,
maxLayover,
maxDuration,
options,
true,
))
}

func findConnections(ctx context.Context, flightsByDeparture map[common.Departure][]*common.Flight, origins, destinations []string, minDeparture, maxDeparture time.Time, maxFlights int, minLayover, maxLayover, maxDuration time.Duration, initial bool) <-chan Connection {
func findConnections(ctx context.Context, flightsByDeparture map[common.Departure][]*common.Flight, origins, destinations []string, minDeparture, maxDeparture time.Time, maxFlights int, minLayover, maxLayover, maxDuration time.Duration, options []ConnectionOption, initial bool) <-chan Connection {
if maxFlights < 1 || maxDuration < 1 {
ch := make(chan Connection)
close(ch)
Expand Down Expand Up @@ -77,7 +94,7 @@ func findConnections(ctx context.Context, flightsByDeparture map[common.Departur
}

for _, f := range flightsByDeparture[d] {
if f.ServiceType != "J" || f.Duration() > maxDuration || f.DepartureTime.Compare(minDeparture) < 0 || f.DepartureTime.Compare(maxDeparture) > 0 {
if f.ServiceType != "J" || f.Duration() > maxDuration || f.DepartureTime.Compare(minDeparture) < 0 || f.DepartureTime.Compare(maxDeparture) > 0 || !allMatch(options, f) {
continue
}

Expand Down Expand Up @@ -114,6 +131,7 @@ func findConnections(ctx context.Context, flightsByDeparture map[common.Departur
minLayover,
maxLayover,
maxDuration-(f.Duration()+minLayover),
options,
false,
)

Expand Down Expand Up @@ -154,6 +172,16 @@ func findConnections(ctx context.Context, flightsByDeparture map[common.Departur
return ch
}

func allMatch(options []ConnectionOption, f *common.Flight) bool {
for _, opt := range options {
if !opt.Matches(f) {
return false
}
}

return true
}

func groupByDeparture(flightsByDate map[common.LocalDate][]*common.Flight) map[common.Departure][]*common.Flight {
result := make(map[common.Departure][]*common.Flight)
for _, flights := range flightsByDate {
Expand Down
77 changes: 37 additions & 40 deletions go/api/web/connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,68 +6,65 @@ import (
"github.com/explore-flights/monorepo/go/api/search"
"github.com/labstack/echo/v4"
"net/http"
"strconv"
"time"
)

type ConnectionsSearchRequest struct {
Origins []string `json:"origins"`
Destinations []string `json:"destinations"`
MinDeparture time.Time `json:"minDeparture"`
MaxDeparture time.Time `json:"maxDeparture"`
MaxFlights int `json:"maxFlights"`
MinLayoverMS int `json:"minLayoverMS"`
MaxLayoverMS int `json:"maxLayoverMS"`
MaxDurationMS int `json:"maxDurationMS"`
IncludeAircraft *[]string `json:"includeAircraft,omitempty"`
ExcludeAircraft *[]string `json:"excludeAircraft,omitempty"`
}

func NewConnectionsEndpoint(ch *search.ConnectionsHandler) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()

q := c.QueryParams()

origins := q["origin"]
destinations := q["destination"]
minDeparture, err := time.Parse(time.RFC3339, q.Get("minDeparture"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}

maxDeparture, err := time.Parse(time.RFC3339, q.Get("maxDeparture"))
if err != nil {
var req ConnectionsSearchRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}

maxFlights, err := strconv.Atoi(q.Get("maxFlights"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
minLayover := time.Duration(req.MinLayoverMS) * time.Millisecond
maxLayover := time.Duration(req.MaxLayoverMS) * time.Millisecond
maxDuration := time.Duration(req.MaxDurationMS) * time.Millisecond

minLayover, err := time.ParseDuration(q.Get("minLayover"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}

maxLayover, err := time.ParseDuration(q.Get("maxLayover"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}

maxDuration, err := time.ParseDuration(q.Get("maxDuration"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}

if len(origins) < 1 || len(origins) > 10 {
if len(req.Origins) < 1 || len(req.Origins) > 10 {
return echo.NewHTTPError(http.StatusBadRequest, "len(origins) must be between 1 and 10")
} else if len(destinations) < 1 || len(destinations) > 10 {
} else if len(req.Destinations) < 1 || len(req.Destinations) > 10 {
return echo.NewHTTPError(http.StatusBadRequest, "len(destinations) must be between 1 and 10")
} else if maxFlights > 4 {
} else if req.MaxFlights > 4 {
return echo.NewHTTPError(http.StatusBadRequest, "maxFlights must be <=4")
} else if maxDeparture.Add(maxDuration).Sub(minDeparture) > time.Hour*24*14 {
} else if req.MaxDeparture.Add(maxDuration).Sub(req.MinDeparture) > time.Hour*24*14 {
return echo.NewHTTPError(http.StatusBadRequest, "range must be <=14d")
}

options := make([]search.ConnectionOption, 0)
if req.IncludeAircraft != nil {
options = append(options, search.WithIncludeAircraft(*req.IncludeAircraft))
}

if req.ExcludeAircraft != nil {
options = append(options, search.WithExcludeAircraft(*req.ExcludeAircraft))
}

conns, err := ch.FindConnections(
ctx,
origins,
destinations,
minDeparture,
maxDeparture,
maxFlights,
req.Origins,
req.Destinations,
req.MinDeparture,
req.MaxDeparture,
req.MaxFlights,
minLayover,
maxLayover,
maxDuration,
options...,
)

if err != nil {
Expand Down
15 changes: 15 additions & 0 deletions go/api/web/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,18 @@ func NewAirportsHandler(dh *data.Handler) echo.HandlerFunc {
return c.JSON(http.StatusOK, airports)
}
}

func NewAircraftHandler(dh *data.Handler) echo.HandlerFunc {
return func(c echo.Context) error {
aircraft, err := dh.Aircraft(c.Request().Context())
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return echo.NewHTTPError(http.StatusRequestTimeout, err)
}

return echo.NewHTTPError(http.StatusInternalServerError)
}

return c.JSON(http.StatusOK, aircraft)
}
}
37 changes: 37 additions & 0 deletions ui/src/components/util/state/use-async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { useEffect, useState } from 'react';

interface AsyncStateLoading {
loading: true;
error: undefined;
}

interface AsyncStateDone {
loading: false;
error: undefined;
}

interface AsyncStateError {
loading: false;
error: unknown;
}

export type AsyncState = AsyncStateLoading | AsyncStateDone | AsyncStateError;

export function useAsync<T>(initial: T, fn: () => Promise<T>, deps: React.DependencyList) {
const [state, setState] = useState<AsyncState>({
loading: true,
error: undefined,
});

const [value, setValue] = useState(initial);

useEffect(() => {
setState({ loading: true, error: undefined });
fn()
.then((r) => setValue(r))
.then(() => setState({ loading: false, error: undefined }))
.catch((e) => setState({ loading: false, error: e }));
}, deps);

return [value, state] as const;
}
Loading

0 comments on commit 7b0d020

Please sign in to comment.