From 7d561ea6ea47af9a71048ebe401896cae60a4255 Mon Sep 17 00:00:00 2001 From: bdoerfchen Date: Sun, 20 Jul 2025 13:47:16 +0200 Subject: [PATCH] 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 +}