Add public web functionalities
This commit is contained in:
@@ -23,6 +23,11 @@ func ReadConfig() (*viper.Viper, error) {
|
||||
"enabled": true,
|
||||
"listen": "0.0.0.0:8081",
|
||||
},
|
||||
"publicweb": map[string]any{
|
||||
"enabled": false,
|
||||
"listen": "0.0.0.0:8082",
|
||||
"root_dir": "public_themes/default",
|
||||
},
|
||||
},
|
||||
"identification": map[string]any{
|
||||
"sessions": map[string]any{
|
||||
|
||||
16
main.go
16
main.go
@@ -10,6 +10,7 @@ import (
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/core/application"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/renderer"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/servers/mcp"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/servers/publicweb"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/servers/web"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/services"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/core/utils/identification"
|
||||
@@ -24,9 +25,10 @@ func main() {
|
||||
}
|
||||
|
||||
var (
|
||||
dev_env = cfg.GetBool("dev_env")
|
||||
webEnabled = cfg.GetBool("server.web.enabled")
|
||||
mcpEnabled = cfg.GetBool("server.mcp.enabled")
|
||||
dev_env = cfg.GetBool("dev_env")
|
||||
webEnabled = cfg.GetBool("server.web.enabled")
|
||||
mcpEnabled = cfg.GetBool("server.mcp.enabled")
|
||||
publicwebEnabled = cfg.GetBool("server.publicweb.enabled")
|
||||
)
|
||||
|
||||
if dev_env {
|
||||
@@ -80,5 +82,13 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
if publicwebEnabled {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
publicweb.Run(cfg, svc, applicationHandler, kv, filestorage)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
142
servers/publicweb/journeys.go
Normal file
142
servers/publicweb/journeys.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package publicweb
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/paulmach/orb/geojson"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// JourneySearchResponse represents the search results for hydration
|
||||
type JourneySearchResponse struct {
|
||||
Searched bool `json:"searched"`
|
||||
DepartureDate string `json:"departure_date,omitempty"`
|
||||
DepartureTime string `json:"departure_time,omitempty"`
|
||||
Departure any `json:"departure,omitempty"`
|
||||
Destination any `json:"destination,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Results struct {
|
||||
SolidarityDrivers struct {
|
||||
Number int `json:"number"`
|
||||
} `json:"solidarity_drivers"`
|
||||
OrganizedCarpools struct {
|
||||
Number int `json:"number"`
|
||||
} `json:"organized_carpools"`
|
||||
Carpools struct {
|
||||
Number int `json:"number"`
|
||||
Results any `json:"results,omitempty"`
|
||||
} `json:"carpools"`
|
||||
PublicTransit struct {
|
||||
Number int `json:"number"`
|
||||
Results any `json:"results,omitempty"`
|
||||
} `json:"public_transit"`
|
||||
Vehicles struct {
|
||||
Number int `json:"number"`
|
||||
Results any `json:"results,omitempty"`
|
||||
} `json:"vehicles"`
|
||||
LocalSolutions struct {
|
||||
Number int `json:"number"`
|
||||
Results any `json:"results,omitempty"`
|
||||
} `json:"local_solutions"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// journeySearchDataProvider provides data for the journey search page
|
||||
func (s *PublicWebServer) journeySearchDataProvider(r *http.Request) (any, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Msg("error parsing form")
|
||||
return JourneySearchResponse{Error: "invalid request"}, nil
|
||||
}
|
||||
|
||||
departureDate := r.FormValue("departuredate")
|
||||
departureTime := r.FormValue("departuretime")
|
||||
departure := r.FormValue("departure")
|
||||
destination := r.FormValue("destination")
|
||||
|
||||
response := JourneySearchResponse{
|
||||
DepartureDate: departureDate,
|
||||
DepartureTime: departureTime,
|
||||
}
|
||||
|
||||
// If no search parameters, return empty response
|
||||
if departure == "" || destination == "" || departureDate == "" || departureTime == "" {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Parse timezone and datetime
|
||||
locTime, err := time.LoadLocation("Europe/Paris")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("timezone error")
|
||||
response.Error = "internal error"
|
||||
return response, nil
|
||||
}
|
||||
|
||||
departureDateTime, err := time.ParseInLocation("2006-01-02 15:04", departureDate+" "+departureTime, locTime)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error parsing datetime")
|
||||
response.Error = "invalid date/time format"
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Parse departure location
|
||||
departureGeo, err := geojson.UnmarshalFeature([]byte(departure))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error unmarshalling departure")
|
||||
response.Error = "invalid departure location"
|
||||
return response, nil
|
||||
}
|
||||
response.Departure = departureGeo
|
||||
|
||||
// Parse destination location
|
||||
destinationGeo, err := geojson.UnmarshalFeature([]byte(destination))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error unmarshalling destination")
|
||||
response.Error = "invalid destination location"
|
||||
return response, nil
|
||||
}
|
||||
response.Destination = destinationGeo
|
||||
|
||||
// Call business logic
|
||||
result, err := s.applicationHandler.SearchJourneys(
|
||||
r.Context(),
|
||||
departureDateTime,
|
||||
departureGeo,
|
||||
destinationGeo,
|
||||
"", // passengerID
|
||||
"", // solidarityTransportExcludeDriver
|
||||
"", // solidarityExcludeGroupId
|
||||
nil, // options - use defaults
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error in journey search")
|
||||
response.Error = "search failed"
|
||||
return response, nil
|
||||
}
|
||||
|
||||
response.Searched = result.Searched
|
||||
|
||||
// Solidarity drivers
|
||||
response.Results.SolidarityDrivers.Number = len(result.DriverJourneys)
|
||||
|
||||
// Organized carpools
|
||||
response.Results.OrganizedCarpools.Number = len(result.OrganizedCarpools)
|
||||
|
||||
// Carpools (from external operators like Movici)
|
||||
response.Results.Carpools.Number = len(result.CarpoolResults)
|
||||
response.Results.Carpools.Results = result.CarpoolResults
|
||||
|
||||
// Public transit
|
||||
response.Results.PublicTransit.Number = len(result.TransitResults)
|
||||
response.Results.PublicTransit.Results = result.TransitResults
|
||||
|
||||
// Fleet vehicles
|
||||
response.Results.Vehicles.Number = len(result.VehicleResults)
|
||||
response.Results.Vehicles.Results = result.VehicleResults
|
||||
|
||||
// Knowledge base / local solutions
|
||||
response.Results.LocalSolutions.Number = len(result.KnowledgeBaseResults)
|
||||
response.Results.LocalSolutions.Results = result.KnowledgeBaseResults
|
||||
|
||||
return response, nil
|
||||
}
|
||||
191
servers/publicweb/publicweb.go
Normal file
191
servers/publicweb/publicweb.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package publicweb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/core/application"
|
||||
cache "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/storage"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/services"
|
||||
)
|
||||
|
||||
// DataProvider returns data to hydrate a page
|
||||
type DataProvider func(r *http.Request) (any, error)
|
||||
|
||||
// DynamicRoute defines a route with its HTML file and data provider
|
||||
type DynamicRoute struct {
|
||||
HTMLFile string
|
||||
DataProvider DataProvider
|
||||
}
|
||||
|
||||
// Regex to find the placeholder script tag
|
||||
var dynamicDataRegex = regexp.MustCompile(`<script\s+id="dynamic-data"\s+type="application/json">\s*</script>`)
|
||||
|
||||
type PublicWebServer struct {
|
||||
cfg *viper.Viper
|
||||
services *services.ServicesHandler
|
||||
kv cache.KVHandler
|
||||
filestorage cache.FileStorage
|
||||
applicationHandler *application.ApplicationHandler
|
||||
rootDir string
|
||||
dynamicRoutes map[string]DynamicRoute
|
||||
}
|
||||
|
||||
func Run(
|
||||
cfg *viper.Viper,
|
||||
svc *services.ServicesHandler,
|
||||
applicationHandler *application.ApplicationHandler,
|
||||
kv cache.KVHandler,
|
||||
filestorage cache.FileStorage,
|
||||
) {
|
||||
address := cfg.GetString("server.publicweb.listen")
|
||||
rootDir := cfg.GetString("server.publicweb.root_dir")
|
||||
serviceName := cfg.GetString("service_name")
|
||||
|
||||
server := &PublicWebServer{
|
||||
cfg: cfg,
|
||||
services: svc,
|
||||
kv: kv,
|
||||
filestorage: filestorage,
|
||||
applicationHandler: applicationHandler,
|
||||
rootDir: rootDir,
|
||||
dynamicRoutes: make(map[string]DynamicRoute),
|
||||
}
|
||||
|
||||
server.registerDynamicRoutes()
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
r.HandleFunc("/health", server.healthHandler).Methods("GET")
|
||||
|
||||
for pattern := range server.dynamicRoutes {
|
||||
r.HandleFunc(pattern, server.dynamicHandler).Methods("GET", "POST")
|
||||
}
|
||||
|
||||
r.PathPrefix("/").Handler(server.fileServerHandler())
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: r,
|
||||
Addr: address,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("service_name", serviceName).
|
||||
Str("address", address).
|
||||
Str("root_dir", rootDir).
|
||||
Msg("Running Public Web HTTP server")
|
||||
|
||||
err := srv.ListenAndServe()
|
||||
log.Error().Err(err).Msg("Public Web server error")
|
||||
}
|
||||
|
||||
func (s *PublicWebServer) registerDynamicRoutes() {
|
||||
s.RegisterDynamicRoute("/recherche/", "recherche/index.html", s.journeySearchDataProvider)
|
||||
}
|
||||
|
||||
func (s *PublicWebServer) RegisterDynamicRoute(pattern, htmlFile string, provider DataProvider) {
|
||||
s.dynamicRoutes[pattern] = DynamicRoute{
|
||||
HTMLFile: htmlFile,
|
||||
DataProvider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PublicWebServer) dynamicHandler(w http.ResponseWriter, r *http.Request) {
|
||||
route := mux.CurrentRoute(r)
|
||||
pattern, _ := route.GetPathTemplate()
|
||||
|
||||
dynRoute, exists := s.dynamicRoutes[pattern]
|
||||
if !exists {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := dynRoute.DataProvider(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("route", pattern).Msg("Error getting data")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.hydrate(w, dynRoute.HTMLFile, data); err != nil {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// hydrate reads an HTML file and injects JSON data into <script id="dynamic-data" type="application/json"></script>
|
||||
func (s *PublicWebServer) hydrate(w http.ResponseWriter, htmlFile string, data any) error {
|
||||
htmlPath := filepath.Join(s.rootDir, htmlFile)
|
||||
htmlContent, err := os.ReadFile(htmlPath)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("file", htmlPath).Msg("Error reading HTML file")
|
||||
return err
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error marshaling data to JSON")
|
||||
return err
|
||||
}
|
||||
|
||||
// Replace the placeholder with a script that assigns data to window.__PARCOURSMOB_DATA__
|
||||
replacement := []byte(`<script id="dynamic-data">window.__PARCOURSMOB_DATA__ = ` + string(jsonData) + `;</script>`)
|
||||
modifiedHTML := dynamicDataRegex.ReplaceAll(htmlContent, replacement)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(modifiedHTML)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PublicWebServer) fileServerHandler() http.Handler {
|
||||
fs := http.FileServer(http.Dir(s.rootDir))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := filepath.Join(s.rootDir, r.URL.Path)
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if filepath.Ext(path) == "" {
|
||||
if idx := filepath.Join(path, "index.html"); fileExists(idx) {
|
||||
http.ServeFile(w, r, idx)
|
||||
return
|
||||
}
|
||||
if idx := filepath.Join(s.rootDir, "index.html"); fileExists(idx) {
|
||||
http.ServeFile(w, r, idx)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if info, _ := os.Stat(path); info != nil && info.IsDir() {
|
||||
if idx := filepath.Join(path, "index.html"); fileExists(idx) && strings.HasSuffix(r.URL.Path, "/") {
|
||||
http.ServeFile(w, r, idx)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *PublicWebServer) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"healthy"}`))
|
||||
}
|
||||
Reference in New Issue
Block a user