From faf86c011cbe6a00e6762df90e8c1a93e84655d7 Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Thu, 9 May 2024 14:09:12 -0400 Subject: [PATCH] :sparkles: Update parka to have single commands and properly watch command directory --- pkg/glazed/handlers/datatables/datatables.go | 19 +- pkg/handlers/command-dir/command-dir.go | 325 ++--------------- pkg/handlers/command/command.go | 167 ++------- pkg/handlers/config-file.go | 16 +- pkg/handlers/generic-command/generic.go | 346 +++++++++++++++++++ 5 files changed, 427 insertions(+), 446 deletions(-) create mode 100644 pkg/handlers/generic-command/generic.go diff --git a/pkg/glazed/handlers/datatables/datatables.go b/pkg/glazed/handlers/datatables/datatables.go index 57ba964..3bf3130 100644 --- a/pkg/glazed/handlers/datatables/datatables.go +++ b/pkg/glazed/handlers/datatables/datatables.go @@ -166,6 +166,7 @@ func WithStreamRows(streamRows bool) QueryHandlerOption { } var _ handlers.Handler = &QueryHandler{} +var _ echo.HandlerFunc = (&QueryHandler{}).Handle func (qh *QueryHandler) Handle(c echo.Context) error { description := qh.cmd.Description() @@ -362,8 +363,8 @@ func (qh *QueryHandler) renderTemplate( func CreateDataTablesHandler( cmd cmds.GlazeCommand, - path string, - commandPath string, + basePath string, + downloadPath string, options ...QueryHandlerOption, ) echo.HandlerFunc { return func(c echo.Context) error { @@ -371,32 +372,32 @@ func CreateDataTablesHandler( 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), + Href: fmt.Sprintf("%s/%s-%s.csv", downloadPath, dateTime, name), Text: "Download CSV", Class: "download", }, { - Href: fmt.Sprintf("%s/download/%s/%s-%s.json", path, commandPath, dateTime, name), + Href: fmt.Sprintf("%s/%s-%s.json", downloadPath, dateTime, name), Text: "Download JSON", Class: "download", }, { - Href: fmt.Sprintf("%s/download/%s/%s-%s.xlsx", path, commandPath, dateTime, name), + Href: fmt.Sprintf("%s/%s-%s.xlsx", downloadPath, dateTime, name), Text: "Download Excel", Class: "download", }, { - Href: fmt.Sprintf("%s/download/%s/%s-%s.md", path, commandPath, dateTime, name), + Href: fmt.Sprintf("%s/%s-%s.md", downloadPath, dateTime, name), Text: "Download Markdown", Class: "download", }, { - Href: fmt.Sprintf("%s/download/%s/%s-%s.html", path, commandPath, dateTime, name), + Href: fmt.Sprintf("%s/%s-%s.html", downloadPath, dateTime, name), Text: "Download HTML", Class: "download", }, { - Href: fmt.Sprintf("%s/download/%s/%s-%s.txt", path, commandPath, dateTime, name), + Href: fmt.Sprintf("%s/%s-%s.txt", downloadPath, dateTime, name), Text: "Download Text", Class: "download", }, @@ -405,7 +406,7 @@ func CreateDataTablesHandler( dt := NewDataTables() dt.Command = cmd.Description() dt.Links = links - dt.BasePath = path + dt.BasePath = basePath dt.JSRendering = true dt.UseDataTables = false diff --git a/pkg/handlers/command-dir/command-dir.go b/pkg/handlers/command-dir/command-dir.go index 155d87b..0451311 100644 --- a/pkg/handlers/command-dir/command-dir.go +++ b/pkg/handlers/command-dir/command-dir.go @@ -33,144 +33,60 @@ package command_dir // returns an error. import ( + "context" "fmt" "github.com/go-go-golems/clay/pkg/repositories" - "github.com/go-go-golems/glazed/pkg/cmds" "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/handlers/sse" - "github.com/go-go-golems/parka/pkg/glazed/handlers/text" "github.com/go-go-golems/parka/pkg/handlers/config" + "github.com/go-go-golems/parka/pkg/handlers/generic-command" "github.com/go-go-golems/parka/pkg/render" parka "github.com/go-go-golems/parka/pkg/server" - "github.com/go-go-golems/parka/pkg/utils" - "github.com/labstack/echo/v4" "github.com/pkg/errors" - "net/http" "os" - "path/filepath" - "strings" ) type CommandDirHandler struct { - DevMode bool + generic_command.GenericCommandHandler - // TemplateName is the name of the template that is lookup up through the given TemplateLookup - // used to render the glazed command. - TemplateName string - // IndexTemplateName is the name of the template that is looked up through TemplateLookup to render - // command indexes. Leave empty to not render index pages at all. - IndexTemplateName string - // TemplateLookup is used to look up both TemplateName and IndexTemplateName - TemplateLookup render.TemplateLookup + DevMode bool // Repository is the command repository that is exposed over HTTP through this handler. Repository *repositories.Repository - - // AdditionalData is passed to the template being rendered. - AdditionalData map[string]interface{} - - ParameterFilter *config.ParameterFilter - - // If true, all glazed outputs will try to use a row output if possible. - // This means that "ragged" objects (where columns might not all be present) - // will have missing columns, only the fields of the first object will be used - // as rows. - // - // This is true per default, and needs to be explicitly set to false to use - // a normal TableMiddleware oriented output. - Stream bool } type CommandDirHandlerOption func(handler *CommandDirHandler) -func WithTemplateName(name string) CommandDirHandlerOption { - return func(handler *CommandDirHandler) { - handler.TemplateName = name - } -} - -func WithParameterFilter(overridesAndDefaults *config.ParameterFilter) CommandDirHandlerOption { - return func(handler *CommandDirHandler) { - handler.ParameterFilter = overridesAndDefaults - } -} - -func WithParameterFilterOptions(opts ...config.ParameterFilterOption) CommandDirHandlerOption { - return func(handler *CommandDirHandler) { - for _, opt := range opts { - opt(handler.ParameterFilter) - } - } -} - -func WithDefaultTemplateName(name string) CommandDirHandlerOption { +func WithDevMode(devMode bool) CommandDirHandlerOption { return func(handler *CommandDirHandler) { - if handler.TemplateName == "" { - handler.TemplateName = name - } + handler.DevMode = devMode } } -func WithIndexTemplateName(name string) CommandDirHandlerOption { +func WithRepository(r *repositories.Repository) CommandDirHandlerOption { return func(handler *CommandDirHandler) { - handler.IndexTemplateName = name + handler.Repository = r } } -func WithDefaultIndexTemplateName(name string) CommandDirHandlerOption { +func WithGenericCommandHandlerOptions(options ...generic_command.GenericCommandHandlerOption) CommandDirHandlerOption { return func(handler *CommandDirHandler) { - if handler.IndexTemplateName == "" { - handler.IndexTemplateName = name + for _, option := range options { + option(&handler.GenericCommandHandler) } } } -// WithMergeAdditionalData merges the passed in map with the handler's AdditionalData map. -// If a value is already set in the AdditionalData map and override is true, it will get overwritten. -func WithMergeAdditionalData(data map[string]interface{}, override bool) CommandDirHandlerOption { - return func(handler *CommandDirHandler) { - if handler.AdditionalData == nil { - handler.AdditionalData = data - } else { - for k, v := range data { - if _, ok := handler.AdditionalData[k]; !ok || override { - handler.AdditionalData[k] = v - } - } - } - } -} - -func WithTemplateLookup(lookup render.TemplateLookup) CommandDirHandlerOption { - return func(handler *CommandDirHandler) { - handler.TemplateLookup = lookup - } -} - -// handling all the ways to configure overrides -func WithDevMode(devMode bool) CommandDirHandlerOption { - return func(handler *CommandDirHandler) { - handler.DevMode = devMode - } -} - -func WithRepository(r *repositories.Repository) CommandDirHandlerOption { - return func(handler *CommandDirHandler) { - handler.Repository = r - } -} - func NewCommandDirHandlerFromConfig( config_ *config.CommandDir, options ...CommandDirHandlerOption, ) (*CommandDirHandler, error) { + genericOptions := []generic_command.GenericCommandHandlerOption{ + generic_command.WithTemplateName(config_.TemplateName), + generic_command.WithIndexTemplateName(config_.IndexTemplateName), + generic_command.WithMergeAdditionalData(config_.AdditionalData, true), + } cd := &CommandDirHandler{ - TemplateName: config_.TemplateName, - IndexTemplateName: config_.IndexTemplateName, - AdditionalData: config_.AdditionalData, - ParameterFilter: &config.ParameterFilter{}, + GenericCommandHandler: *generic_command.NewGenericCommandHandler(genericOptions...), } cd.ParameterFilter.Overrides = config_.Overrides @@ -218,209 +134,14 @@ func NewCommandDirHandlerFromConfig( return cd, nil } -func (cd *CommandDirHandler) Serve(server *parka.Server, path string) error { - if cd.Repository == nil { - return fmt.Errorf("no repository configured") - } - - path = strings.TrimSuffix(path, "/") - - middlewares_ := cd.ParameterFilter.ComputeMiddlewares(cd.Stream) - server.Router.GET(path+"/data/*path", func(c echo.Context) error { - commandPath := c.Param("path") - commandPath = strings.TrimPrefix(commandPath, "/") - command, err := getRepositoryCommand(c, cd.Repository, commandPath) - if err != nil { - return err - } - - switch v := command.(type) { - case cmds.GlazeCommand: - return json.CreateJSONQueryHandler(v, json.WithMiddlewares(middlewares_...))(c) - default: - return text.CreateQueryHandler(v)(c) - } - }) - - server.Router.GET(path+"/text/*path", func(c echo.Context) error { - commandPath := c.Param("path") - commandPath = strings.TrimPrefix(commandPath, "/") - command, err := getRepositoryCommand(c, cd.Repository, commandPath) - if err != nil { - return err - } - - return text.CreateQueryHandler(command, middlewares_...)(c) - }) - - server.Router.GET(path+"/streaming/*path", func(c echo.Context) error { - commandPath := c.Param("path") - commandPath = strings.TrimPrefix(commandPath, "/") - command, err := getRepositoryCommand(c, cd.Repository, commandPath) - if err != nil { - return err - } - - return sse.CreateQueryHandler(command, middlewares_...)(c) - }) - - server.Router.GET(path+"/datatables/*", - func(c echo.Context) error { - commandPath := c.Param("*") - commandPath = strings.TrimPrefix(commandPath, "/") - - // Get repository command - command, err := getRepositoryCommand(c, cd.Repository, commandPath) - if err != nil { - return err - } - - switch v := command.(type) { - case cmds.GlazeCommand: - options := []datatables.QueryHandlerOption{ - datatables.WithMiddlewares(middlewares_...), - datatables.WithTemplateLookup(cd.TemplateLookup), - datatables.WithTemplateName(cd.TemplateName), - datatables.WithAdditionalData(cd.AdditionalData), - datatables.WithStreamRows(cd.Stream), - } - - return datatables.CreateDataTablesHandler(v, path, commandPath, options...)(c) - default: - return c.JSON(http.StatusInternalServerError, utils.H{"error": fmt.Sprintf("command %s is not a glazed command", commandPath)}) - } - }) - - server.Router.GET(path+"/download/*path", func(c echo.Context) error { - path_ := c.Param("path") - path_ = strings.TrimPrefix(path_, "/") - index := strings.LastIndex(path_, "/") - if index == -1 { - return c.JSON(http.StatusInternalServerError, utils.H{"error": "could not find file name"}) - } - if index >= len(path_)-1 { - return c.JSON(http.StatusInternalServerError, utils.H{"error": "could not find file name"}) - } - fileName := path_[index+1:] - - commandPath := strings.TrimPrefix(path_[:index], "/") - command, err := getRepositoryCommand(c, cd.Repository, commandPath) - if err != nil { - return err - } - - switch v := command.(type) { - case cmds.GlazeCommand: - return output_file.CreateGlazedFileHandler( - v, - fileName, - middlewares_..., - )(c) - - case cmds.WriterCommand: - handler := text.NewQueryHandler(command) - - baseName := filepath.Base(fileName) - c.Response().Header().Set("Content-Disposition", "attachment; filename="+baseName) - - err := handler.Handle(c) - if err != nil { - return err - } - - return nil - - default: - return c.JSON(http.StatusInternalServerError, utils.H{"error": fmt.Sprintf("command %s is not a glazed/writer command", commandPath)}) - } - - }) - - server.Router.GET(path+"/commands/*path", func(c echo.Context) error { - path_ := c.Param("path") - path_ = strings.TrimPrefix(path_, "/") - path_ = strings.TrimSuffix(path_, "/") - splitPath := strings.Split(path_, "/") - if path_ == "" { - splitPath = []string{} - } - renderNode, ok := cd.Repository.GetRenderNode(splitPath) - if !ok { - return errors.Errorf("command %s not found", path_) - } - templateName := cd.IndexTemplateName - if cd.IndexTemplateName == "" { - templateName = "commands.tmpl.html" - } - templ, err := cd.TemplateLookup.Lookup(templateName) - if err != nil { - return err - } - - var nodes []*repositories.RenderNode - - if renderNode.Command != nil { - nodes = append(nodes, renderNode) - } else { - nodes = append(nodes, renderNode.Children...) - } - err = templ.Execute(c.Response(), utils.H{ - "nodes": nodes, - "path": path, - }) - if err != nil { - return err - } - - return nil - }) - - return nil -} - -type CommandNotFound struct { - CommandPath string -} - -func (e CommandNotFound) Error() string { - return fmt.Sprintf("command %s not found", e.CommandPath) +func (cd *CommandDirHandler) Watch(ctx context.Context) error { + return cd.Repository.Watch(ctx) } -type AmbiguousCommand struct { - CommandPath string - PotentialCommands []string -} - -func (e AmbiguousCommand) Error() string { - return fmt.Sprintf("command %s is ambiguous, could be one of: %s", e.CommandPath, strings.Join(e.PotentialCommands, ", ")) - -} - -// getRepositoryCommand lookups a command in the given repository and return success as bool and the given command, -// or sends an error code over HTTP using the gin.Context. -func getRepositoryCommand(c echo.Context, r *repositories.Repository, commandPath string) ( - cmds.Command, - error, -) { - path := strings.Split(commandPath, "/") - commands := r.CollectCommands(path, false) - if len(commands) == 0 { - return nil, CommandNotFound{CommandPath: commandPath} - } - - if len(commands) > 1 { - err := &AmbiguousCommand{ - CommandPath: commandPath, - } - for _, command := range commands { - description := command.Description() - err.PotentialCommands = append(err.PotentialCommands, strings.Join(description.Parents, " ")+" "+description.Name) - } - return nil, err +func (cd *CommandDirHandler) Serve(server *parka.Server, basePath string) error { + if cd.Repository == nil { + return fmt.Errorf("no repository configured") } - // NOTE(manuel, 2023-05-15) Check if this is actually an alias, and populate the defaults from the alias flags - // This could potentially be moved to the repository code itself - - return commands[0], nil + return cd.GenericCommandHandler.ServeRepository(server, basePath, cd.Repository) } diff --git a/pkg/handlers/command/command.go b/pkg/handlers/command/command.go index 9dfafbd..8e8b4bd 100644 --- a/pkg/handlers/command/command.go +++ b/pkg/handlers/command/command.go @@ -5,44 +5,21 @@ import ( "github.com/go-go-golems/glazed/pkg/cmds/alias" "github.com/go-go-golems/glazed/pkg/cmds/loaders" "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/handlers/config" + generic_command "github.com/go-go-golems/parka/pkg/handlers/generic-command" "github.com/go-go-golems/parka/pkg/render" parka "github.com/go-go-golems/parka/pkg/server" - "github.com/go-go-golems/parka/pkg/utils" - "github.com/labstack/echo/v4" "github.com/pkg/errors" - "net/http" "os" "strings" ) type CommandHandler struct { + generic_command.GenericCommandHandler DevMode bool - // TemplateName is the name of the template that is lookup up through the given TemplateLookup - // used to render the glazed command. - TemplateName string - // TemplateLookup is used to look up both TemplateName and IndexTemplateName - TemplateLookup render.TemplateLookup - // can be any of BareCommand, WriterCommand or GlazeCommand - Command cmds.GlazeCommand - - // AdditionalData is passed to the template being rendered. - AdditionalData map[string]interface{} - - ParameterFilter *config.ParameterFilter - - // If true, all glazed outputs will try to use a row output if possible. - // This means that "ragged" objects (where columns might not all be present) - // will have missing columns, only the fields of the first object will be used - // as rows. - // - // This is true per default, and needs to be explicitly set to false to use - // a normal TableMiddleware oriented output. - Stream bool + Command cmds.Command } type CommandHandlerOption func(*CommandHandler) @@ -53,65 +30,21 @@ func WithDevMode(devMode bool) CommandHandlerOption { } } -func WithTemplateName(templateName string) CommandHandlerOption { - return func(handler *CommandHandler) { - handler.TemplateName = templateName - } -} - -func WithDefaultTemplateName(defaultTemplateName string) CommandHandlerOption { +func WithGenericCommandHandlerOptions(options ...generic_command.GenericCommandHandlerOption) CommandHandlerOption { return func(handler *CommandHandler) { - if handler.TemplateName == "" { - handler.TemplateName = defaultTemplateName - } - } -} - -func WithTemplateLookup(templateLookup render.TemplateLookup) CommandHandlerOption { - return func(handler *CommandHandler) { - handler.TemplateLookup = templateLookup - } -} - -// WithMergeAdditionalData merges the passed in map with the handler's AdditionalData map. -// If a value is already set in the AdditionalData map and override is true, it will get overwritten. -func WithMergeAdditionalData(data map[string]interface{}, override bool) CommandHandlerOption { - return func(handler *CommandHandler) { - if handler.AdditionalData == nil { - handler.AdditionalData = data - } else { - for k, v := range data { - if _, ok := handler.AdditionalData[k]; !ok || override { - handler.AdditionalData[k] = v - } - } - } - } -} - -func WithParameterFilter(parameterFilter *config.ParameterFilter) CommandHandlerOption { - return func(handler *CommandHandler) { - handler.ParameterFilter = parameterFilter - } -} - -func WithParameterFilterOptions(opts ...config.ParameterFilterOption) CommandHandlerOption { - return func(handler *CommandHandler) { - for _, opt := range opts { - opt(handler.ParameterFilter) + for _, option := range options { + option(&handler.GenericCommandHandler) } } } func NewCommandHandler( - command cmds.GlazeCommand, + command cmds.Command, options ...CommandHandlerOption, ) *CommandHandler { c := &CommandHandler{ - Command: command, - TemplateName: "", - AdditionalData: map[string]interface{}{}, - ParameterFilter: &config.ParameterFilter{}, + GenericCommandHandler: *generic_command.NewGenericCommandHandler(), + Command: command, } for _, opt := range options { @@ -121,24 +54,14 @@ func NewCommandHandler( return c } -func NewCommandHandlerFromConfig( - config_ *config.Command, - loader loaders.CommandLoader, - options ...CommandHandlerOption, -) (*CommandHandler, error) { - c := &CommandHandler{ - TemplateName: config_.TemplateName, - AdditionalData: config_.AdditionalData, - ParameterFilter: &config.ParameterFilter{}, - } - - fs_, filePath, err := loaders.FileNameToFsFilePath(config_.File) +func LoadCommandFromFile(path string, loader loaders.CommandLoader) (cmds.Command, error) { + fs_, filePath, err := loaders.FileNameToFsFilePath(path) if err != nil { return nil, errors.Wrap(err, "failed to get absolute path") } cmds_, err := loaders.LoadCommandsFromFS( - fs_, filePath, config_.File, + fs_, filePath, path, loader, []cmds.CommandDescriptionOption{}, []alias.Option{}) if err != nil { return nil, errors.Wrap(err, "failed to load commands from file") @@ -173,7 +96,27 @@ func NewCommandHandlerFromConfig( ) } - c.Command = allCmds[0] + return allCmds[0], nil +} + +func NewCommandHandlerFromConfig( + config_ *config.Command, + loader loaders.CommandLoader, + options ...CommandHandlerOption, +) (*CommandHandler, error) { + genericOptions := []generic_command.GenericCommandHandlerOption{ + generic_command.WithTemplateName(config_.TemplateName), + generic_command.WithMergeAdditionalData(config_.AdditionalData, true), + } + // TODO(manuel, 2024-05-09) To make this reloadable on dev mode, we would actually need to thunk this and pass the thunk to the GenericCommandHandler + cmd, err := LoadCommandFromFile(config_.File, loader) + if err != nil { + return nil, err + } + + c := NewCommandHandler(cmd, WithGenericCommandHandlerOptions(genericOptions...)) + // TODO(manuel, 2024-05-09) Handle devmode + c.Command = cmd // TODO(manuel, 2023-08-06) I think we hav eto find the proper command here @@ -195,6 +138,9 @@ func NewCommandHandlerFromConfig( option(c) } + // TODO(manuel, 2024-05-09) The TemplateLookup initialization probably makes sense to be extracted as a reusable component + // to load templates dynamically in dev mode, since it's shared across quite a few handlers (see command-dir). + // we run this after the options in order to get the DevMode value if c.TemplateLookup == nil { if config_.TemplateLookup != nil { @@ -228,44 +174,5 @@ func NewCommandHandlerFromConfig( func (ch *CommandHandler) Serve(server *parka.Server, path string) error { path = strings.TrimSuffix(path, "/") - middlewares_ := ch.ParameterFilter.ComputeMiddlewares(ch.Stream) - - server.Router.GET(path+"/data", func(c echo.Context) error { - options := []json.QueryHandlerOption{ - json.WithMiddlewares(middlewares_...), - } - return json.CreateJSONQueryHandler(ch.Command, options...)(c) - }) - // TODO(manuel, 2024-01-17) This doesn't seem to match what is in command-dir - server.Router.GET(path+"/glazed", func(c echo.Context) error { - options := []datatables.QueryHandlerOption{ - datatables.WithMiddlewares(middlewares_...), - datatables.WithTemplateLookup(ch.TemplateLookup), - datatables.WithTemplateName(ch.TemplateName), - datatables.WithAdditionalData(ch.AdditionalData), - datatables.WithStreamRows(ch.Stream), - } - - return datatables.CreateDataTablesHandler(ch.Command, path, "", options...)(c) - }) - server.Router.GET(path+"/download/*path", func(c echo.Context) error { - path_ := c.Param("path") - path_ = strings.TrimPrefix(path_, "/") - index := strings.LastIndex(path_, "/") - if index == -1 { - return c.JSON(http.StatusInternalServerError, utils.H{"error": "could not find file name"}) - } - if index >= len(path_)-1 { - return c.JSON(http.StatusInternalServerError, utils.H{"error": "could not find file name"}) - } - fileName := path_[index+1:] - - return output_file.CreateGlazedFileHandler( - ch.Command, - fileName, - middlewares_..., - )(c) - }) - - return nil + return ch.GenericCommandHandler.ServeSingleCommand(server, path, ch.Command) } diff --git a/pkg/handlers/config-file.go b/pkg/handlers/config-file.go index d5d8ad1..36aa56c 100644 --- a/pkg/handlers/config-file.go +++ b/pkg/handlers/config-file.go @@ -8,6 +8,7 @@ import ( "github.com/go-go-golems/glazed/pkg/cmds/loaders" "github.com/go-go-golems/glazed/pkg/help" "github.com/go-go-golems/glazed/pkg/helpers/strings" + "github.com/go-go-golems/parka/pkg/handlers/command" "github.com/go-go-golems/parka/pkg/handlers/command-dir" "github.com/go-go-golems/parka/pkg/handlers/config" "github.com/go-go-golems/parka/pkg/handlers/static-dir" @@ -45,6 +46,7 @@ func NewRepositoryFactoryFromReaderLoaders( FS: os.DirFS(dir), RootDirectory: ".", RootDocDirectory: "doc", + WatchDirectory: dir, Name: dir, SourcePrefix: "file", }) @@ -93,6 +95,7 @@ type ConfigFileHandler struct { CommandDirectoryOptions []command_dir.CommandDirHandlerOption TemplateDirectoryOptions []template_dir.TemplateDirHandlerOption TemplateOptions []template.TemplateHandlerOption + CommandOptions []command.CommandHandlerOption // ConfigFileLocation is an optional path to the config file on disk in case it needs to be reloaded ConfigFileLocation string @@ -123,6 +126,12 @@ func WithAppendTemplateDirHandlerOptions(options ...template_dir.TemplateDirHand } } +func WithAppendCommandHandlerOptions(options ...command.CommandHandlerOption) ConfigFileHandlerOption { + return func(handler *ConfigFileHandler) { + handler.CommandOptions = append(handler.CommandOptions, options...) + } +} + func WithAppendTemplateHandlerOptions(options ...template.TemplateHandlerOption) ConfigFileHandlerOption { return func(handler *ConfigFileHandler) { handler.TemplateOptions = append(handler.TemplateOptions, options...) @@ -365,12 +374,9 @@ func (cfh *ConfigFileHandler) Serve(server_ *server.Server) error { func (cfh *ConfigFileHandler) Watch(ctx context.Context) error { errGroup, ctx2 := errgroup.WithContext(ctx) for _, cdh := range cfh.commandDirectoryHandlers { - cdh2 := cdh - if cdh.Repository == nil { - continue - } + cdh_ := cdh errGroup.Go(func() error { - return cdh2.Repository.Watch(ctx2) + return cdh_.Watch(ctx2) }) } diff --git a/pkg/handlers/generic-command/generic.go b/pkg/handlers/generic-command/generic.go new file mode 100644 index 0000000..a903d6c --- /dev/null +++ b/pkg/handlers/generic-command/generic.go @@ -0,0 +1,346 @@ +package generic_command + +import ( + "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/middlewares" + "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/handlers/sse" + "github.com/go-go-golems/parka/pkg/glazed/handlers/text" + "github.com/go-go-golems/parka/pkg/handlers/config" + "github.com/go-go-golems/parka/pkg/render" + parka "github.com/go-go-golems/parka/pkg/server" + "github.com/go-go-golems/parka/pkg/utils" + "github.com/labstack/echo/v4" + "net/http" + "path/filepath" + "strings" +) + +type GenericCommandHandler struct { + // If true, all glazed outputs will try to use a row output if possible. + // This means that "ragged" objects (where columns might not all be present) + // will have missing columns, only the fields of the first object will be used + // as rows. + // + // This is true per default, and needs to be explicitly set to false to use + // a normal TableMiddleware oriented output. + Stream bool + + // AdditionalData is passed to the template being rendered. + AdditionalData map[string]interface{} + + ParameterFilter *config.ParameterFilter + + // TemplateName is the name of the template that is lookup up through the given TemplateLookup + // used to render the glazed command. + TemplateName string + // IndexTemplateName is the name of the template that is looked up through TemplateLookup to render + // command indexes. Leave empty to not render index pages at all. + IndexTemplateName string + // TemplateLookup is used to look up both TemplateName and IndexTemplateName + TemplateLookup render.TemplateLookup + + // path under which the command handler is served + BasePath string + + middlewares []middlewares.Middleware +} + +func NewGenericCommandHandler(options ...GenericCommandHandlerOption) *GenericCommandHandler { + handler := &GenericCommandHandler{ + AdditionalData: map[string]interface{}{}, + ParameterFilter: &config.ParameterFilter{}, + } + + for _, opt := range options { + opt(handler) + } + + return handler +} + +type GenericCommandHandlerOption func(handler *GenericCommandHandler) + +func WithTemplateName(name string) GenericCommandHandlerOption { + return func(handler *GenericCommandHandler) { + handler.TemplateName = name + } +} + +func WithParameterFilter(overridesAndDefaults *config.ParameterFilter) GenericCommandHandlerOption { + return func(handler *GenericCommandHandler) { + handler.ParameterFilter = overridesAndDefaults + } +} + +func WithParameterFilterOptions(opts ...config.ParameterFilterOption) GenericCommandHandlerOption { + return func(handler *GenericCommandHandler) { + for _, opt := range opts { + opt(handler.ParameterFilter) + } + } +} + +func WithDefaultTemplateName(name string) GenericCommandHandlerOption { + return func(handler *GenericCommandHandler) { + if handler.TemplateName == "" { + handler.TemplateName = name + } + } +} + +func WithIndexTemplateName(name string) GenericCommandHandlerOption { + return func(handler *GenericCommandHandler) { + handler.IndexTemplateName = name + } +} + +func WithDefaultIndexTemplateName(name string) GenericCommandHandlerOption { + return func(handler *GenericCommandHandler) { + if handler.IndexTemplateName == "" { + handler.IndexTemplateName = name + } + } +} + +// WithMergeAdditionalData merges the passed in map with the handler's AdditionalData map. +// If a value is already set in the AdditionalData map and override is true, it will get overwritten. +func WithMergeAdditionalData(data map[string]interface{}, override bool) GenericCommandHandlerOption { + return func(handler *GenericCommandHandler) { + if handler.AdditionalData == nil { + handler.AdditionalData = data + } else { + for k, v := range data { + if _, ok := handler.AdditionalData[k]; !ok || override { + handler.AdditionalData[k] = v + } + } + } + } +} + +func WithTemplateLookup(lookup render.TemplateLookup) GenericCommandHandlerOption { + return func(handler *GenericCommandHandler) { + handler.TemplateLookup = lookup + } +} + +func (gch *GenericCommandHandler) ServeSingleCommand(server *parka.Server, basePath string, command cmds.Command) error { + gch.BasePath = basePath + + gch.middlewares = gch.ParameterFilter.ComputeMiddlewares(gch.Stream) + server.Router.GET(basePath+"/data", func(c echo.Context) error { + return gch.ServeData(c, command) + }) + server.Router.GET(basePath+"/text", func(c echo.Context) error { + return gch.ServeText(c, command) + }) + server.Router.GET(basePath+"/stream", func(c echo.Context) error { + return gch.ServeStreaming(c, command) + }) + server.Router.GET(basePath+"/download/*", func(c echo.Context) error { + return gch.ServeDownload(c, command) + }) + // don't use a specific datatables path here + server.Router.GET(basePath, func(c echo.Context) error { + return gch.ServeDataTables(c, command, basePath+"/download") + }) + + return nil +} + +func (gch *GenericCommandHandler) ServeRepository(server *parka.Server, basePath string, repository *repositories.Repository) error { + basePath = strings.TrimSuffix(basePath, "/") + gch.BasePath = basePath + + gch.middlewares = gch.ParameterFilter.ComputeMiddlewares(gch.Stream) + + server.Router.GET(basePath+"/data/*", func(c echo.Context) error { + commandPath := c.Param("*") + commandPath = strings.TrimPrefix(commandPath, "/") + command, err := getRepositoryCommand(repository, commandPath) + if err != nil { + return err + } + + return gch.ServeData(c, command) + }) + + server.Router.GET(basePath+"/text/*", func(c echo.Context) error { + commandPath := c.Param("*") + commandPath = strings.TrimPrefix(commandPath, "/") + command, err := getRepositoryCommand(repository, commandPath) + if err != nil { + return err + } + + return gch.ServeText(c, command) + }) + + server.Router.GET(basePath+"/streaming/*", func(c echo.Context) error { + commandPath := c.Param("*") + commandPath = strings.TrimPrefix(commandPath, "/") + command, err := getRepositoryCommand(repository, commandPath) + if err != nil { + return err + } + + return gch.ServeStreaming(c, command) + }) + + server.Router.GET(basePath+"/datatables/*", func(c echo.Context) error { + commandPath := c.Param("*") + commandPath = strings.TrimPrefix(commandPath, "/") + + // Get repository command + command, err := getRepositoryCommand(repository, commandPath) + if err != nil { + return err + } + + return gch.ServeDataTables(c, command, basePath+"/download/"+commandPath) + }) + + server.Router.GET(basePath+"/download/*", func(c echo.Context) error { + commandPath := c.Param("*") + commandPath = strings.TrimPrefix(commandPath, "/") + // strip file name from path + index := strings.LastIndex(commandPath, "/") + if index == -1 { + return c.JSON(http.StatusInternalServerError, utils.H{"error": "could not find file name"}) + } + if index >= len(commandPath)-1 { + return c.JSON(http.StatusInternalServerError, utils.H{"error": "could not find file name"}) + } + commandPath = commandPath[:index] + + command, err := getRepositoryCommand(repository, commandPath) + if err != nil { + return err + } + + return gch.ServeDownload(c, command) + }) + + return nil +} + +func (gch *GenericCommandHandler) ServeData(c echo.Context, command cmds.Command) error { + switch v := command.(type) { + case cmds.GlazeCommand: + return json.CreateJSONQueryHandler(v, json.WithMiddlewares(gch.middlewares...))(c) + default: + return text.CreateQueryHandler(v)(c) + } +} + +func (gch *GenericCommandHandler) ServeText(c echo.Context, command cmds.Command) error { + return text.CreateQueryHandler(command, gch.middlewares...)(c) +} + +func (gch *GenericCommandHandler) ServeStreaming(c echo.Context, command cmds.Command) error { + return sse.CreateQueryHandler(command, gch.middlewares...)(c) +} + +func (gch *GenericCommandHandler) ServeDataTables(c echo.Context, command cmds.Command, downloadPath string) error { + switch v := command.(type) { + case cmds.GlazeCommand: + options := []datatables.QueryHandlerOption{ + datatables.WithMiddlewares(gch.middlewares...), + datatables.WithTemplateLookup(gch.TemplateLookup), + datatables.WithTemplateName(gch.TemplateName), + datatables.WithAdditionalData(gch.AdditionalData), + datatables.WithStreamRows(gch.Stream), + } + + return datatables.CreateDataTablesHandler(v, gch.BasePath, downloadPath, options...)(c) + default: + return c.JSON(http.StatusInternalServerError, utils.H{"error": "command is not a glazed command"}) + } +} + +func (gch *GenericCommandHandler) ServeDownload(c echo.Context, command cmds.Command) error { + path_ := c.Request().URL.Path + index := strings.LastIndex(path_, "/") + if index == -1 { + return c.JSON(http.StatusInternalServerError, utils.H{"error": "could not find file name"}) + } + if index >= len(path_)-1 { + return c.JSON(http.StatusInternalServerError, utils.H{"error": "could not find file name"}) + } + fileName := path_[index+1:] + + switch v := command.(type) { + case cmds.GlazeCommand: + return output_file.CreateGlazedFileHandler( + v, + fileName, + gch.middlewares..., + )(c) + + case cmds.WriterCommand: + handler := text.NewQueryHandler(command) + + baseName := filepath.Base(fileName) + c.Response().Header().Set("Content-Disposition", "attachment; filename="+baseName) + + err := handler.Handle(c) + if err != nil { + return err + } + + return nil + + default: + return c.JSON(http.StatusInternalServerError, utils.H{"error": "command is not a glazed/writer command"}) + } + +} + +// getRepositoryCommand lookups a command in the given repository and return success as bool and the given command, +// or sends an error code over HTTP using the gin.Context. +func getRepositoryCommand(r *repositories.Repository, commandPath string) (cmds.Command, error) { + path := strings.Split(commandPath, "/") + commands := r.CollectCommands(path, false) + if len(commands) == 0 { + return nil, CommandNotFound{CommandPath: commandPath} + } + + if len(commands) > 1 { + err := &AmbiguousCommand{ + CommandPath: commandPath, + } + for _, command := range commands { + description := command.Description() + err.PotentialCommands = append(err.PotentialCommands, strings.Join(description.Parents, " ")+" "+description.Name) + } + return nil, err + } + + // NOTE(manuel, 2023-05-15) Check if this is actually an alias, and populate the defaults from the alias flags + // This could potentially be moved to the repository code itself + + return commands[0], nil +} + +type CommandNotFound struct { + CommandPath string +} + +func (e CommandNotFound) Error() string { + return fmt.Sprintf("command %s not found", e.CommandPath) +} + +type AmbiguousCommand struct { + CommandPath string + PotentialCommands []string +} + +func (e AmbiguousCommand) Error() string { + return fmt.Sprintf("command %s is ambiguous, could be one of: %s", e.CommandPath, strings.Join(e.PotentialCommands, ", ")) + +}