From 7d561ea6ea47af9a71048ebe401896cae60a4255 Mon Sep 17 00:00:00 2001 From: bdoerfchen Date: Sun, 20 Jul 2025 13:47:16 +0200 Subject: [PATCH 1/6] feat: base implementation --- .gitignore | 2 + .vscode/launch.json | 18 ++++++ cmd/config.go | 14 ++++ cmd/output.go | 5 ++ cmd/resources.go | 19 ++++++ cmd/root.go | 42 ++++++++++++ cmd/verbs.go | 99 +++++++++++++++++++++++++++++ core/handler/menu/menu.go | 88 +++++++++++++++++++++++++ core/interfaces/handler.go | 29 +++++++++ core/interfaces/http.go | 7 ++ core/interfaces/params/params.go | 56 ++++++++++++++++ core/services/jlog/context.go | 23 +++++++ core/services/jlog/logger.go | 24 +++++++ core/services/stwhbclient/client.go | 66 +++++++++++++++++++ go.mod | 9 +++ go.sum | 11 ++++ main.go | 7 ++ model/external/stwbremen/dish.go | 21 ++++++ model/external/stwbremen/result.go | 7 ++ model/resources/menu.go | 65 +++++++++++++++++++ util/slices.go | 23 +++++++ 21 files changed, 635 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 cmd/config.go create mode 100644 cmd/output.go create mode 100644 cmd/resources.go create mode 100644 cmd/root.go create mode 100644 cmd/verbs.go create mode 100644 core/handler/menu/menu.go create mode 100644 core/interfaces/handler.go create mode 100644 core/interfaces/http.go create mode 100644 core/interfaces/params/params.go create mode 100644 core/services/jlog/context.go create mode 100644 core/services/jlog/logger.go create mode 100644 core/services/stwhbclient/client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 model/external/stwbremen/dish.go create mode 100644 model/external/stwbremen/result.go create mode 100644 model/resources/menu.go create mode 100644 util/slices.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90af283 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +unifood +__bin* \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..bf3097e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug app", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/main.go", + "args": [ + "get", "menu" + ] + } + ] +} \ No newline at end of file diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..4407be2 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,14 @@ +package cmd + +type AppConfig struct { + OutputVerbose bool + OutputMode string +} + +type OutputMode string + +const ( + Go OutputMode = "go" + Json OutputMode = "json" + Yaml OutputMode = "yaml" +) diff --git a/cmd/output.go b/cmd/output.go new file mode 100644 index 0000000..1d5a0ab --- /dev/null +++ b/cmd/output.go @@ -0,0 +1,5 @@ +package cmd + +type OutputProvider interface { + Convert(item any) (string, error) +} diff --git a/cmd/resources.go b/cmd/resources.go new file mode 100644 index 0000000..b93e26e --- /dev/null +++ b/cmd/resources.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "git.bissendorf.co/bissendorf/unifood/m/v2/core/handler/menu" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/services/stwhbclient" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" +) + +var availableResources = []interfaces.ResourceCommand[any]{ + { + Name: "menu", + Aliases: []string{"m"}, + Verbs: []interfaces.Verb{interfaces.VerbGet}, + Handler: &menu.MenuHandler{ + QueryClient: stwhbclient.New[[]stwbremen.Dish](), + }, + }, +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..aca0c6c --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "context" + "fmt" + "log/slog" + "os" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/services/jlog" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "unifood", + Short: "Unifood is a CLI for retrieving restaurant information", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + }, +} + +func Execute() { + initRootCmd() + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func initRootCmd() { + var appConfig AppConfig + + rootCmd.PersistentFlags().BoolVarP(&appConfig.OutputVerbose, "verbose", "v", false, "Enable verbose output") + rootCmd.PersistentFlags().StringVarP(&appConfig.OutputMode, "output", "o", string(Json), "Set output format") + + logger := jlog.New(slog.LevelDebug) + ctx := jlog.ContextWith(context.Background(), logger) + + logger.Debug("Register verb commands") + rootCmd.AddCommand(getVerbs(ctx)...) + logger.Debug("Verb commands registered successfully") +} diff --git a/cmd/verbs.go b/cmd/verbs.go new file mode 100644 index 0000000..5f8a107 --- /dev/null +++ b/cmd/verbs.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "slices" + "strings" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/services/jlog" + "github.com/spf13/cobra" +) + +type VerbItem struct { + Name interfaces.Verb + Aliases []string + Description string + RunFn func(ctx context.Context, handler interfaces.ResourceHandler, params params.Container) error +} + +var verbs = []VerbItem{ + { + Name: interfaces.VerbGet, + Description: "Retrieve resource information", + RunFn: func(ctx context.Context, handler interfaces.ResourceHandler, params params.Container) error { + h, ok := handler.(interfaces.GetHandler) + if !ok { + return fmt.Errorf("resource does not support GET") + } + + item, err := h.Get(ctx, params) + if err != nil { + return fmt.Errorf("retrieving item failed: %w", err) + } + + json.NewEncoder(os.Stdout).Encode(item) + + return nil + }, + }, +} + +func getVerbs(ctx context.Context) (commands []*cobra.Command) { + logger := jlog.FromContext(ctx) + + for _, v := range verbs { + verbCommand := &cobra.Command{ + Use: strings.ToLower(string(v.Name)), + Aliases: v.Aliases, + Short: v.Description, + } + + // Add all resources that can handle this verb + for _, r := range availableResources { + if !slices.Contains(r.Verbs, v.Name) { + continue + } + + var params params.Container + + resourceCommand := &cobra.Command{ + Use: r.Name, + Aliases: r.Aliases, + Short: r.Description, + Run: func(cmd *cobra.Command, args []string) { + logger := jlog.New(slog.LevelInfo) + ctx := jlog.ContextWith(context.Background(), logger) + err := v.RunFn(ctx, r.Handler, params) + if err != nil { + logger.ErrorContext(ctx, fmt.Sprintf("%s %s failed", strings.ToUpper(string(v.Name)), r.Name), "error", err.Error()) + os.Exit(1) + } + }, + } + + // Register parameters + for _, param := range r.Handler.GetParametersForVerb(v.Name) { + resourceCommand.Flags().StringVarP( + params.Register(param.Name, param.ParseFunc), + param.Name, + param.ShortHand, + param.DefaultFunc(), + param.Description, + ) + } + verbCommand.AddCommand(resourceCommand) + + logger.Debug(fmt.Sprintf("Registered %s %s", strings.ToUpper(string(v.Name)), r.Name)) + } + + commands = append(commands, verbCommand) + } + + return +} diff --git a/core/handler/menu/menu.go b/core/handler/menu/menu.go new file mode 100644 index 0000000..df23836 --- /dev/null +++ b/core/handler/menu/menu.go @@ -0,0 +1,88 @@ +package menu + +import ( + "context" + "fmt" + "time" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/resources" + "git.bissendorf.co/bissendorf/unifood/m/v2/util" +) + +type MenuHandler struct { + interfaces.ResourceHandler + interfaces.GetHandler + + QueryClient interfaces.QueryClient[[]stwbremen.Dish] +} + +const ( + paramDate = "date" + paramLocation = "location" +) + +func (h *MenuHandler) Get(ctx context.Context, params params.Container) (any, error) { + // Read parameters + p, err := params.GetValue(paramDate) + if err != nil { + return nil, fmt.Errorf("unable to parse date parameter: %w", err) + } + date := p.(time.Time) + + location, err := params.GetValue(paramLocation) + if err != nil { + return nil, fmt.Errorf("unable to parse location parameter: %w", err) + } + + // Build query + query := fmt.Sprintf( + `page('meals').children.filterBy('location', '%s').filterBy('date', '%s').filterBy('printonly', 0)`, + location, + date.Format(time.DateOnly), + ) + + // Run query + dishes, err := h.QueryClient.Get(ctx, + query, + `{"title":true,"ingredients":"page.ingredients.toObject","prices":"page.prices.toObject","location":true,"counter":true,"date":true,"mealadds":true,"mark":true,"frei3":true,"printonly":true,"kombicategory":true,"categories":"page.categories.split"}`, + false, + ) + if err != nil { + return nil, fmt.Errorf("querying menu failed: %w", err) + } + + // Return + return &resources.Menu{ + Location: location.(string), + Dishes: util.Transform(*dishes, func(i *stwbremen.Dish) resources.Dish { + d, err := resources.DishFromDTO(*i) + if err != nil { + return resources.Dish{} + } + + return *d + }), + }, nil +} + +func (h *MenuHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration { + return []params.Registration{ + { + Name: paramDate, + ShortHand: "d", + Description: "Menu date", + DefaultFunc: func() string { return time.Now().Format(time.DateOnly) }, + ParseFunc: func(value string) (any, error) { return time.Parse(time.DateOnly, value) }, + }, + { + Name: paramLocation, + ShortHand: "l", + Description: "Define the restaurant", + DefaultFunc: func() string { return "330" }, + ParseFunc: params.ParseString, + }, + } +} diff --git a/core/interfaces/handler.go b/core/interfaces/handler.go new file mode 100644 index 0000000..40e488f --- /dev/null +++ b/core/interfaces/handler.go @@ -0,0 +1,29 @@ +package interfaces + +import ( + "context" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" +) + +type ResourceCommand[T any] struct { + Name string + Aliases []string + Description string + Verbs []Verb + Handler ResourceHandler +} + +type ResourceHandler interface { + GetParametersForVerb(verb Verb) []params.Registration +} + +type GetHandler interface { + Get(ctx context.Context, params params.Container) (any, error) +} + +type Verb string + +const ( + VerbGet Verb = "get" +) diff --git a/core/interfaces/http.go b/core/interfaces/http.go new file mode 100644 index 0000000..eef4bc1 --- /dev/null +++ b/core/interfaces/http.go @@ -0,0 +1,7 @@ +package interfaces + +import "context" + +type QueryClient[T any] interface { + Get(ctx context.Context, queryStatement string, selectStatement string, allowCache bool) (*T, error) +} diff --git a/core/interfaces/params/params.go b/core/interfaces/params/params.go new file mode 100644 index 0000000..e763510 --- /dev/null +++ b/core/interfaces/params/params.go @@ -0,0 +1,56 @@ +package params + +import "errors" + +var ( + ErrParamNotFound = errors.New("parameter not found") +) + +type Registration struct { + Name string + ShortHand string + Description string + DefaultFunc func() string + ParseFunc ParseFn +} + +type ParseFn func(value string) (any, error) + +var ParseString ParseFn = func(value string) (any, error) { return value, nil } + +type Container struct { + params map[string]Value +} + +type Value struct { + value *string + parseFn ParseFn +} + +func (c *Container) Register(key string, parseFn ParseFn) *string { + if c.params == nil { + c.params = make(map[string]Value) + } + + item, exists := c.params[key] + if exists { + return item.value + } + + var str string + value := Value{ + parseFn: parseFn, + value: &str, + } + c.params[key] = value + return value.value +} + +func (c *Container) GetValue(key string) (any, error) { + item, exists := c.params[key] + if !exists { + return nil, ErrParamNotFound + } + + return item.parseFn(*item.value) +} diff --git a/core/services/jlog/context.go b/core/services/jlog/context.go new file mode 100644 index 0000000..96038b5 --- /dev/null +++ b/core/services/jlog/context.go @@ -0,0 +1,23 @@ +package jlog + +import ( + "context" + "log/slog" +) + +type ctxKey string + +const ctxkeyLogger ctxKey = "jlog:logger" + +func FromContext(ctx context.Context) *slog.Logger { + logger, exists := ctx.Value(ctxkeyLogger).(*slog.Logger) + if !exists { + return New(slog.LevelInfo) + } + + return logger +} + +func ContextWith(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, ctxkeyLogger, logger) +} diff --git a/core/services/jlog/logger.go b/core/services/jlog/logger.go new file mode 100644 index 0000000..e2534ef --- /dev/null +++ b/core/services/jlog/logger.go @@ -0,0 +1,24 @@ +package jlog + +import ( + "io" + "log/slog" + "math/rand" + "os" + "strconv" +) + +func New(level slog.Level) *slog.Logger { + return NewFor(level, os.Stderr) +} + +func NewFor(level slog.Level, output io.Writer) *slog.Logger { + return slog.New(slog.NewJSONHandler(output, &slog.HandlerOptions{ + // AddSource: true, + Level: level, + })).With(slog.String("traceid", newTraceID())) +} + +func newTraceID() string { + return strconv.Itoa(rand.Intn(1000000)) +} diff --git a/core/services/stwhbclient/client.go b/core/services/stwhbclient/client.go new file mode 100644 index 0000000..89b4386 --- /dev/null +++ b/core/services/stwhbclient/client.go @@ -0,0 +1,66 @@ +package stwhbclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" +) + +type stwHBClientFactory struct{} + +func NewFactory() *stwHBClientFactory { return &stwHBClientFactory{} } + +func Create[T any]() *stwHBClient[T] { + return New[T]() +} + +type stwHBClient[T any] struct { + interfaces.QueryClient[T] + + client *http.Client + baseUrl string +} + +func New[T any]() *stwHBClient[T] { + return &stwHBClient[T]{ + client: http.DefaultClient, + baseUrl: "https://content.stw-bremen.de", + } +} + +func (c *stwHBClient[T]) Get(ctx context.Context, queryStatement string, selectStatement string, allowCache bool) (*T, error) { + // Setup url + url := c.baseUrl + "/api/kql" + if !allowCache { + url = url + "nocache" + } + + // Create request from query + fullQuery := fmt.Sprintf(`{"query": "%s", "select": %s}`, queryStatement, selectStatement) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(fullQuery)) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + // Perform request + response, err := c.client.Do(req) + if err != nil || response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("performing request failed: %w", err) + } + defer response.Body.Close() + + // Read response + var result stwbremen.Result[T] + err = json.NewDecoder(response.Body).Decode(&result) + if err != nil { + return nil, fmt.Errorf("unable to parse result: %w", err) + } + + return &result.Result, nil + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c2be15a --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module git.bissendorf.co/bissendorf/unifood/m/v2 + +go 1.24.0 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4aae07f --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..0b346da --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "git.bissendorf.co/bissendorf/unifood/m/v2/cmd" + +func main() { + cmd.Execute() +} diff --git a/model/external/stwbremen/dish.go b/model/external/stwbremen/dish.go new file mode 100644 index 0000000..84f48a8 --- /dev/null +++ b/model/external/stwbremen/dish.go @@ -0,0 +1,21 @@ +package stwbremen + +type Dish struct { + Title string `json:"title"` + Ingredients []Ingredient `json:"ingredients"` + Prices []Price `json:"prices"` + Location string `json:"location"` + Date string `json:"date"` // YYYY-MM-DD + Counter string `json:"counter"` + Tags string `json:"mealadds"` +} + +type Ingredient struct { + Label string `json:"label"` + Additionals []string `json:"additionals"` +} + +type Price struct { + Label string `json:"label"` + Price string `json:"price"` +} diff --git a/model/external/stwbremen/result.go b/model/external/stwbremen/result.go new file mode 100644 index 0000000..df990c1 --- /dev/null +++ b/model/external/stwbremen/result.go @@ -0,0 +1,7 @@ +package stwbremen + +type Result[T any] struct { + Code uint16 `json:"code"` + Status string `json:"status"` + Result T `json:"result"` +} diff --git a/model/resources/menu.go b/model/resources/menu.go new file mode 100644 index 0000000..cfd0e62 --- /dev/null +++ b/model/resources/menu.go @@ -0,0 +1,65 @@ +package resources + +import ( + "fmt" + "strconv" + "strings" + "time" + + "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" + "git.bissendorf.co/bissendorf/unifood/m/v2/util" +) + +type Menu struct { + Location string + Dishes []Dish +} + +func DishFromDTO(dish stwbremen.Dish) (*Dish, error) { + date, err := time.Parse(time.DateOnly, dish.Date) + if err != nil { + return nil, fmt.Errorf("unable to parse dish date: %w", err) + } + + return &Dish{ + Title: dish.Title, + Date: date, + Tags: strings.Split(strings.Replace(dish.Tags, " ", "", -1), ","), + Counter: dish.Counter, + Prices: util.Transform(dish.Prices, func(i *stwbremen.Price) price { + p, err := strconv.ParseFloat(strings.Trim(i.Price, " "), 32) + if err != nil { + p = 0 + } + return price{ + Label: i.Label, + Price: float32(p), + } + }), + Ingredients: util.Select(util.Transform(dish.Ingredients, func(i *stwbremen.Ingredient) ingredient { + return ingredient{ + Name: i.Label, + Additionals: i.Additionals, + } + }), func(i *ingredient) bool { return i.Name != "" }), + }, nil +} + +type Dish struct { + Title string + Ingredients []ingredient + Prices []price + Date time.Time + Counter string + Tags []string +} + +type ingredient struct { + Name string + Additionals []string +} + +type price struct { + Label string + Price float32 +} diff --git a/util/slices.go b/util/slices.go new file mode 100644 index 0000000..0ad9b1f --- /dev/null +++ b/util/slices.go @@ -0,0 +1,23 @@ +package util + +func Transform[Tin any, Tout any](s []Tin, transformFn func(i *Tin) Tout) (out []Tout) { + out = make([]Tout, 0) + + for _, item := range s { + out = append(out, transformFn(&item)) + } + + return +} + +func Select[T any](s []T, selectFn func(i *T) bool) (out []T) { + out = make([]T, 0) + + for _, item := range s { + if selectFn(&item) { + out = append(out, item) + } + } + + return +} -- 2.49.0 From 0b0f0c9f0a4d866b7415da8efafb71978a540163 Mon Sep 17 00:00:00 2001 From: bdoerfchen Date: Sun, 20 Jul 2025 14:15:20 +0200 Subject: [PATCH 2/6] feat: json output formatter --- cmd/config.go | 8 -------- cmd/output.go | 5 ----- cmd/root.go | 4 ++-- cmd/verbs.go | 28 ++++++++++++++++++++++------ core/interfaces/output.go | 7 +++++++ core/output/formatter.go | 12 ++++++++++++ core/output/json.go | 15 +++++++++++++++ go.mod | 3 ++- 8 files changed, 60 insertions(+), 22 deletions(-) delete mode 100644 cmd/output.go create mode 100644 core/interfaces/output.go create mode 100644 core/output/formatter.go create mode 100644 core/output/json.go diff --git a/cmd/config.go b/cmd/config.go index 4407be2..767e769 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,11 +4,3 @@ type AppConfig struct { OutputVerbose bool OutputMode string } - -type OutputMode string - -const ( - Go OutputMode = "go" - Json OutputMode = "json" - Yaml OutputMode = "yaml" -) diff --git a/cmd/output.go b/cmd/output.go deleted file mode 100644 index 1d5a0ab..0000000 --- a/cmd/output.go +++ /dev/null @@ -1,5 +0,0 @@ -package cmd - -type OutputProvider interface { - Convert(item any) (string, error) -} diff --git a/cmd/root.go b/cmd/root.go index aca0c6c..9a4b24f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,12 +31,12 @@ func initRootCmd() { var appConfig AppConfig rootCmd.PersistentFlags().BoolVarP(&appConfig.OutputVerbose, "verbose", "v", false, "Enable verbose output") - rootCmd.PersistentFlags().StringVarP(&appConfig.OutputMode, "output", "o", string(Json), "Set output format") + rootCmd.PersistentFlags().StringVarP(&appConfig.OutputMode, "output", "o", "json", "Set output format") logger := jlog.New(slog.LevelDebug) ctx := jlog.ContextWith(context.Background(), logger) logger.Debug("Register verb commands") - rootCmd.AddCommand(getVerbs(ctx)...) + rootCmd.AddCommand(getVerbs(ctx, appConfig)...) logger.Debug("Verb commands registered successfully") } diff --git a/cmd/verbs.go b/cmd/verbs.go index 5f8a107..827c747 100644 --- a/cmd/verbs.go +++ b/cmd/verbs.go @@ -2,8 +2,8 @@ package cmd import ( "context" - "encoding/json" "fmt" + "io" "log/slog" "os" "slices" @@ -11,6 +11,7 @@ import ( "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/output" "git.bissendorf.co/bissendorf/unifood/m/v2/core/services/jlog" "github.com/spf13/cobra" ) @@ -19,32 +20,47 @@ type VerbItem struct { Name interfaces.Verb Aliases []string Description string - RunFn func(ctx context.Context, handler interfaces.ResourceHandler, params params.Container) error + RunFn func(ctx context.Context, config AppConfig, handler interfaces.ResourceHandler, params params.Container) error } var verbs = []VerbItem{ { Name: interfaces.VerbGet, Description: "Retrieve resource information", - RunFn: func(ctx context.Context, handler interfaces.ResourceHandler, params params.Container) error { + RunFn: func(ctx context.Context, config AppConfig, handler interfaces.ResourceHandler, params params.Container) error { h, ok := handler.(interfaces.GetHandler) if !ok { return fmt.Errorf("resource does not support GET") } + // Get item item, err := h.Get(ctx, params) if err != nil { return fmt.Errorf("retrieving item failed: %w", err) } - json.NewEncoder(os.Stdout).Encode(item) + formatterName := strings.ToLower(config.OutputMode) + formatter, exists := output.Formatters[formatterName] + if !exists { + return fmt.Errorf("could not find output formatter '%s'", formatterName) + } + + // Format and output + formatted, err := formatter.Format(item) + if err != nil { + return fmt.Errorf("failed to format output: %w", err) + } + _, err = io.Copy(os.Stdout, formatted) + if err != nil { + return fmt.Errorf("failed to write output: %w", err) + } return nil }, }, } -func getVerbs(ctx context.Context) (commands []*cobra.Command) { +func getVerbs(ctx context.Context, config AppConfig) (commands []*cobra.Command) { logger := jlog.FromContext(ctx) for _, v := range verbs { @@ -69,7 +85,7 @@ func getVerbs(ctx context.Context) (commands []*cobra.Command) { Run: func(cmd *cobra.Command, args []string) { logger := jlog.New(slog.LevelInfo) ctx := jlog.ContextWith(context.Background(), logger) - err := v.RunFn(ctx, r.Handler, params) + err := v.RunFn(ctx, config, r.Handler, params) if err != nil { logger.ErrorContext(ctx, fmt.Sprintf("%s %s failed", strings.ToUpper(string(v.Name)), r.Name), "error", err.Error()) os.Exit(1) diff --git a/core/interfaces/output.go b/core/interfaces/output.go new file mode 100644 index 0000000..2af3a09 --- /dev/null +++ b/core/interfaces/output.go @@ -0,0 +1,7 @@ +package interfaces + +import "io" + +type Formatter interface { + Format(object any) (io.Reader, error) +} diff --git a/core/output/formatter.go b/core/output/formatter.go new file mode 100644 index 0000000..243caab --- /dev/null +++ b/core/output/formatter.go @@ -0,0 +1,12 @@ +package output + +import ( + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" +) + +var Formatters = map[string]interfaces.Formatter{ + "json": &JsonFormatter{}, + "go": nil, + "table": nil, + "xml": nil, +} diff --git a/core/output/json.go b/core/output/json.go new file mode 100644 index 0000000..2aa27ec --- /dev/null +++ b/core/output/json.go @@ -0,0 +1,15 @@ +package output + +import ( + "bytes" + "encoding/json" + "io" +) + +type JsonFormatter struct{} + +func (f *JsonFormatter) Format(object any) (io.Reader, error) { + var buffer = make([]byte, 0, 1024) + outputBuffer := bytes.NewBuffer(buffer) + return outputBuffer, json.NewEncoder(outputBuffer).Encode(object) +} diff --git a/go.mod b/go.mod index c2be15a..3f24465 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,9 @@ module git.bissendorf.co/bissendorf/unifood/m/v2 go 1.24.0 +require github.com/spf13/cobra v1.9.1 + require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.7 // indirect ) -- 2.49.0 From ef07dd1b8669a6e9f7a64a1ed6b8ba06eedc2c4e Mon Sep 17 00:00:00 2001 From: bdoerfchen Date: Sun, 20 Jul 2025 15:19:47 +0200 Subject: [PATCH 3/6] feat: go output formatter --- cmd/config.go | 5 +++-- cmd/root.go | 5 +++-- cmd/verbs.go | 21 ++++++++++++++++----- core/interfaces/params/params.go | 13 +++++++++++++ core/output/formatter.go | 4 ++-- core/output/go.go | 13 +++++++++++++ 6 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 core/output/go.go diff --git a/cmd/config.go b/cmd/config.go index 767e769..560a052 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -1,6 +1,7 @@ package cmd type AppConfig struct { - OutputVerbose bool - OutputMode string + OutputVerbose bool + OutputFormatter string + PrintConfig bool } diff --git a/cmd/root.go b/cmd/root.go index 9a4b24f..804832e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,12 +31,13 @@ func initRootCmd() { var appConfig AppConfig rootCmd.PersistentFlags().BoolVarP(&appConfig.OutputVerbose, "verbose", "v", false, "Enable verbose output") - rootCmd.PersistentFlags().StringVarP(&appConfig.OutputMode, "output", "o", "json", "Set output format") + rootCmd.PersistentFlags().StringVarP(&appConfig.OutputFormatter, "output", "o", "json", "Set output format") + rootCmd.PersistentFlags().BoolVar(&appConfig.PrintConfig, "print-config", false, "Enable printing the application config") logger := jlog.New(slog.LevelDebug) ctx := jlog.ContextWith(context.Background(), logger) logger.Debug("Register verb commands") - rootCmd.AddCommand(getVerbs(ctx, appConfig)...) + rootCmd.AddCommand(getVerbs(ctx, &appConfig)...) logger.Debug("Verb commands registered successfully") } diff --git a/cmd/verbs.go b/cmd/verbs.go index 827c747..6775c9c 100644 --- a/cmd/verbs.go +++ b/cmd/verbs.go @@ -20,14 +20,14 @@ type VerbItem struct { Name interfaces.Verb Aliases []string Description string - RunFn func(ctx context.Context, config AppConfig, handler interfaces.ResourceHandler, params params.Container) error + RunFn func(ctx context.Context, config *AppConfig, handler interfaces.ResourceHandler, params params.Container) error } var verbs = []VerbItem{ { Name: interfaces.VerbGet, Description: "Retrieve resource information", - RunFn: func(ctx context.Context, config AppConfig, handler interfaces.ResourceHandler, params params.Container) error { + RunFn: func(ctx context.Context, config *AppConfig, handler interfaces.ResourceHandler, params params.Container) error { h, ok := handler.(interfaces.GetHandler) if !ok { return fmt.Errorf("resource does not support GET") @@ -39,7 +39,7 @@ var verbs = []VerbItem{ return fmt.Errorf("retrieving item failed: %w", err) } - formatterName := strings.ToLower(config.OutputMode) + formatterName := strings.ToLower(config.OutputFormatter) formatter, exists := output.Formatters[formatterName] if !exists { return fmt.Errorf("could not find output formatter '%s'", formatterName) @@ -60,7 +60,7 @@ var verbs = []VerbItem{ }, } -func getVerbs(ctx context.Context, config AppConfig) (commands []*cobra.Command) { +func getVerbs(ctx context.Context, config *AppConfig) (commands []*cobra.Command) { logger := jlog.FromContext(ctx) for _, v := range verbs { @@ -83,8 +83,19 @@ func getVerbs(ctx context.Context, config AppConfig) (commands []*cobra.Command) Aliases: r.Aliases, Short: r.Description, Run: func(cmd *cobra.Command, args []string) { - logger := jlog.New(slog.LevelInfo) + // Configure log + var logLevel = slog.LevelWarn + if config.OutputVerbose { + logLevel = slog.LevelDebug + } + logger := jlog.New(logLevel) ctx := jlog.ContextWith(context.Background(), logger) + + // Print config + if config.PrintConfig { + logger.WarnContext(ctx, "Printing app config", slog.Any("config", config), slog.Any("parameters", params.ToMap())) + } + err := v.RunFn(ctx, config, r.Handler, params) if err != nil { logger.ErrorContext(ctx, fmt.Sprintf("%s %s failed", strings.ToUpper(string(v.Name)), r.Name), "error", err.Error()) diff --git a/core/interfaces/params/params.go b/core/interfaces/params/params.go index e763510..f2f7fbb 100644 --- a/core/interfaces/params/params.go +++ b/core/interfaces/params/params.go @@ -54,3 +54,16 @@ func (c *Container) GetValue(key string) (any, error) { return item.parseFn(*item.value) } + +// Returns a map of all parameters. Parameters that produce errors during parsing are ignored. +func (c *Container) ToMap() (out map[string]any) { + out = make(map[string]any) + for key := range c.params { + v, err := c.GetValue(key) + if err == nil { + out[key] = v + } + } + + return +} diff --git a/core/output/formatter.go b/core/output/formatter.go index 243caab..c6b3159 100644 --- a/core/output/formatter.go +++ b/core/output/formatter.go @@ -6,7 +6,7 @@ import ( var Formatters = map[string]interfaces.Formatter{ "json": &JsonFormatter{}, - "go": nil, + "yaml": nil, + "go": &GoFormatter{}, "table": nil, - "xml": nil, } diff --git a/core/output/go.go b/core/output/go.go new file mode 100644 index 0000000..4cf80bf --- /dev/null +++ b/core/output/go.go @@ -0,0 +1,13 @@ +package output + +import ( + "fmt" + "io" + "strings" +) + +type GoFormatter struct{} + +func (f *GoFormatter) Format(object any) (io.Reader, error) { + return strings.NewReader(fmt.Sprintf("%#v", object)), nil +} -- 2.49.0 From 2e16c6732137d7be443326a04d15b26aa54a3713 Mon Sep 17 00:00:00 2001 From: bdoerfchen Date: Sun, 20 Jul 2025 15:28:10 +0200 Subject: [PATCH 4/6] feat: yaml output formatter --- .vscode/launch.json | 2 +- core/output/formatter.go | 2 +- core/output/yaml.go | 19 +++++++++++++++++++ go.mod | 1 + go.sum | 2 ++ 5 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 core/output/yaml.go diff --git a/.vscode/launch.json b/.vscode/launch.json index bf3097e..a49da6a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "mode": "debug", "program": "${workspaceFolder}/main.go", "args": [ - "get", "menu" + "get", "menu", "-o", "yaml", "--print-config", "true" ] } ] diff --git a/core/output/formatter.go b/core/output/formatter.go index c6b3159..2a9db20 100644 --- a/core/output/formatter.go +++ b/core/output/formatter.go @@ -6,7 +6,7 @@ import ( var Formatters = map[string]interfaces.Formatter{ "json": &JsonFormatter{}, - "yaml": nil, + "yaml": &YamlFormatter{}, "go": &GoFormatter{}, "table": nil, } diff --git a/core/output/yaml.go b/core/output/yaml.go new file mode 100644 index 0000000..020dedb --- /dev/null +++ b/core/output/yaml.go @@ -0,0 +1,19 @@ +package output + +import ( + "bytes" + "io" + + "github.com/goccy/go-yaml" +) + +type YamlFormatter struct{} + +func (f *YamlFormatter) Format(object any) (io.Reader, error) { + buffer, err := yaml.Marshal(object) + if err != nil { + return nil, err + } + + return bytes.NewBuffer(buffer), nil +} diff --git a/go.mod b/go.mod index 3f24465..17ffee2 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require github.com/spf13/cobra v1.9.1 require ( + github.com/goccy/go-yaml v1.18.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.7 // indirect ) diff --git a/go.sum b/go.sum index 4aae07f..3c2a4cc 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -- 2.49.0 From 45712dacf6492deb00f55c3fc4adcd80fb75a864 Mon Sep 17 00:00:00 2001 From: bdoerfchen Date: Sun, 20 Jul 2025 15:42:43 +0200 Subject: [PATCH 5/6] feat: get handlers return object slices --- .vscode/launch.json | 5 +++- cmd/resources.go | 8 +++--- cmd/root.go | 2 -- cmd/verbs.go | 10 +++---- .../{menu/menu.go => dishes/handler.go} | 26 +++++++++---------- core/interfaces/handler.go | 2 +- core/interfaces/output.go | 2 +- core/output/go.go | 4 +-- core/output/json.go | 4 +-- core/output/yaml.go | 4 +-- model/resources/{menu.go => dish.go} | 5 ---- 11 files changed, 31 insertions(+), 41 deletions(-) rename core/handler/{menu/menu.go => dishes/handler.go} (80%) rename model/resources/{menu.go => dish.go} (95%) diff --git a/.vscode/launch.json b/.vscode/launch.json index a49da6a..edf630b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,10 @@ "mode": "debug", "program": "${workspaceFolder}/main.go", "args": [ - "get", "menu", "-o", "yaml", "--print-config", "true" + "get", "dishes", + "-o", "yaml", + "-d", "2025-07-21", + "--print-config" ] } ] diff --git a/cmd/resources.go b/cmd/resources.go index b93e26e..3aafc30 100644 --- a/cmd/resources.go +++ b/cmd/resources.go @@ -1,7 +1,7 @@ package cmd import ( - "git.bissendorf.co/bissendorf/unifood/m/v2/core/handler/menu" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/handler/dishes" "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" "git.bissendorf.co/bissendorf/unifood/m/v2/core/services/stwhbclient" "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" @@ -9,10 +9,10 @@ import ( var availableResources = []interfaces.ResourceCommand[any]{ { - Name: "menu", - Aliases: []string{"m"}, + Name: "dishes", + Aliases: []string{"dish", "d"}, Verbs: []interfaces.Verb{interfaces.VerbGet}, - Handler: &menu.MenuHandler{ + Handler: &dishes.DishesHandler{ QueryClient: stwhbclient.New[[]stwbremen.Dish](), }, }, diff --git a/cmd/root.go b/cmd/root.go index 804832e..5f27db3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -37,7 +37,5 @@ func initRootCmd() { logger := jlog.New(slog.LevelDebug) ctx := jlog.ContextWith(context.Background(), logger) - logger.Debug("Register verb commands") rootCmd.AddCommand(getVerbs(ctx, &appConfig)...) - logger.Debug("Verb commands registered successfully") } diff --git a/cmd/verbs.go b/cmd/verbs.go index 6775c9c..8cfbf78 100644 --- a/cmd/verbs.go +++ b/cmd/verbs.go @@ -33,8 +33,8 @@ var verbs = []VerbItem{ return fmt.Errorf("resource does not support GET") } - // Get item - item, err := h.Get(ctx, params) + // Get items + items, err := h.Get(ctx, params) if err != nil { return fmt.Errorf("retrieving item failed: %w", err) } @@ -46,7 +46,7 @@ var verbs = []VerbItem{ } // Format and output - formatted, err := formatter.Format(item) + formatted, err := formatter.Format(items) if err != nil { return fmt.Errorf("failed to format output: %w", err) } @@ -61,8 +61,6 @@ var verbs = []VerbItem{ } func getVerbs(ctx context.Context, config *AppConfig) (commands []*cobra.Command) { - logger := jlog.FromContext(ctx) - for _, v := range verbs { verbCommand := &cobra.Command{ Use: strings.ToLower(string(v.Name)), @@ -115,8 +113,6 @@ func getVerbs(ctx context.Context, config *AppConfig) (commands []*cobra.Command ) } verbCommand.AddCommand(resourceCommand) - - logger.Debug(fmt.Sprintf("Registered %s %s", strings.ToUpper(string(v.Name)), r.Name)) } commands = append(commands, verbCommand) diff --git a/core/handler/menu/menu.go b/core/handler/dishes/handler.go similarity index 80% rename from core/handler/menu/menu.go rename to core/handler/dishes/handler.go index df23836..cd9ff5f 100644 --- a/core/handler/menu/menu.go +++ b/core/handler/dishes/handler.go @@ -1,4 +1,4 @@ -package menu +package dishes import ( "context" @@ -12,7 +12,7 @@ import ( "git.bissendorf.co/bissendorf/unifood/m/v2/util" ) -type MenuHandler struct { +type DishesHandler struct { interfaces.ResourceHandler interfaces.GetHandler @@ -24,7 +24,7 @@ const ( paramLocation = "location" ) -func (h *MenuHandler) Get(ctx context.Context, params params.Container) (any, error) { +func (h *DishesHandler) Get(ctx context.Context, params params.Container) ([]any, error) { // Read parameters p, err := params.GetValue(paramDate) if err != nil { @@ -55,20 +55,18 @@ func (h *MenuHandler) Get(ctx context.Context, params params.Container) (any, er } // Return - return &resources.Menu{ - Location: location.(string), - Dishes: util.Transform(*dishes, func(i *stwbremen.Dish) resources.Dish { - d, err := resources.DishFromDTO(*i) - if err != nil { - return resources.Dish{} - } + return util.Transform(*dishes, func(i *stwbremen.Dish) any { + d, err := resources.DishFromDTO(*i) + if err != nil { + return resources.Dish{} + } + + return *d + }), nil - return *d - }), - }, nil } -func (h *MenuHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration { +func (h *DishesHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration { return []params.Registration{ { Name: paramDate, diff --git a/core/interfaces/handler.go b/core/interfaces/handler.go index 40e488f..d92ff54 100644 --- a/core/interfaces/handler.go +++ b/core/interfaces/handler.go @@ -19,7 +19,7 @@ type ResourceHandler interface { } type GetHandler interface { - Get(ctx context.Context, params params.Container) (any, error) + Get(ctx context.Context, params params.Container) ([]any, error) } type Verb string diff --git a/core/interfaces/output.go b/core/interfaces/output.go index 2af3a09..6f22941 100644 --- a/core/interfaces/output.go +++ b/core/interfaces/output.go @@ -3,5 +3,5 @@ package interfaces import "io" type Formatter interface { - Format(object any) (io.Reader, error) + Format(object []any) (io.Reader, error) } diff --git a/core/output/go.go b/core/output/go.go index 4cf80bf..3e73d57 100644 --- a/core/output/go.go +++ b/core/output/go.go @@ -8,6 +8,6 @@ import ( type GoFormatter struct{} -func (f *GoFormatter) Format(object any) (io.Reader, error) { - return strings.NewReader(fmt.Sprintf("%#v", object)), nil +func (f *GoFormatter) Format(objects []any) (io.Reader, error) { + return strings.NewReader(fmt.Sprintf("%#v", objects)), nil } diff --git a/core/output/json.go b/core/output/json.go index 2aa27ec..bff02d7 100644 --- a/core/output/json.go +++ b/core/output/json.go @@ -8,8 +8,8 @@ import ( type JsonFormatter struct{} -func (f *JsonFormatter) Format(object any) (io.Reader, error) { +func (f *JsonFormatter) Format(objects []any) (io.Reader, error) { var buffer = make([]byte, 0, 1024) outputBuffer := bytes.NewBuffer(buffer) - return outputBuffer, json.NewEncoder(outputBuffer).Encode(object) + return outputBuffer, json.NewEncoder(outputBuffer).Encode(objects) } diff --git a/core/output/yaml.go b/core/output/yaml.go index 020dedb..62fbb5c 100644 --- a/core/output/yaml.go +++ b/core/output/yaml.go @@ -9,8 +9,8 @@ import ( type YamlFormatter struct{} -func (f *YamlFormatter) Format(object any) (io.Reader, error) { - buffer, err := yaml.Marshal(object) +func (f *YamlFormatter) Format(objects []any) (io.Reader, error) { + buffer, err := yaml.Marshal(objects) if err != nil { return nil, err } diff --git a/model/resources/menu.go b/model/resources/dish.go similarity index 95% rename from model/resources/menu.go rename to model/resources/dish.go index cfd0e62..ebf8ab2 100644 --- a/model/resources/menu.go +++ b/model/resources/dish.go @@ -10,11 +10,6 @@ import ( "git.bissendorf.co/bissendorf/unifood/m/v2/util" ) -type Menu struct { - Location string - Dishes []Dish -} - func DishFromDTO(dish stwbremen.Dish) (*Dish, error) { date, err := time.Parse(time.DateOnly, dish.Date) if err != nil { -- 2.49.0 From 4a15a4db66228291b915a7f9f7abdf7217f29e69 Mon Sep 17 00:00:00 2001 From: bdoerfchen Date: Sun, 20 Jul 2025 19:22:36 +0200 Subject: [PATCH 6/6] feat: resource list and table output formatter --- .vscode/launch.json | 2 - cmd/root.go | 2 +- cmd/verbs.go | 2 +- core/handler/dishes/handler.go | 19 ++++---- core/interfaces/handler.go | 2 +- core/interfaces/output.go | 11 ++++- core/interfaces/resource.go | 12 +++++ core/output/formatter.go | 11 +++-- core/output/go.go | 6 ++- core/output/json.go | 6 ++- core/output/table.go | 81 ++++++++++++++++++++++++++++++++++ core/output/yaml.go | 5 ++- go.mod | 18 +++++++- go.sum | 32 ++++++++++++++ model/resources/dish.go | 30 +++++++------ util/slices.go | 10 +++++ 16 files changed, 209 insertions(+), 40 deletions(-) create mode 100644 core/interfaces/resource.go create mode 100644 core/output/table.go diff --git a/.vscode/launch.json b/.vscode/launch.json index edf630b..ff6a5b6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,8 +13,6 @@ "args": [ "get", "dishes", "-o", "yaml", - "-d", "2025-07-21", - "--print-config" ] } ] diff --git a/cmd/root.go b/cmd/root.go index 5f27db3..3c12f02 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,7 +31,7 @@ func initRootCmd() { var appConfig AppConfig rootCmd.PersistentFlags().BoolVarP(&appConfig.OutputVerbose, "verbose", "v", false, "Enable verbose output") - rootCmd.PersistentFlags().StringVarP(&appConfig.OutputFormatter, "output", "o", "json", "Set output format") + rootCmd.PersistentFlags().StringVarP(&appConfig.OutputFormatter, "output", "o", "table", "Set output format") rootCmd.PersistentFlags().BoolVar(&appConfig.PrintConfig, "print-config", false, "Enable printing the application config") logger := jlog.New(slog.LevelDebug) diff --git a/cmd/verbs.go b/cmd/verbs.go index 8cfbf78..fa2f9cd 100644 --- a/cmd/verbs.go +++ b/cmd/verbs.go @@ -26,7 +26,7 @@ type VerbItem struct { var verbs = []VerbItem{ { Name: interfaces.VerbGet, - Description: "Retrieve resource information", + Description: "Retrieve a list of resources", RunFn: func(ctx context.Context, config *AppConfig, handler interfaces.ResourceHandler, params params.Container) error { h, ok := handler.(interfaces.GetHandler) if !ok { diff --git a/core/handler/dishes/handler.go b/core/handler/dishes/handler.go index cd9ff5f..a3fbea5 100644 --- a/core/handler/dishes/handler.go +++ b/core/handler/dishes/handler.go @@ -24,7 +24,7 @@ const ( paramLocation = "location" ) -func (h *DishesHandler) Get(ctx context.Context, params params.Container) ([]any, error) { +func (h *DishesHandler) Get(ctx context.Context, params params.Container) (*interfaces.ResourceList, error) { // Read parameters p, err := params.GetValue(paramDate) if err != nil { @@ -55,14 +55,17 @@ func (h *DishesHandler) Get(ctx context.Context, params params.Container) ([]any } // Return - return util.Transform(*dishes, func(i *stwbremen.Dish) any { - d, err := resources.DishFromDTO(*i) - if err != nil { - return resources.Dish{} - } + return &interfaces.ResourceList{ + ItemKind: resources.ResourceDish, + Items: util.Transform(*dishes, func(i *stwbremen.Dish) interfaces.Resource { + d, err := resources.DishFromDTO(*i) + if err != nil { + return &resources.Dish{} + } - return *d - }), nil + return d + }), + }, nil } diff --git a/core/interfaces/handler.go b/core/interfaces/handler.go index d92ff54..1f61fde 100644 --- a/core/interfaces/handler.go +++ b/core/interfaces/handler.go @@ -19,7 +19,7 @@ type ResourceHandler interface { } type GetHandler interface { - Get(ctx context.Context, params params.Container) ([]any, error) + Get(ctx context.Context, params params.Container) (*ResourceList, error) } type Verb string diff --git a/core/interfaces/output.go b/core/interfaces/output.go index 6f22941..fc9448f 100644 --- a/core/interfaces/output.go +++ b/core/interfaces/output.go @@ -1,7 +1,14 @@ package interfaces -import "io" +import ( + "io" +) type Formatter interface { - Format(object []any) (io.Reader, error) + Format(object *ResourceList) (io.Reader, error) +} + +type TableOutput interface { + ColumnNames() []string + Columns() []any } diff --git a/core/interfaces/resource.go b/core/interfaces/resource.go new file mode 100644 index 0000000..6072d57 --- /dev/null +++ b/core/interfaces/resource.go @@ -0,0 +1,12 @@ +package interfaces + +type Resource interface { + Kind() string + + Name() string +} + +type ResourceList struct { + ItemKind string + Items []Resource +} diff --git a/core/output/formatter.go b/core/output/formatter.go index 2a9db20..f5f0583 100644 --- a/core/output/formatter.go +++ b/core/output/formatter.go @@ -5,8 +5,11 @@ import ( ) var Formatters = map[string]interfaces.Formatter{ - "json": &JsonFormatter{}, - "yaml": &YamlFormatter{}, - "go": &GoFormatter{}, - "table": nil, + "json": &JsonFormatter{}, + "yaml": &YamlFormatter{}, + "go": &GoFormatter{}, + "table": &TableFormatter{}, + "csv": &TableFormatter{HideSummary: true, RenderFormat: tableFormatCSV}, + "html": &TableFormatter{HideSummary: true, RenderFormat: tableFormatHTML}, + "markdown": &TableFormatter{HideSummary: true, RenderFormat: tableFormatMarkdown}, } diff --git a/core/output/go.go b/core/output/go.go index 3e73d57..fc476d6 100644 --- a/core/output/go.go +++ b/core/output/go.go @@ -4,10 +4,12 @@ import ( "fmt" "io" "strings" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" ) type GoFormatter struct{} -func (f *GoFormatter) Format(objects []any) (io.Reader, error) { - return strings.NewReader(fmt.Sprintf("%#v", objects)), nil +func (f *GoFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) { + return strings.NewReader(fmt.Sprintf("%#v", list)), nil } diff --git a/core/output/json.go b/core/output/json.go index bff02d7..3b2884f 100644 --- a/core/output/json.go +++ b/core/output/json.go @@ -4,12 +4,14 @@ import ( "bytes" "encoding/json" "io" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" ) type JsonFormatter struct{} -func (f *JsonFormatter) Format(objects []any) (io.Reader, error) { +func (f *JsonFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) { var buffer = make([]byte, 0, 1024) outputBuffer := bytes.NewBuffer(buffer) - return outputBuffer, json.NewEncoder(outputBuffer).Encode(objects) + return outputBuffer, json.NewEncoder(outputBuffer).Encode(list) } diff --git a/core/output/table.go b/core/output/table.go new file mode 100644 index 0000000..83289d1 --- /dev/null +++ b/core/output/table.go @@ -0,0 +1,81 @@ +package output + +import ( + "bytes" + "fmt" + "io" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/util" + "github.com/jedib0t/go-pretty/table" +) + +type tableRenderFormat string + +const ( + tableFormatNormal tableRenderFormat = "table" + tableFormatCSV tableRenderFormat = "csv" + tableFormatHTML tableRenderFormat = "html" + tableFormatMarkdown tableRenderFormat = "markdown" +) + +type TableFormatter struct { + HideSummary bool + RenderFormat tableRenderFormat +} + +func (f *TableFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) { + + var buffer = make([]byte, 0, 1024) + outputBuffer := bytes.NewBuffer(buffer) + + if !f.HideSummary { + outputBuffer.WriteString( + fmt.Sprintf("Resource: %s\r\nCount: %v\r\n\r\n", list.ItemKind, len(list.Items)), + ) + } + + if len(list.Items) <= 0 { + return outputBuffer, nil + } + + // Setup table + t := table.NewWriter() + t.SetOutputMirror(outputBuffer) + t.SetStyle(table.StyleLight) + + // Write header + tableFormat, ok := list.Items[0].(interfaces.TableOutput) + headerRow := []any{"Name"} + if ok { + columnHeaders := util.Transform(tableFormat.ColumnNames(), func(i *string) any { return *i }) + headerRow = append(headerRow, columnHeaders...) + } + t.AppendHeader(headerRow) + + // Write rows + for _, item := range list.Items { + rowOutput := []any{item.Name()} + + itemFormatter, ok := item.(interfaces.TableOutput) + if ok { + rowOutput = append(rowOutput, itemFormatter.Columns()...) + } + + t.AppendRow(rowOutput) + } + + // Render + switch f.RenderFormat { + case tableFormatCSV: + t.RenderCSV() + case tableFormatHTML: + t.RenderHTML() + case tableFormatMarkdown: + t.RenderMarkdown() + default: + t.Render() + } + + return outputBuffer, nil +} diff --git a/core/output/yaml.go b/core/output/yaml.go index 62fbb5c..551d2d8 100644 --- a/core/output/yaml.go +++ b/core/output/yaml.go @@ -4,13 +4,14 @@ import ( "bytes" "io" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" "github.com/goccy/go-yaml" ) type YamlFormatter struct{} -func (f *YamlFormatter) Format(objects []any) (io.Reader, error) { - buffer, err := yaml.Marshal(objects) +func (f *YamlFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) { + buffer, err := yaml.Marshal(list) if err != nil { return nil, err } diff --git a/go.mod b/go.mod index 17ffee2..37c1538 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,24 @@ module git.bissendorf.co/bissendorf/unifood/m/v2 go 1.24.0 -require github.com/spf13/cobra v1.9.1 +require ( + github.com/goccy/go-yaml v1.18.0 + github.com/jedib0t/go-pretty v4.3.0+incompatible + github.com/spf13/cobra v1.9.1 +) require ( - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.7 // indirect + github.com/stretchr/testify v1.10.0 // indirect + go.mongodb.org/mongo-driver v1.14.0 // indirect + golang.org/x/sys v0.30.0 // indirect ) diff --git a/go.sum b/go.sum index 3c2a4cc..4dd9ed4 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,45 @@ +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= +github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= +go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/model/resources/dish.go b/model/resources/dish.go index ebf8ab2..96601b8 100644 --- a/model/resources/dish.go +++ b/model/resources/dish.go @@ -17,19 +17,17 @@ func DishFromDTO(dish stwbremen.Dish) (*Dish, error) { } return &Dish{ - Title: dish.Title, - Date: date, - Tags: strings.Split(strings.Replace(dish.Tags, " ", "", -1), ","), - Counter: dish.Counter, - Prices: util.Transform(dish.Prices, func(i *stwbremen.Price) price { + Title: dish.Title, + Location: dish.Location, + Date: date, + Tags: strings.Split(strings.Replace(dish.Tags, " ", "", -1), ","), + Counter: dish.Counter, + Prices: util.Map(dish.Prices, func(i *stwbremen.Price) (string, float32) { p, err := strconv.ParseFloat(strings.Trim(i.Price, " "), 32) if err != nil { p = 0 } - return price{ - Label: i.Label, - Price: float32(p), - } + return i.Label, float32(p) }), Ingredients: util.Select(util.Transform(dish.Ingredients, func(i *stwbremen.Ingredient) ingredient { return ingredient{ @@ -40,10 +38,13 @@ func DishFromDTO(dish stwbremen.Dish) (*Dish, error) { }, nil } +const ResourceDish = "dish" + type Dish struct { Title string + Location string Ingredients []ingredient - Prices []price + Prices map[string]float32 Date time.Time Counter string Tags []string @@ -54,7 +55,10 @@ type ingredient struct { Additionals []string } -type price struct { - Label string - Price float32 +func (d *Dish) Kind() string { return ResourceDish } +func (d *Dish) Name() string { return d.Title } + +func (d *Dish) ColumnNames() []string { return []string{"Location", "Date", "Counter", "Price"} } +func (d *Dish) Columns() []any { + return []any{d.Location, d.Date.Format(time.DateOnly), d.Counter, d.Prices["Studierende"]} } diff --git a/util/slices.go b/util/slices.go index 0ad9b1f..4221f34 100644 --- a/util/slices.go +++ b/util/slices.go @@ -21,3 +21,13 @@ func Select[T any](s []T, selectFn func(i *T) bool) (out []T) { return } + +func Map[T any, Tkey comparable, Tval any](s []T, transformFn func(i *T) (Tkey, Tval)) (out map[Tkey]Tval) { + out = make(map[Tkey]Tval) + for _, i := range s { + key, val := transformFn(&i) + out[key] = val + } + + return +} -- 2.49.0