diff --git a/db/migrations/00009_add_netex_data.sql b/db/migrations/00009_add_netex_data.sql new file mode 100644 index 0000000..54d90d9 --- /dev/null +++ b/db/migrations/00009_add_netex_data.sql @@ -0,0 +1,64 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE NETEX_StopPlace ( + id VARCHAR(255) PRIMARY KEY, + modification VARCHAR(50), + created_timestamp TIMESTAMPTZ, + changed_timestamp TIMESTAMPTZ, + valid_from_date TIMESTAMPTZ, + valid_to_date TIMESTAMPTZ, + name VARCHAR(255), + name_lang VARCHAR(10), + longitude DECIMAL(9,6), + latitude DECIMAL(8,6), + transport_mode VARCHAR(50), + other_transport_modes TEXT, + stop_place_type VARCHAR(50), + weighting VARCHAR(50), + UNIQUE (id) +); + +CREATE TABLE NETEX_Quay ( + id VARCHAR(255) PRIMARY KEY, + changed_timestamp TIMESTAMPTZ, + name VARCHAR(255), + name_lang VARCHAR(10), + longitude DECIMAL(9,6), + latitude DECIMAL(8,6), + postal_region VARCHAR(50), + site_ref_stopplace_id VARCHAR(255) REFERENCES NETEX_StopPlace(id) ON DELETE SET NULL, + transport_mode VARCHAR(50), + UNIQUE (id) +); + +CREATE TABLE NETEX_StopPlace_QuayRef ( + stop_place_id VARCHAR(255) REFERENCES NETEX_StopPlace(id) ON DELETE CASCADE, + quay_id VARCHAR(255) REFERENCES NETEX_Quay(id) ON DELETE CASCADE, + quay_ref_version VARCHAR(50), + PRIMARY KEY (stop_place_id, quay_id), + UNIQUE (stop_place_id, quay_id) +); + +CREATE TABLE NETEX_Line ( + id TEXT PRIMARY KEY, -- ex: "NAOLIBORG:Line:3B:LOC" + version TEXT, + name TEXT, + short_name TEXT, + transport_mode TEXT, + public_code TEXT, + private_code TEXT, + colour TEXT, + text_colour TEXT, + route_sort_order INTEGER, + UNIQUE (id) +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE NETEX_StopPlace; +DROP TABLE NETEX_Quay; +DROP TABLE NETEX_StopPlace_QuayRef; +DROP TABLE NETEX_Line; +-- +goose StatementEnd diff --git a/handlers/naolib.go b/handlers/naolib.go new file mode 100644 index 0000000..2e05e79 --- /dev/null +++ b/handlers/naolib.go @@ -0,0 +1,173 @@ +package handlers + +import ( + "database/sql" + "os" + + "github.com/gofiber/fiber/v2" + "github.com/plugimt/transat-backend/models" + "github.com/plugimt/transat-backend/services" + "github.com/plugimt/transat-backend/services/naolib/netex" + "github.com/plugimt/transat-backend/services/naolib/siri" +) + +const ( + ChantrerieStopPlaceId = "FR_NAOLIB:StopPlace:244" +) + +type NaolibHandler struct { + service *services.NaolibService + netexService *netex.NetexService + db *sql.DB +} + +func NewNaolibHandler(service *services.NaolibService, netexService *netex.NetexService, db *sql.DB) *NaolibHandler { + return &NaolibHandler{ + service: service, + netexService: netexService, + db: db, + } +} + +func (h *NaolibHandler) GetNextDeparturesChantrerie(c *fiber.Ctx) error { + departures, err := h.service.GetDepartures(ChantrerieStopPlaceId) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return c.JSON(departures) +} + +func (h *NaolibHandler) ImportNetexOffer(c *fiber.Ctx) error { + var body struct { + Url string `json:"url"` + } + + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + url := body.Url + + if url == "" { + return c.Status(fiber.StatusBadRequest).SendString("URL is required") + } + + fileName, err := h.netexService.DownloadAndExtractIfNeededOffer(url) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + defer os.Remove(fileName) + + netexData, err := h.netexService.DecodeNetexOfferData(fileName) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + err = h.netexService.SaveNetexOfferToDatabase(netexData) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return c.JSON(map[string]any{ + "success": true, + }) +} + +func (h *NaolibHandler) ImportNetexStops(c *fiber.Ctx) error { + var body struct { + Url string `json:"url"` + } + + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + url := body.Url + + if url == "" { + return c.Status(fiber.StatusBadRequest).SendString("URL is required") + } + + fileName, err := h.netexService.DownloadAndExtractIfNeeded(url) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + defer os.Remove(fileName) + + netexData, err := h.netexService.DecodeNetexStopsData(fileName) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + err = h.netexService.SaveNetexStopsToDatabase(netexData) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return c.JSON(map[string]any{ + "success": true, + }) +} + +func (h *NaolibHandler) SearchStopPlace(c *fiber.Ctx) error { + query := c.Query("query") + if query == "" { + return c.Status(fiber.StatusBadRequest).SendString("Query is required") + } + + rows, err := h.db.Query("SELECT id, name FROM NETEX_StopPlace WHERE name ILIKE $1", "%"+query+"%") + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + defer rows.Close() + + var stopPlaces []models.StopPlace + for rows.Next() { + var stopPlace models.StopPlace + err = rows.Scan(&stopPlace.ID, &stopPlace.Name) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + stopPlaces = append(stopPlaces, stopPlace) + } + + type StopPlaceResponse struct { + ID string `json:"id"` + Name string `json:"name"` + } + + stopPlacesArray := make([]StopPlaceResponse, len(stopPlaces)) + for i, stopPlace := range stopPlaces { + stopPlacesArray[i] = StopPlaceResponse{ + ID: stopPlace.ID, + Name: stopPlace.Name, + } + } + + return c.JSON(stopPlacesArray) +} + +func (h *NaolibHandler) GenerateNetexRequest(c *fiber.Ctx) error { + stops := []string{"CTRE2", "CTRE4"} + request, err := siri.GenerateStopMonitoringRequest(stops) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error() + "\n") + } + + return c.SendString(request + "\n") +} + +func (h *NaolibHandler) GetDepartures(c *fiber.Ctx) error { + stopPlaceId := c.Query("stopPlaceId") + if stopPlaceId == "" { + return c.Status(fiber.StatusBadRequest).SendString("Stop place ID is required") + } + + departuresMap, err := h.service.GetDepartures(stopPlaceId) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return c.JSON(departuresMap) +} diff --git a/main.go b/main.go index 36fc7c5..471d1cb 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/plugimt/transat-backend/routes" "github.com/plugimt/transat-backend/scheduler" // Import our scheduler package "github.com/plugimt/transat-backend/services" + "github.com/plugimt/transat-backend/services/naolib/netex" "github.com/plugimt/transat-backend/utils" "github.com/robfig/cron/v3" @@ -119,6 +120,10 @@ func main() { // Initialize Handlers that need explicit instantiation (e.g., for Cron) restHandler := restaurantHandler.NewRestaurantHandler(db, translationService, notificationService) +// Naolib + netexService := netex.NewNetexService(db) + naolibService := services.NewNaolibService(db, netexService) + // Initialize Weather Service and Handler weatherService, err := services.NewWeatherService() if err != nil { @@ -197,6 +202,7 @@ func main() { routes.SetupWashingMachineRoutes(api) // Setup washing machine routes routes.SetupWeatherRoutes(api, weatherHandler) // Setup weather routes routes.SetupNotificationRoutes(api, db, notificationService) // Setup notification test routes + routes.SetupNaolibRoutes(api, naolibService, netexService, db) app.Get("/health", func(c *fiber.Ctx) error { return c.SendString("OK") diff --git a/models/naolib.go b/models/naolib.go new file mode 100644 index 0000000..371f908 --- /dev/null +++ b/models/naolib.go @@ -0,0 +1,32 @@ +package models + +import "time" + +type DepartureDirection struct { + Direction string `json:"direction"` + Departures []Departure `json:"departures"` +} + +type Departures struct { + DepartureDirectionAller DepartureDirection `json:"aller"` + DepartureDirectionRetour DepartureDirection `json:"retour"` +} + +type Departure struct { + Line Line `json:"line"` + LineRef string `json:"lineRef"` + Direction string `json:"direction"` + DestinationName string `json:"destinationName"` + DepartureTime time.Time `json:"departureTime"` + ArrivalTime time.Time `json:"arrivalTime"` + VehicleMode string `json:"vehicleMode"` +} + +type Line struct { + ID string `json:"id"` + Name string `json:"name"` + TransportMode string `json:"transportMode"` + Number string `json:"number"` + BackgroundColour string `json:"backgroundColour"` + ForegroundColour string `json:"foregroundColour"` +} diff --git a/models/netex.go b/models/netex.go new file mode 100644 index 0000000..a3fc663 --- /dev/null +++ b/models/netex.go @@ -0,0 +1,224 @@ +package models + +import ( + "encoding/xml" + "os" +) + +type NETEXStopsFile struct { + XMLName xml.Name `xml:"PublicationDelivery"` + PublicationDelivery PublicationDelivery `xml:"PublicationDelivery"` +} + +type PublicationDelivery struct { + XMLName xml.Name `xml:"PublicationDelivery"` + PublicationTimestamp string `xml:"PublicationTimestamp"` + ParticipantRef string `xml:"ParticipantRef"` + DataObjects DataObjects `xml:"dataObjects"` + Version string `xml:"version,attr"` + Xmlns string `xml:"xmlns,attr"` + XmlnsAcbs string `xml:"xmlns:acbs,attr"` + XmlnsGML string `xml:"xmlns:gml,attr"` + XmlnsIfopt string `xml:"xmlns:ifopt,attr"` + XmlnsSiri string `xml:"xmlns:siri,attr"` + XmlnsXSD string `xml:"xmlns:xsd,attr"` + XmlnsXsi string `xml:"xmlns:xsi,attr"` +} + +type DataObjects struct { + GeneralFrame GeneralFrame `xml:"GeneralFrame"` +} + +type GeneralFrame struct { + ID string `xml:"id,attr"` + Modification string `xml:"modification,attr"` + Version string `xml:"version,attr"` + TypeOfFrameRef TypeOfFrameRef `xml:"TypeOfFrameRef"` + Members Members `xml:"members"` +} + +type TypeOfFrameRef struct { + Ref string `xml:"ref,attr"` + Text string `xml:",chardata"` +} + +type Members struct { + Quays []Quay `xml:"Quay"` + StopPlaces []StopPlace `xml:"StopPlace"` +} + +type Quay struct { + ID string `xml:"id,attr"` + Modification string `xml:"modification,attr"` + Version string `xml:"version,attr"` + KeyList []KeyValueHolder `xml:"keyList>KeyValue"` + Name string `xml:"Name"` + Centroid Centroid `xml:"Centroid"` + PostalAddress PostalAddress `xml:"PostalAddress"` + SiteRef SiteRef `xml:"SiteRef"` + TransportMode string `xml:"TransportMode"` +} + +type StopPlace struct { + ID string `xml:"id,attr"` + Modification string `xml:"modification,attr"` + Version string `xml:"version,attr"` + Name string `xml:"Name"` + Centroid Centroid `xml:"Centroid"` + TransportMode string `xml:"TransportMode"` + OtherTransportModes string `xml:"OtherTransportModes"` + StopPlaceType string `xml:"StopPlaceType"` + Weighting string `xml:"Weighting"` + QuayRefs []QuayRef `xml:"quays>QuayRef"` + KeyList []KeyValueHolder `xml:"keyList>KeyValue"` +} + +type KeyValueHolder struct { + Key string `xml:"Key"` + Value string `xml:"Value"` +} + +type QuayRef struct { + Ref string `xml:"ref,attr"` + Version string `xml:"version,attr"` +} + +type Centroid struct { + Location Location `xml:"Location"` +} + +type Location struct { + Longitude string `xml:"Longitude"` + Latitude string `xml:"Latitude"` +} + +type PostalAddress struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + Name string `xml:"Name"` + PostalRegion string `xml:"PostalRegion"` +} + +type SiteRef struct { + Ref string `xml:"ref,attr"` +} + +type NETEXCommonFile struct { + XMLName xml.Name `xml:"PublicationDelivery"` + PublicationTimestamp string `xml:"PublicationTimestamp"` + ParticipantRef string `xml:"ParticipantRef"` + DataObjects CommonDataObjects `xml:"dataObjects"` +} + +type CommonDataObjects struct { + GeneralFrame GeneralFrameOffer `xml:"GeneralFrame"` +} + +type GeneralFrameOffer struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + TypeOfFrameRef TypeOfFrameRef `xml:"TypeOfFrameRef"` + Members struct { + Network Network `xml:"Network"` + Lines []SIRILine `xml:"Line"` + Operators []Operator `xml:"Operator"` + Authorities []Authority `xml:"Authority"` + } `xml:"members"` +} + +type Network struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + Name string `xml:"Name"` + Members []LineRef `xml:"members>LineRef"` + AuthorityRef AuthorityRef `xml:"AuthorityRef"` +} + +type LineRef struct { + Ref string `xml:"ref,attr"` + Version string `xml:"version,attr,omitempty"` +} + +type AuthorityRef struct { + Ref string `xml:"ref,attr"` + Version string `xml:"version,attr,omitempty"` +} + +type SIRILine struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + KeyList []KeyValue `xml:"keyList>KeyValue"` + Name string `xml:"Name"` + ShortName string `xml:"ShortName"` + TransportMode string `xml:"TransportMode"` + PublicCode string `xml:"PublicCode"` + PrivateCode string `xml:"PrivateCode"` + OperatorRef LineRef `xml:"OperatorRef"` + Presentation Presentation `xml:"Presentation"` + AccessibilityAssessment AccessibilityAssessment `xml:"AccessibilityAssessment"` +} + +type KeyValue struct { + Key string `xml:"Key"` + Value string `xml:"Value"` +} + +type Presentation struct { + Colour string `xml:"Colour"` + TextColour string `xml:"TextColour"` +} + +type AccessibilityAssessment struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + MobilityImpairedAccess string `xml:"MobilityImpairedAccess"` + Limitations Limitations `xml:"limitations"` +} + +type Limitations struct { + AccessibilityLimitation AccessibilityLimitation `xml:"AccessibilityLimitation"` +} + +type AccessibilityLimitation struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + WheelchairAccess string `xml:"WheelchairAccess"` +} + +type Operator struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + CompanyNumber string `xml:"CompanyNumber"` + Name string `xml:"Name"` + ContactDetails ContactDetails `xml:"ContactDetails"` + OrganisationType string `xml:"OrganisationType"` +} + +type Authority struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + CompanyNumber string `xml:"CompanyNumber"` + Name string `xml:"Name"` + ContactDetails ContactDetails `xml:"ContactDetails"` + OrganisationType string `xml:"OrganisationType"` +} + +type ContactDetails struct { + Phone string `xml:"Phone"` + Url string `xml:"Url"` +} + +func DecodeCommonNetexData(file string) (*NETEXCommonFile, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var netexData NETEXCommonFile + err = xml.Unmarshal(data, &netexData) + if err != nil { + return nil, err + } + + return &netexData, nil +} diff --git a/models/siri.go b/models/siri.go new file mode 100644 index 0000000..5f1d769 --- /dev/null +++ b/models/siri.go @@ -0,0 +1,60 @@ +package models + +import ( + "encoding/xml" + "time" +) + +type SIRI struct { + XMLName xml.Name `xml:"Siri"` + ServiceDelivery ServiceDelivery `xml:"ServiceDelivery"` +} + +type ServiceDelivery struct { + ResponseTimestamp time.Time `xml:"ResponseTimestamp"` + ProducerRef string `xml:"ProducerRef"` + RequestMessageRef string `xml:"RequestMessageRef"` + Status bool `xml:"Status"` + MoreData bool `xml:"MoreData"` + StopMonitoringDelivery StopMonitoringDelivery `xml:"StopMonitoringDelivery"` +} + +type StopMonitoringDelivery struct { + ResponseTimestamp time.Time `xml:"ResponseTimestamp"` + MonitoredStopVisits []MonitoredStopVisit `xml:"MonitoredStopVisit"` +} + +type MonitoredStopVisit struct { + RecordedAtTime time.Time `xml:"RecordedAtTime"` + ItemIdentifier string `xml:"ItemIdentifier"` + MonitoringRef string `xml:"MonitoringRef"` + MonitoredVehicleJourney MonitoredVehicleJourney `xml:"MonitoredVehicleJourney"` +} + +type MonitoredVehicleJourney struct { + LineRef string `xml:"LineRef"` + FramedVehicleJourneyRef FramedVehicleJourneyRef `xml:"FramedVehicleJourneyRef"` + VehicleMode string `xml:"VehicleMode"` + PublishedLineName string `xml:"PublishedLineName"` + DirectionName string `xml:"DirectionName"` + DestinationRef string `xml:"DestinationRef"` + DestinationName string `xml:"DestinationName"` + FirstOrLastJourney string `xml:"FirstOrLastJourney"` + Monitored bool `xml:"Monitored"` + MonitoredCall MonitoredCall `xml:"MonitoredCall"` +} + +type FramedVehicleJourneyRef struct { + DataFrameRef string `xml:"DataFrameRef"` + DatedVehicleJourneyRef string `xml:"DatedVehicleJourneyRef"` +} + +type MonitoredCall struct { + StopPointRef string `xml:"StopPointRef"` + Order int `xml:"Order"` + AimedArrivalTime time.Time `xml:"AimedArrivalTime"` + ExpectedArrivalTime time.Time `xml:"ExpectedArrivalTime"` + ArrivalStatus string `xml:"ArrivalStatus"` + AimedDepartureTime time.Time `xml:"AimedDepartureTime"` + ExpectedDepartureTime time.Time `xml:"ExpectedDepartureTime"` +} diff --git a/routes/naolib_routes.go b/routes/naolib_routes.go new file mode 100644 index 0000000..dbdd740 --- /dev/null +++ b/routes/naolib_routes.go @@ -0,0 +1,29 @@ +package routes + +import ( + "database/sql" + + "github.com/gofiber/fiber/v2" + "github.com/plugimt/transat-backend/handlers" + "github.com/plugimt/transat-backend/services" + "github.com/plugimt/transat-backend/services/naolib/netex" +) + +func SetupNaolibRoutes(router fiber.Router, naolibService *services.NaolibService, netexService *netex.NetexService, db *sql.DB) { + // Groupe de routes pour Naolib + naolib := router.Group("/naolib") + handler := handlers.NewNaolibHandler(naolibService, netexService, db) + + // Route pour obtenir les prochains départs + naolib.Get("/departures/chantrerie", handler.GetNextDeparturesChantrerie) + + // TODO: protéger ces routes ! + naolib.Post("/import/netex/stops", handler.ImportNetexStops) + naolib.Post("/import/netex/offer", handler.ImportNetexOffer) + + naolib.Get("/search", handler.SearchStopPlace) + + naolib.Get("/generate-request", handler.GenerateNetexRequest) + + naolib.Get("/get-departures", handler.GetDepartures) +} diff --git a/services/naolib.go b/services/naolib.go new file mode 100644 index 0000000..e0c90eb --- /dev/null +++ b/services/naolib.go @@ -0,0 +1,126 @@ +package services + +import ( + "database/sql" + "strings" + + "github.com/plugimt/transat-backend/models" + "github.com/plugimt/transat-backend/services/naolib/netex" + "github.com/plugimt/transat-backend/services/naolib/siri" +) + +type NaolibService struct { + db *sql.DB + netexService *netex.NetexService +} + +func NewNaolibService(db *sql.DB, netexService *netex.NetexService) *NaolibService { + return &NaolibService{ + db: db, + netexService: netexService, + } +} + +func (s *NaolibService) GetDepartures(stopPlaceId string) (map[string]models.Departures, error) { + + rows, err := s.db.Query("SELECT id FROM NETEX_Quay WHERE site_ref_stopplace_id = $1", stopPlaceId) + if err != nil { + return nil, err + } + defer rows.Close() + + var quays []string + for rows.Next() { + var quay string + err = rows.Scan(&quay) + if err != nil { + return nil, err + } + quays = append(quays, quay) + } + + siriResponse, err := siri.CallStopMonitoringRequest(quays) + if err != nil { + return nil, err + } + + departures := siriResponse.ServiceDelivery.StopMonitoringDelivery.MonitoredStopVisits + + departuresMap := make(map[string]models.Departures) + + for _, departure := range departures { + lineRef := departure.MonitoredVehicleJourney.LineRef + + lineDepartures, ok := departuresMap[lineRef] + if !ok { + lineDepartures = models.Departures{ + DepartureDirectionAller: models.DepartureDirection{ + Direction: "", + Departures: []models.Departure{}, + }, + DepartureDirectionRetour: models.DepartureDirection{ + Direction: "", + Departures: []models.Departure{}, + }, + } + } + + if departure.MonitoredVehicleJourney.DirectionName == "A" { + // si la destination n'est pas "" (valeur par défaut), ça signifie qu'on l'a déjà changé. on doit aller vérifier + // si c'est la même destination, sinon on rajoute la destination avec "/ " + if lineDepartures.DepartureDirectionAller.Direction == "" { + lineDepartures.DepartureDirectionAller.Direction = departure.MonitoredVehicleJourney.DestinationName + } else { + if !strings.Contains(lineDepartures.DepartureDirectionAller.Direction, departure.MonitoredVehicleJourney.DestinationName) { + lineDepartures.DepartureDirectionAller.Direction += " / " + departure.MonitoredVehicleJourney.DestinationName + } + } + + line, err := s.netexService.GetLine(lineRef) + if err != nil { + return nil, err + } + + departure := models.Departure{ + Line: *line, + LineRef: lineRef, + Direction: lineDepartures.DepartureDirectionAller.Direction, + DestinationName: departure.MonitoredVehicleJourney.DestinationName, + DepartureTime: departure.MonitoredVehicleJourney.MonitoredCall.ExpectedDepartureTime, + ArrivalTime: departure.MonitoredVehicleJourney.MonitoredCall.ExpectedArrivalTime, + VehicleMode: departure.MonitoredVehicleJourney.VehicleMode, + } + + lineDepartures.DepartureDirectionAller.Departures = append(lineDepartures.DepartureDirectionAller.Departures, departure) + } else { + if lineDepartures.DepartureDirectionRetour.Direction == "" { + lineDepartures.DepartureDirectionRetour.Direction = departure.MonitoredVehicleJourney.DestinationName + } else { + if !strings.Contains(lineDepartures.DepartureDirectionRetour.Direction, departure.MonitoredVehicleJourney.DestinationName) { + lineDepartures.DepartureDirectionRetour.Direction += " / " + departure.MonitoredVehicleJourney.DestinationName + } + } + + line, err := s.netexService.GetLine(lineRef) + if err != nil { + return nil, err + } + + departure := models.Departure{ + Line: *line, + LineRef: lineRef, + Direction: lineDepartures.DepartureDirectionRetour.Direction, + DestinationName: departure.MonitoredVehicleJourney.DestinationName, + DepartureTime: departure.MonitoredVehicleJourney.MonitoredCall.ExpectedDepartureTime, + ArrivalTime: departure.MonitoredVehicleJourney.MonitoredCall.ExpectedArrivalTime, + VehicleMode: departure.MonitoredVehicleJourney.VehicleMode, + } + + lineDepartures.DepartureDirectionRetour.Departures = append(lineDepartures.DepartureDirectionRetour.Departures, departure) + } + + departuresMap[lineRef] = lineDepartures + } + + return departuresMap, nil +} diff --git a/services/naolib/netex/decode.go b/services/naolib/netex/decode.go new file mode 100644 index 0000000..54a9774 --- /dev/null +++ b/services/naolib/netex/decode.go @@ -0,0 +1,335 @@ +package netex + +import ( + "archive/zip" + "bytes" + "database/sql" + "encoding/xml" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/plugimt/transat-backend/models" + "github.com/plugimt/transat-backend/utils" +) + +type NetexService struct { + db *sql.DB +} + +func NewNetexService(db *sql.DB) *NetexService { + return &NetexService{ + db: db, + } +} + +func (n *NetexService) DownloadAndExtractIfNeeded(url string) (string, error) { + // download the file + resp, err := http.Get(url) + if err != nil { + return "", err + } + + // save the file to the local filesystem to /tmp/ + fileName := fmt.Sprintf("/tmp/%s", uuid.New().String()) + file, err := os.Create(fileName) + if err != nil { + return "", err + } + defer file.Close() + io.Copy(file, resp.Body) + defer resp.Body.Close() + defer os.Remove(fileName) + + // check the file's first 4 bytes to see if it's a ZIP file + zipHeader := []byte{0x50, 0x4B, 0x03, 0x04} + zipHeaderBytes := make([]byte, 4) + _, err = file.ReadAt(zipHeaderBytes, 0) + if err != nil { + return "", err + } + if bytes.Equal(zipHeaderBytes, zipHeader) { + utils.LogMessage(utils.LevelInfo, "💥 File is a ZIP file") + dst := fmt.Sprintf("/tmp/%s", uuid.New().String()) + archive, err := zip.OpenReader(fileName) + if err != nil { + panic(err) + } + defer archive.Close() + + if len(archive.File) == 0 || len(archive.File) > 1 { + return "", fmt.Errorf("invalid file") + } + + zipFile := archive.File[0] + + if !strings.HasSuffix(zipFile.Name, ".xml") { + return "", fmt.Errorf("invalid file") + } + + filePath := filepath.Join(dst, zipFile.Name) + fmt.Println("unzipping file ", filePath) + + if !strings.HasPrefix(filePath, filepath.Clean(dst)+string(os.PathSeparator)) { + fmt.Println("invalid file path") + return "", fmt.Errorf("invalid file path") + } + if zipFile.FileInfo().IsDir() { + return "", fmt.Errorf("file is a directory") + } + + if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { + panic(err) + } + + dstFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode()) + if err != nil { + panic(err) + } + + fileInArchive, err := zipFile.Open() + if err != nil { + panic(err) + } + + if _, err := io.Copy(dstFile, fileInArchive); err != nil { + panic(err) + } + + dstFile.Close() + fileInArchive.Close() + + // delete the zip file + os.Remove(fileName) + + fileName = filePath + + } + + return fileName, nil +} + +func (n *NetexService) DownloadAndExtractIfNeededOffer(url string) (string, error) { + // download the file + resp, err := http.Get(url) + if err != nil { + return "", err + } + + // save the file to the local filesystem to /tmp/ + fileName := fmt.Sprintf("/tmp/%s", uuid.New().String()) + file, err := os.Create(fileName) + if err != nil { + return "", err + } + defer file.Close() + io.Copy(file, resp.Body) + defer resp.Body.Close() + defer os.Remove(fileName) + + // check the file's first 4 bytes to see if it's a ZIP file + zipHeader := []byte{0x50, 0x4B, 0x03, 0x04} + zipHeaderBytes := make([]byte, 4) + _, err = file.ReadAt(zipHeaderBytes, 0) + if err != nil { + return "", err + } + if !bytes.Equal(zipHeaderBytes, zipHeader) { + return "", fmt.Errorf("invalid file") + } + + utils.LogMessage(utils.LevelInfo, "💥 File is a ZIP file") + dst := fmt.Sprintf("/tmp/%s", uuid.New().String()) + archive, err := zip.OpenReader(fileName) + if err != nil { + panic(err) + } + defer archive.Close() + + if len(archive.File) == 0 { + return "", fmt.Errorf("invalid file") + } + + // this time, the ZIP contains a folder which contains a lot of XML files. We are only interested in the xxx_commun.xml file. + // we need to unzip the file and return the path to the xxx_commun.xml file. + + // find the xxx_commun.xml file + var communFileName string + for _, f := range archive.File { + filePath := filepath.Join(dst, f.Name) + if !strings.HasSuffix(filePath, "_commun.xml") { + continue + } + + if !strings.HasPrefix(filePath, filepath.Clean(dst)+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid file path") + } + if f.FileInfo().IsDir() { + os.MkdirAll(filePath, os.ModePerm) + continue + } + + if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { + panic(err) + } + + dstFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + panic(err) + } + + fileInArchive, err := f.Open() + if err != nil { + panic(err) + } + + if _, err := io.Copy(dstFile, fileInArchive); err != nil { + panic(err) + } + + dstFile.Close() + fileInArchive.Close() + + communFileName = filePath + } + + // delete the zip file + os.Remove(fileName) + + return communFileName, nil +} + +func (n *NetexService) DecodeNetexStopsData(file string) (*models.PublicationDelivery, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var netexData models.PublicationDelivery + err = xml.Unmarshal(data, &netexData) + if err != nil { + return nil, err + } + + return &netexData, nil +} + +func (n *NetexService) DecodeNetexOfferData(fileName string) (*models.NETEXCommonFile, error) { + data, err := os.ReadFile(fileName) + if err != nil { + return nil, err + } + + var netexData models.NETEXCommonFile + err = xml.Unmarshal(data, &netexData) + if err != nil { + return nil, err + } + + return &netexData, nil +} + +func (n *NetexService) SaveNetexStopsToDatabase(netexData *models.PublicationDelivery) error { + stopPlaces := netexData.DataObjects.GeneralFrame.Members.StopPlaces + quays := netexData.DataObjects.GeneralFrame.Members.Quays + + // on crée une transaction et on supprime toutes les données existantes, avant de les insérer + tx, err := n.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + _, err = tx.Exec("DELETE FROM NETEX_StopPlace") + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM NETEX_Quay") + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM NETEX_StopPlace_QuayRef") + if err != nil { + return err + } + + for _, stopPlace := range stopPlaces { + _, err := tx.Exec("INSERT INTO NETEX_StopPlace (id, modification, name, longitude, latitude, transport_mode, other_transport_modes, stop_place_type, weighting) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", stopPlace.ID, stopPlace.Modification, stopPlace.Name, stopPlace.Centroid.Location.Longitude, stopPlace.Centroid.Location.Latitude, stopPlace.TransportMode, stopPlace.OtherTransportModes, stopPlace.StopPlaceType, stopPlace.Weighting) + if err != nil { + return err + } + } + + for _, quay := range quays { + _, err := tx.Exec("INSERT INTO NETEX_Quay (id, name, longitude, latitude, site_ref_stopplace_id, transport_mode) VALUES ($1, $2, $3, $4, $5, $6)", quay.ID, quay.Name, quay.Centroid.Location.Longitude, quay.Centroid.Location.Latitude, quay.SiteRef.Ref, quay.TransportMode) + if err != nil { + return err + } + } + + // now, populate the NETEX_StopPlace_QuayRef link table + for _, stopPlace := range stopPlaces { + for _, quayRef := range stopPlace.QuayRefs { + _, err := tx.Exec("INSERT INTO NETEX_StopPlace_QuayRef (stop_place_id, quay_id, quay_ref_version) VALUES ($1, $2, $3)", stopPlace.ID, quayRef.Ref, quayRef.Version) + if err != nil { + return err + } + } + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func (n *NetexService) SaveNetexOfferToDatabase(netexData *models.NETEXCommonFile) error { + lines := netexData.DataObjects.GeneralFrame.Members.Lines + + // on crée une transaction et on supprime toutes les données existantes, avant de les insérer + tx, err := n.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + _, err = tx.Exec("DELETE FROM NETEX_Line") + if err != nil { + return err + } + + for _, line := range lines { + routeSortOrder := 0 + if line.KeyList != nil { + for _, keyValue := range line.KeyList { + if keyValue.Key == "route_sort_order" { + routeSortOrder, err = strconv.Atoi(keyValue.Value) + if err != nil { + return err + } + } + } + } + + _, err := tx.Exec("INSERT INTO NETEX_Line (id, version, name, short_name, transport_mode, public_code, private_code, colour, text_colour, route_sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", line.ID, line.Version, line.Name, line.ShortName, line.TransportMode, line.PublicCode, line.PrivateCode, line.Presentation.Colour, line.Presentation.TextColour, routeSortOrder) + if err != nil { + return err + } + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} diff --git a/services/naolib/netex/lines.go b/services/naolib/netex/lines.go new file mode 100644 index 0000000..1424c28 --- /dev/null +++ b/services/naolib/netex/lines.go @@ -0,0 +1,33 @@ +package netex + +import "github.com/plugimt/transat-backend/models" + +func (n *NetexService) GetLines() ([]models.Line, error) { + lines := []models.Line{} + + rows, err := n.db.Query("SELECT id, name, transport_mode, public_code, colour, text_colour FROM NETEX_Line ORDER BY route_sort_order") + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var line models.Line + err := rows.Scan(&line.ID, &line.Name, &line.TransportMode, &line.Number, &line.BackgroundColour, &line.ForegroundColour) + if err != nil { + return nil, err + } + lines = append(lines, line) + } + + return lines, nil +} + +func (n *NetexService) GetLine(id string) (*models.Line, error) { + var line models.Line + err := n.db.QueryRow("SELECT id, name, transport_mode, public_code, colour, text_colour FROM NETEX_Line WHERE id = $1", id).Scan(&line.ID, &line.Name, &line.TransportMode, &line.Number, &line.BackgroundColour, &line.ForegroundColour) + if err != nil { + return nil, err + } + return &line, nil +} diff --git a/services/naolib/siri/consts.go b/services/naolib/siri/consts.go new file mode 100644 index 0000000..7fc6b8b --- /dev/null +++ b/services/naolib/siri/consts.go @@ -0,0 +1,9 @@ +package siri + +const ( + RequestorRef = "opendata" + datasetId = "NAOLIBORG" + APIScheme = "https" + APIHost = "api.okina.fr" + APIPath = "/gateway/sem/realtime/anshar/services" +) diff --git a/services/naolib/siri/generateXML.go b/services/naolib/siri/generateXML.go new file mode 100644 index 0000000..e1b93c4 --- /dev/null +++ b/services/naolib/siri/generateXML.go @@ -0,0 +1,44 @@ +package siri + +import ( + "bytes" + "embed" + "io" + "text/template" +) + +//go:embed template/*.xml.template +var templatesFS embed.FS + +func GenerateStopMonitoringRequest(stops []string) (string, error) { + templ, err := templatesFS.Open("template/StopMonitoringRequest.xml.template") + if err != nil { + return "", err + } + + content, err := io.ReadAll(templ) + if err != nil { + return "", err + } + + templateData := struct { + RequestorRef string + Stops []string + }{ + RequestorRef: RequestorRef, + Stops: stops, + } + + tmpl, err := template.New("StopMonitoringRequest.xml.template").Parse(string(content)) + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, templateData) + if err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/services/naolib/siri/request.go b/services/naolib/siri/request.go new file mode 100644 index 0000000..5a8ce0d --- /dev/null +++ b/services/naolib/siri/request.go @@ -0,0 +1,77 @@ +package siri + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/plugimt/transat-backend/models" +) + +var httpClient = &http.Client{ + Timeout: 10 * time.Second, +} + +var mu sync.Mutex +var lastRequestTime time.Time + +func CallStopMonitoringRequest(stops []string) (*models.SIRI, error) { + mu.Lock() + defer mu.Unlock() + + if time.Since(lastRequestTime) < 1*time.Second { + time.Sleep(1 * time.Second) + } + + content, err := GenerateStopMonitoringRequest(stops) + if err != nil { + return nil, err + } + + url := url.URL{ + Scheme: APIScheme, + Host: APIHost, + Path: APIPath, + } + + request := http.Request{ + Method: "POST", + URL: &url, + Body: io.NopCloser(bytes.NewBufferString(content)), + Header: http.Header{ + "Content-Type": []string{"application/xml"}, + "datasetId": []string{datasetId}, + }, + } + + resp, err := httpClient.Do(&request) + if err != nil { + return nil, err + } + + lastRequestTime = time.Now() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code: %d", resp.StatusCode) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var siri models.SIRI + err = xml.Unmarshal(body, &siri) + if err != nil { + return nil, err + } + + return &siri, nil +} diff --git a/services/naolib/siri/template/StopMonitoringRequest.xml.template b/services/naolib/siri/template/StopMonitoringRequest.xml.template new file mode 100644 index 0000000..2b75517 --- /dev/null +++ b/services/naolib/siri/template/StopMonitoringRequest.xml.template @@ -0,0 +1,13 @@ + + + + {{ .RequestorRef }} + {{ range .Stops }} + + {{ . }} + + {{ end }} + + \ No newline at end of file