feat: unifood base with GET dishes (#1)
Features: - CLI structure with verbs and resources - Application config and parameters - Output formatters - Initial resource: dishes Reviewed-on: #1 Co-authored-by: bdoerfchen <git@bissendorf.co> Co-committed-by: bdoerfchen <git@bissendorf.co>
This commit is contained in:
89
core/handler/dishes/handler.go
Normal file
89
core/handler/dishes/handler.go
Normal file
@ -0,0 +1,89 @@
|
||||
package dishes
|
||||
|
||||
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 DishesHandler struct {
|
||||
interfaces.ResourceHandler
|
||||
interfaces.GetHandler
|
||||
|
||||
QueryClient interfaces.QueryClient[[]stwbremen.Dish]
|
||||
}
|
||||
|
||||
const (
|
||||
paramDate = "date"
|
||||
paramLocation = "location"
|
||||
)
|
||||
|
||||
func (h *DishesHandler) Get(ctx context.Context, params params.Container) (*interfaces.ResourceList, 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 &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
|
||||
|
||||
}
|
||||
|
||||
func (h *DishesHandler) 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
29
core/interfaces/handler.go
Normal file
29
core/interfaces/handler.go
Normal file
@ -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) (*ResourceList, error)
|
||||
}
|
||||
|
||||
type Verb string
|
||||
|
||||
const (
|
||||
VerbGet Verb = "get"
|
||||
)
|
||||
7
core/interfaces/http.go
Normal file
7
core/interfaces/http.go
Normal file
@ -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)
|
||||
}
|
||||
14
core/interfaces/output.go
Normal file
14
core/interfaces/output.go
Normal file
@ -0,0 +1,14 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
type Formatter interface {
|
||||
Format(object *ResourceList) (io.Reader, error)
|
||||
}
|
||||
|
||||
type TableOutput interface {
|
||||
ColumnNames() []string
|
||||
Columns() []any
|
||||
}
|
||||
69
core/interfaces/params/params.go
Normal file
69
core/interfaces/params/params.go
Normal file
@ -0,0 +1,69 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
12
core/interfaces/resource.go
Normal file
12
core/interfaces/resource.go
Normal file
@ -0,0 +1,12 @@
|
||||
package interfaces
|
||||
|
||||
type Resource interface {
|
||||
Kind() string
|
||||
|
||||
Name() string
|
||||
}
|
||||
|
||||
type ResourceList struct {
|
||||
ItemKind string
|
||||
Items []Resource
|
||||
}
|
||||
15
core/output/formatter.go
Normal file
15
core/output/formatter.go
Normal file
@ -0,0 +1,15 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces"
|
||||
)
|
||||
|
||||
var Formatters = map[string]interfaces.Formatter{
|
||||
"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},
|
||||
}
|
||||
15
core/output/go.go
Normal file
15
core/output/go.go
Normal file
@ -0,0 +1,15 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces"
|
||||
)
|
||||
|
||||
type GoFormatter struct{}
|
||||
|
||||
func (f *GoFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) {
|
||||
return strings.NewReader(fmt.Sprintf("%#v", list)), nil
|
||||
}
|
||||
17
core/output/json.go
Normal file
17
core/output/json.go
Normal file
@ -0,0 +1,17 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces"
|
||||
)
|
||||
|
||||
type JsonFormatter struct{}
|
||||
|
||||
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(list)
|
||||
}
|
||||
81
core/output/table.go
Normal file
81
core/output/table.go
Normal file
@ -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
|
||||
}
|
||||
20
core/output/yaml.go
Normal file
20
core/output/yaml.go
Normal file
@ -0,0 +1,20 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
type YamlFormatter struct{}
|
||||
|
||||
func (f *YamlFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) {
|
||||
buffer, err := yaml.Marshal(list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewBuffer(buffer), nil
|
||||
}
|
||||
23
core/services/jlog/context.go
Normal file
23
core/services/jlog/context.go
Normal file
@ -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)
|
||||
}
|
||||
24
core/services/jlog/logger.go
Normal file
24
core/services/jlog/logger.go
Normal file
@ -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))
|
||||
}
|
||||
66
core/services/stwhbclient/client.go
Normal file
66
core/services/stwhbclient/client.go
Normal file
@ -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
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user