initial revision

This commit is contained in:
Gabriel Augendre 2024-09-11 20:17:57 +02:00
commit fb8a40579b
8 changed files with 385627 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.idea

18
go.mod Normal file
View file

@ -0,0 +1,18 @@
module github.com/Crocmagnon/lyon-transports
go 1.23.1
require (
github.com/carlmjohnson/requests v0.24.2
github.com/danielgtaylor/huma/v2 v2.22.1
github.com/jarcoal/httpmock v1.3.1
gotest.tools/v3 v3.5.1
)
require (
github.com/google/go-cmp v0.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/cobra v1.8.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/net v0.27.0 // indirect
)

35
go.sum Normal file
View file

@ -0,0 +1,35 @@
github.com/carlmjohnson/requests v0.24.2 h1:JDakhAmTIKL/qL/1P7Kkc2INGBJIkIFP6xUeUmPzLso=
github.com/carlmjohnson/requests v0.24.2/go.mod h1:duYA/jDnyZ6f3xbcF5PpZ9N8clgopubP2nK5i6MVMhU=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/danielgtaylor/huma/v2 v2.22.1 h1:fXhyjGSj5u5VeI+laa+e+7OxiQsP9RC55/tWZZvI4YA=
github.com/danielgtaylor/huma/v2 v2.22.1/go.mod h1:2NZmGf/A+SstJYQlq0Xp4nsTDCmPvKS2w9vI8c9sf1A=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=

95
main.go Normal file
View file

@ -0,0 +1,95 @@
package main
import (
"context"
"errors"
"fmt"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/danielgtaylor/huma/v2/humacli"
"net/http"
"time"
)
type Options struct {
Host string `help:"Host to listen to" default:"127.0.0.1"`
Port int `help:"Port to listen on" default:"8888"`
GrandLyonUsername string `help:"Grand Lyon username" short:"u" required:"true"`
GrandLyonPassword string `help:"Grand Lyon password" short:"p" required:"true"`
}
type statusOutput struct {
Body struct {
Status string `json:"status" example:"ok" doc:"API status"`
}
}
type stopOutput struct {
Body Passages
}
func addRoutes(api huma.API, glConfig GrandLyonConfig, now func() time.Time) {
huma.Register(api, huma.Operation{
OperationID: "healthcheck",
Method: http.MethodGet,
Path: "/",
Summary: "Get API status",
Description: "Get the status of the API.",
}, func(ctx context.Context, input *struct{}) (*statusOutput, error) {
resp := &statusOutput{}
resp.Body.Status = "ok"
return resp, nil
})
huma.Get(api, "/tcl/stop/{stopID}", func(ctx context.Context, input *struct {
StopID int `path:"stopID" doc:"Stop id to monitor. Can be obtained using https://data.grandlyon.com/jeux-de-donnees/points-arret-reseau-transports-commun-lyonnais/donnees"`
}) (*stopOutput, error) {
passages, err := getPassages(ctx, glConfig, now, input.StopID)
if errors.Is(err, errNoPassageFound) {
return nil, huma.NewError(http.StatusNotFound, "no passage found")
}
if err != nil {
return nil, err
}
return &stopOutput{Body: *passages}, nil
})
}
func main() {
// Create a CLI app which takes a port option.
cli := humacli.New(func(hooks humacli.Hooks, options *Options) {
// Create a new router & API
router := http.NewServeMux()
api := humago.New(router, huma.DefaultConfig("My API", "1.0.0"))
server := http.Server{
Addr: fmt.Sprintf("%s:%d", options.Host, options.Port),
Handler: router,
}
glConfig := GrandLyonConfig{
Username: options.GrandLyonUsername,
Password: options.GrandLyonPassword,
}
addRoutes(api, glConfig, time.Now)
hooks.OnStart(func() {
fmt.Printf("Starting server on %s...\n", server.Addr)
if err := server.ListenAndServe(); err != nil {
fmt.Printf("Error running server: %s\n", err)
}
})
hooks.OnStop(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
fmt.Printf("Error shutting down server: %s\n", err)
}
})
})
cli.Run()
}

88
main_test.go Normal file
View file

@ -0,0 +1,88 @@
package main
import (
"encoding/json"
"github.com/danielgtaylor/huma/v2/humatest"
"github.com/jarcoal/httpmock"
"gotest.tools/v3/assert"
"net/http"
"testing"
"time"
)
func TestGetStatus(t *testing.T) {
_, api := humatest.New(t)
addRoutes(api, GrandLyonConfig{}, nil)
resp := api.Get("/")
assert.Equal(t, resp.Code, http.StatusOK)
}
func TestGetStop(t *testing.T) {
_, api := humatest.New(t)
transport := httpmock.NewMockTransport()
client := &http.Client{
Transport: transport,
}
config := GrandLyonConfig{
Client: client,
}
now := func() time.Time {
location, err := time.LoadLocation("Europe/Paris")
if err != nil {
t.Errorf("Could not load Europe/Paris")
}
return time.Date(2022, 8, 25, 8, 23, 10, 0, location)
}
transport.RegisterResponder(http.MethodGet,
"https://download.data.grandlyon.com/ws/rdata/tcl_sytral.tclarret/all.json?maxfeatures=-1",
httpmock.NewBytesResponder(http.StatusOK, httpmock.File("./testdata/stops.json").Bytes()))
transport.RegisterResponder(http.MethodGet,
"https://download.data.grandlyon.com/ws/rdata/tcl_sytral.tclpassagearret/all.json?maxfeatures=-1",
httpmock.NewBytesResponder(http.StatusOK, httpmock.File("./testdata/passages.json").Bytes()))
addRoutes(api, config, now)
t.Run("stop not found", func(t *testing.T) {
resp := api.Get("/tcl/stop/0")
assert.Equal(t, resp.Code, http.StatusNotFound)
})
t.Run("stop exists", func(t *testing.T) {
resp := api.Get("/tcl/stop/290")
assert.Equal(t, resp.Code, http.StatusOK)
var passages Passages
err := json.Unmarshal(resp.Body.Bytes(), &passages)
assert.NilError(t, err)
assert.DeepEqual(t, passages, Passages{
Passages: []Passage{
{
Ligne: "37",
Delays: []string{"Passé", "Proche"},
Destination: Stop{
ID: 46642,
Name: "Charpennes",
},
},
{
Ligne: "C17",
Delays: []string{"Proche", "10 min"},
Destination: Stop{
ID: 46644,
Name: "Charpennes",
},
},
},
Stop: Stop{
ID: 290,
Name: "Buers - Salengro",
},
})
})
}

224
tcl_stop.go Normal file
View file

@ -0,0 +1,224 @@
package main
import (
"context"
"errors"
"fmt"
"github.com/carlmjohnson/requests"
"net/http"
"regexp"
"slices"
"time"
)
type GrandLyonConfig struct {
Username string
Password string
Client *http.Client
}
type Stop struct {
ID int `json:"id" example:"290"`
Name string `json:"name" example:"Grange Blanche"`
}
func NewStop(tclStop TCLStop) Stop {
return Stop{
ID: tclStop.Id,
Name: tclStop.Nom,
}
}
type Passage struct {
Ligne string `json:"ligne" example:"49A"`
Delays []string `json:"delays" example:"53 min"`
Destination Stop `json:"destination"`
}
type Passages struct {
Passages []Passage `json:"passages"`
Stop Stop `json:"stop"`
}
type delay int
func (d delay) String() string {
switch d {
case -2:
return "Passé"
case -1:
return "Proche"
default:
return fmt.Sprintf("%d min", d)
}
}
var errNoPassageFound = errors.New("no passage found")
func getPassages(ctx context.Context, config GrandLyonConfig, now func() time.Time, stopID int) (*Passages, error) {
client := config.Client
if client == nil {
client = &http.Client{}
}
var tclPassages TCLPassages
err := requests.URL("https://download.data.grandlyon.com/ws/rdata/tcl_sytral.tclpassagearret/all.json?maxfeatures=-1").
Client(client).
BasicAuth(config.Username, config.Password).
ToJSON(&tclPassages).
Fetch(ctx)
if err != nil {
return nil, fmt.Errorf("fetching passages: %w", err)
}
type passageKey struct {
line string
destination int
}
stops := map[int]TCLStop{stopID: {}}
passages := make(map[passageKey][]delay)
for _, passage := range tclPassages.Values {
if passage.Id != stopID || passage.Type != "E" {
continue
}
// Remove letter suffix to group by commercial line name
line := regexp.MustCompile("[A-Z]$").ReplaceAllString(passage.Ligne, "")
destination := passage.Idtarretdestination
stops[destination] = TCLStop{}
key := passageKey{line: line, destination: destination}
delays := passages[key]
delays = append(delays, getDelay(passage.Heurepassage, now()))
passages[key] = delays
}
if len(passages) == 0 {
return nil, errNoPassageFound
}
var tclStops TCLStops
err = requests.URL("https://download.data.grandlyon.com/ws/rdata/tcl_sytral.tclarret/all.json?maxfeatures=-1").
Client(client).
BasicAuth(config.Username, config.Password).
ToJSON(&tclStops).
Fetch(ctx)
if err != nil {
return nil, fmt.Errorf("fetching stops: %w", err)
}
updated := 0
for _, stop := range tclStops.Values {
if _, stopToUpdate := stops[stop.Id]; stopToUpdate {
stops[stop.Id] = stop
updated++
}
if updated == len(stops) {
break
}
}
resPassages := make([]Passage, 0, len(passages))
for key, delays := range passages {
slices.Sort(delays)
delaysStr := make([]string, len(delays))
for i, delay := range delays {
delaysStr[i] = delay.String()
}
resPassages = append(resPassages, Passage{
Ligne: key.line,
Delays: delaysStr,
Destination: NewStop(stops[key.destination]),
})
}
slices.SortFunc(resPassages, func(a, b Passage) int {
if a.Ligne < b.Ligne {
return -1
}
return 1
})
return &Passages{
Passages: resPassages,
Stop: NewStop(stops[stopID]),
}, nil
}
func getDelay(heurepassage string, now time.Time) delay {
location, err := time.LoadLocation("Europe/Paris")
if err != nil {
location = time.UTC
}
passage, err := time.ParseInLocation("2006-01-02 15:04:05", heurepassage, location)
if err != nil {
return delay(-1)
}
if passage.Before(now) {
return delay(-2)
}
dur := passage.Sub(now)
minutes := int(dur.Minutes())
if minutes <= 0 {
return delay(-1)
}
return delay(minutes)
}
type TCLPassage struct {
Coursetheorique string `json:"coursetheorique"`
Delaipassage string `json:"delaipassage"`
Direction string `json:"direction"`
Gid int `json:"gid"`
Heurepassage string `json:"heurepassage"`
Id int `json:"id"`
Idtarretdestination int `json:"idtarretdestination"`
LastUpdateFme string `json:"last_update_fme"`
Ligne string `json:"ligne"`
Type string `json:"type"`
}
type TCLPassages struct {
Fields []string `json:"fields"`
LayerName string `json:"layer_name"`
NbResults int `json:"nb_results"`
TableAlias interface{} `json:"table_alias"`
TableHref string `json:"table_href"`
Values []TCLPassage `json:"values"`
}
type TCLStop struct {
Adresse string `json:"adresse"`
Ascenseur bool `json:"ascenseur"`
Commune *string `json:"commune"`
Desserte string `json:"desserte"`
Escalator bool `json:"escalator"`
Gid int `json:"gid"`
Id int `json:"id"`
Insee *string `json:"insee"`
LastUpdate string `json:"last_update"`
LastUpdateFme string `json:"last_update_fme"`
Lat float64 `json:"lat"`
LocaliseFaceAAdresse bool `json:"localise_face_a_adresse"`
Lon float64 `json:"lon"`
Nom string `json:"nom"`
Pmr bool `json:"pmr"`
}
type TCLStops struct {
Fields []string `json:"fields"`
LayerName string `json:"layer_name"`
NbResults int `json:"nb_results"`
TableAlias interface{} `json:"table_alias"`
TableHref string `json:"table_href"`
Values []TCLStop `json:"values"`
}

333788
testdata/passages.json vendored Normal file

File diff suppressed because it is too large Load diff

51378
testdata/stops.json vendored Normal file

File diff suppressed because it is too large Load diff