diff --git a/Dockerfile b/Dockerfile index 52a9a73..6479581 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,19 @@ -FROM golang:1.22.0 as build +FROM golang:1.22.3 as build LABEL author="vlbeaudoin" WORKDIR /go/src/app -COPY go.mod go.sum main.go ./ +COPY go.mod go.sum client.go client_test.go cmd.go config.go db.go entity.go main.go request.go response.go routes.go template.go ./ -ADD cmd/ cmd/ -ADD data/ data/ -ADD handlers/ handlers/ -ADD models/ models/ -ADD responses/ responses/ -ADD web/ web/ +ADD sql/ sql/ +ADD templates/ templates/ -RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o bottin . +RUN CGO_ENABLED=0 go build -a -o bottin . # Alpine -FROM alpine:3.19 +FROM alpine:3.20 WORKDIR /app diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..31a6c23 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +## This Makefile uses the help target explained in the following blogpost: +## +## https://victoria.dev/blog/how-to-create-a-self-documenting-makefile/ + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## Show this help + @egrep -h '\s##\s' $(MAKEFILE_LIST) | \ + sort | \ + awk \ + 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +.PHONY: test-integration +test-integration: ## run integration tests through API client. Config is read from `~/.bottin.yaml`. WARNING: affects data in the database, do not run on production server + docker-compose down && docker-compose up -d --build && sleep 2 && go test diff --git a/client.go b/client.go new file mode 100644 index 0000000..e4958a6 --- /dev/null +++ b/client.go @@ -0,0 +1,170 @@ +package main + +import ( + "fmt" + + "codeberg.org/vlbeaudoin/voki/v3" +) + +type APIClient struct { + Voki *voki.Voki +} + +func (c APIClient) GetHealth() (health string, err error) { + var request HealthGETRequest + response, err := request.Request(c.Voki) + if err != nil { + return "", err + } + + if code, message := response.StatusCode(), response.Message; code >= 400 { + err = fmt.Errorf("%d: %s", code, message) + return + } + + return response.Message, nil +} + +func (c APIClient) InsertProgrammes(programmes ...Programme) (amountInserted int64, err error) { + var request ProgrammesPOSTRequest + request.Data.Programmes = programmes + + response, err := request.Request(c.Voki) + if err != nil { + return + } + + if code, message := response.StatusCode(), response.Message; code >= 400 { + err = fmt.Errorf("%d: %s", code, message) + return + } + + return response.Data.ProgrammesInserted, nil +} + +func (c APIClient) InsertMembres(membres ...Membre) (amountInserted int64, err error) { + var request MembresPOSTRequest + request.Data.Membres = membres + + response, err := request.Request(c.Voki) + if err != nil { + return + } + + if code, message := response.StatusCode(), response.Message; code >= 400 { + err = fmt.Errorf("%d: %s", code, message) + return + } + + return response.Data.MembresInserted, nil +} + +func (c APIClient) GetMembre(membreID string) (membre Membre, err error) { + var request MembreGETRequest + request.Param.MembreID = membreID + + response, err := request.Request(c.Voki) + if err != nil { + return + } + + if code, message := response.StatusCode(), response.Message; code >= 400 { + err = fmt.Errorf("%d: %s", code, message) + return + } + + return response.Data.Membre, nil +} + +func (c APIClient) GetMembres(limit int) (membres []Membre, err error) { + var request MembresGETRequest + + request.Query.Limit = limit + + response, err := request.Request(c.Voki) + if err != nil { + return + } + + if code, message := response.StatusCode(), response.Message; code >= 400 { + err = fmt.Errorf("%d: %s", code, message) + return + } + + return response.Data.Membres, nil +} + +func (c APIClient) GetProgrammes(limit int) (programmes []Programme, err error) { + var request ProgrammesGETRequest + + request.Query.Limit = limit + + response, err := request.Request(c.Voki) + if err != nil { + return + } + + if code, message := response.StatusCode(), response.Message; code >= 400 { + err = fmt.Errorf("%d: %s", code, message) + return + } + + return response.Data.Programmes, nil +} + +func (c APIClient) UpdateMembrePreferedName(membreID string, name string) (err error) { + var request MembrePreferedNamePUTRequest + + if !IsMembreID(membreID) { + return fmt.Errorf("Numéro étudiant '%s' invalide", membreID) + } + request.Param.MembreID = membreID + request.Data.PreferedName = name + + response, err := request.Request(c.Voki) + if err != nil { + return + } + + if code, message := response.StatusCode(), response.Message; code >= 400 { + err = fmt.Errorf("%d: %s", code, message) + return + } + + return nil +} + +func (c APIClient) GetMembreForDisplay(membreID string) (membre MembreForDisplay, err error) { + var request MembreDisplayGETRequest + request.Param.MembreID = membreID + + response, err := request.Request(c.Voki) + if err != nil { + return + } + + if code, message := response.StatusCode(), response.Message; code >= 400 { + err = fmt.Errorf("%d: %s", code, message) + return + } + + return response.Data.Membre, nil +} + +func (c APIClient) GetMembresForDisplay(limit int) (membres []MembreForDisplay, err error) { + var request MembresDisplayGETRequest + + request.Query.Limit = limit + + response, err := request.Request(c.Voki) + if err != nil { + return + } + + if code, message := response.StatusCode(), response.Message; code >= 400 { + err = fmt.Errorf("%d: %s", code, message) + return + } + + return response.Data.Membres, nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..a2aea93 --- /dev/null +++ b/client_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "net/http" + "testing" + + "codeberg.org/vlbeaudoin/voki/v3" + "github.com/spf13/viper" +) + +func init() { + initConfig() +} + +func TestAPI(t *testing.T) { + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + t.Error(err) + return + } + + httpClient := http.DefaultClient + defer httpClient.CloseIdleConnections() + + vokiClient := voki.New(httpClient, "localhost", cfg.API.Key, cfg.API.Port, "http") + apiClient := APIClient{vokiClient} + + t.Run("get API health", func(t *testing.T) { + health, err := apiClient.GetHealth() + if err != nil { + t.Error(err) + } + + want := "ok" + got := health + + if want != got { + t.Errorf("want=%s got=%s", want, got) + } + }) + + t.Run("insert programmes", + func(t *testing.T) { + programmes := []Programme{ + {"404.42", "Cool programme"}, + {"200.10", "Autre programme"}, + } + t.Log("programmes:", programmes) + _, err := apiClient.InsertProgrammes(programmes...) + if err != nil { + t.Error(err) + } + }) + + testMembres := []Membre{ + { + ID: "0000000", + FirstName: "Test", + LastName: "User", + ProgrammeID: "404.42", + }, + { + ID: "1234567", + FirstName: "Deadname", + LastName: "User", + PreferedName: "User, Test-Name", + ProgrammeID: "200.10", + }, + } + + t.Run("get programmes, max 50", + func(t *testing.T) { + programmes, err := apiClient.GetProgrammes(50) + if err != nil { + t.Error(err) + } + t.Log(programmes) + }) + + t.Run("insert membres", + func(t *testing.T) { + _, err := apiClient.InsertMembres(testMembres...) + if err != nil { + t.Error(err) + } + }) + + t.Run("get membre", + func(t *testing.T) { + membre, err := apiClient.GetMembre(testMembres[0].ID) + if err != nil { + t.Error(err) + } + + want := testMembres[0].LastName + got := membre.LastName + + if want != got { + t.Errorf("want=%s got=%s", want, got) + } + }) + + t.Run("get invalid membre", + func(t *testing.T) { + _, err := apiClient.GetMembre("invalid") + if err == nil { + t.Error("`invalid` should not have been accepted as value to GetMembre, but did") + } + }) + + t.Run("update membre prefered name", + func(t *testing.T) { + if err := apiClient.UpdateMembrePreferedName(testMembres[0].ID, "User, Galaxy"); err != nil { + t.Error(err) + } + }) + t.Run("get membres, max 50", + func(t *testing.T) { + membres, err := apiClient.GetMembres(50) + if err != nil { + t.Error(err) + } + t.Log(membres) + }) + + t.Run("get membre for display", + func(t *testing.T) { + membre, err := apiClient.GetMembreForDisplay(testMembres[0].ID) + if err != nil { + t.Error(err) + } + t.Log(membre) + }) + + t.Run("get membres for display, max 5", + func(t *testing.T) { + membres, err := apiClient.GetMembresForDisplay(5) + if err != nil { + t.Error(err) + } + t.Log(membres) + }) +} diff --git a/cmd.go b/cmd.go new file mode 100644 index 0000000..8c5571d --- /dev/null +++ b/cmd.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "crypto/subtle" + "fmt" + "html/template" + "log" + "net/http" + "os" + + "codeberg.org/vlbeaudoin/voki/v3" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "bottin", + Short: "Bottin étudiant de l'AGECEM", +} + +// execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +// apiCmd represents the api command +var apiCmd = &cobra.Command{ + Use: "api", + Short: "Démarrer le serveur API", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + log.Fatal("parse config:", err) + } + + e := echo.New() + + // Middlewares + + e.Pre(middleware.AddTrailingSlash()) + + if cfg.API.Key != "" { + e.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) { + return subtle.ConstantTimeCompare([]byte(key), []byte(cfg.API.Key)) == 1, nil + })) + } + + // DataClient + ctx := context.Background() + + //prep + pool, err := pgxpool.New( + ctx, + fmt.Sprintf( + "user=%s password=%s database=%s host=%s port=%d sslmode=%s ", + cfg.DB.User, + cfg.DB.Password, + cfg.DB.Database, + cfg.DB.Host, + cfg.DB.Port, + cfg.DB.SSLMode, + )) + if err != nil { + log.Fatal("init pgx pool:", err) + } + defer pool.Close() + + db := &PostgresClient{ + Ctx: ctx, + Pool: pool, + } + if err := db.Pool.Ping(ctx); err != nil { + log.Fatal("ping db:", err) + } + + if err := db.CreateOrReplaceSchema(); err != nil { + log.Fatal("create or replace schema:", err) + } + + if err := db.CreateOrReplaceViews(); err != nil { + log.Fatal("create or replace views:", err) + } + + // Routes + if err := addRoutes(e, db, cfg); err != nil { + log.Fatal("add routes:", err) + } + /* + h := handlers.New(client) + + e.GET("/v7/health/", h.GetHealth) + + e.POST("/v7/membres/", h.PostMembres) + + e.GET("/v7/membres/", h.ListMembres) + + e.GET("/v7/membres/:membre_id/", h.ReadMembre) + + e.PUT("/v7/membres/:membre_id/prefered_name/", h.PutMembrePreferedName) + + e.POST("/v7/programmes/", h.PostProgrammes) + + e.POST("/v7/seed/", h.PostSeed) + */ + + // Execution + e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", cfg.API.Port))) + }, +} + +// webCmd represents the web command +var webCmd = &cobra.Command{ + Use: "web", + Short: "Démarrer le client web", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + // Parse config + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + log.Fatal("init config:", err) + } + + e := echo.New() + + // Middlewares + + // Trailing slash + e.Pre(middleware.AddTrailingSlash()) + + // Auth + e.Use(middleware.BasicAuth(func(user, password string, c echo.Context) (bool, error) { + usersMatch := subtle.ConstantTimeCompare([]byte(user), []byte(cfg.Web.User)) == 1 + passwordsMatch := subtle.ConstantTimeCompare([]byte(password), []byte(cfg.Web.Password)) == 1 + return usersMatch && passwordsMatch, nil + })) + + // Templating + e.Renderer = &Template{ + templates: template.Must(template.ParseFS(templatesFS, "templates/*.html")), + } + + // API Client + apiClient := APIClient{voki.New( + http.DefaultClient, + cfg.Web.API.Host, + cfg.Web.API.Key, + cfg.Web.API.Port, + cfg.Web.API.Protocol, + )} + defer apiClient.Voki.CloseIdleConnections() + + // Routes + e.GET("/", func(c echo.Context) error { + pingResult, err := apiClient.GetHealth() + if err != nil { + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: fmt.Sprintf("impossible d'accéder au serveur API: %s", err)}, + ) + } + + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: pingResult}, + ) + }) + + e.GET("/membre/", func(c echo.Context) error { + membreID := c.QueryParam("membre_id") + switch { + case membreID == "": + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: "❗Veuillez entrer un numéro étudiant à rechercher"}, + ) + case !IsMembreID(membreID): + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: fmt.Sprintf("❗Numéro étudiant '%s' invalide", membreID)}, + ) + } + + membre, err := apiClient.GetMembreForDisplay(membreID) + if err != nil { + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: fmt.Sprintf("❗erreur: %s", err)}, + ) + } + + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: fmt.Sprintf(` +Numéro étudiant: %s +Nom d'usage: %s +Programme: [%s] %s +`, + membre.ID, + membre.Name, + membre.ProgrammeID, + membre.ProgrammeName, + )}, + ) + }) + + // Execution + e.Logger.Fatal(e.Start( + fmt.Sprintf(":%d", cfg.Web.Port))) + }, +} diff --git a/cmd/api.go b/cmd/api.go deleted file mode 100644 index b03be9a..0000000 --- a/cmd/api.go +++ /dev/null @@ -1,122 +0,0 @@ -package cmd - -import ( - "crypto/subtle" - "fmt" - "log" - - "codeberg.org/vlbeaudoin/serpents" - "git.agecem.com/agecem/bottin/v6/data" - "git.agecem.com/agecem/bottin/v6/handlers" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - apiPort int - apiKey string -) - -// apiCmd represents the api command -var apiCmd = &cobra.Command{ - Use: "api", - Short: "Démarrer le serveur API", - Args: cobra.ExactArgs(0), - Run: func(cmd *cobra.Command, args []string) { - apiKey = viper.GetString("api.key") - apiPort = viper.GetInt("api.port") - - e := echo.New() - - // Middlewares - - e.Pre(middleware.AddTrailingSlash()) - - if apiKey != "" { - e.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) { - return subtle.ConstantTimeCompare([]byte(key), []byte(apiKey)) == 1, nil - })) - } - - // DataClient - - client, err := data.NewDataClientFromViper() - if err != nil { - log.Fatalf("Could not establish database connection.\n Error: %s\n", err) - } - defer client.DB.Close() - - err = client.DB.Ping() - if err != nil { - log.Fatalf("Database was supposed to be ready but Ping() failed.\n Error: %s\n", err) - } - - _, err = client.Seed() - if err != nil { - log.Fatalf("Error during client.Seed(): %s", err) - } - - h := handlers.New(client) - - // Routes - - e.GET("/v6/health/", h.GetHealth) - - e.POST("/v6/membres/", h.PostMembres) - - e.GET("/v6/membres/", h.ListMembres) - - e.GET("/v6/membres/:membre_id/", h.ReadMembre) - - e.PUT("/v6/membres/:membre_id/prefered_name/", h.PutMembrePreferedName) - - e.POST("/v6/programmes/", h.PostProgrammes) - - e.POST("/v6/seed/", h.PostSeed) - - // Execution - - e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", apiPort))) - }, -} - -func init() { - rootCmd.AddCommand(apiCmd) - - // api.key - serpents.String(apiCmd.Flags(), - "api.key", "api-key", "bottin", - "API server key. Leave empty for no key auth") - - // api.port - serpents.Int(apiCmd.Flags(), - "api.port", "api-port", 1312, - "API server port") - - // db.database - serpents.String(apiCmd.Flags(), - "db.database", "db-database", "bottin", - "Postgres database") - - // db.host - serpents.String(apiCmd.Flags(), - "db.host", "db-host", "db", - "Postgres host") - - // db.password - serpents.String(apiCmd.Flags(), - "db.password", "db-password", "bottin", - "Postgres password") - - // db.port - serpents.Int(apiCmd.Flags(), - "db.port", "db-port", 5432, - "Postgres port") - - // db.user - serpents.String(apiCmd.Flags(), - "db.user", "db-user", "bottin", - "Postgres user") -} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index 0b7562e..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,59 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var cfgFile string - -// rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ - Use: "bottin", - Short: "Application de gestion de distribution d'agendas", -} - -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func init() { - cobra.OnInitialize(initConfig) - - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bottin.yaml)") -} - -// initConfig reads in config file and ENV variables if set. -func initConfig() { - if cfgFile != "" { - // Use config file from the flag. - viper.SetConfigFile(cfgFile) - } else { - // Find home directory. - home, err := os.UserHomeDir() - cobra.CheckErr(err) - - // Search config in home directory with name ".bottin" (without extension). - viper.AddConfigPath(home) - viper.SetConfigType("yaml") - viper.SetConfigName(".bottin") - } - - viper.SetEnvPrefix("BOTTIN") - viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - viper.AutomaticEnv() // read in environment variables that match - - // If a config file is found, read it in. - if err := viper.ReadInConfig(); err == nil { - fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) - } -} diff --git a/cmd/web.go b/cmd/web.go deleted file mode 100644 index 4d1a556..0000000 --- a/cmd/web.go +++ /dev/null @@ -1,142 +0,0 @@ -package cmd - -import ( - "crypto/subtle" - "embed" - "fmt" - "html/template" - "io" - "log" - "net/http" - - "codeberg.org/vlbeaudoin/serpents" - "git.agecem.com/agecem/bottin/v6/data" - "git.agecem.com/agecem/bottin/v6/web" - "git.agecem.com/agecem/bottin/v6/web/webhandlers" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - webUser string - webPassword string - webPort int - webApiHost string - webApiKey string - webApiPort int - webApiProtocol string -) - -var templatesFS embed.FS - -type Template struct { - templates *template.Template -} - -func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { - return t.templates.ExecuteTemplate(w, name, data) -} - -// webCmd represents the web command -var webCmd = &cobra.Command{ - Use: "web", - Short: "Démarrer le client web", - Args: cobra.ExactArgs(0), - Run: func(cmd *cobra.Command, args []string) { - webApiHost = viper.GetString("web.api.host") - webApiKey = viper.GetString("web.api.key") - webApiPort = viper.GetInt("web.api.port") - webApiProtocol = viper.GetString("web.api.protocol") - webPassword = viper.GetString("web.password") - webPort = viper.GetInt("web.port") - webUser = viper.GetString("web.user") - - // Ping API server - - client := http.DefaultClient - defer client.CloseIdleConnections() - - apiClient := data.NewApiClient(client, webApiKey, webApiHost, webApiProtocol, webApiPort) - - pingResult, err := apiClient.GetHealth() - if err != nil { - log.Fatal(err) - } - - log.Println(pingResult) - - e := echo.New() - - // Middlewares - - e.Pre(middleware.AddTrailingSlash()) - - e.Use(middleware.BasicAuth(func(user, password string, c echo.Context) (bool, error) { - usersMatch := subtle.ConstantTimeCompare([]byte(user), []byte(webUser)) == 1 - passwordsMatch := subtle.ConstantTimeCompare([]byte(password), []byte(webPassword)) == 1 - return usersMatch && passwordsMatch, nil - })) - - // Template - - t := &Template{ - templates: template.Must(template.ParseFS(templatesFS, "templates/*.html")), - } - - e.Renderer = t - - // Routes - - handler := webhandlers.Handler{APIClient: apiClient} - - e.GET("/", handler.GetIndex) - e.GET("/membre/", handler.GetMembre) - - // Execution - - e.Logger.Fatal(e.Start( - fmt.Sprintf(":%d", webPort))) - }, -} - -func init() { - rootCmd.AddCommand(webCmd) - templatesFS = web.GetTemplates() - - // web.api.host - serpents.String(webCmd.Flags(), - "web.api.host", "web-api-host", "api", - "Remote API server host") - - // web.api.key - serpents.String(webCmd.Flags(), - "web.api.key", "web-api-key", "bottin", - "Remote API server key") - - // web.api.protocol - serpents.String(webCmd.Flags(), - "web.api.protocol", "web-api-protocol", "http", - "Remote API server protocol") - - // web.api.port - serpents.Int(webCmd.Flags(), - "web.api.port", "web-api-port", 1312, - "Remote API server port") - - // web.password - serpents.String(webCmd.Flags(), - "web.password", "web-password", "bottin", - "Web client password") - - // web.port - serpents.Int(webCmd.Flags(), - "web.port", "web-port", 2312, - "Web client port") - - // web.user - serpents.String(webCmd.Flags(), - "web.user", "web-user", "bottin", - "Web client user") -} diff --git a/config.go b/config.go new file mode 100644 index 0000000..3096960 --- /dev/null +++ b/config.go @@ -0,0 +1,268 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + ViperAPIPort string = "api.port" + FlagAPIPort string = "api-port" + DefaultAPIPort int = 1312 + DescriptionAPIPort string = "API server port" + + ViperAPIKey string = "api.key" + FlagAPIKey string = "api-key" + DefaultAPIKey string = "bottin" + DescriptionAPIKey string = "API server key. Leave empty for no key auth (not recommended)" + + ViperDBDatabase string = "db.database" + FlagDBDatabase string = "db-database" + DefaultDBDatabase string = "bottin" + DescriptionDBDatabase string = "Postgres database" + + ViperDBSSLMode string = "db.sslmode" + FlagDBSSLMode string = "db-sslmode" + DefaultDBSSLMode string = "prefer" + DescriptionDBSSLMode string = "Postgres sslmode" + + ViperDBHost string = "db.host" + FlagDBHost string = "db-host" + DefaultDBHost string = "db" + DescriptionDBHost string = "Postgres host" + + ViperDBPassword string = "db.password" + FlagDBPassword string = "db-password" + DefaultDBPassword string = "bottin" + DescriptionDBPassword string = "Postgres password" + + ViperDBPort string = "db.port" + FlagDBPort string = "db-port" + DefaultDBPort int = 5432 + DescriptionDBPort string = "Postgres port" + + ViperDBUser string = "db.user" + FlagDBUser string = "db-user" + DefaultDBUser string = "bottin" + DescriptionDBUser string = "Postgres user" + + ViperWebUser string = "web.user" + FlagWebUser string = "web-user" + DefaultWebUser string = "bottin" + DescriptionWebUser string = "Web client basic auth user" + + ViperWebPassword string = "web.password" + FlagWebPassword string = "web-password" + DefaultWebPassword string = "bottin" + DescriptionWebPassword string = "Web client basic auth password" + + ViperWebPort string = "web.port" + FlagWebPort string = "web-port" + DefaultWebPort int = 2312 + DescriptionWebPort string = "Web client port" + + ViperWebAPIHost string = "web.api.host" + FlagWebAPIHost string = "web-api-host" + DefaultWebAPIHost string = "api" + DescriptionWebAPIHost string = "Target API server host" + + ViperWebAPIKey string = "web.api.key" + FlagWebAPIKey string = "web-api-key" + DefaultWebAPIKey string = "bottin" + DescriptionWebAPIKey string = "Target API server key" + + ViperWebAPIPort string = "web.api.port" + FlagWebAPIPort string = "web-api-port" + DefaultWebAPIPort int = 1312 + DescriptionWebAPIPort string = "Target API server port" + + ViperWebAPIProtocol string = "web.api.protocol" + FlagWebAPIProtocol string = "web-api-protocol" + DefaultWebAPIProtocol string = "http" + DescriptionWebAPIProtocol string = "Target API server protocol (http/https)" +) + +type Config struct { + API struct { + Port int `yaml:"port"` + Key string `yaml:"key"` + } `yaml:"api"` + DB struct { + Database string `yaml:"database"` + Host string `yaml:"host"` + SSLMode string `yaml:"sslmode"` + Password string `yaml:"password"` + Port int `yaml:"port"` + User string `yaml:"user"` + } `yaml:"db"` + Web struct { + User string `yaml:"user"` + Password string `yaml:"password"` + Port int `yaml:"port"` + API struct { + Host string `yaml:"host"` + Key string `yaml:"key"` + Port int `yaml:"port"` + Protocol string `yaml:"protocol"` + } `yaml:"api"` + } `yaml:"web"` +} + +// DefaultConfig returns a Config filled with the default values from the +// `Default*` constants defined in this file. +func DefaultConfig() (cfg Config) { + cfg.API.Port = DefaultAPIPort + cfg.API.Key = DefaultAPIKey + cfg.DB.Database = DefaultDBDatabase + cfg.DB.Host = DefaultDBHost + cfg.DB.SSLMode = DefaultDBSSLMode + cfg.DB.Password = DefaultDBPassword + cfg.DB.Port = DefaultDBPort + cfg.DB.User = DefaultDBUser + cfg.Web.User = DefaultWebUser + cfg.Web.Password = DefaultWebPassword + cfg.Web.Port = DefaultWebPort + cfg.Web.API.Host = DefaultWebAPIHost + cfg.Web.API.Key = DefaultWebAPIKey + cfg.Web.API.Port = DefaultWebAPIPort + cfg.Web.API.Protocol = DefaultWebAPIProtocol + return +} + +func init() { + // rootCmd + + cobra.OnInitialize(initConfig) + + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bottin.yaml)") + + // apiCmd + + rootCmd.AddCommand(apiCmd) + + // api.key + apiCmd.Flags().String(FlagAPIKey, DefaultAPIKey, DescriptionAPIKey) + if err := viper.BindPFlag(ViperAPIKey, apiCmd.Flags().Lookup(FlagAPIKey)); err != nil { + log.Fatal(err) + } + + // api.port + apiCmd.Flags().Int(FlagAPIPort, DefaultAPIPort, DescriptionAPIPort) + if err := viper.BindPFlag(ViperAPIPort, apiCmd.Flags().Lookup(FlagAPIPort)); err != nil { + log.Fatal(err) + } + + // db.database + apiCmd.Flags().String(FlagDBDatabase, DefaultDBDatabase, DescriptionDBDatabase) + if err := viper.BindPFlag(ViperDBDatabase, apiCmd.Flags().Lookup(FlagDBDatabase)); err != nil { + log.Fatal(err) + } + + // db.sslmode + apiCmd.Flags().String(FlagDBSSLMode, DefaultDBSSLMode, DescriptionDBSSLMode) + if err := viper.BindPFlag(ViperDBSSLMode, apiCmd.Flags().Lookup(FlagDBSSLMode)); err != nil { + log.Fatal(err) + } + + // db.host + apiCmd.Flags().String(FlagDBHost, DefaultDBHost, DescriptionDBHost) + if err := viper.BindPFlag(ViperDBHost, apiCmd.Flags().Lookup(FlagDBHost)); err != nil { + log.Fatal(err) + } + + // db.password + apiCmd.Flags().String(FlagDBPassword, DefaultDBPassword, DescriptionDBPassword) + if err := viper.BindPFlag(ViperDBPassword, apiCmd.Flags().Lookup(FlagDBPassword)); err != nil { + log.Fatal(err) + } + + // db.port + apiCmd.Flags().Int(FlagDBPort, DefaultDBPort, DescriptionDBPort) + if err := viper.BindPFlag(ViperDBPort, apiCmd.Flags().Lookup(FlagDBPort)); err != nil { + log.Fatal(err) + } + + // db.user + apiCmd.Flags().String(FlagDBUser, DefaultDBUser, DescriptionDBUser) + if err := viper.BindPFlag(ViperDBUser, apiCmd.Flags().Lookup(FlagDBUser)); err != nil { + log.Fatal(err) + } + + // WebCmd + rootCmd.AddCommand(webCmd) + + // web.api.host + webCmd.Flags().String(FlagWebAPIHost, DefaultWebAPIHost, DescriptionWebAPIHost) + if err := viper.BindPFlag(ViperWebAPIHost, webCmd.Flags().Lookup(FlagWebAPIHost)); err != nil { + log.Fatal(err) + } + + // web.api.key + webCmd.Flags().String(FlagWebAPIKey, DefaultWebAPIKey, DescriptionWebAPIKey) + if err := viper.BindPFlag(ViperWebAPIKey, webCmd.Flags().Lookup(FlagWebAPIKey)); err != nil { + log.Fatal(err) + } + + // web.api.protocol + webCmd.Flags().String(FlagWebAPIProtocol, DefaultWebAPIProtocol, DescriptionWebAPIProtocol) + if err := viper.BindPFlag(ViperWebAPIProtocol, webCmd.Flags().Lookup(FlagWebAPIProtocol)); err != nil { + log.Fatal(err) + } + + // web.api.port + webCmd.Flags().Int(FlagWebAPIPort, DefaultWebAPIPort, DescriptionWebAPIPort) + if err := viper.BindPFlag(ViperWebAPIPort, webCmd.Flags().Lookup(FlagWebAPIPort)); err != nil { + log.Fatal(err) + } + + // web.password + webCmd.Flags().String(FlagWebPassword, DefaultWebPassword, DescriptionWebPassword) + if err := viper.BindPFlag(ViperWebPassword, webCmd.Flags().Lookup(FlagWebPassword)); err != nil { + log.Fatal(err) + } + + // web.port + webCmd.Flags().Int(FlagWebPort, DefaultWebPort, DescriptionWebPort) + if err := viper.BindPFlag(ViperWebPort, webCmd.Flags().Lookup(FlagWebPort)); err != nil { + log.Fatal(err) + } + + // web.user + webCmd.Flags().String(FlagWebUser, DefaultWebUser, DescriptionWebUser) + if err := viper.BindPFlag(ViperWebUser, webCmd.Flags().Lookup(FlagWebUser)); err != nil { + log.Fatal(err) + } +} + +var cfgFile string + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) + } else { + // Find home directory. + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + // Search config in home directory with name ".bottin" (without extension). + viper.AddConfigPath(home) + viper.SetConfigType("yaml") + viper.SetConfigName(".bottin") + } + + viper.SetEnvPrefix("BOTTIN") + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.AutomaticEnv() // read in environment variables that match + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err == nil { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) + } +} diff --git a/data/apiclient.go b/data/apiclient.go deleted file mode 100644 index da70a18..0000000 --- a/data/apiclient.go +++ /dev/null @@ -1,78 +0,0 @@ -package data - -import ( - "errors" - "fmt" - "net/http" - - "codeberg.org/vlbeaudoin/voki/v2" - "git.agecem.com/agecem/bottin/v6/models" - "git.agecem.com/agecem/bottin/v6/responses" - "github.com/spf13/viper" -) - -type ApiClient struct { - Voki *voki.Voki -} - -func NewApiClientFromViper(client *http.Client) *ApiClient { - apiClientKey := viper.GetString("web.api.key") - apiClientHost := viper.GetString("web.api.host") - apiClientProtocol := viper.GetString("web.api.protocol") - apiClientPort := viper.GetInt("web.api.port") - - return NewApiClient(client, apiClientKey, apiClientHost, apiClientProtocol, apiClientPort) -} - -func NewApiClient(client *http.Client, key, host, protocol string, port int) *ApiClient { - return &ApiClient{ - Voki: voki.New(client, host, key, port, protocol), - } -} - -// GetHealth allows checking for API server health -func (a *ApiClient) GetHealth() (string, error) { - var getHealthResponse responses.GetHealthResponse - err := a.Voki.Unmarshal(http.MethodGet, "/v6/health", nil, true, &getHealthResponse) - if err != nil { - return getHealthResponse.Message, err - } - - if getHealthResponse.Message == "" { - return getHealthResponse.Message, errors.New("Could not confirm that API server is up, no response message") - } - - return getHealthResponse.Message, nil -} - -func (a *ApiClient) GetMembre(membreID string) (models.Membre, error) { - var getMembreResponse struct { - Message string `json:"message"` - Data struct { - Membre models.Membre `json:"membre"` - } `json:"data"` - } - - if membreID == "" { - return getMembreResponse.Data.Membre, errors.New("Veuillez fournir un numéro étudiant à rechercher") - } - - err := a.Voki.Unmarshal(http.MethodGet, fmt.Sprintf("/v6/membres/%s", membreID), nil, true, &getMembreResponse) - if err != nil { - return getMembreResponse.Data.Membre, err - } - - if getMembreResponse.Data.Membre == *new(models.Membre) { - if getMembreResponse.Message != "" { - return getMembreResponse.Data.Membre, fmt.Errorf(getMembreResponse.Message) - } - - return getMembreResponse.Data.Membre, fmt.Errorf("Ce numéro étudiant ne correspond à aucunE membre") - } - - return getMembreResponse.Data.Membre, nil -} - -func (a *ApiClient) ListMembres() (r responses.ListMembresResponse, err error) { - return r, a.Voki.Unmarshal(http.MethodGet, "/v6/membres", nil, true, &r) -} diff --git a/data/data.go b/data/data.go deleted file mode 100644 index fa8678c..0000000 --- a/data/data.go +++ /dev/null @@ -1,182 +0,0 @@ -package data - -import ( - "errors" - "fmt" - - "git.agecem.com/agecem/bottin/v6/models" - _ "github.com/jackc/pgx/stdlib" - "github.com/jmoiron/sqlx" - "github.com/spf13/viper" -) - -// DataClient is a postgres client based on sqlx -type DataClient struct { - PostgresConnection PostgresConnection - DB sqlx.DB -} - -type PostgresConnection struct { - User string - Password string - Database string - Host string - Port int - SSL bool -} - -func NewDataClientFromViper() (*DataClient, error) { - client, err := NewDataClient( - PostgresConnection{ - User: viper.GetString("db.user"), - Password: viper.GetString("db.password"), - Host: viper.GetString("db.host"), - Database: viper.GetString("db.database"), - Port: viper.GetInt("db.port"), - }) - - return client, err -} - -func NewDataClient(connection PostgresConnection) (*DataClient, error) { - client := &DataClient{PostgresConnection: connection} - - connectionString := fmt.Sprintf("postgres://%s:%s@%s:%d/%s", - client.PostgresConnection.User, - client.PostgresConnection.Password, - client.PostgresConnection.Host, - client.PostgresConnection.Port, - client.PostgresConnection.Database, - ) - - db, err := sqlx.Connect("pgx", connectionString) - if err != nil { - return nil, err - } - - client.DB = *db - - return client, nil -} - -func (d *DataClient) Seed() (int64, error) { - result, err := d.DB.Exec(models.Schema) - if err != nil { - return 0, err - } - - rows, err := result.RowsAffected() - if err != nil { - return rows, err - } - - return rows, nil -} - -// InsertMembres inserts a slice of Membre into a database, returning the amount inserted and any error encountered -func (d *DataClient) InsertMembres(membres []models.Membre) (int64, error) { - var rowsInserted int64 - tx, err := d.DB.Beginx() - if err != nil { - return rowsInserted, err - } - defer tx.Rollback() - - for _, membre := range membres { - if membre.ID == "" { - return 0, errors.New("Cannot insert membre with no membre_id") - } - result, err := tx.NamedExec("INSERT INTO membres (id, last_name, first_name, prefered_name, programme_id) VALUES (:id, :last_name, :first_name, :prefered_name, :programme_id) ON CONFLICT (id) DO NOTHING;", &membre) - if err != nil { - return 0, err - } - - rows, err := result.RowsAffected() - if err != nil { - return 0, err - } - - rowsInserted += rows - } - - err = tx.Commit() - if err != nil { - return rowsInserted, err - } - - return rowsInserted, nil -} - -func (d *DataClient) InsertProgrammes(programmes []models.Programme) (int64, error) { - var rowsInserted int64 - tx, err := d.DB.Beginx() - if err != nil { - return rowsInserted, err - } - defer tx.Rollback() - - for _, programme := range programmes { - if programme.ID == "" { - return 0, errors.New("Cannot insert programme with no programme_id") - } - - result, err := tx.NamedExec("INSERT INTO programmes (id, titre) VALUES (:id, :titre) ON CONFLICT DO NOTHING;", &programme) - if err != nil { - return 0, err - } - - rows, err := result.RowsAffected() - if err != nil { - return 0, err - } - - rowsInserted += rows - } - - err = tx.Commit() - if err != nil { - return rowsInserted, err - } - - return rowsInserted, nil -} - -func (d *DataClient) GetMembre(membreID string) (models.Membre, error) { - var membre models.Membre - - rows, err := d.DB.Queryx("SELECT * FROM membres WHERE id = $1 LIMIT 1;", membreID) - if err != nil { - return membre, err - } - - for rows.Next() { - err := rows.StructScan(&membre) - if err != nil { - return membre, err - } - } - - if membre.ID == "" { - return membre, fmt.Errorf("No membre by that id was found") - } - - return membre, nil -} - -func (d *DataClient) UpdateMembreName(membreID, newName string) (int64, error) { - result, err := d.DB.Exec("UPDATE membres SET prefered_name = $1 WHERE id = $2;", newName, membreID) - if err != nil { - return 0, err - } - - rows, err := result.RowsAffected() - if err != nil { - return rows, err - } - - return rows, nil -} - -func (d *DataClient) GetMembres() (membres []models.Membre, err error) { - return membres, d.DB.Select(&membres, "SELECT * FROM membres;") -} diff --git a/db.go b/db.go new file mode 100644 index 0000000..f0b2f4e --- /dev/null +++ b/db.go @@ -0,0 +1,358 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed sql/schema.sql +var sqlSchema string + +//go:embed sql/views.sql +var sqlViews string + +type PostgresClient struct { + //TODO move context out of client + Ctx context.Context + Pool *pgxpool.Pool +} + +func (db *PostgresClient) CreateOrReplaceSchema() error { + _, err := db.Pool.Exec(db.Ctx, sqlSchema) + return err +} + +func (db *PostgresClient) CreateOrReplaceViews() error { + _, err := db.Pool.Exec(db.Ctx, sqlViews) + return err +} + +// InsertMembres inserts a slice of Membre into a database, returning the amount inserted and any error encountered +func (d *PostgresClient) InsertMembres(membres ...Membre) (inserted int64, err error) { + select { + case <-d.Ctx.Done(): + return inserted, fmt.Errorf("PostgresClient.Ctx closed: %s", d.Ctx.Err()) + default: + tx, err := d.Pool.Begin(d.Ctx) + if err != nil { + return inserted, err + } + defer tx.Rollback(d.Ctx) + + for i, membre := range membres { + if membre.ID == "" { + return inserted, fmt.Errorf("insertion ligne %d: membre requiert numéro étudiant valide", i) + } + + result, err := tx.Exec(d.Ctx, ` +INSERT INTO membres + (id, last_name, first_name, prefered_name, programme_id) +VALUES + ($1, $2, $3, $4, $5) +ON CONFLICT (id) DO NOTHING;`, + membre.ID, + membre.LastName, + membre.FirstName, + membre.PreferedName, + membre.ProgrammeID, + ) + if err != nil { + return 0, err + } + + inserted += result.RowsAffected() + } + + if err = tx.Commit(d.Ctx); err != nil { + return 0, err + } + + return inserted, err + } +} + +func (d *PostgresClient) InsertProgrammes(programmes ...Programme) (inserted int64, err error) { + select { + case <-d.Ctx.Done(): + return inserted, fmt.Errorf("PostgresClient.Ctx closed: %s", d.Ctx.Err()) + default: + tx, err := d.Pool.Begin(d.Ctx) + if err != nil { + return inserted, err + } + defer tx.Rollback(d.Ctx) + + for _, programme := range programmes { + if programme.ID == "" { + return 0, fmt.Errorf("Cannot insert programme with no programme_id") + } + + result, err := tx.Exec(d.Ctx, ` +INSERT INTO programmes +(id, name) +VALUES ($1, $2) ON CONFLICT DO NOTHING;`, + programme.ID, + programme.Name) + if err != nil { + return 0, err + } + + inserted += result.RowsAffected() + } + + if err := tx.Commit(d.Ctx); err != nil { + return inserted, err + } + + return inserted, err + } +} + +func (d *PostgresClient) GetMembre(membreID string) (membre Membre, err error) { + select { + case <-d.Ctx.Done(): + err = fmt.Errorf("PostgresClient.Ctx closed: %s", d.Ctx.Err()) + return + default: + if err = d.Pool.QueryRow(d.Ctx, ` +SELECT + "membres".id, + "membres".last_name, + "membres".first_name, + "membres".prefered_name, + "membres".programme_id +FROM + "membres" +WHERE + "membres".id = $1 +LIMIT + 1; +`, membreID).Scan( + &membre.ID, + &membre.LastName, + &membre.FirstName, + &membre.PreferedName, + &membre.ProgrammeID, + ); err != nil { + return + } + + if membre.ID == "" { + return membre, fmt.Errorf("Aucun membre trouvé avec numéro '%s'", membre.ID) + } + + return membre, nil + } +} + +/* +func (d *PostgresClient) UpdateMembreName(membreID, newName string) (int64, error) { + result, err := d.Pool.Exec("UPDATE membres SET prefered_name = $1 WHERE id = $2;", newName, membreID) + if err != nil { + return 0, err + } + + rows, err := result.RowsAffected() + if err != nil { + return rows, err + } + + return rows, nil +} +*/ + +func (d *PostgresClient) GetMembres(limit int) (membres []Membre, err error) { + select { + case <-d.Ctx.Done(): + return nil, fmt.Errorf("PostgresClient.Ctx closed: %s", d.Ctx.Err()) + default: + rows, err := d.Pool.Query(d.Ctx, ` +SELECT + "membres".id, + "membres".last_name, + "membres".first_name, + "membres".prefered_name, + "membres".programme_id +FROM + "membres" +ORDER BY + "membres".id +LIMIT + $1; +`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var membre Membre + + if err = rows.Scan( + &membre.ID, + &membre.LastName, + &membre.FirstName, + &membre.PreferedName, + &membre.ProgrammeID, + ); err != nil { + return nil, err + } + + membres = append(membres, membre) + } + if rows.Err() != nil { + return membres, rows.Err() + } + + return membres, nil + } +} + +func (d *PostgresClient) GetProgrammes(limit int) (programmes []Programme, err error) { + select { + case <-d.Ctx.Done(): + return nil, fmt.Errorf("PostgresClient.Ctx closed: %s", d.Ctx.Err()) + default: + rows, err := d.Pool.Query(d.Ctx, ` +SELECT + "programmes".id, + "programmes".name +FROM + "programmes" +ORDER BY + "programmes".id +LIMIT + $1; +`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var programme Programme + + if err = rows.Scan( + &programme.ID, + &programme.Name, + ); err != nil { + return nil, err + } + + programmes = append(programmes, programme) + } + if rows.Err() != nil { + return programmes, rows.Err() + } + + return programmes, nil + } +} + +func (d *PostgresClient) UpdateMembrePreferedName(membreID string, name string) (err error) { + select { + case <-d.Ctx.Done(): + return fmt.Errorf("PostgresClient.Ctx closed: %s", d.Ctx.Err()) + default: + if !IsMembreID(membreID) { + return fmt.Errorf("Numéro étudiant '%s' invalide", membreID) + } + + _, err = d.Pool.Exec(d.Ctx, ` +UPDATE + "membres" +SET + prefered_name = $1 +WHERE + "membres".id = $2; +`, name, membreID) + } + return +} + +func (d *PostgresClient) GetMembreForDisplay(membreID string) (membre MembreForDisplay, err error) { + select { + case <-d.Ctx.Done(): + err = fmt.Errorf("PostgresClient.Ctx closed: %s", d.Ctx.Err()) + return + default: + if err = d.Pool.QueryRow(d.Ctx, ` +SELECT + "membres_for_display".id, + "membres_for_display".name, + "membres_for_display".programme_id, + "membres_for_display".programme_name +FROM + "membres_for_display" +WHERE + "membres_for_display".id = $1 +LIMIT + 1; +`, membreID).Scan( + &membre.ID, + &membre.Name, + &membre.ProgrammeID, + &membre.ProgrammeName, + ); err != nil { + if err == pgx.ErrNoRows { + err = fmt.Errorf("Numéro étudiant valide mais aucun·e membre trouvé·e") + } + return + } + + if membre.ID == "" { + return membre, fmt.Errorf("Aucun membre trouvé avec numéro '%s'", membre.ID) + } + + return membre, nil + } +} + +func (d *PostgresClient) GetMembresForDisplay(limit int) (membres []MembreForDisplay, err error) { + select { + case <-d.Ctx.Done(): + return nil, fmt.Errorf("PostgresClient.Ctx closed: %s", d.Ctx.Err()) + default: + rows, err := d.Pool.Query(d.Ctx, ` +SELECT + "membres_for_display".id, + "membres_for_display".name, + "membres_for_display".programme_id, + "membres_for_display".programme_name +FROM + "membres_for_display" +ORDER BY + "membres_for_display".id +LIMIT + $1; +`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var membre MembreForDisplay + + if err = rows.Scan( + &membre.ID, + &membre.Name, + &membre.ProgrammeID, + &membre.ProgrammeName, + ); err != nil { + return nil, err + } + + membres = append(membres, membre) + } + if rows.Err() != nil { + return membres, rows.Err() + } + + return membres, nil + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 1f739d8..9f2e604 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ services: db: - image: 'docker.io/library/postgres:16.1' + image: 'docker.io/library/postgres:16' environment: POSTGRES_DATABASE: "${BOTTIN_POSTGRES_DATABASE}" POSTGRES_PASSWORD: "${BOTTIN_POSTGRES_PASSWORD}" diff --git a/entity.go b/entity.go new file mode 100644 index 0000000..672f54c --- /dev/null +++ b/entity.go @@ -0,0 +1,38 @@ +package main + +import "unicode" + +type Programme struct { + ID string `db:"id" json:"programme_id" csv:"programme_id"` + Name string `db:"name" json:"nom_programme" csv:"nom_programme"` +} + +type Membre struct { + ID string `db:"id" json:"membre_id" csv:"membre_id"` + LastName string `db:"last_name" json:"last_name" csv:"last_name"` + FirstName string `db:"first_name" json:"first_name" csv:"first_name"` + PreferedName string `db:"prefered_name" json:"prefered_name" csv:"prefered_name"` + ProgrammeID string `db:"programme_id" json:"programme_id" csv:"programme_id"` +} + +// MembreForDisplay maps to the `membres_for_display` view declared in `sql/views.sql` +type MembreForDisplay struct { + ID string `db:"id" json:"membre_id" csv:"membre_id"` + Name string `db:"name" json:"name" csv:"name"` + ProgrammeID string `db:"programme_id" json:"programme_id" csv:"programme_id"` + ProgrammeName string `db:"programme_name" json:"programme_name" csv:"programme_name"` +} + +func IsMembreID(membre_id string) bool { + if len(membre_id) != 7 { + return false + } + + for _, character := range membre_id { + if !unicode.IsDigit(character) { + return false + } + } + + return true +} diff --git a/examples/example.csv b/examples/example.csv new file mode 100644 index 0000000..fce75ff --- /dev/null +++ b/examples/example.csv @@ -0,0 +1,3 @@ +programme_id;nom_programme; +000.00;test programme; +111.11;autre test programme; diff --git a/go.mod b/go.mod index bd60d6f..2c52fee 100644 --- a/go.mod +++ b/go.mod @@ -1,36 +1,33 @@ -module git.agecem.com/agecem/bottin/v6 +module git.agecem.com/agecem/bottin/v7 go 1.22.0 require ( - codeberg.org/vlbeaudoin/serpents v1.1.0 - codeberg.org/vlbeaudoin/voki/v2 v2.0.3 - github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a - github.com/jackc/pgx v3.6.2+incompatible - github.com/jmoiron/sqlx v1.3.5 - github.com/labstack/echo/v4 v4.11.4 - github.com/spf13/cobra v1.8.0 - github.com/spf13/viper v1.18.2 + codeberg.org/vlbeaudoin/pave/v2 v2.0.0 + codeberg.org/vlbeaudoin/voki/v3 v3.0.0 + github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 + github.com/jackc/pgx/v5 v5.6.0 + github.com/labstack/echo/v4 v4.12.0 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 ) require ( - github.com/cockroachdb/apd v1.1.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/shopspring/decimal v1.3.1 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect @@ -39,11 +36,12 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.19.0 // indirect - golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect - golang.org/x/net v0.21.0 // indirect - golang.org/x/sys v0.17.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index b8fe100..e1ad5b4 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,8 @@ -codeberg.org/vlbeaudoin/serpents v1.1.0 h1:U9f2+2D1yUVHx90yePi2ZOLRLG/Wkoob4JXDIVyoBwA= -codeberg.org/vlbeaudoin/serpents v1.1.0/go.mod h1:3bE/R0ToABwcUJtS1VcGEBa86K5FYhrZGAbFl2qL8kQ= -codeberg.org/vlbeaudoin/voki/v2 v2.0.3 h1:H3j7yk8uBiDK19OUWAKbYKmw0tsSw4t0LA5lyAfyT3E= -codeberg.org/vlbeaudoin/voki/v2 v2.0.3/go.mod h1:TVdOLAxB94EJkylt5dleJlTkBzuxau8Xwd4TANQIR7U= -github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= -github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +codeberg.org/vlbeaudoin/pave/v2 v2.0.0 h1:hfB5KnqMMu17g5QBWgLvWOsqidrYaohRfu2LflmTrb0= +codeberg.org/vlbeaudoin/pave/v2 v2.0.0/go.mod h1:TsTfP6IA+3Ph33vLZigeJWS5vgBPgkW1tfs3zFPfycU= +codeberg.org/vlbeaudoin/voki/v3 v3.0.0 h1:XdF/UTe9YUNj3hYrAyEvdmIMDYLL8SkqTwPkqw1yJ2c= +codeberg.org/vlbeaudoin/voki/v3 v3.0.0/go.mod h1:+6LMXosAu2ijNKV04sMwkeujpH+cghZU1fydqj2y95g= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -13,36 +11,32 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a h1:RYfmiM0zluBJOiPDJseKLEN4BapJ42uSi9SZBQ2YyiA= -github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= -github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= -github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ= +github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc= -github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= -github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o= -github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= -github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -50,45 +44,43 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= -github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 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/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -97,23 +89,25 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= -golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.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/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers/handlers.go b/handlers/handlers.go deleted file mode 100644 index 0b35b23..0000000 --- a/handlers/handlers.go +++ /dev/null @@ -1,11 +0,0 @@ -package handlers - -import "git.agecem.com/agecem/bottin/v6/data" - -type Handler struct { - DataClient *data.DataClient -} - -func New(dataClient *data.DataClient) *Handler { - return &Handler{DataClient: dataClient} -} diff --git a/handlers/health.go b/handlers/health.go deleted file mode 100644 index 4923493..0000000 --- a/handlers/health.go +++ /dev/null @@ -1,36 +0,0 @@ -package handlers - -import ( - "net/http" - - "git.agecem.com/agecem/bottin/v6/data" - "git.agecem.com/agecem/bottin/v6/responses" - "github.com/labstack/echo/v4" -) - -func (h *Handler) GetHealth(c echo.Context) error { - var response responses.GetHealthResponse - - dataClient, err := data.NewDataClientFromViper() - if err != nil { - response.StatusCode = http.StatusInternalServerError - response.Message = "Error during data.NewDataClientFromViper()" - response.Error = err.Error() - - return c.JSON(response.StatusCode, response) - } - defer dataClient.DB.Close() - - if err = dataClient.DB.Ping(); err != nil { - response.StatusCode = http.StatusInternalServerError - response.Message = "Error during dataClient.DB.Ping()" - response.Error = err.Error() - - return c.JSON(response.StatusCode, response) - } - - response.StatusCode = http.StatusOK - response.Message = "Bottin API v6 is ready" - - return c.JSON(response.StatusCode, response) -} diff --git a/handlers/insert.go b/handlers/insert.go deleted file mode 100644 index 8039505..0000000 --- a/handlers/insert.go +++ /dev/null @@ -1,129 +0,0 @@ -package handlers - -import ( - "encoding/csv" - "io" - "net/http" - - "git.agecem.com/agecem/bottin/v6/models" - "git.agecem.com/agecem/bottin/v6/responses" - "github.com/labstack/echo/v4" - - "github.com/gocarina/gocsv" -) - -func (h *Handler) PostMembres(c echo.Context) error { - var response responses.PostMembresResponse - - var membres []models.Membre - - switch c.Request().Header.Get("Content-Type") { - case "application/json": - if err := c.Bind(&membres); err != nil { - response.StatusCode = http.StatusBadRequest - response.Message = "Could not bind membres" - response.Error = err.Error() - return c.JSON(response.StatusCode, response) - } - case "text/csv": - body := c.Request().Body - if body == nil { - response.StatusCode = http.StatusBadRequest - response.Message = "Request body is empty" - return c.JSON(response.StatusCode, response) - } - defer body.Close() - - // Parse the CSV data from the request body using gocsv. - if err := gocsv.Unmarshal(body, &membres); err != nil { - response.StatusCode = http.StatusBadRequest - response.Message = "Could not unmarshal into membres" - response.Error = err.Error() - return c.JSON(response.StatusCode, response) - } - default: - response.StatusCode = http.StatusBadRequest - response.Message = "Invalid Content-Type: Please use application/json or text/csv" - return c.JSON(response.StatusCode, response) - } - - if len(membres) == 0 { - response.StatusCode = http.StatusOK - response.Message = "Nothing to do" - return c.JSON(response.StatusCode, response) - } - - newMembres, err := h.DataClient.InsertMembres(membres) - if err != nil { - response.StatusCode = http.StatusInternalServerError - response.Message = "Could not insert membres" - response.Error = err.Error() - return c.JSON(response.StatusCode, response) - } - - response.StatusCode = http.StatusCreated - response.Message = "Insert successful" - response.Data.MembresInserted = newMembres - return c.JSON(response.StatusCode, response) -} - -func (h *Handler) PostProgrammes(c echo.Context) error { - var response responses.PostProgrammesResponse - - var programmes []models.Programme - - switch c.Request().Header.Get("Content-Type") { - case "application/json": - if err := c.Bind(&programmes); err != nil { - response.StatusCode = http.StatusBadRequest - response.Message = "Could not bind programmes" - response.Error = err.Error() - return c.JSON(response.StatusCode, response) - } - case "text/csv": - body := c.Request().Body - if body == nil { - response.StatusCode = http.StatusBadRequest - response.Message = "Request body is empty" - return c.JSON(response.StatusCode, response) - } - defer body.Close() - - gocsv.SetCSVReader(func(in io.Reader) gocsv.CSVReader { - r := csv.NewReader(in) - r.Comma = ';' - return r // Allows use ; as delimiter - }) - - // Parse the CSV data from the request body using gocsv. - if err := gocsv.Unmarshal(body, &programmes); err != nil { - response.StatusCode = http.StatusBadRequest - response.Message = "Could not unmarshal into programmes" - response.Error = err.Error() - return c.JSON(response.StatusCode, response) - } - default: - response.StatusCode = http.StatusBadRequest - response.Message = "Invalid Content-Type" - return c.JSON(response.StatusCode, response) - } - - if len(programmes) == 0 { - response.StatusCode = http.StatusOK - response.Message = "Nothing to do" - return c.JSON(response.StatusCode, response) - } - - newProgrammes, err := h.DataClient.InsertProgrammes(programmes) - if err != nil { - response.StatusCode = http.StatusInternalServerError - response.Message = "Could not insert programmes" - response.Error = err.Error() - return c.JSON(response.StatusCode, response) - } - - response.StatusCode = http.StatusCreated - response.Message = "Insert successful" - response.Data.ProgrammesInserted = newProgrammes - return c.JSON(response.StatusCode, response) -} diff --git a/handlers/read.go b/handlers/read.go deleted file mode 100644 index 0a5b1fd..0000000 --- a/handlers/read.go +++ /dev/null @@ -1,61 +0,0 @@ -package handlers - -import ( - "fmt" - "net/http" - - "git.agecem.com/agecem/bottin/v6/responses" - "github.com/labstack/echo/v4" -) - -func (h *Handler) ReadMembre(c echo.Context) error { - membreID := c.Param("membre_id") - - membre, err := h.DataClient.GetMembre(membreID) - if err != nil { - if err.Error() == "No membre by that id was found" { - return c.JSON(http.StatusNotFound, map[string]string{ - "message": "Not Found", - }) - } - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Unknown error during GetMembre", - "error": err.Error(), - }) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "message": "Read successful", - "data": map[string]interface{}{ - "membre": &membre, - }, - }) -} - -func (h *Handler) ListMembres(c echo.Context) error { - var r responses.ListMembresResponse - - membres, err := h.DataClient.GetMembres() - if err != nil { - r.StatusCode = http.StatusInternalServerError - r.Error = err.Error() - r.Message = "Error during (*handlers.Handler).DataClient.GetMembres" - - return c.JSON(r.StatusCode, r) - } - - r.StatusCode = http.StatusOK - - switch membres := len(membres); membres { - case 0: - r.Message = "No membres returned from database" - case 1: - r.Message = "Membre returned from database" - default: - r.Message = fmt.Sprintf("%d membres returned from database", membres) - } - - r.Data.Membres = membres - - return c.JSON(r.StatusCode, r) -} diff --git a/handlers/seed.go b/handlers/seed.go deleted file mode 100644 index 27f5958..0000000 --- a/handlers/seed.go +++ /dev/null @@ -1,24 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/labstack/echo/v4" -) - -func (h *Handler) PostSeed(c echo.Context) error { - rows, err := h.DataClient.Seed() - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Seed failed", - "error": err.Error(), - }) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "message": "Seed successful", - "data": map[string]interface{}{ - "rows": rows, - }, - }) -} diff --git a/handlers/update.go b/handlers/update.go deleted file mode 100644 index 8b4bd04..0000000 --- a/handlers/update.go +++ /dev/null @@ -1,42 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/labstack/echo/v4" -) - -func (h *Handler) PutMembrePreferedName(c echo.Context) error { - membreID := c.Param("membre_id") - - var newName string - - err := c.Bind(&newName) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{ - "message": "Could not bind newName", - "error": err.Error(), - }) - } - - rows, err := h.DataClient.UpdateMembreName(membreID, newName) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Could not update membre name", - "error": err.Error(), - }) - } - - if rows == 0 { - return c.JSON(http.StatusBadRequest, map[string]string{ - "message": "No update was done, probably no membre by that id", - }) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "message": "Update successful", - "data": map[string]interface{}{ - "rows": rows, - }, - }) -} diff --git a/main.go b/main.go index f0b7d52..6f81738 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,18 @@ package main -import "git.agecem.com/agecem/bottin/v6/cmd" - func main() { - cmd.Execute() + /* TODO + if err := Run(context.Background(), Config{}, nil, os.Stdout); err != nil { + log.Fatal(err) + } + */ + + // Handle the command-line via cobra and viper + execute() } + +/* TODO +func Run(ctx context.Context, config Config, args []string, stdout io.Writer) error { + return fmt.Errorf("not implemented") +} +*/ diff --git a/models/models.go b/models/models.go deleted file mode 100644 index 845d28d..0000000 --- a/models/models.go +++ /dev/null @@ -1,33 +0,0 @@ -package models - -const Schema = ` -CREATE TABLE IF NOT EXISTS programmes ( - id TEXT PRIMARY KEY, - titre TEXT -); - -CREATE TABLE IF NOT EXISTS membres ( - id VARCHAR(7) PRIMARY KEY, - last_name TEXT, - first_name TEXT, - prefered_name TEXT, - programme_id TEXT REFERENCES programmes(id) -); -` - -type Programme struct { - ID string `db:"id" json:"programme_id" csv:"programme_id"` - Titre string `db:"titre" json:"nom_programme" csv:"nom_programme"` -} - -type Membre struct { - ID string `db:"id" json:"membre_id" csv:"membre_id"` - LastName string `db:"last_name" json:"last_name" csv:"last_name"` - FirstName string `db:"first_name" json:"first_name" csv:"first_name"` - PreferedName string `db:"prefered_name" json:"prefered_name" csv:"prefered_name"` - ProgrammeID string `db:"programme_id" json:"programme_id" csv:"programme_id"` -} - -type Entry interface { - Programme | Membre -} diff --git a/request.go b/request.go new file mode 100644 index 0000000..8f47f10 --- /dev/null +++ b/request.go @@ -0,0 +1,350 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "codeberg.org/vlbeaudoin/voki/v3" +) + +var _ voki.Requester[HealthGETResponse] = HealthGETRequest{} + +type HealthGETRequest struct{} + +func (request HealthGETRequest) Complete() bool { return true } + +func (request HealthGETRequest) Request(v *voki.Voki) (response HealthGETResponse, err error) { + if !request.Complete() { + err = fmt.Errorf("Incomplete HealthGET request") + return + } + + statusCode, body, err := v.CallAndParse( + http.MethodGet, + "/api/v7/health/", + nil, + true, + ) + if err != nil { + err = fmt.Errorf("%d: %s", statusCode, err) + return + } + response.SetStatusCode(statusCode) + + if err = json.Unmarshal(body, &response); err != nil { + return + } + + return +} + +var _ voki.Requester[ProgrammesPOSTResponse] = ProgrammesPOSTRequest{} + +type ProgrammesPOSTRequest struct { + Data struct { + Programmes []Programme + } +} + +func (request ProgrammesPOSTRequest) Complete() bool { + return len(request.Data.Programmes) != 0 +} +func (request ProgrammesPOSTRequest) Request(v *voki.Voki) (response ProgrammesPOSTResponse, err error) { + if !request.Complete() { + err = fmt.Errorf("Incomplete ProgrammesPOSTRequest") + return + } + + var buf bytes.Buffer + if err = json.NewEncoder(&buf).Encode(request.Data); err != nil { + return + } + + statusCode, body, err := v.CallAndParse( + http.MethodPost, + "/api/v7/programme/", + &buf, + true, + ) + if err != nil { + err = fmt.Errorf("code=%d err=%s", statusCode, err) + return + } + response.SetStatusCode(statusCode) + if err = json.Unmarshal(body, &response); err != nil { + return + } + + return +} + +var _ voki.Requester[MembresPOSTResponse] = MembresPOSTRequest{} + +type MembresPOSTRequest struct { + Data struct { + Membres []Membre + } +} + +func (request MembresPOSTRequest) Complete() bool { + return len(request.Data.Membres) != 0 +} + +func (request MembresPOSTRequest) Request(v *voki.Voki) (response MembresPOSTResponse, err error) { + if !request.Complete() { + err = fmt.Errorf("Incomplete MembresPOSTRequest") + return + } + + var buf bytes.Buffer + if err = json.NewEncoder(&buf).Encode(request.Data); err != nil { + return + } + + statusCode, body, err := v.CallAndParse( + http.MethodPost, + "/api/v7/membre/", + &buf, + true, + ) + if err != nil { + err = fmt.Errorf("code=%d err=%s", statusCode, err) + return + } + response.SetStatusCode(statusCode) + if err = json.Unmarshal(body, &response); err != nil { + return + } + + return +} + +var _ voki.Requester[MembreGETResponse] = MembreGETRequest{} + +type MembreGETRequest struct { + Param struct { + MembreID string `json:"membre_id" param:"membre_id"` + } +} + +func (request MembreGETRequest) Complete() bool { + return request.Param.MembreID != "" +} + +func (request MembreGETRequest) Request(v *voki.Voki) (response MembreGETResponse, err error) { + if !request.Complete() { + err = fmt.Errorf("Incomplete MembreGETRequest") + return + } + + if id := request.Param.MembreID; !IsMembreID(id) { + err = fmt.Errorf("MembreID '%s' invalide", id) + return + } + + statusCode, body, err := v.CallAndParse( + http.MethodGet, + fmt.Sprintf("/api/v7/membre/%s/", request.Param.MembreID), + nil, + true, + ) + if err != nil { + err = fmt.Errorf("code=%d err=%s", statusCode, err) + return + } + response.SetStatusCode(statusCode) + if err = json.Unmarshal(body, &response); err != nil { + return + } + + return +} + +var _ voki.Requester[MembresGETResponse] = MembresGETRequest{} + +type MembresGETRequest struct { + Query struct { + Limit int `json:"limit" query:"limit"` + } +} + +func (request MembresGETRequest) Complete() bool { return true } + +func (request MembresGETRequest) Request(v *voki.Voki) (response MembresGETResponse, err error) { + if !request.Complete() { + err = fmt.Errorf("Incomplete MembresGETRequest") + return + } + + statusCode, body, err := v.CallAndParse( + http.MethodGet, + fmt.Sprintf("/api/v7/membre/?limit=%d", request.Query.Limit), + nil, + true, + ) + if err != nil { + err = fmt.Errorf("code=%d err=%s", statusCode, err) + return + } + response.SetStatusCode(statusCode) + if err = json.Unmarshal(body, &response); err != nil { + return + } + + return +} + +var _ voki.Requester[MembrePreferedNamePUTResponse] = MembrePreferedNamePUTRequest{} + +type MembrePreferedNamePUTRequest struct { + Data struct { + PreferedName string `json:"prefered_name"` + } `json:"data"` + Param struct { + MembreID string `json:"membre_id" param:"membre_id"` + } `json:"param"` +} + +func (request MembrePreferedNamePUTRequest) Complete() bool { + return IsMembreID(request.Param.MembreID) && len(request.Data.PreferedName) != 0 +} + +func (request MembrePreferedNamePUTRequest) Request(v *voki.Voki) (response MembrePreferedNamePUTResponse, err error) { + if !request.Complete() { + err = fmt.Errorf("Incomplete MembrePreferedNamePUTRequest") + return + } + + var buf bytes.Buffer + if err = json.NewEncoder(&buf).Encode(request.Data); err != nil { + return + } + + statusCode, body, err := v.CallAndParse( + http.MethodPut, + fmt.Sprintf("/api/v7/membre/%s/prefered_name/", request.Param.MembreID), + &buf, + true, + ) + if err != nil { + err = fmt.Errorf("code=%d err=%s", statusCode, err) + return + } + response.SetStatusCode(statusCode) + if err = json.Unmarshal(body, &response); err != nil { + return + } + + return +} + +var _ voki.Requester[ProgrammesGETResponse] = ProgrammesGETRequest{} + +type ProgrammesGETRequest struct { + Query struct { + Limit int `json:"limit" query:"limit"` + } +} + +func (request ProgrammesGETRequest) Complete() bool { return true } + +func (request ProgrammesGETRequest) Request(v *voki.Voki) (response ProgrammesGETResponse, err error) { + if !request.Complete() { + err = fmt.Errorf("Incomplete ProgrammesGETRequest") + return + } + + statusCode, body, err := v.CallAndParse( + http.MethodGet, + fmt.Sprintf("/api/v7/programme/?limit=%d", request.Query.Limit), + nil, + true, + ) + if err != nil { + err = fmt.Errorf("code=%d err=%s", statusCode, err) + return + } + response.SetStatusCode(statusCode) + if err = json.Unmarshal(body, &response); err != nil { + return + } + + return +} + +var _ voki.Requester[MembresDisplayGETResponse] = MembresDisplayGETRequest{} + +type MembresDisplayGETRequest struct { + Query struct { + Limit int `json:"limit" query:"limit"` + } +} + +func (request MembresDisplayGETRequest) Complete() bool { return true } + +func (request MembresDisplayGETRequest) Request(v *voki.Voki) (response MembresDisplayGETResponse, err error) { + if !request.Complete() { + err = fmt.Errorf("Incomplete MembresDisplayGETRequest") + return + } + + statusCode, body, err := v.CallAndParse( + http.MethodGet, + fmt.Sprintf("/api/v7/membre/display/?limit=%d", request.Query.Limit), + nil, + true, + ) + if err != nil { + err = fmt.Errorf("code=%d err=%s", statusCode, err) + return + } + response.SetStatusCode(statusCode) + if err = json.Unmarshal(body, &response); err != nil { + return + } + + return +} + +var _ voki.Requester[MembreDisplayGETResponse] = MembreDisplayGETRequest{} + +type MembreDisplayGETRequest struct { + Param struct { + MembreID string `json:"membre_id" param:"membre_id"` + } +} + +func (request MembreDisplayGETRequest) Complete() bool { + return request.Param.MembreID != "" +} + +func (request MembreDisplayGETRequest) Request(v *voki.Voki) (response MembreDisplayGETResponse, err error) { + if !request.Complete() { + err = fmt.Errorf("Incomplete MembreDisplayGETRequest") + return + } + + if id := request.Param.MembreID; !IsMembreID(id) { + err = fmt.Errorf("MembreID '%s' invalide", id) + return + } + + statusCode, body, err := v.CallAndParse( + http.MethodGet, + fmt.Sprintf("/api/v7/membre/%s/display/", request.Param.MembreID), + nil, + true, + ) + if err != nil { + err = fmt.Errorf("code=%d err=%s", statusCode, err) + return + } + response.SetStatusCode(statusCode) + if err = json.Unmarshal(body, &response); err != nil { + return + } + + return +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..19818df --- /dev/null +++ b/response.go @@ -0,0 +1,91 @@ +package main + +import ( + "fmt" + + "codeberg.org/vlbeaudoin/voki/v3" +) + +type APIResponse struct { + voki.MessageResponse + statusCode int +} + +func (R APIResponse) StatusCode() int { return R.statusCode } + +func (R *APIResponse) SetStatusCode(code int) error { + if code <= 0 { + return fmt.Errorf("Cannot set status code to %d", code) + } + R.statusCode = code + return nil +} + +type HealthGETResponse struct { + APIResponse +} + +type MembreGETResponse struct { + APIResponse + Data MembreGETResponseData `json:"data"` +} +type MembreGETResponseData struct { + Membre Membre `json:"membre"` +} + +type MembrePreferedNamePUTResponse struct { + APIResponse +} + +type MembresGETResponse struct { + APIResponse + Data MembresGETResponseData `json:"data"` +} + +type MembresGETResponseData struct { + Membres []Membre `json:"membres"` +} + +type MembreDisplayGETResponse struct { + APIResponse + Data MembreDisplayGETResponseData `json:"data"` +} +type MembreDisplayGETResponseData struct { + Membre MembreForDisplay `json:"membre_for_display"` +} + +type MembresDisplayGETResponse struct { + APIResponse + Data MembresDisplayGETResponseData `json:"data"` +} + +type MembresDisplayGETResponseData struct { + Membres []MembreForDisplay `json:"membres_for_display"` +} + +type MembresPOSTResponse struct { + APIResponse + Data MembresPOSTResponseData `json:"data"` +} + +type MembresPOSTResponseData struct { + MembresInserted int64 `json:"membres_inserted"` +} + +type ProgrammesPOSTResponse struct { + APIResponse + Data ProgrammesPOSTResponseData `json:"data"` +} + +type ProgrammesPOSTResponseData struct { + ProgrammesInserted int64 `json:"programmes_inserted"` +} + +type ProgrammesGETResponse struct { + APIResponse + Data ProgrammesGETResponseData `json:"data"` +} + +type ProgrammesGETResponseData struct { + Programmes []Programme `json:"programmes"` +} diff --git a/responses/health.go b/responses/health.go deleted file mode 100644 index 59c6fe5..0000000 --- a/responses/health.go +++ /dev/null @@ -1,7 +0,0 @@ -package responses - -import "codeberg.org/vlbeaudoin/voki/v2" - -type GetHealthResponse struct { - voki.ResponseWithError -} diff --git a/responses/list.go b/responses/list.go deleted file mode 100644 index 414883b..0000000 --- a/responses/list.go +++ /dev/null @@ -1,13 +0,0 @@ -package responses - -import ( - "codeberg.org/vlbeaudoin/voki/v2" - "git.agecem.com/agecem/bottin/v6/models" -) - -type ListMembresResponse struct { - voki.ResponseWithError - Data struct { - Membres []models.Membre - } -} diff --git a/responses/post.go b/responses/post.go deleted file mode 100644 index 7f1b7b4..0000000 --- a/responses/post.go +++ /dev/null @@ -1,17 +0,0 @@ -package responses - -import "codeberg.org/vlbeaudoin/voki/v2" - -type PostMembresResponse struct { - voki.ResponseWithError - Data struct { - MembresInserted int64 - } -} - -type PostProgrammesResponse struct { - voki.ResponseWithError - Data struct { - ProgrammesInserted int64 - } -} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..ca7dcb2 --- /dev/null +++ b/routes.go @@ -0,0 +1,464 @@ +package main + +import ( + "encoding/csv" + "fmt" + "io" + "net/http" + "strconv" + + "codeberg.org/vlbeaudoin/pave/v2" + "codeberg.org/vlbeaudoin/voki/v3" + "github.com/gocarina/gocsv" + "github.com/labstack/echo/v4" +) + +func addRoutes(e *echo.Echo, db *PostgresClient, cfg Config) error { + _ = db + _ = cfg + + apiPath := "/api/v7" + apiGroup := e.Group(apiPath) + p := pave.New() + if err := pave.EchoRegister[HealthGETRequest]( + apiGroup, + &p, + apiPath, + http.MethodGet, + "/health/", + "Get API server health", + "HealthGET", func(c echo.Context) error { + var request, response = HealthGETRequest{}, HealthGETResponse{} + if !request.Complete() { + var response voki.ResponseBadRequest + response.Message = "Incomplete HealthGET request received" + return c.JSON(response.StatusCode(), response) + } + if err := response.SetStatusCode(http.StatusOK); err != nil { + var response voki.ResponseInternalServerError + response.Message = fmt.Sprintf("handler: %s", err) + return c.JSON(response.StatusCode(), response) + } + + response.Message = "ok" + return c.JSON(response.StatusCode(), response) + + }); err != nil { + return err + } + + if err := pave.EchoRegister[ProgrammesPOSTRequest]( + apiGroup, + &p, + apiPath, + http.MethodPost, + "/programme/", + "Insert programmes", + "ProgrammesPOST", func(c echo.Context) error { + var request, response = ProgrammesPOSTRequest{}, ProgrammesPOSTResponse{} + + switch contentType := c.Request().Header.Get("Content-Type"); contentType { + case "application/json": + if err := c.Bind(&request.Data); err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("parse request body: %s", err) + return c.JSON(response.StatusCode(), response) + } + case "text/csv": + body := c.Request().Body + if body == nil { + var response voki.ResponseBadRequest + response.Message = "empty request body cannot be parsed" + return c.JSON(response.StatusCode(), response) + } + defer body.Close() + + gocsv.SetCSVReader(func(in io.Reader) gocsv.CSVReader { + r := csv.NewReader(in) + r.Comma = ';' + return r // Allows use ; as delimiter + }) + + // Parse CSV data using gocsv + if err := gocsv.Unmarshal(body, &request.Data.Programmes); err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("parse programmes from csv: %s", err) + return c.JSON(response.StatusCode(), response) + } + default: + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("cannot parse body with content-type: %s", contentType) + return c.JSON(response.StatusCode(), response) + } + + if !request.Complete() { + var response voki.ResponseBadRequest + response.Message = "Incomplete ProgrammesPOST request received" + return c.JSON(response.StatusCode(), response) + } + + amountInserted, err := db.InsertProgrammes(request.Data.Programmes...) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("db: %s", err) + return c.JSON(response.StatusCode(), response) + } + response.Data.ProgrammesInserted = amountInserted + + if err := response.SetStatusCode(http.StatusOK); err != nil { + var response voki.ResponseInternalServerError + response.Message = fmt.Sprintf("handler: %s", err) + return c.JSON(response.StatusCode(), response) + } + + response.Message = "ok" + return c.JSON(response.StatusCode(), response) + + }); err != nil { + return err + } + + if err := pave.EchoRegister[MembresPOSTRequest]( + apiGroup, + &p, + apiPath, + http.MethodPost, + "/membre/", + "Insert membres", + "MembresPOST", func(c echo.Context) error { + var request, response = MembresPOSTRequest{}, MembresPOSTResponse{} + + switch contentType := c.Request().Header.Get("Content-Type"); contentType { + case "application/json": + if err := c.Bind(&request.Data); err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("parse request body: %s", err) + return c.JSON(response.StatusCode(), response) + } + case "text/csv": + body := c.Request().Body + if body == nil { + var response voki.ResponseBadRequest + response.Message = "empty request body cannot be parsed" + return c.JSON(response.StatusCode(), response) + } + defer body.Close() + + gocsv.SetCSVReader(func(in io.Reader) gocsv.CSVReader { + r := csv.NewReader(in) + r.Comma = ';' + return r // Allows use ; as delimiter + }) + + // Parse CSV data using gocsv + if err := gocsv.Unmarshal(body, &request.Data.Membres); err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("parse membres from csv: %s", err) + return c.JSON(response.StatusCode(), response) + } + default: + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("cannot parse body with content-type: %s", contentType) + return c.JSON(response.StatusCode(), response) + } + + if !request.Complete() { + var response voki.ResponseBadRequest + response.Message = "Incomplete MembresPOST request received" + return c.JSON(response.StatusCode(), response) + } + + amountInserted, err := db.InsertMembres(request.Data.Membres...) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("db: %s", err) + return c.JSON(response.StatusCode(), response) + } + response.Data.MembresInserted = amountInserted + + if err := response.SetStatusCode(http.StatusOK); err != nil { + var response voki.ResponseInternalServerError + response.Message = fmt.Sprintf("handler: %s", err) + return c.JSON(response.StatusCode(), response) + } + + response.Message = "ok" + return c.JSON(response.StatusCode(), response) + + }); err != nil { + return err + } + + if err := pave.EchoRegister[MembreGETRequest]( + apiGroup, + &p, + apiPath, + http.MethodGet, + "/membre/:membre_id/", + "Get membre", + "MembreGET", func(c echo.Context) error { + var request, response = MembreGETRequest{}, MembreGETResponse{} + + request.Param.MembreID = c.Param("membre_id") + + if !request.Complete() { + var response voki.ResponseBadRequest + response.Message = "Incomplete MembreGET request received" + return c.JSON(response.StatusCode(), response) + } + + membre, err := db.GetMembre(request.Param.MembreID) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("db: %s", err) + return c.JSON(response.StatusCode(), response) + } + response.Data.Membre = membre + + if err := response.SetStatusCode(http.StatusOK); err != nil { + var response voki.ResponseInternalServerError + response.Message = fmt.Sprintf("handler: %s", err) + return c.JSON(response.StatusCode(), response) + } + + response.Message = "ok" + return c.JSON(response.StatusCode(), response) + + }); err != nil { + return err + } + + if err := pave.EchoRegister[MembresGETRequest]( + apiGroup, + &p, + apiPath, + http.MethodGet, + "/membre/", + "Get membres", + "MembresGET", func(c echo.Context) (err error) { + var request, response = MembresGETRequest{}, MembresGETResponse{} + + queryLimit := c.QueryParam("limit") + if queryLimit != "" { + request.Query.Limit, err = strconv.Atoi(queryLimit) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("parsing limit: %s", err) + return c.JSON(response.StatusCode(), response) + } + + } else { + //TODO cfg.API.DefaultLimit + request.Query.Limit = 1000 + } + + if !request.Complete() { + var response voki.ResponseBadRequest + response.Message = "Incomplete MembresGET request received" + return c.JSON(response.StatusCode(), response) + } + + response.Data.Membres, err = db.GetMembres(request.Query.Limit) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("db: %s", err) + return c.JSON(response.StatusCode(), response) + } + + if err := response.SetStatusCode(http.StatusOK); err != nil { + var response voki.ResponseInternalServerError + response.Message = fmt.Sprintf("handler: %s", err) + return c.JSON(response.StatusCode(), response) + } + + response.Message = "ok" + return c.JSON(response.StatusCode(), response) + + }); err != nil { + return err + } + + if err := pave.EchoRegister[ProgrammesGETRequest]( + apiGroup, + &p, + apiPath, + http.MethodGet, + "/programme/", + "Get programmes", + "ProgrammesGET", func(c echo.Context) (err error) { + var request, response = ProgrammesGETRequest{}, ProgrammesGETResponse{} + + queryLimit := c.QueryParam("limit") + if queryLimit != "" { + request.Query.Limit, err = strconv.Atoi(queryLimit) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("parsing limit: %s", err) + return c.JSON(response.StatusCode(), response) + } + + } else { + //TODO cfg.API.DefaultLimit + request.Query.Limit = 1000 + } + + if !request.Complete() { + var response voki.ResponseBadRequest + response.Message = "Incomplete ProgrammesGET request received" + return c.JSON(response.StatusCode(), response) + } + + response.Data.Programmes, err = db.GetProgrammes(request.Query.Limit) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("db: %s", err) + return c.JSON(response.StatusCode(), response) + } + + if err := response.SetStatusCode(http.StatusOK); err != nil { + var response voki.ResponseInternalServerError + response.Message = fmt.Sprintf("handler: %s", err) + return c.JSON(response.StatusCode(), response) + } + + response.Message = "ok" + return c.JSON(response.StatusCode(), response) + + }); err != nil { + return err + } + + if err := pave.EchoRegister[MembrePreferedNamePUTRequest]( + apiGroup, + &p, + apiPath, + http.MethodPut, + "/membre/:membre_id/prefered_name/", + "Update membre prefered name, which is prioritized in the membres_for_display view", + "MembrePreferedNamePUT", func(c echo.Context) error { + var request, response = MembrePreferedNamePUTRequest{}, MembrePreferedNamePUTResponse{} + + request.Param.MembreID = c.Param("membre_id") + + if err := c.Bind(&request.Data); err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("parse request body: %s", err) + return c.JSON(response.StatusCode(), response) + } + + if !request.Complete() { + var response voki.ResponseBadRequest + response.Message = "Incomplete MembrePreferedNamePUT request received" + return c.JSON(response.StatusCode(), response) + } + + if err := db.UpdateMembrePreferedName(request.Param.MembreID, request.Data.PreferedName); err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("db: %s", err) + return c.JSON(response.StatusCode(), response) + } + + if err := response.SetStatusCode(http.StatusOK); err != nil { + var response voki.ResponseInternalServerError + response.Message = fmt.Sprintf("handler: %s", err) + return c.JSON(response.StatusCode(), response) + } + + response.Message = fmt.Sprintf("Updated membre %s name to %s", request.Param.MembreID, request.Data.PreferedName) + return c.JSON(response.StatusCode(), response) + + }); err != nil { + return err + } + + if err := pave.EchoRegister[MembresDisplayGETRequest]( + apiGroup, + &p, + apiPath, + http.MethodGet, + "/membre/display/", + "Get membres", + "MembresDisplayGET", func(c echo.Context) (err error) { + var request, response = MembresDisplayGETRequest{}, MembresDisplayGETResponse{} + + queryLimit := c.QueryParam("limit") + if queryLimit != "" { + request.Query.Limit, err = strconv.Atoi(queryLimit) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("parsing limit: %s", err) + return c.JSON(response.StatusCode(), response) + } + + } else { + //TODO cfg.API.DefaultLimit + request.Query.Limit = 1000 + } + + if !request.Complete() { + var response voki.ResponseBadRequest + response.Message = "Incomplete MembresDisplayGET request received" + return c.JSON(response.StatusCode(), response) + } + + response.Data.Membres, err = db.GetMembresForDisplay(request.Query.Limit) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("db: %s", err) + return c.JSON(response.StatusCode(), response) + } + + if err := response.SetStatusCode(http.StatusOK); err != nil { + var response voki.ResponseInternalServerError + response.Message = fmt.Sprintf("handler: %s", err) + return c.JSON(response.StatusCode(), response) + } + + response.Message = "ok" + return c.JSON(response.StatusCode(), response) + + }); err != nil { + return err + } + + if err := pave.EchoRegister[MembreDisplayGETRequest]( + apiGroup, + &p, + apiPath, + http.MethodGet, + "/membre/:membre_id/display/", + "Get membre", + "MembreDisplayGET", func(c echo.Context) error { + var request, response = MembreDisplayGETRequest{}, MembreDisplayGETResponse{} + + request.Param.MembreID = c.Param("membre_id") + + if !request.Complete() { + var response voki.ResponseBadRequest + response.Message = "Incomplete MembreDisplayGET request received" + return c.JSON(response.StatusCode(), response) + } + + membre, err := db.GetMembreForDisplay(request.Param.MembreID) + if err != nil { + var response voki.ResponseBadRequest + response.Message = fmt.Sprintf("db: %s", err) + return c.JSON(response.StatusCode(), response) + } + response.Data.Membre = membre + + if err := response.SetStatusCode(http.StatusOK); err != nil { + var response voki.ResponseInternalServerError + response.Message = fmt.Sprintf("handler: %s", err) + return c.JSON(response.StatusCode(), response) + } + + response.Message = "ok" + return c.JSON(response.StatusCode(), response) + + }); err != nil { + return err + } + + return nil +} diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..6253131 --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS programmes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS membres ( + id VARCHAR(7) PRIMARY KEY, + last_name TEXT NOT NULL, + first_name TEXT NOT NULL, + prefered_name TEXT, + programme_id TEXT REFERENCES programmes(id) NOT NULL +); diff --git a/sql/views.sql b/sql/views.sql new file mode 100644 index 0000000..ecdde1a --- /dev/null +++ b/sql/views.sql @@ -0,0 +1,23 @@ +-- membres_for_display affiche le numéro étudiant, nom complet OU prefered_name, et titre du programme. +-- +-- Utilisé par l'application web pour rechercher et afficher les informations des membres +CREATE OR REPLACE VIEW + "membres_for_display" +AS ( + SELECT + "membres".id, + CASE + WHEN + "membres".prefered_name != '' AND "membres".prefered_name IS NOT NULL + THEN + "membres".prefered_name + ELSE + CONCAT("membres".last_name, ', ', "membres".first_name) + END AS name, + "programmes".id AS programme_id, + "programmes".name AS programme_name + FROM + "membres" + INNER JOIN + "programmes" ON "programmes".id = "membres".programme_id +); diff --git a/template.go b/template.go new file mode 100644 index 0000000..a5b4980 --- /dev/null +++ b/template.go @@ -0,0 +1,20 @@ +package main + +import ( + "embed" + "html/template" + "io" + + "github.com/labstack/echo/v4" +) + +//go:embed templates/* +var templatesFS embed.FS + +type Template struct { + templates *template.Template +} + +func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { + return t.templates.ExecuteTemplate(w, name, data) +} diff --git a/web/templates/index.html b/templates/index.html similarity index 95% rename from web/templates/index.html rename to templates/index.html index 700d9bb..2822147 100644 --- a/web/templates/index.html +++ b/templates/index.html @@ -83,7 +83,7 @@ button {
- Scannez la carte étudiante d'unE membre
+ Scannez la carte étudiante d'un·e membre
-ou-
Entrez manuellement le code à 7 chiffres
{{ .Result }}
+{{ .Message }}