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 }} -
+

Top

{{.Command.Name}}

{{.Command.Short}}

@@ -133,7 +133,7 @@

{{.Command.Name}}

+ + + {{ $hasError := false }} + {{ range .ErrorStream }} + {{ $hasError = true }} +
+
+
+
+ Error: {{.}} +
+
+
+
+ {{ end }} + {{ if $hasError }} + + {{ end }} + diff --git a/pkg/glazed/handlers/glazed/glazed.go b/pkg/glazed/handlers/glazed/glazed.go new file mode 100644 index 0000000..115b3fc --- /dev/null +++ b/pkg/glazed/handlers/glazed/glazed.go @@ -0,0 +1,114 @@ +package glazed + +import ( + "github.com/gin-gonic/gin" + "github.com/go-go-golems/glazed/pkg/cmds" + "github.com/go-go-golems/glazed/pkg/settings" + "github.com/go-go-golems/parka/pkg/glazed" + "github.com/go-go-golems/parka/pkg/glazed/parser" + "github.com/rs/zerolog/log" + "io" + "net/http" +) + +type QueryHandler struct { + cmd cmds.GlazeCommand + contextMiddlewares []glazed.ContextMiddleware + parserOptions []parser.ParserOption +} + +type QueryHandlerOption func(*QueryHandler) + +func NewQueryHandler(cmd cmds.GlazeCommand, options ...QueryHandlerOption) *QueryHandler { + h := &QueryHandler{ + cmd: cmd, + } + + for _, option := range options { + option(h) + } + + return h +} + +func WithQueryHandlerContextMiddlewares(middlewares ...glazed.ContextMiddleware) QueryHandlerOption { + return func(h *QueryHandler) { + h.contextMiddlewares = middlewares + } +} + +// WithQueryHandlerParserOptions sets the parser options for the QueryHandler +func WithQueryHandlerParserOptions(options ...parser.ParserOption) QueryHandlerOption { + return func(h *QueryHandler) { + h.parserOptions = options + } +} + +func (h *QueryHandler) Handle(c *gin.Context, writer io.Writer) error { + pc := glazed.NewCommandContext(h.cmd) + + h.contextMiddlewares = append( + h.contextMiddlewares, + glazed.NewContextParserMiddleware( + h.cmd, + glazed.NewCommandQueryParser(h.cmd, h.parserOptions...), + ), + ) + + for _, h := range h.contextMiddlewares { + err := h.Handle(c, pc) + if err != nil { + return err + } + } + + glazedLayer := pc.ParsedLayers["glazed"] + + ps := make(map[string]interface{}) + if glazedLayer != nil { + ps = glazedLayer.Parameters + } + + gp, err := settings.SetupTableProcessor(ps) + if err != nil { + return err + } + + of, err := settings.SetupProcessorOutput(gp, ps, writer) + if err != nil { + return err + } + + c.Header("Content-Type", of.ContentType()) + + ctx := c.Request.Context() + err = h.cmd.Run(ctx, pc.ParsedLayers, pc.ParsedParameters, gp) + if err != nil { + return err + } + + err = gp.Close(ctx) + if err != nil { + return err + } + + return nil +} + +func HandleQueryHandler( + cmd cmds.GlazeCommand, + parserOptions ...parser.ParserOption, +) gin.HandlerFunc { + handler := NewQueryHandler(cmd, + WithQueryHandlerParserOptions(parserOptions...), + ) + return func(c *gin.Context) { + err := handler.Handle(c, c.Writer) + if err != nil { + log.Error().Err(err).Msg("failed to handle query") + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + } + } +} diff --git a/pkg/glazed/handlers/handler.go b/pkg/glazed/handlers/handler.go new file mode 100644 index 0000000..6a61c90 --- /dev/null +++ b/pkg/glazed/handlers/handler.go @@ -0,0 +1,10 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" + "io" +) + +type Handler interface { + Handle(c *gin.Context, w io.Writer) error +} diff --git a/pkg/glazed/handlers/helpers.go b/pkg/glazed/handlers/helpers.go new file mode 100644 index 0000000..da5f029 --- /dev/null +++ b/pkg/glazed/handlers/helpers.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "github.com/go-go-golems/glazed/pkg/middlewares" + "github.com/go-go-golems/glazed/pkg/settings" + "github.com/go-go-golems/parka/pkg/glazed" +) + +func CreateTableProcessorWithOutput(pc *glazed.CommandContext, outputType string, tableFormat string) (*middlewares.TableProcessor, error) { + var gp *middlewares.TableProcessor + var err error + + glazedLayer := pc.ParsedLayers["glazed"] + + if glazedLayer != nil { + glazedLayer.Parameters["output"] = outputType + glazedLayer.Parameters["table"] = tableFormat + gp, err = settings.SetupTableProcessor(glazedLayer.Parameters) + if err != nil { + return nil, err + } + } else { + gp, err = settings.SetupTableProcessor(map[string]interface{}{ + "output": outputType, + "table-format": tableFormat, + }) + } + + return gp, err +} + +func CreateTableProcessor(pc *glazed.CommandContext) (*middlewares.TableProcessor, error) { + var gp *middlewares.TableProcessor + var err error + + glazedLayer := pc.ParsedLayers["glazed"] + + if glazedLayer != nil { + gp, err = settings.SetupTableProcessor(glazedLayer.Parameters) + if err != nil { + return nil, err + } + } else { + gp, err = settings.SetupTableProcessor(map[string]interface{}{}) + } + + return gp, err +} diff --git a/pkg/glazed/handlers/json/json.go b/pkg/glazed/handlers/json/json.go new file mode 100644 index 0000000..05f0b7e --- /dev/null +++ b/pkg/glazed/handlers/json/json.go @@ -0,0 +1,118 @@ +package json + +import ( + "github.com/gin-gonic/gin" + "github.com/go-go-golems/glazed/pkg/cmds" + json2 "github.com/go-go-golems/glazed/pkg/formatters/json" + "github.com/go-go-golems/glazed/pkg/middlewares/row" + "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/rs/zerolog/log" + "io" + "net/http" +) + +type QueryHandler struct { + cmd cmds.GlazeCommand + contextMiddlewares []glazed.ContextMiddleware + parserOptions []parser.ParserOption +} + +type QueryHandlerOption func(*QueryHandler) + +func NewQueryHandler(cmd cmds.GlazeCommand, options ...QueryHandlerOption) *QueryHandler { + h := &QueryHandler{ + cmd: cmd, + } + + for _, option := range options { + option(h) + } + + return h +} + +func WithQueryHandlerContextMiddlewares(middlewares ...glazed.ContextMiddleware) QueryHandlerOption { + return func(h *QueryHandler) { + h.contextMiddlewares = middlewares + } +} + +// WithQueryHandlerParserOptions sets the parser options for the QueryHandler +func WithQueryHandlerParserOptions(options ...parser.ParserOption) QueryHandlerOption { + return func(h *QueryHandler) { + h.parserOptions = options + } +} + +func (h *QueryHandler) Handle(c *gin.Context, writer io.Writer) error { + pc := glazed.NewCommandContext(h.cmd) + + h.contextMiddlewares = append( + h.contextMiddlewares, + glazed.NewContextParserMiddleware( + h.cmd, + glazed.NewCommandQueryParser(h.cmd, h.parserOptions...), + ), + ) + + for _, h := range h.contextMiddlewares { + err := h.Handle(c, pc) + if err != nil { + return err + } + } + + gp, err := handlers.CreateTableProcessorWithOutput(pc, "json", "") + if err != nil { + return err + } + + // remove table middlewares because we are a streaming handler + gp.ReplaceTableMiddleware() + gp.AddRowMiddleware(row.NewOutputMiddleware(json2.NewOutputFormatter(), writer)) + + c.Header("Content-Type", "application/json") + + _, err = writer.Write([]byte("[\n")) + if err != nil { + return err + } + + ctx := c.Request.Context() + err = h.cmd.Run(ctx, pc.ParsedLayers, pc.ParsedParameters, gp) + if err != nil { + return err + } + + err = gp.Close(ctx) + if err != nil { + return err + } + + _, err = writer.Write([]byte("\n]")) + if err != nil { + return err + } + + return nil +} + +func CreateJSONQueryHandler( + cmd cmds.GlazeCommand, + parserOptions ...parser.ParserOption, +) gin.HandlerFunc { + handler := NewQueryHandler(cmd, + WithQueryHandlerParserOptions(parserOptions...), + ) + return func(c *gin.Context) { + err := handler.Handle(c, c.Writer) + if err != nil { + log.Error().Err(err).Msg("failed to handle query") + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + } + } +} diff --git a/pkg/glazed/handlers/output-file/output-file.go b/pkg/glazed/handlers/output-file/output-file.go new file mode 100644 index 0000000..cdd924e --- /dev/null +++ b/pkg/glazed/handlers/output-file/output-file.go @@ -0,0 +1,137 @@ +package output_file + +import ( + "bytes" + "fmt" + "github.com/gin-gonic/gin" + "github.com/go-go-golems/glazed/pkg/cmds" + "github.com/go-go-golems/parka/pkg/glazed/handlers" + "github.com/go-go-golems/parka/pkg/glazed/handlers/glazed" + "github.com/go-go-golems/parka/pkg/glazed/parser" + "io" + "os" + "path/filepath" + "strings" +) + +type OutputFileHandler struct { + handler handlers.Handler + outputFileName string +} + +func NewOutputFileHandler(handler handlers.Handler, outputFileName string) *OutputFileHandler { + h := &OutputFileHandler{ + handler: handler, + outputFileName: outputFileName, + } + + return h +} + +func (h *OutputFileHandler) Handle(c *gin.Context, w io.Writer) error { + buf := &bytes.Buffer{} + err := h.handler.Handle(c, buf) + if err != nil { + return err + } + + c.Status(200) + + f, err := os.Open(h.outputFileName) + if err != nil { + return err + } + defer func(f *os.File) { + _ = f.Close() + }(f) + + _, err = io.Copy(c.Writer, f) + if err != nil { + return err + } + + return nil +} + +func CreateGlazedFileHandler( + cmd cmds.GlazeCommand, + fileName string, + parserOptions ...parser.ParserOption, +) gin.HandlerFunc { + return func(c *gin.Context) { + glazedOverrides := map[string]interface{}{} + needsRealFileOutput := false + + // create a temporary file for glazed output + if strings.HasSuffix(fileName, ".csv") { + glazedOverrides["output"] = "table" + glazedOverrides["table-format"] = "csv" + } else if strings.HasSuffix(fileName, ".tsv") { + glazedOverrides["output"] = "table" + glazedOverrides["table-format"] = "tsv" + } else if strings.HasSuffix(fileName, ".md") { + glazedOverrides["output"] = "table" + glazedOverrides["table-format"] = "markdown" + } else if strings.HasSuffix(fileName, ".html") { + glazedOverrides["output"] = "table" + glazedOverrides["table-format"] = "html" + } else if strings.HasSuffix(fileName, ".json") { + glazedOverrides["output"] = "json" + } else if strings.HasSuffix(fileName, ".yaml") { + glazedOverrides["output"] = "yaml" + } else if strings.HasSuffix(fileName, ".xlsx") { + glazedOverrides["output"] = "excel" + needsRealFileOutput = true + } else if strings.HasSuffix(fileName, ".txt") { + glazedOverrides["output"] = "table" + glazedOverrides["table-format"] = "ascii" + } else { + c.JSON(500, gin.H{"error": "could not determine output format"}) + return + } + + var tmpFile *os.File + var err error + + // excel output needs a real output file, otherwise we can go stream to the HTTP response + if needsRealFileOutput { + tmpFile, err = os.CreateTemp("/tmp", fmt.Sprintf("glazed-output-*.%s", fileName)) + if err != nil { + c.JSON(500, gin.H{"error": "could not create temporary file"}) + return + } + defer func(name string) { + _ = os.Remove(name) + }(tmpFile.Name()) + + // now check file suffix for content-type + glazedOverrides["output-file"] = tmpFile.Name() + } + + parserOptions = append(parserOptions, parser.WithAppendOverrides("glazed", glazedOverrides)) + handler := glazed.NewQueryHandler(cmd, glazed.WithQueryHandlerParserOptions(parserOptions...)) + + baseName := filepath.Base(fileName) + c.Writer.Header().Set("Content-Disposition", "attachment; filename="+baseName) + + if needsRealFileOutput { + outputFileHandler := NewOutputFileHandler(handler, tmpFile.Name()) + + err = outputFileHandler.Handle(c, c.Writer) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + } else { + + err = handler.Handle(c, c.Writer) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + } + + // override parameter layers at the end + + } +} diff --git a/pkg/handlers/command-dir/command-dir.go b/pkg/handlers/command-dir/command-dir.go index 1c55606..4c84c46 100644 --- a/pkg/handlers/command-dir/command-dir.go +++ b/pkg/handlers/command-dir/command-dir.go @@ -6,17 +6,16 @@ import ( "github.com/go-go-golems/clay/pkg/repositories" "github.com/go-go-golems/glazed/pkg/cmds" "github.com/go-go-golems/glazed/pkg/cmds/layers" - "github.com/go-go-golems/parka/pkg/glazed" + "github.com/go-go-golems/parka/pkg/glazed/handlers/datatables" + "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/glazed/parser" "github.com/go-go-golems/parka/pkg/handlers/config" "github.com/go-go-golems/parka/pkg/render" - "github.com/go-go-golems/parka/pkg/render/datatables" - "github.com/go-go-golems/parka/pkg/render/layout" parka "github.com/go-go-golems/parka/pkg/server" "github.com/pkg/errors" "os" "strings" - "time" ) type HandlerParameters struct { @@ -413,26 +412,21 @@ func (cd *CommandDirHandler) Serve(server *parka.Server, path string) error { path = strings.TrimSuffix(path, "/") server.Router.GET(path+"/data/*path", func(c *gin.Context) { - commandPath := c.Param("CommandPath") - commandPath = strings.TrimPrefix(commandPath, "/") + commandPath := c.Param("path") + commandPath = strings.TrimPrefix(commandPath, path+"/") sqlCommand, ok := getRepositoryCommand(c, cd.Repository, commandPath) if !ok { c.JSON(404, gin.H{"error": "command not found"}) return } - handle := server.HandleSimpleQueryCommand(sqlCommand, - glazed.WithCreateProcessor(glazed.CreateJSONProcessor), - glazed.WithParserOptions(cd.computeParserOptions()...), - ) - - handle(c) + json.CreateJSONQueryHandler(sqlCommand)(c) }) server.Router.GET(path+"/sqleton/*path", func(c *gin.Context) { - // Get command path from the route - commandPath := strings.TrimPrefix(c.Param("path"), "/") + commandPath := c.Param("path") + commandPath = strings.TrimPrefix(commandPath, path+"/") // Get repository command sqlCommand, ok := getRepositoryCommand(c, cd.Repository, commandPath) @@ -441,136 +435,45 @@ func (cd *CommandDirHandler) Serve(server *parka.Server, path string) error { return } - name := sqlCommand.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", - }, - } - - // TODO(manuel, 2023-05-25) Ignore indexTemplateName for now - // See https://github.com/go-go-golems/sqleton/issues/162 - _ = cd.IndexTemplateName - - dataTablesProcessorFunc := datatables.NewDataTablesCreateOutputProcessorFunc( - cd.TemplateLookup, - cd.TemplateName, - datatables.WithLinks(links...), - datatables.WithJSRendering(), + options := []datatables.QueryHandlerOption{ + datatables.WithParserOptions(cd.computeParserOptions()...), + datatables.WithTemplateLookup(cd.TemplateLookup), + datatables.WithTemplateName(cd.TemplateName), datatables.WithAdditionalData(cd.AdditionalData), - datatables.WithBasePath(path), - ) - - handle := server.HandleSimpleQueryCommand( - sqlCommand, - glazed.WithCreateProcessor(dataTablesProcessorFunc), - glazed.WithParserOptions(cd.computeParserOptions()...), - ) + } - handle(c) + datatables.CreateDataTablesHandler(sqlCommand, path, commandPath, options...)(c) }) server.Router.GET(path+"/download/*path", func(c *gin.Context) { - // get file name at end of path - index := strings.LastIndex(path, "/") + path_ := c.Param("path") + path_ = strings.TrimPrefix(path_, path+"/") + index := strings.LastIndex(path_, "/") if index == -1 { c.JSON(500, gin.H{"error": "could not find file name"}) return } - if index >= len(path)-1 { + if index >= len(path_)-1 { c.JSON(500, gin.H{"error": "could not find file name"}) return } - fileName := path[index+1:] + fileName := path_[index+1:] - commandPath := strings.TrimPrefix(path[:index], "/") + commandPath := strings.TrimPrefix(path_[:index], path+"/") sqlCommand, ok := getRepositoryCommand(c, cd.Repository, commandPath) if !ok { // JSON output and error code already handled by getRepositoryCommand return } - - // create a temporary file for glazed output - tmpFile, err := os.CreateTemp("/tmp", fmt.Sprintf("glazed-output-*.%s", fileName)) - if err != nil { - c.JSON(500, gin.H{"error": "could not create temporary file"}) - return - } - defer func(name string) { - _ = os.Remove(name) - }(tmpFile.Name()) - - // now check file suffix for content-type - glazedOverrides := map[string]interface{}{ - "output-file": tmpFile.Name(), - } - if strings.HasSuffix(fileName, ".csv") { - glazedOverrides["output"] = "table" - glazedOverrides["table-format"] = "csv" - } else if strings.HasSuffix(fileName, ".tsv") { - glazedOverrides["output"] = "table" - glazedOverrides["table-format"] = "tsv" - } else if strings.HasSuffix(fileName, ".md") { - glazedOverrides["output"] = "table" - glazedOverrides["table-format"] = "markdown" - } else if strings.HasSuffix(fileName, ".html") { - glazedOverrides["output"] = "table" - glazedOverrides["table-format"] = "html" - } else if strings.HasSuffix(fileName, ".json") { - glazedOverrides["output"] = "json" - } else if strings.HasSuffix(fileName, ".yaml") { - glazedOverrides["yaml"] = "yaml" - } else if strings.HasSuffix(fileName, ".xlsx") { - glazedOverrides["output"] = "excel" - } else if strings.HasSuffix(fileName, ".txt") { - glazedOverrides["output"] = "table" - glazedOverrides["table-format"] = "ascii" - } else { - c.JSON(500, gin.H{"error": "could not determine output format"}) - return - } - parserOptions := cd.computeParserOptions() - // override parameter layers at the end - parserOptions = append(parserOptions, parser.WithAppendOverrides("glazed", glazedOverrides)) - - handle := server.HandleSimpleQueryOutputFileCommand( + // TODO(manuel, 223-07-03) this is really only necessary for excel, I think. + // Other formats can render straight to the stream + output_file.CreateGlazedFileHandler( sqlCommand, - tmpFile.Name(), fileName, - glazed.WithParserOptions(parserOptions...), - ) - - handle(c) + parserOptions..., + )(c) }) return nil diff --git a/pkg/handlers/config-file.go b/pkg/handlers/config-file.go index 589b19b..6f8deed 100644 --- a/pkg/handlers/config-file.go +++ b/pkg/handlers/config-file.go @@ -2,7 +2,10 @@ package handlers import ( "context" + "fmt" "github.com/go-go-golems/clay/pkg/repositories" + "github.com/go-go-golems/glazed/pkg/cmds" + "github.com/go-go-golems/glazed/pkg/cmds/loaders" "github.com/go-go-golems/glazed/pkg/helpers/strings" "github.com/go-go-golems/parka/pkg/handlers/command-dir" "github.com/go-go-golems/parka/pkg/handlers/config" @@ -13,6 +16,7 @@ import ( "github.com/go-go-golems/parka/pkg/render" "github.com/go-go-golems/parka/pkg/server" "github.com/pkg/errors" + "github.com/rs/zerolog/log" "github.com/spf13/viper" "golang.org/x/sync/errgroup" "os" @@ -26,6 +30,47 @@ import ( // This is used to provision the CommandDir handlers. type RepositoryFactory func(dirs []string) (*repositories.Repository, error) +func NewRepositoryFactoryFromLoaders( + commandLoader loaders.ReaderCommandLoader, + fsLoader loaders.FSCommandLoader, +) RepositoryFactory { + return func(dirs []string) (*repositories.Repository, error) { + r := repositories.NewRepository( + repositories.WithDirectories(dirs), + repositories.WithUpdateCallback(func(cmd cmds.Command) error { + description := cmd.Description() + log.Info().Str("name", description.Name). + Str("source", description.Source). + Msg("Updating cmd") + // TODO(manuel, 2023-04-19) This is where we would recompute the HandlerFunc used below in GET and POST + return nil + }), + repositories.WithRemoveCallback(func(cmd cmds.Command) error { + description := cmd.Description() + log.Info().Str("name", description.Name). + Str("source", description.Source). + Msg("Removing cmd") + // TODO(manuel, 2023-04-19) This is where we would recompute the HandlerFunc used below in GET and POST + // NOTE(manuel, 2023-05-25) Regarding the above TODO, why? + // We don't need to recompute the func, since it fetches the command at runtime. + return nil + }), + repositories.WithCommandLoader(commandLoader), + repositories.WithFSLoader(fsLoader), + ) + + err := r.LoadCommands() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error initializing commands: %s\n", err) + os.Exit(1) + } + + return r, nil + + } + +} + // ConfigFileHandler contains everything needed to serve a config file type ConfigFileHandler struct { Config *config.Config diff --git a/pkg/render/datatables/datatables.go b/pkg/render/datatables/datatables.go deleted file mode 100644 index dd547f8..0000000 --- a/pkg/render/datatables/datatables.go +++ /dev/null @@ -1,278 +0,0 @@ -package datatables - -import ( - "context" - "embed" - "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" - "github.com/go-go-golems/glazed/pkg/formatters/table" - "github.com/go-go-golems/glazed/pkg/processor" - "github.com/go-go-golems/glazed/pkg/settings" - "github.com/go-go-golems/glazed/pkg/types" - "github.com/go-go-golems/parka/pkg/glazed" - "github.com/go-go-golems/parka/pkg/render" - "github.com/go-go-golems/parka/pkg/render/layout" - "html/template" - "io" -) - -// 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 string - - 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 - // 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{} -} - -type DataTablesOutputFormatter struct { - *render.HTMLTemplateOutputFormatter - dataTablesData *DataTables -} - -type DataTablesOutputFormatterOption func(*DataTablesOutputFormatter) - -func WithCommand(cmd *cmds.CommandDescription) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.Command = cmd - } -} - -func WithLongDescription(desc string) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.LongDescription = desc - } -} - -func WithReplaceAdditionalData(data map[string]interface{}) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.AdditionalData = data - } -} - -func WithAdditionalData(data map[string]interface{}) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - if d.dataTablesData.AdditionalData == nil { - d.dataTablesData.AdditionalData = data - } else { - for k, v := range data { - d.dataTablesData.AdditionalData[k] = v - } - } - } -} - -// WithJSRendering enables JS rendering for the DataTables renderer. -// This means that we will render the table into the toplevel element -// `tableData` in javascript, and not call the parent output formatter -func WithJSRendering() DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.JSRendering = true - } -} - -func WithLayout(l *layout.Layout) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.Layout = l - } -} - -func WithLinks(links ...layout.Link) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.Links = links - } -} - -func WithBasePath(path string) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.BasePath = path - } -} - -func WithAppendLinks(links ...layout.Link) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.Links = append(d.dataTablesData.Links, links...) - } -} - -func WithPrependLinks(links ...layout.Link) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.Links = append(links, d.dataTablesData.Links...) - } -} - -func WithColumns(columns ...string) DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.Columns = columns - } -} - -func WithUseDataTables() DataTablesOutputFormatterOption { - return func(d *DataTablesOutputFormatter) { - d.dataTablesData.UseDataTables = true - } -} - -//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 NewDataTablesOutputFormatter( - t *template.Template, - of *table.OutputFormatter, - options ...DataTablesOutputFormatterOption, -) *DataTablesOutputFormatter { - ret := &DataTablesOutputFormatter{ - HTMLTemplateOutputFormatter: render.NewHTMLTemplateOutputFormatter(t, of), - dataTablesData: &DataTables{}, - } - - for _, option := range options { - option(ret) - } - - return ret -} - -func (d *DataTablesOutputFormatter) ContentType() string { - return "text/html; charset=utf-8" -} - -func (d *DataTablesOutputFormatter) Output(ctx context.Context, table *types.Table, w io.Writer) error { - dt := d.dataTablesData - if dt.JSRendering { - jsonOutputFormatter := json.NewOutputFormatter() - dt.JSStream = formatters.StartFormatIntoChannel[template.JS](ctx, table, jsonOutputFormatter) - } else { - dt.HTMLStream = formatters.StartFormatIntoChannel[template.HTML](ctx, table, d.OutputFormatter) - } - - // TODO(manuel, 2023-06-20) We need to properly pass the columns here, which can't be set upstream - // since we already pass in the JSStream here and we keep it, I think we are better off cloning the - // DataTables struct, or even separating it out to make d.dataTablesData immutable and just contain the - // toplevel config. - if table != nil { - dt.Columns = table.Columns - } - - err := d.HTMLTemplateOutputFormatter.Template.Execute(w, dt) - - if err != nil { - return err - } - - return nil -} - -// NewDataTablesCreateOutputProcessorFunc creates a glazed.CreateProcessorFunc based on a TemplateLookup -// and a template name. -func NewDataTablesCreateOutputProcessorFunc( - lookup render.TemplateLookup, - templateName string, - options ...DataTablesOutputFormatterOption, -) glazed.CreateProcessorFunc { - return func(c *gin.Context, pc *glazed.CommandContext) ( - processor.TableProcessor, - error, - ) { - // Lookup template on every request, not up front. That way, templates can be reloaded without recreating the gin - // server. - t, err := lookup.Lookup(templateName) - if err != nil { - return nil, err - } - - l, ok := pc.ParsedLayers["glazed"] - var gp *processor.GlazeProcessor - - if ok { - l.Parameters["output"] = "table" - l.Parameters["table-format"] = "html" - - gp, err = settings.SetupProcessor(l.Parameters) - } else { - gp, err = settings.SetupProcessor(map[string]interface{}{ - "output": "table", - "table-format": "html", - }) - } - - if err != nil { - return nil, err - } - - layout_, err := layout.ComputeLayout(pc) - if err != nil { - return nil, err - } - - description := pc.Cmd.Description() - - longHTML, err := render.RenderMarkdownToHTML(description.Long) - if err != nil { - return nil, err - } - - options_ := []DataTablesOutputFormatterOption{ - WithCommand(description), - WithLongDescription(longHTML), - WithLayout(layout_), - } - options_ = append(options_, options...) - - of := NewDataTablesOutputFormatter( - t, - gp.OutputFormatter().(*table.OutputFormatter), - options_..., - ) - - gp2, err := processor.NewGlazeProcessor(of) - if err != nil { - return nil, err - } - - return gp2, nil - } -} diff --git a/pkg/render/formatters/html.go b/pkg/render/formatters/html.go new file mode 100644 index 0000000..ea43620 --- /dev/null +++ b/pkg/render/formatters/html.go @@ -0,0 +1,112 @@ +package formatters + +import ( + "github.com/gin-gonic/gin" + "github.com/go-go-golems/parka/pkg/glazed" + "github.com/go-go-golems/parka/pkg/render" + "github.com/go-go-golems/parka/pkg/render/layout" + "html/template" + "io" +) + +// NOTE(manuel, 2023-06-04): I don'Template think any of this is necessary. +// +// So it looks like the steps to output glazed data is to use a CreateProcessorFunc to create +// a processor.TableProcessor. Here we create a processor that uses a HTMLTemplateOutputFormatter (which +// we are converting to a more specialized DataTableOutputFormatter), and then wrap all this through a +// HTMLTableProcessor. But really the HTMLTableProcessor is just there to wrap the output formatter and +// the template used. But the template used should be captured by the TableOutputFormatter in the first place. +// +// As such, we can use a generic TableProcessor (why is there even a processor to be overloaded, if the definition of +// processor.TableProcessor is the following: +// +//type TableProcessor interface { +// AddRow(ctx context.Context, obj map[string]interface{}) error +// TableOutputFormatter() formatters.TableOutputFormatter +//} +// +// Probably because we use the processor.GlazeProcessor class as a helper, which is able to handle the different +// middlewares and output formatters. This means we can kill HTMLTemplateProcessor, capture the template in the +// HTMLTemplateOutputFormatter and then use the standard GlazeProcessor. + +// HTMLTemplateOutputFormatter wraps a normal HTML table output formatter, and allows +// a template to be added in the back in the front. +type HTMLTemplateOutputFormatter struct { + TemplateName string + Lookup render.TemplateLookup + Data map[string]interface{} +} + +type HTMLTemplateOutputFormatterOption func(*HTMLTemplateOutputFormatter) + +func WithHTMLTemplateOutputFormatterData(data map[string]interface{}) HTMLTemplateOutputFormatterOption { + return func(of *HTMLTemplateOutputFormatter) { + if of.Data == nil { + of.Data = map[string]interface{}{} + } + for k, v := range data { + of.Data[k] = v + } + } +} + +func NewHTMLTemplateOutputFormatter( + lookup render.TemplateLookup, + templateName string, + options ...HTMLTemplateOutputFormatterOption, +) *HTMLTemplateOutputFormatter { + ret := &HTMLTemplateOutputFormatter{ + TemplateName: templateName, + Lookup: lookup, + } + + for _, option := range options { + option(ret) + } + + return ret +} + +func (H *HTMLTemplateOutputFormatter) Output(c *gin.Context, pc *glazed.CommandContext, w io.Writer) 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 + + 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 + } + + data := map[string]interface{}{} + for k, v := range H.Data { + data[k] = v + } + data["Command"] = description + data["LongDescription"] = template.HTML(longHTML) + data["Layout"] = layout_ + + // TODO(manuel, 2023-06-30) Get the column names out of a RowOutputMiddleware + //data["Columns"] = table.Columns + + t, err := H.Lookup.Lookup(H.TemplateName) + if err != nil { + return err + } + + // TODO: we are missing the background processing of the rows here + + err = t.Execute(w, data) + if err != nil { + return err + } + + return err +} diff --git a/pkg/render/html.go b/pkg/render/html.go deleted file mode 100644 index 893885b..0000000 --- a/pkg/render/html.go +++ /dev/null @@ -1,163 +0,0 @@ -package render - -import ( - "context" - "github.com/gin-gonic/gin" - "github.com/go-go-golems/glazed/pkg/formatters/table" - "github.com/go-go-golems/glazed/pkg/processor" - "github.com/go-go-golems/glazed/pkg/settings" - "github.com/go-go-golems/glazed/pkg/types" - "github.com/go-go-golems/parka/pkg/glazed" - "github.com/go-go-golems/parka/pkg/render/layout" - "html/template" - "io" -) - -// NOTE(manuel, 2023-06-04): I don'Template think any of this is necessary. -// -// So it looks like the steps to output glazed data is to use a CreateProcessorFunc to create -// a processor.TableProcessor. Here we create a processor that uses a HTMLTemplateOutputFormatter (which -// we are converting to a more specialized DataTableOutputFormatter), and then wrap all this through a -// HTMLTableProcessor. But really the HTMLTableProcessor is just there to wrap the output formatter and -// the template used. But the template used should be captured by the OutputFormatter in the first place. -// -// As such, we can use a generic TableProcessor (why is there even a processor to be overloaded, if the definition of -// processor.TableProcessor is the following: -// -//type TableProcessor interface { -// AddRow(ctx context.Context, obj map[string]interface{}) error -// OutputFormatter() formatters.OutputFormatter -//} -// -// Probably because we use the processor.GlazeProcessor class as a helper, which is able to handle the different -// middlewares and output formatters. This means we can kill HTMLTemplateProcessor, capture the template in the -// HTMLTemplateOutputFormatter and then use the standard GlazeProcessor. - -// HTMLTemplateOutputFormatter wraps a normal HTML table output formatter, and allows -// a template to be added in the back in the front. -type HTMLTemplateOutputFormatter struct { - // We use a table outputFormatter because we need to access the Table itself. - *table.OutputFormatter - Template *template.Template - Data map[string]interface{} -} - -type HTMLTemplateOutputFormatterOption func(*HTMLTemplateOutputFormatter) - -func WithHTMLTemplateOutputFormatterData(data map[string]interface{}) HTMLTemplateOutputFormatterOption { - return func(of *HTMLTemplateOutputFormatter) { - if of.Data == nil { - of.Data = map[string]interface{}{} - } - for k, v := range data { - of.Data[k] = v - } - } -} - -func NewHTMLTemplateOutputFormatter( - t *template.Template, - of *table.OutputFormatter, - options ...HTMLTemplateOutputFormatterOption, -) *HTMLTemplateOutputFormatter { - ret := &HTMLTemplateOutputFormatter{ - OutputFormatter: of, - Template: t, - } - - for _, option := range options { - option(ret) - } - - return ret -} - -func (H *HTMLTemplateOutputFormatter) Output(ctx context.Context, table *types.Table, w io.Writer) error { - data := map[string]interface{}{} - for k, v := range H.Data { - - data[k] = v - } - data["Columns"] = table.Columns - - err := H.Template.Execute(w, data) - - if err != nil { - return err - } - - return err -} - -// NewHTMLTemplateLookupCreateProcessorFunc creates a glazed.CreateProcessorFunc based on a TemplateLookup -// and a template name. -func NewHTMLTemplateLookupCreateProcessorFunc( - lookup TemplateLookup, - templateName string, - options ...HTMLTemplateOutputFormatterOption, -) glazed.CreateProcessorFunc { - return func(c *gin.Context, pc *glazed.CommandContext) ( - processor.TableProcessor, - error, - ) { - // Lookup template on every request, not up front. That way, templates can be reloaded without recreating the gin - // server. - t, err := lookup.Lookup(templateName) - if err != nil { - return nil, err - } - - // Here, we use the parsed layer to configure the glazed middlewares. - // We then use the created formatters.OutputFormatter as a basis for - // our own output formatter that renders into an HTML template. - l, ok := pc.ParsedLayers["glazed"] - - var gp *processor.GlazeProcessor - - if ok { - l.Parameters["output"] = "table" - l.Parameters["table-format"] = "html" - - gp, err = settings.SetupProcessor(l.Parameters) - } else { - gp, err = settings.SetupProcessor(map[string]interface{}{ - "output": "table", - "table-format": "html", - }) - } - - if err != nil { - return nil, err - } - - layout_, err := layout.ComputeLayout(pc) - if err != nil { - return nil, err - } - - description := pc.Cmd.Description() - - longHTML, err := RenderMarkdownToHTML(description.Long) - if err != nil { - return nil, err - } - - options_ := []HTMLTemplateOutputFormatterOption{ - WithHTMLTemplateOutputFormatterData( - map[string]interface{}{ - "Command": description, - "LongDescription": template.HTML(longHTML), - "Layout": layout_, - }), - } - options_ = append(options_, options...) - - of := NewHTMLTemplateOutputFormatter(t, gp.OutputFormatter().(*table.OutputFormatter), options_...) - gp2, err := processor.NewGlazeProcessor(of) - if err != nil { - return nil, err - } - - return gp2, nil - } -} diff --git a/pkg/server/server.go b/pkg/server/server.go index a396b23..e52472c 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -6,8 +6,6 @@ import ( "fmt" "github.com/gin-gonic/contrib/gzip" "github.com/gin-gonic/gin" - "github.com/go-go-golems/glazed/pkg/cmds" - "github.com/go-go-golems/parka/pkg/glazed" "github.com/go-go-golems/parka/pkg/render" utils_fs "github.com/go-go-golems/parka/pkg/utils/fs" "golang.org/x/sync/errgroup" @@ -217,44 +215,3 @@ func (s *Server) Run(ctx context.Context) error { return eg.Wait() } - -func (s *Server) HandleSimpleQueryCommand( - cmd cmds.GlazeCommand, - options ...glazed.HandleOption, -) gin.HandlerFunc { - opts := glazed.NewHandleOptions(options) - opts.Handlers = append(opts.Handlers, - glazed.NewParserCommandHandlerFunc(cmd, - glazed.NewCommandQueryParser(cmd, opts.ParserOptions...)), - ) - return glazed.GinHandleGlazedCommand(cmd, opts) -} - -func (s *Server) HandleSimpleQueryOutputFileCommand( - cmd cmds.GlazeCommand, - outputFile string, - fileName string, - options ...glazed.HandleOption, -) gin.HandlerFunc { - opts := glazed.NewHandleOptions(options) - opts.Handlers = append(opts.Handlers, - glazed.NewParserCommandHandlerFunc(cmd, glazed.NewCommandQueryParser(cmd, opts.ParserOptions...)), - ) - return glazed.GinHandleGlazedCommandWithOutputFile(cmd, outputFile, fileName, opts) -} - -// TODO(manuel, 2023-02-28) We want to provide a handler to catch errors while parsing parameters - -func (s *Server) HandleSimpleFormCommand( - cmd cmds.GlazeCommand, - options ...glazed.HandleOption, -) gin.HandlerFunc { - opts := &glazed.HandleOptions{} - for _, option := range options { - option(opts) - } - opts.Handlers = append(opts.Handlers, - glazed.NewParserCommandHandlerFunc(cmd, glazed.NewCommandFormParser(cmd, opts.ParserOptions...)), - ) - return glazed.GinHandleGlazedCommand(cmd, opts) -} diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index dabbb8b..34b3d38 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -6,8 +6,9 @@ import ( "github.com/gin-gonic/gin" "github.com/go-go-golems/glazed/pkg/cmds" "github.com/go-go-golems/glazed/pkg/cmds/layers" - "github.com/go-go-golems/glazed/pkg/processor" + "github.com/go-go-golems/glazed/pkg/middlewares" "github.com/go-go-golems/glazed/pkg/types" + json2 "github.com/go-go-golems/parka/pkg/glazed/handlers/json" "github.com/go-go-golems/parka/pkg/server" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,7 +28,7 @@ func (t *TestCommand) Run( ctx context.Context, parsedLayers map[string]*layers.ParsedParameterLayer, ps map[string]interface{}, - gp processor.TableProcessor, + gp middlewares.Processor, ) error { err := gp.AddRow(ctx, types.NewRow( types.MRP("foo", 1), @@ -47,7 +48,7 @@ func TestRunGlazedCommand(t *testing.T) { s, err := server.NewServer() require.NoError(t, err) - handler := s.HandleSimpleQueryCommand(tc) + handler := json2.CreateJSONQueryHandler(tc) gin.SetMode(gin.TestMode) @@ -69,11 +70,13 @@ func TestRunGlazedCommand(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) // content type json assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) - v := map[string]interface{}{} + v := []map[string]interface{}{} err = json.Unmarshal(body, &v) require.NoError(t, err) - assert.Equal(t, float64(1), v["foo"]) - assert.Equal(t, "baz", v["bar"]) - }) + require.Len(t, v, 1) + v_ := v[0] + assert.Equal(t, float64(1), v_["foo"]) + assert.Equal(t, "baz", v_["bar"]) + }) }