mirror of
https://github.com/Crocmagnon/lyon-transports.git
synced 2024-12-21 19:41:49 +01:00
initial revision
This commit is contained in:
commit
fb8a40579b
8 changed files with 385627 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.idea
|
18
go.mod
Normal file
18
go.mod
Normal 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
35
go.sum
Normal 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
95
main.go
Normal 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
88
main_test.go
Normal 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
224
tcl_stop.go
Normal 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
333788
testdata/passages.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
51378
testdata/stops.json
vendored
Normal file
51378
testdata/stops.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue