diff --git a/cmd/parka/cmds/examples.go b/cmd/parka/cmds/examples.go index be682ff..53b161d 100644 --- a/cmd/parka/cmds/examples.go +++ b/cmd/parka/cmds/examples.go @@ -6,8 +6,10 @@ import ( "github.com/go-go-golems/glazed/pkg/cmds" "github.com/go-go-golems/glazed/pkg/cmds/layers" "github.com/go-go-golems/glazed/pkg/cmds/parameters" - "github.com/go-go-golems/glazed/pkg/processor" + "github.com/go-go-golems/glazed/pkg/middlewares" + "github.com/go-go-golems/glazed/pkg/settings" "github.com/go-go-golems/glazed/pkg/types" + "github.com/spf13/cobra" ) type ExampleCommand struct { @@ -15,93 +17,100 @@ type ExampleCommand struct { } func NewExampleCommand() *ExampleCommand { - return &ExampleCommand{ - description: &cmds.CommandDescription{ - Name: "example", - Short: "Short parka example command", - Long: "", - Flags: []*parameters.ParameterDefinition{ - // required string test argument - { - Name: "test", - ShortFlag: "t", - Type: parameters.ParameterTypeString, - Help: "Test string argument", - Required: true, - }, - { - Name: "string", - ShortFlag: "s", - Type: parameters.ParameterTypeString, - Help: "Test string flag", - Default: "default", - Required: false, - }, - { - Name: "string_from_file", - Type: parameters.ParameterTypeStringFromFile, - Help: "Test string from file flag", - }, - { - Name: "object_from_file", - Type: parameters.ParameterTypeObjectFromFile, - Help: "Test object from file flag", - }, - { - Name: "integer", - ShortFlag: "i", - Type: parameters.ParameterTypeInteger, - Help: "Test integer flag", - Default: 1, - }, - { - Name: "float", - ShortFlag: "f", - Type: parameters.ParameterTypeFloat, - Help: "Test float flag", - Default: 1.0, - }, - { - Name: "bool", - ShortFlag: "b", - Type: parameters.ParameterTypeBool, - Help: "Test bool flag", - }, - { - Name: "date", - ShortFlag: "d", - Type: parameters.ParameterTypeDate, - Help: "Test date flag", - }, - { - Name: "string_list", - ShortFlag: "l", - Type: parameters.ParameterTypeStringList, - Help: "Test string list flag", - Default: []string{"default", "default2"}, - }, - { - Name: "integer_list", - Type: parameters.ParameterTypeIntegerList, - Help: "Test integer list flag", - Default: []int{1, 2}, - }, - { - Name: "float_list", - Type: parameters.ParameterTypeFloatList, - Help: "Test float list flag", - Default: []float64{1.0, 2.0}, - }, - { - Name: "choice", - ShortFlag: "c", - Type: parameters.ParameterTypeChoice, - Help: "Test choice flag", - Choices: []string{"choice1", "choice2"}, - Default: "choice1", - }, + glazedParameterLayer, err := settings.NewGlazedParameterLayers() + cobra.CheckErr(err) + + description := &cmds.CommandDescription{ + Name: "example", + Short: "Short parka example command", + Long: "", + Flags: []*parameters.ParameterDefinition{ + // required string test argument + { + Name: "test", + ShortFlag: "t", + Type: parameters.ParameterTypeString, + Help: "Test string argument", + Default: "test", + }, + { + Name: "string", + ShortFlag: "s", + Type: parameters.ParameterTypeString, + Help: "Test string flag", + Default: "default", + Required: false, + }, + { + Name: "string_from_file", + Type: parameters.ParameterTypeStringFromFile, + Help: "Test string from file flag", + }, + { + Name: "object_from_file", + Type: parameters.ParameterTypeObjectFromFile, + Help: "Test object from file flag", + }, + { + Name: "integer", + ShortFlag: "i", + Type: parameters.ParameterTypeInteger, + Help: "Test integer flag", + Default: 1, + }, + { + Name: "float", + ShortFlag: "f", + Type: parameters.ParameterTypeFloat, + Help: "Test float flag", + Default: 1.0, + }, + { + Name: "bool", + ShortFlag: "b", + Type: parameters.ParameterTypeBool, + Help: "Test bool flag", + }, + { + Name: "date", + ShortFlag: "d", + Type: parameters.ParameterTypeDate, + Help: "Test date flag", + }, + { + Name: "string_list", + ShortFlag: "l", + Type: parameters.ParameterTypeStringList, + Help: "Test string list flag", + Default: []string{"default", "default2"}, + }, + { + Name: "integer_list", + Type: parameters.ParameterTypeIntegerList, + Help: "Test integer list flag", + Default: []int{1, 2}, + }, + { + Name: "float_list", + Type: parameters.ParameterTypeFloatList, + Help: "Test float list flag", + Default: []float64{1.0, 2.0}, + }, + { + Name: "choice", + ShortFlag: "c", + Type: parameters.ParameterTypeChoice, + Help: "Test choice flag", + Choices: []string{"choice1", "choice2"}, + Default: "choice1", }, }, + Layers: []layers.ParameterLayer{ + glazedParameterLayer, + }, + } + return &ExampleCommand{ + description: description, } } @@ -109,7 +118,7 @@ func (e *ExampleCommand) Run( ctx context.Context, parsedLayers map[string]*layers.ParsedParameterLayer, ps map[string]interface{}, - gp processor.TableProcessor, + gp middlewares.Processor, ) error { obj := types.NewRow( types.MRP("test", ps["test"]), @@ -133,8 +142,8 @@ func (e *ExampleCommand) Run( err = gp.AddRow(ctx, types.NewRow( types.MRP("test", "test"), - types.MRP("test2", []int{123, 123, 123, 123}), - types.MRP("test3", map[string]interface{}{ + types.MRP("integer_list", []int{123, 123, 123, 123}), + types.MRP("object_from_file", map[string]interface{}{ "test": "test", "test2": []int{123, 123, 123, 123}, }), @@ -149,6 +158,11 @@ func (e *ExampleCommand) Description() *cmds.CommandDescription { return e.description } -func (e *ExampleCommand) RunFromParka(c *gin.Context, parsedLayers map[string]*layers.ParsedParameterLayer, ps map[string]interface{}, gp *processor.GlazeProcessor) error { +func (e *ExampleCommand) RunFromParka( + c *gin.Context, + parsedLayers map[string]*layers.ParsedParameterLayer, + ps map[string]interface{}, + gp middlewares.Processor, +) error { return e.Run(c, parsedLayers, ps, gp) } diff --git a/cmd/parka/cmds/serve.go b/cmd/parka/cmds/serve.go index 095cf42..646afc6 100644 --- a/cmd/parka/cmds/serve.go +++ b/cmd/parka/cmds/serve.go @@ -7,6 +7,9 @@ import ( "github.com/go-go-golems/glazed/pkg/cli" "github.com/go-go-golems/glazed/pkg/helpers" "github.com/go-go-golems/glazed/pkg/types" + "github.com/go-go-golems/parka/pkg/glazed/handlers/datatables" + json2 "github.com/go-go-golems/parka/pkg/glazed/handlers/json" + output_file "github.com/go-go-golems/parka/pkg/glazed/handlers/output-file" "github.com/go-go-golems/parka/pkg/render" "github.com/go-go-golems/parka/pkg/server" "github.com/go-go-golems/parka/pkg/utils/fs" @@ -22,10 +25,15 @@ var ServeCmd = &cobra.Command{ Short: "Starts the server", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - _, err := cmd.Flags().GetUint16("port") + port, err := cmd.Flags().GetUint16("port") + cobra.CheckErr(err) + host, err := cmd.Flags().GetString("host") cobra.CheckErr(err) - serverOptions := []server.ServerOption{} + serverOptions := []server.ServerOption{ + server.WithPort(port), + server.WithAddress(host), + } defaultLookups := []render.TemplateLookup{} dev, _ := cmd.Flags().GetBool("dev") @@ -64,8 +72,9 @@ var ServeCmd = &cobra.Command{ // NOTE(manuel, 2023-05-26) This could also be done with a simple Command config file struct once // implemented as part of sqleton serve - s.Router.GET("/api/example", s.HandleSimpleQueryCommand(NewExampleCommand())) - s.Router.POST("/api/example", s.HandleSimpleFormCommand(NewExampleCommand())) + s.Router.GET("/api/example", json2.CreateJSONQueryHandler(NewExampleCommand())) + s.Router.GET("/example", datatables.CreateDataTablesHandler(NewExampleCommand(), "", "example")) + s.Router.GET("/download/example.csv", output_file.CreateGlazedFileHandler(NewExampleCommand(), "example.csv")) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -109,7 +118,7 @@ var LsServerCmd = &cobra.Command{ err = json.Unmarshal(body, &cmds) cobra.CheckErr(err) - gp, err := cli.CreateGlazedProcessorFromCobra(cmd) + gp, _, err := cli.CreateGlazedProcessorFromCobra(cmd) cobra.CheckErr(err) for _, cmd := range cmds { @@ -117,16 +126,14 @@ var LsServerCmd = &cobra.Command{ cobra.CheckErr(err) } - err = gp.Finalize(ctx) - cobra.CheckErr(err) - - err = gp.OutputFormatter().Output(ctx, gp.GetTable(), os.Stdout) + err = gp.Close(ctx) cobra.CheckErr(err) }, } func init() { ServeCmd.Flags().Uint16("port", 8080, "Port to listen on") + ServeCmd.Flags().String("host", "localhost", "Port to listen on") ServeCmd.Flags().String("template-dir", "pkg/web/src/templates", "Directory containing templates") ServeCmd.Flags().Bool("dev", false, "Enable development mode") diff --git a/go.mod b/go.mod index 7783bca..dd7a397 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require ( github.com/alecthomas/chroma/v2 v2.2.0 github.com/gin-gonic/contrib v0.0.0-20221130124618-7e01895a63f2 github.com/gin-gonic/gin v1.9.0 - github.com/go-go-golems/clay v0.0.17 - github.com/go-go-golems/glazed v0.3.0 + github.com/go-go-golems/clay v0.0.18 + github.com/go-go-golems/glazed v0.3.2 github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.29.0 github.com/spf13/cobra v1.6.1 @@ -101,6 +101,7 @@ require ( golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/errgo.v2 v2.1.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 01456ef..c3561e8 100644 --- a/go.sum +++ b/go.sum @@ -111,10 +111,10 @@ github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH8 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-go-golems/clay v0.0.17 h1:lFOXpAs3AiBMu3LwUO2xvJ7JvkIVU4g2beRODyKGvTM= -github.com/go-go-golems/clay v0.0.17/go.mod h1:2QM24Cz+xBFrnXr5h561DAQ/lxLYlnUO8EjlxiOsYk4= -github.com/go-go-golems/glazed v0.3.0 h1:L4lJAz1WvYasKjvkxd3UTJImoPEcl6gEvV21qUqjjcM= -github.com/go-go-golems/glazed v0.3.0/go.mod h1:zMZnD5ZCj5h/UAQpfpaWV6UkGMdILd8yWXGh9y8sdvU= +github.com/go-go-golems/clay v0.0.18 h1:smhX//d2xuinPWAx+MOlZxMva2bTQDg5Kl+dN1b3YVc= +github.com/go-go-golems/clay v0.0.18/go.mod h1:lwCix3m4IQDIbXnb5F6uVayADuKVHNKPngJdxCTEelU= +github.com/go-go-golems/glazed v0.3.2 h1:VS55mFMXYiZfaEXGQo+gDS7p+n5GF+Vx2egDc0PzXPY= +github.com/go-go-golems/glazed v0.3.2/go.mod h1:nv4FnbQ7kgWQlzyxA3+10jmEc/GDsw3SB0A/sWH14FQ= github.com/go-openapi/errors v0.20.3 h1:rz6kiC84sqNQoqrtulzaL/VERgkoCyB6WdEkc2ujzUc= github.com/go-openapi/errors v0.20.3/go.mod h1:Z3FlZ4I8jEGxjUK+bugx3on2mIAk4txuAOhlsB1FSgk= github.com/go-openapi/strfmt v0.21.7 h1:rspiXgNWgeUzhjo1YU01do6qsahtJNByjLVbPLNHb8k= @@ -686,6 +686,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/pkg/glazed/command.go b/pkg/glazed/command.go index a5a6d9c..fdaa9cf 100644 --- a/pkg/glazed/command.go +++ b/pkg/glazed/command.go @@ -74,45 +74,64 @@ func (pc *CommandContext) GetAllParameterValues() map[string]interface{} { return ret } -// CommandHandlerFunc mirrors gin's HandlerFunc, but also gets passed a CommandContext. -// That allows it to reuse data from the gin.Context, most importantly the request itself. -type CommandHandlerFunc func(*gin.Context, *CommandContext) error - -// HandlePrepopulatedParameters sets the given parameters in the CommandContext's ParsedParameters. -// If any of the given parameters also belong to a layer, they are also set there. -func HandlePrepopulatedParameters(ps map[string]interface{}) CommandHandlerFunc { - return func(c *gin.Context, pc *CommandContext) error { - for k, v := range ps { - pc.ParsedParameters[k] = v - - // Now check if the parameter is in any of the layers, and if so, set it there as well - for _, layer := range pc.ParsedLayers { - if _, ok := layer.Parameters[k]; ok { - layer.Parameters[k] = v - } +// ContextMiddleware is used to build up a CommandContext. It is similar to the HandlerFunc +// in gin, but instead of handling the HTTP response, it is only used to build up the CommandContext. +type ContextMiddleware interface { + Handle(*gin.Context, *CommandContext) error +} + +// PrepopulatedContextMiddleware is a ContextMiddleware that prepopulates the CommandContext with +// the given map of parameters. It overwrites existing parameters in pc.ParsedParameters, +// and overwrites parameters in individual parser layers if they have already been set. +type PrepopulatedContextMiddleware struct { + ps map[string]interface{} +} + +func (p *PrepopulatedContextMiddleware) Handle(c *gin.Context, pc *CommandContext) error { + for k, v := range p.ps { + pc.ParsedParameters[k] = v + + // Now check if the parameter is in any of the layers, and if so, set it there as well + for _, layer := range pc.ParsedLayers { + if _, ok := layer.Parameters[k]; ok { + layer.Parameters[k] = v } } - return nil - } -} - -// HandlePrepopulatedParsedLayers sets the given layers in the CommandContext's Layers, -// overriding the parameters of any layers that are already present. -// This means that if a parameter is not set in layers_ but is set in the Layers, -// the value in the Layers will be kept. -func HandlePrepopulatedParsedLayers(layers_ map[string]*layers.ParsedParameterLayer) CommandHandlerFunc { - return func(c *gin.Context, pc *CommandContext) error { - for k, v := range layers_ { - parsedLayer, ok := pc.ParsedLayers[k] - if ok { - for k2, v2 := range v.Parameters { - parsedLayer.Parameters[k2] = v2 - } - } else { - pc.ParsedLayers[k] = v + } + return nil +} + +func NewPrepopulatedContextMiddleware(ps map[string]interface{}) *PrepopulatedContextMiddleware { + return &PrepopulatedContextMiddleware{ + ps: ps, + } +} + +// PrepopulatedParsedLayersContextMiddleware is a ContextMiddleware that prepopulates the CommandContext with +// the given map of layers. If a layer already exists, it overwrites its parameters. +type PrepopulatedParsedLayersContextMiddleware struct { + layers map[string]*layers.ParsedParameterLayer +} + +func (p *PrepopulatedParsedLayersContextMiddleware) Handle(c *gin.Context, pc *CommandContext) error { + for k, v := range p.layers { + parsedLayer, ok := pc.ParsedLayers[k] + if ok { + for k2, v2 := range v.Parameters { + parsedLayer.Parameters[k2] = v2 } + } else { + pc.ParsedLayers[k] = v } - return nil + } + return nil +} + +func NewPrepopulatedParsedLayersContextMiddleware( + layers map[string]*layers.ParsedParameterLayer, +) *PrepopulatedParsedLayersContextMiddleware { + return &PrepopulatedParsedLayersContextMiddleware{ + layers: layers, } } @@ -164,31 +183,39 @@ func NewCommandFormParser(cmd cmds.GlazeCommand, options ...parser.ParserOption) return ph } -// NewParserCommandHandlerFunc creates a CommandHandlerFunc using the given parser.Parser. -// It also first establishes a set of defaults by loading them from an alias definition. -func NewParserCommandHandlerFunc(cmd cmds.GlazeCommand, parserHandler *parser.Parser) CommandHandlerFunc { - return func(c *gin.Context, pc *CommandContext) error { - parseState := parser.NewParseStateFromCommandDescription(cmd) - err := parserHandler.Parse(c, parseState) - if err != nil { - return err - } +type ContextParserMiddleware struct { + command cmds.GlazeCommand + parser *parser.Parser +} - pc.ParsedParameters = parseState.FlagsAndArguments.Parameters - pc.ParsedLayers = map[string]*layers.ParsedParameterLayer{} - commandLayers := pc.Cmd.Description().Layers - for _, v := range commandLayers { - parsedParameterLayer := &layers.ParsedParameterLayer{ - Layer: v, - Parameters: map[string]interface{}{}, - } - parsedLayers, ok := parseState.Layers[v.GetSlug()] - if ok { - parsedParameterLayer.Parameters = parsedLayers.Parameters - } - pc.ParsedLayers[v.GetSlug()] = parsedParameterLayer +func (cpm *ContextParserMiddleware) Handle(c *gin.Context, pc *CommandContext) error { + parseState := parser.NewParseStateFromCommandDescription(cpm.command) + err := cpm.parser.Parse(c, parseState) + if err != nil { + return err + } + + pc.ParsedParameters = parseState.FlagsAndArguments.Parameters + pc.ParsedLayers = map[string]*layers.ParsedParameterLayer{} + commandLayers := pc.Cmd.Description().Layers + for _, v := range commandLayers { + parsedParameterLayer := &layers.ParsedParameterLayer{ + Layer: v, + Parameters: map[string]interface{}{}, + } + parsedLayers, ok := parseState.Layers[v.GetSlug()] + if ok { + parsedParameterLayer.Parameters = parsedLayers.Parameters } + pc.ParsedLayers[v.GetSlug()] = parsedParameterLayer + } + + return nil +} - return nil +func NewContextParserMiddleware(cmd cmds.GlazeCommand, parser *parser.Parser) *ContextParserMiddleware { + return &ContextParserMiddleware{ + command: cmd, + parser: parser, } } diff --git a/pkg/glazed/handler.go b/pkg/glazed/handler.go deleted file mode 100644 index 234cfb2..0000000 --- a/pkg/glazed/handler.go +++ /dev/null @@ -1,257 +0,0 @@ -package glazed - -import ( - "bytes" - "github.com/gin-gonic/gin" - "github.com/go-go-golems/glazed/pkg/cmds" - "github.com/go-go-golems/glazed/pkg/formatters/json" - "github.com/go-go-golems/glazed/pkg/processor" - "github.com/go-go-golems/glazed/pkg/settings" - "github.com/go-go-golems/parka/pkg/glazed/parser" - "io" - "net/http" - "os" -) - -// CreateProcessorFunc is a simple func type to create a cmds.GlazeProcessor -// and formatters.OutputFormatter out of a CommandContext. -// -// This is so that we can create a processor that is configured based on the input -// data provided in CommandContext. For example, the user might want to request a specific response -// format through a query argument or through a header. -type CreateProcessorFunc func(c *gin.Context, pc *CommandContext) ( - processor.TableProcessor, - error, -) - -// HandleOptions groups all the settings for a gin handler that handles a glazed command. -type HandleOptions struct { - // ParserOptions are passed to the given parser (the thing that gathers the glazed.Command - // flags and arguments. - ParserOptions []parser.ParserOption - - // Handlers are run right at the start of the gin.Handler to build up the CommandContext based on the - // gin.Context. They can be chained because they get passed the previous CommandContext. - // - // NOTE(manuel, 2023-06-22) We currently use a single CommandHandler, which is created with NewParserCommandHandlerFunc. - // This creates a command handler that uses a parser.Parser to parse the gin.Context and return a CommandContext. - // For example, the FormParser will parse command parameters passed as a HTML form. - // - // While we currently only use a single handler, the current setup allows us to chain a middleware of handlers. - // This would potentially allow us to catch parse errors and return an appropriate error template - // I'm not entirely sure if this all makes sense. - Handlers []CommandHandlerFunc - - // CreateProcessor takes a gin.Context and a CommandContext and returns a processor.TableProcessor (and a content-type) - CreateProcessor CreateProcessorFunc - - // This is the actual gin output writer - Writer io.Writer -} - -type HandleOption func(*HandleOptions) - -func (h *HandleOptions) Copy(options ...HandleOption) *HandleOptions { - ret := &HandleOptions{ - ParserOptions: h.ParserOptions, - Handlers: h.Handlers, - CreateProcessor: h.CreateProcessor, - Writer: h.Writer, - } - - for _, option := range options { - option(ret) - } - - return ret -} - -func NewHandleOptions(options []HandleOption) *HandleOptions { - opts := &HandleOptions{} - for _, option := range options { - option(opts) - } - return opts -} - -func WithParserOptions(parserOptions ...parser.ParserOption) HandleOption { - return func(o *HandleOptions) { - o.ParserOptions = parserOptions - } -} - -func WithHandlers(handlers ...CommandHandlerFunc) HandleOption { - return func(o *HandleOptions) { - o.Handlers = handlers - } -} - -func WithWriter(w io.Writer) HandleOption { - return func(o *HandleOptions) { - o.Writer = w - } -} - -func WithCreateProcessor(createProcessor CreateProcessorFunc) HandleOption { - return func(o *HandleOptions) { - o.CreateProcessor = createProcessor - } -} - -func CreateJSONProcessor(_ *gin.Context, pc *CommandContext) ( - processor.TableProcessor, - error, -) { - l, ok := pc.ParsedLayers["glazed"] - l.Parameters["output"] = "json" - - var gp *processor.GlazeProcessor - var err error - - if ok { - gp, err = settings.SetupProcessor(l.Parameters) - } else { - gp, err = settings.SetupProcessor(map[string]interface{}{ - "output": "json", - }) - } - - if err != nil { - return nil, err - } - - return gp, nil -} - -// GinHandleGlazedCommand returns a gin.HandlerFunc that runs a glazed.Command and writes -// the results to the gin.Context ResponseWriter. -func GinHandleGlazedCommand( - cmd cmds.GlazeCommand, - opts *HandleOptions, -) gin.HandlerFunc { - return func(c *gin.Context) { - err := runGlazeCommand(c, cmd, opts) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": err.Error(), - }) - return - } - - c.Status(200) - } -} - -// GinHandleGlazedCommandWithOutputFile returns a gin.HandlerFunc that is responsible for -// running the GlazeCommand, and then returning the output file as an attachment. -// This usually requires the caller to provide a temporary file path. -// -// TODO(manuel, 2023-06-22) Now that OutputFormatter renders directly into a io.Writer, -// I don't think we need all this anymore, we just need to set the relevant header. -func GinHandleGlazedCommandWithOutputFile( - cmd cmds.GlazeCommand, - outputFile string, - fileName string, - opts *HandleOptions, -) gin.HandlerFunc { - return func(c *gin.Context) { - buf := &bytes.Buffer{} - opts_ := opts.Copy(WithWriter(buf)) - err := runGlazeCommand(c, cmd, opts_) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": err.Error(), - }) - return - } - - c.Status(200) - - f, err := os.Open(outputFile) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - defer func(f *os.File) { - _ = f.Close() - }(f) - - c.Writer.Header().Set("Content-Disposition", "attachment; filename="+fileName) - - _, err = io.Copy(c.Writer, f) - if err != nil { - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - } - } -} - -func runGlazeCommand(c *gin.Context, cmd cmds.GlazeCommand, opts *HandleOptions) error { - pc := NewCommandContext(cmd) - - for _, h := range opts.Handlers { - err := h(c, pc) - if err != nil { - return err - } - } - - var gp processor.TableProcessor - - var err error - if opts.CreateProcessor != nil { - // TODO(manuel, 2023-03-02) We might want to switch on the requested content type here too - // This would be done by passing in a handler that configures the glazed layer accordingly. - gp, err = opts.CreateProcessor(c, pc) - } else { - gp, err = SetupProcessor(pc) - } - if err != nil { - return err - } - - of := gp.OutputFormatter() - contentType := of.ContentType() - - if opts.Writer == nil { - c.Writer.Header().Set("Content-Type", contentType) - } - - err = cmd.Run(c, pc.ParsedLayers, pc.ParsedParameters, gp) - if err != nil { - return err - } - - var writer io.Writer = c.Writer - if opts.Writer != nil { - writer = opts.Writer - } - err = of.Output(c, gp.GetTable(), writer) - if err != nil { - return err - } - - return err -} - -// SetupProcessor creates a new cmds.GlazeProcessor. It uses the parsed layer glazed if present, and return -// a simple JsonOutputFormatter and standard glazed processor otherwise. -func SetupProcessor(pc *CommandContext, options ...processor.GlazeProcessorOption) (*processor.GlazeProcessor, error) { - l, ok := pc.ParsedLayers["glazed"] - if ok { - gp, err := settings.SetupProcessor(l.Parameters) - return gp, err - } - - of := json.NewOutputFormatter( - json.WithOutputIndividualRows(true), - ) - gp, err := processor.NewGlazeProcessor(of, options...) - if err != nil { - return nil, err - } - - return gp, nil -} diff --git a/pkg/glazed/handlers/datatables/datatables.go b/pkg/glazed/handlers/datatables/datatables.go new file mode 100644 index 0000000..78ee743 --- /dev/null +++ b/pkg/glazed/handlers/datatables/datatables.go @@ -0,0 +1,392 @@ +package datatables + +import ( + "context" + "embed" + "fmt" + "github.com/gin-gonic/gin" + "github.com/go-go-golems/glazed/pkg/cmds" + "github.com/go-go-golems/glazed/pkg/formatters" + "github.com/go-go-golems/glazed/pkg/formatters/json" + table_formatter "github.com/go-go-golems/glazed/pkg/formatters/table" + "github.com/go-go-golems/glazed/pkg/middlewares/row" + "github.com/go-go-golems/glazed/pkg/types" + "github.com/go-go-golems/parka/pkg/glazed" + "github.com/go-go-golems/parka/pkg/glazed/handlers" + "github.com/go-go-golems/parka/pkg/glazed/parser" + "github.com/go-go-golems/parka/pkg/render" + "github.com/go-go-golems/parka/pkg/render/layout" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "golang.org/x/sync/errgroup" + "html/template" + "io" + "time" +) + +// DataTables describes the data passed to the template displaying the results of a glazed command. +// It's meant to be a more structured layer on top of the HTMLOutputTemplateFormatter +// that parka offers for having users provide their own template formatting. +type DataTables struct { + Command *cmds.CommandDescription + // LongDescription is the HTML of the rendered markdown of the long description of the command. + LongDescription template.HTML + + Layout *layout.Layout + Links []layout.Link + + BasePath string + + // Stream provides a channel where each element represents a row of the table + // to be rendered, already formatted. + // Per default, we will render the individual rows as HTML, but the JSRendering + // flag will make this output individual entries of a JS array. + // + // TODO(manuel, 2023-06-04): Maybe we could make this be an iterator of rows that provide access to the individual + // columns for more interesting HTML shenanigans too. + JSStream chan template.JS + HTMLStream chan template.HTML + ErrorStream chan string + // Configuring the template to load the table data through javascript, and provide the data itself + // as a JSON array inlined in the HTML of the page. + JSRendering bool + + Columns []string + + // UseDataTables is using the datatables.net framework. + // This is an opinionated way of proposing different table layouts and javascript functionality + // (for now). If a user wants more advanced customization, they can use the HTMLTemplateOutputFormatter + // or use this implementation for inspiration. + UseDataTables bool + + // AdditionalData to be passed to the rendering engine + AdditionalData map[string]interface{} +} + +//go:embed templates/* +var templateFS embed.FS + +func NewDataTablesLookupTemplate() *render.LookupTemplateFromFS { + l := render.NewLookupTemplateFromFS( + render.WithFS(templateFS), + render.WithBaseDir("templates/"), + render.WithPatterns("**/*.tmpl.html"), + ) + + _ = l.Reload() + + return l +} + +func (dt *DataTables) Clone() *DataTables { + return &DataTables{ + Command: dt.Command, + LongDescription: dt.LongDescription, + Layout: dt.Layout, + Links: dt.Links, + BasePath: dt.BasePath, + JSStream: dt.JSStream, + HTMLStream: dt.HTMLStream, + JSRendering: dt.JSRendering, + Columns: dt.Columns, + UseDataTables: dt.UseDataTables, + AdditionalData: dt.AdditionalData, + } +} + +type QueryHandler struct { + cmd cmds.GlazeCommand + contextMiddlewares []glazed.ContextMiddleware + parserOptions []parser.ParserOption + + templateName string + lookup render.TemplateLookup + + dt *DataTables +} + +type QueryHandlerOption func(qh *QueryHandler) + +func NewQueryHandler( + cmd cmds.GlazeCommand, + options ...QueryHandlerOption, +) *QueryHandler { + qh := &QueryHandler{ + cmd: cmd, + dt: &DataTables{}, + lookup: NewDataTablesLookupTemplate(), + templateName: "data-tables.tmpl.html", + } + + for _, option := range options { + option(qh) + } + + return qh +} + +func WithDataTables(dt *DataTables) QueryHandlerOption { + return func(qh *QueryHandler) { + qh.dt = dt + } +} + +func WithContextMiddlewares(middlewares ...glazed.ContextMiddleware) QueryHandlerOption { + return func(h *QueryHandler) { + h.contextMiddlewares = middlewares + } +} + +// WithParserOptions sets the parser options for the QueryHandler +func WithParserOptions(options ...parser.ParserOption) QueryHandlerOption { + return func(h *QueryHandler) { + h.parserOptions = options + } +} + +func WithTemplateLookup(lookup render.TemplateLookup) QueryHandlerOption { + return func(h *QueryHandler) { + h.lookup = lookup + } +} + +func WithTemplateName(templateName string) QueryHandlerOption { + return func(h *QueryHandler) { + h.templateName = templateName + } +} + +func WithAdditionalData(data map[string]interface{}) QueryHandlerOption { + return func(h *QueryHandler) { + h.dt.AdditionalData = data + } +} + +func (qh *QueryHandler) Handle(c *gin.Context, w io.Writer) error { + pc := glazed.NewCommandContext(qh.cmd) + + qh.contextMiddlewares = append( + qh.contextMiddlewares, + glazed.NewContextParserMiddleware( + qh.cmd, + glazed.NewCommandQueryParser(qh.cmd, qh.parserOptions...), + ), + ) + + for _, h := range qh.contextMiddlewares { + err := h.Handle(c, pc) + if err != nil { + return err + } + } + + // rowC is the channel where the rows are sent to. They will need to get converted + // to template.JS or template.HTML before being sent to either + rowC := make(chan string, 100) + + // columnsC is the channel where the column names are sent to. Since the row.ColumnsChannelMiddleware + // instance that sends columns to this channel is running before the row firmware, we should be careful + // about not blocking. Potentially, this could be done by starting a goroutine in the middleware, + // since we have a context there, and there is no need to block the middleware processing. + columnsC := make(chan []types.FieldName, 10) + + dt_ := qh.dt.Clone() + var of formatters.RowOutputFormatter + // buffered so that we don't hang on it when exciting + dt_.ErrorStream = make(chan string, 1) + if dt_.JSRendering { + of = json.NewOutputFormatter() + dt_.JSStream = make(chan template.JS, 100) + } else { + of = table_formatter.NewOutputFormatter("html") + dt_.HTMLStream = make(chan template.HTML, 100) + } + + gp, err := handlers.CreateTableProcessorWithOutput(pc, "table", "") + if err != nil { + return err + } + + gp.ReplaceTableMiddleware() + gp.AddRowMiddleware(row.NewColumnsChannelMiddleware(columnsC, true)) + gp.AddRowMiddleware(row.NewOutputChannelMiddleware(of, rowC)) + + ctx := c.Request.Context() + ctx2, cancel := context.WithCancel(ctx) + eg, ctx3 := errgroup.WithContext(ctx2) + + // copy the json rows to the template stream + eg.Go(func() error { + defer func() { + if dt_.JSRendering { + close(dt_.JSStream) + } else { + close(dt_.HTMLStream) + } + }() + for { + select { + case <-ctx3.Done(): + return ctx3.Err() + case row_, ok := <-rowC: + // check if channel is closed + if !ok { + continue + } + + if dt_.JSRendering { + dt_.JSStream <- template.JS(row_) + } else { + dt_.HTMLStream <- template.HTML(row_) + } + } + } + }) + + // actually run the command + eg.Go(func() error { + defer func() { + close(rowC) + close(columnsC) + close(dt_.ErrorStream) + cancel() + }() + + err = qh.cmd.Run(ctx3, pc.ParsedLayers, pc.ParsedParameters, gp) + if err != nil { + dt_.ErrorStream <- err.Error() + return err + } + + err = gp.Close(ctx3) + if err != nil { + return err + } + + return nil + }) + + eg.Go(func() error { + err := qh.renderTemplate(c, pc, w, dt_, columnsC) + if err != nil { + return err + } + + return nil + }) + + return eg.Wait() +} + +func (qh *QueryHandler) renderTemplate( + c *gin.Context, + pc *glazed.CommandContext, + w io.Writer, + dt_ *DataTables, + columnsC chan []types.FieldName, +) error { + // Here, we use the parsed layer to configure the glazed middlewares. + // We then use the created formatters.TableOutputFormatter as a basis for + // our own output formatter that renders into an HTML template. + var err error + + t, err := qh.lookup.Lookup(qh.templateName) + if err != nil { + return err + } + + layout_, err := layout.ComputeLayout(pc) + if err != nil { + return err + } + + description := pc.Cmd.Description() + + longHTML, err := render.RenderMarkdownToHTML(description.Long) + if err != nil { + return err + } + + dt_.Layout = layout_ + dt_.LongDescription = template.HTML(longHTML) + dt_.Command = description + + // Wait for the column names to be sent to the channel. This will only + // take the first row into account. + columns := <-columnsC + dt_.Columns = columns + + // start copying from rowC to HTML or JS stream + + err = t.Execute(w, dt_) + if err != nil { + return err + } + + return nil +} + +func CreateDataTablesHandler( + cmd cmds.GlazeCommand, + path string, + commandPath string, + options ...QueryHandlerOption, +) gin.HandlerFunc { + // TODO(manuel, 2023-07-02) Move this to the datatables package + return func(c *gin.Context) { + name := cmd.Description().Name + dateTime := time.Now().Format("2006-01-02--15-04-05") + links := []layout.Link{ + { + Href: fmt.Sprintf("%s/download/%s/%s-%s.csv", path, commandPath, dateTime, name), + Text: "Download CSV", + Class: "download", + }, + { + Href: fmt.Sprintf("%s/download/%s/%s-%s.json", path, commandPath, dateTime, name), + Text: "Download JSON", + Class: "download", + }, + { + Href: fmt.Sprintf("%s/download/%s/%s-%s.xlsx", path, commandPath, dateTime, name), + Text: "Download Excel", + Class: "download", + }, + { + Href: fmt.Sprintf("%s/download/%s/%s-%s.md", path, commandPath, dateTime, name), + Text: "Download Markdown", + Class: "download", + }, + { + Href: fmt.Sprintf("%s/download/%s/%s-%s.html", path, commandPath, dateTime, name), + Text: "Download HTML", + Class: "download", + }, + { + Href: fmt.Sprintf("%s/download/%s/%s-%s.txt", path, commandPath, dateTime, name), + Text: "Download Text", + Class: "download", + }, + } + + dt := &DataTables{ + Command: cmd.Description(), + Links: links, + BasePath: path, + JSRendering: true, + UseDataTables: false, + } + + options_ := []QueryHandlerOption{ + WithDataTables(dt), + } + options_ = append(options_, options...) + + handler := NewQueryHandler(cmd, options_...) + + err := handler.Handle(c, c.Writer) + if err != nil && !errors.Is(err, context.Canceled) { + log.Error().Err(err).Msg("error handling query") + } + } +} diff --git a/pkg/render/datatables/templates/data-tables.tmpl.html b/pkg/glazed/handlers/datatables/templates/data-tables.tmpl.html similarity index 91% rename from pkg/render/datatables/templates/data-tables.tmpl.html rename to pkg/glazed/handlers/datatables/templates/data-tables.tmpl.html index be92393..8e035e2 100644 --- a/pkg/render/datatables/templates/data-tables.tmpl.html +++ b/pkg/glazed/handlers/datatables/templates/data-tables.tmpl.html @@ -72,7 +72,7 @@ {{ end }} -
{{.Command.Short}}
@@ -133,7 +133,7 @@