Skip to content
Snippets Groups Projects
Select Git revision
  • 9d0f67f09845f34e1646b2f0fd1f838c3a7f38b6
  • main default protected
2 results

web.go

Blame
  • Code owners
    Assign users and groups as approvers for specific file changes. Learn more.
    web.go 14.03 KiB
    package eiffel
    
    import (
    	"encoding/json"
    	"errors"
    	"fmt"
    	"github.com/google/uuid"
    	"github.com/org-harmony/harmony/src/app/template"
    	"github.com/org-harmony/harmony/src/app/template/parser"
    	"github.com/org-harmony/harmony/src/app/user"
    	"github.com/org-harmony/harmony/src/core/config"
    	"github.com/org-harmony/harmony/src/core/event"
    	"github.com/org-harmony/harmony/src/core/hctx"
    	"github.com/org-harmony/harmony/src/core/persistence"
    	"github.com/org-harmony/harmony/src/core/util"
    	"github.com/org-harmony/harmony/src/core/validation"
    	"github.com/org-harmony/harmony/src/core/web"
    	"net/http"
    	"path/filepath"
    	"strings"
    )
    
    const (
    	// TemplateDisplayString will display the rule value as text.
    	TemplateDisplayString TemplateDisplayType = "text"
    	// TemplateDisplayInputTypeText will display the rule value as a text input.
    	TemplateDisplayInputTypeText TemplateDisplayType = "input-text"
    	// TemplateDisplayInputTypeTextarea will display the rule value as a textarea.
    	TemplateDisplayInputTypeTextarea TemplateDisplayType = "input-textarea"
    	// TemplateDisplayInputTypeSingleSelect will display the rule value as an input field with datalist and single select.
    	TemplateDisplayInputTypeSingleSelect TemplateDisplayType = "input-single-select"
    )
    
    var (
    	// ErrTemplateNotFound will be displayed to the user if the template could not be found.
    	ErrTemplateNotFound = errors.New("eiffel.elicitation.template.not-found")
    	// ErrTemplateVariantNotFound will be displayed to the user if the template variant could not be found.
    	ErrTemplateVariantNotFound = errors.New("eiffel.elicitation.template.variant.not-found")
    	// ErrInvalidFilepath will be displayed to the user if the filepath is invalid.
    	ErrInvalidFilepath = errors.New("eiffel.elicitation.output.file.path-invalid")
    	// ErrCouldNotCreateFile will be displayed to the user if the file could not be created.
    	ErrCouldNotCreateFile = errors.New("eiffel.elicitation.output.file.create-failed")
    )
    
    // TemplateDisplayType specifies how a rule should be displayed in the UI.
    type TemplateDisplayType string
    
    // TemplateFormData is the data that is passed to the template rendering the elicitation form.
    type TemplateFormData struct {
    	Template *BasicTemplate
    	// Variant is the currently selected variant. This might be through a specified variant name parameter
    	// or as a default value because no variant was explicitly specified. However, Variant is expected to be filled.
    	Variant *BasicVariant
    	// VariantKey is the key through which the variant was selected. It is not the name of the variant.
    	// If the variant was auto-selected as default the key will still be filled.
    	VariantKey string
    	// DisplayTypes is a map of rule names to display types. The rule names are the keys of the BasicTemplate.Rules map.
    	// The display types are used to determine how the rule should be displayed in the UI.
    	DisplayTypes map[string]TemplateDisplayType
    	// TemplateID is the ID of the template that is currently being rendered.
    	TemplateID uuid.UUID
    	// CopyAfterParse is a flag indicating if the user wants to copy the parsed requirement to the clipboard.
    	CopyAfterParse bool
    	// ParsingResult is the result of the parsing process. This can be empty if no parsing was done yet.
    	ParsingResult *parser.ParsingResult
    	// SegmentMap is a map of rule names to their corresponding segment value.
    	// This is used to fill the segments with their values after parsing.
    	SegmentMap map[string]string
    	// OutputFormData is the form data for the output directory and file.
    	OutputFormData web.BaseTemplateData
    }
    
    // SearchTemplateData contains templates to render as search results and a flag indicating if the query was too short.
    type SearchTemplateData struct {
    	Templates     []*template.Template
    	QueryTooShort bool
    }
    
    // OutputFormData is the form data for the output directory and file search form.
    // It is used to fill the form with the previously selected values.
    type OutputFormData struct {
    	OutputDir  string
    	OutputFile string
    }
    
    func RegisterController(appCtx *hctx.AppCtx, webCtx *web.Ctx) {
    	cfg := Cfg{}
    	util.Ok(config.C(&cfg, config.From("eiffel"), config.Validate(appCtx.Validator)))
    
    	// TODO move this to module init when module manager is implemented (see subscribeEvents)
    	subscribeEvents(appCtx)
    
    	registerNavigation(appCtx, webCtx)
    
    	router := webCtx.Router.With(user.LoggedInMiddleware(appCtx))
    
    	router.Get("/eiffel", eiffelElicitationPage(appCtx, webCtx).ServeHTTP)
    	router.Get("/eiffel/{templateID}", eiffelElicitationPage(appCtx, webCtx).ServeHTTP)
    	router.Get("/eiffel/{templateID}/{variant}", eiffelElicitationPage(appCtx, webCtx).ServeHTTP)
    	router.Get("/eiffel/elicitation/templates/search/modal", searchModal(appCtx, webCtx).ServeHTTP)
    	router.Post("/eiffel/elicitation/templates/search", searchTemplate(appCtx, webCtx).ServeHTTP)
    	router.Get("/eiffel/elicitation/{templateID}", elicitationTemplate(appCtx, webCtx, true).ServeHTTP)
    	router.Get("/eiffel/elicitation/{templateID}/{variant}", elicitationTemplate(appCtx, webCtx, false).ServeHTTP)
    	router.Post("/eiffel/elicitation/{templateID}/{variant}", parseRequirement(appCtx, webCtx, cfg).ServeHTTP)
    	router.Post("/eiffel/elicitation/output/search", outputSearchForm(appCtx, webCtx, cfg).ServeHTTP)
    	router.Post("/eiffel/elicitation/output/dir/search", outputSearchDir(appCtx, webCtx, cfg).ServeHTTP)
    	router.Post("/eiffel/elicitation/output/file/search", outputFileSearch(appCtx, webCtx, cfg).ServeHTTP)
    }
    
    func subscribeEvents(appCtx *hctx.AppCtx) {
    	// TODO remove this with module manager
    	appCtx.EventManager.Subscribe("template.config.validate", func(event event.Event, args *event.PublishArgs) error {
    		validateEvent, ok := event.Payload().(*template.ValidateTemplateConfigEvent)
    		if !ok {
    			return nil
    		}
    		if strings.ToLower(validateEvent.TemplateType) != BasicTemplateType {
    			return nil
    		}
    		if validateEvent.DidValidate {
    			return nil
    		}
    		validateEvent.DidValidate = true
    
    		ebt := &BasicTemplate{}
    		// Important notice: Unmarshalling is always case-insensitive if no other match could be found.
    		// Therefore, NAME will be unmarshalled to Name. Keep this in mind.
    		err := json.Unmarshal([]byte(validateEvent.Config), ebt)
    		if err != nil {
    			return err
    		}
    
    		validationErrs := ebt.Validate(appCtx.Validator, RuleParsers())
    		if len(validationErrs) > 0 {
    			validateEvent.AddErrors(validationErrs...)
    			validateEvent.AddErrors(validation.Error{Msg: "eiffel.parser.error.invalid-template"})
    			return nil
    		}
    
    		return nil
    	}, event.DefaultEventPriority)
    }
    
    func registerNavigation(appCtx *hctx.AppCtx, webCtx *web.Ctx) {
    	webCtx.Navigation.Add("eiffel.elicitation", web.NavItem{
    		URL:  "/eiffel",
    		Name: "harmony.menu.eiffel",
    		Display: func(io web.IO) (bool, error) {
    			return true, nil
    		},
    		Position: 100,
    	})
    }
    
    func eiffelElicitationPage(appCtx *hctx.AppCtx, webCtx *web.Ctx) http.Handler {
    	templateRepository := util.UnwrapType[template.Repository](appCtx.Repository(template.RepositoryName))
    
    	return web.NewController(appCtx, webCtx, func(io web.IO) error {
    		templateID := web.URLParam(io.Request(), "templateID")
    		variantKey := web.URLParam(io.Request(), "variant")
    		if templateID == "" {
    			return renderElicitationPage(io, TemplateFormData{}, nil, nil)
    		}
    
    		formData, err := TemplateFormFromRequest(
    			io.Context(),
    			templateID,
    			variantKey,
    			templateRepository,
    			RuleParsers(),
    			appCtx.Validator,
    			true,
    		)
    
    		return renderElicitationPage(io, formData, nil, []error{err})
    	})
    }
    
    func renderElicitationPage(io web.IO, data TemplateFormData, success []string, errs []error) error {
    	return io.Render(
    		web.NewFormData(data, success, errs...),
    		"eiffel.elicitation.page",
    		"eiffel/elicitation-page.go.html",
    		"eiffel/_elicitation-template.go.html",
    		"eiffel/_form-elicitation.go.html",
    		"eiffel/_form-output-file.go.html",
    	)
    }
    
    func searchModal(appCtx *hctx.AppCtx, webCtx *web.Ctx) http.Handler {
    	return web.NewController(appCtx, webCtx, func(io web.IO) error {
    		return io.Render(nil, "eiffel.template.search.modal", "eiffel/_modal-template-search.go.html")
    	})
    }
    
    func searchTemplate(appCtx *hctx.AppCtx, webCtx *web.Ctx) http.Handler {
    	templateRepository := util.UnwrapType[template.Repository](appCtx.Repository(template.RepositoryName))
    
    	return web.NewController(appCtx, webCtx, func(io web.IO) error {
    		request := io.Request()
    		err := request.ParseForm()
    		if err != nil {
    			return io.InlineError(web.ErrInternal, err)
    		}
    
    		query := request.FormValue("search")
    		if len(query) < 3 {
    			return io.Render(
    				&SearchTemplateData{QueryTooShort: true},
    				"eiffel.template.search.result",
    				"eiffel/_template-search-result.go.html",
    			)
    		}
    
    		templates, err := templateRepository.FindByQueryForType(io.Context(), query, BasicTemplateType)
    		if err != nil && !errors.Is(err, persistence.ErrNotFound) {
    			return io.InlineError(web.ErrInternal, err)
    		}
    
    		return io.Render(
    			&SearchTemplateData{Templates: templates},
    			"eiffel.template.search.result",
    			"eiffel/_template-search-result.go.html",
    		)
    	})
    }
    
    func elicitationTemplate(appCtx *hctx.AppCtx, webCtx *web.Ctx, defaultFirstVariant bool) http.Handler {
    	templateRepository := util.UnwrapType[template.Repository](appCtx.Repository(template.RepositoryName))
    
    	return web.NewController(appCtx, webCtx, func(io web.IO) error {
    		templateID := web.URLParam(io.Request(), "templateID")
    		variant := web.URLParam(io.Request(), "variant")
    
    		io.Response().Header().Set("HX-Push-URL", fmt.Sprintf("/eiffel/%s", templateID))
    
    		formData, err := TemplateFormFromRequest(
    			io.Context(),
    			templateID,
    			variant,
    			templateRepository,
    			RuleParsers(),
    			appCtx.Validator,
    			defaultFirstVariant,
    		)
    		if err != nil {
    			return io.InlineError(err)
    		}
    
    		io.Response().Header().Set("HX-Push-URL", fmt.Sprintf("/eiffel/%s/%s", templateID, formData.VariantKey))
    
    		return io.Render(
    			web.NewFormData(formData, nil),
    			"eiffel.elicitation.template",
    			"eiffel/_elicitation-template.go.html",
    			"eiffel/_form-elicitation.go.html",
    		)
    	})
    }
    
    func parseRequirement(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler {
    	templateRepository := util.UnwrapType[template.Repository](appCtx.Repository(template.RepositoryName))
    
    	return web.NewController(appCtx, webCtx, func(io web.IO) error {
    		request := io.Request()
    		ctx := request.Context()
    		parsers := RuleParsers()
    
    		templateID := web.URLParam(request, "templateID")
    		variant := web.URLParam(request, "variant")
    		outputDir := BuildDirPath(cfg.Output.BaseDir, request.FormValue("elicitationOutputDir"))
    		outputFileRaw := request.FormValue("elicitationOutputFile")
    
    		formData, err := TemplateFormFromRequest(
    			ctx,
    			templateID,
    			variant,
    			templateRepository,
    			parsers,
    			appCtx.Validator,
    			false,
    		)
    		if err != nil {
    			return io.InlineError(err)
    		}
    
    		if outputFileRaw == "" {
    			outputFileRaw = formData.Template.Name
    		}
    		outputFile := BuildFilename(outputFileRaw)
    
    		segmentMap, err := SegmentMapFromRequest(request, len(formData.Variant.Rules))
    		if err != nil {
    			return io.InlineError(web.ErrInternal)
    		}
    		formData.SegmentMap = segmentMap
    
    		parsingResult, err := formData.Template.Parse(ctx, parsers, formData.VariantKey, SegmentMapToSegments(segmentMap)...)
    		formData.ParsingResult = &parsingResult
    
    		var s []string
    		if parsingResult.Flawless() {
    			s = []string{"eiffel.elicitation.parse.flawless-success"}
    		} else if parsingResult.Ok() {
    			s = []string{"eiffel.elicitation.parse.success"}
    		}
    
    		if parsingResult.Ok() {
    			usr := user.MustCtxUser(ctx)
    			csv, created, err := CreateIfNotExists(outputDir, outputFile, 0750)
    			if err != nil {
    				return io.InlineError(ErrCouldNotCreateFile, err)
    			}
    
    			writer := CSVWriter{file: csv}
    			if created {
    				err := writer.WriteHeaderRow()
    				if err != nil {
    					return io.InlineError(web.ErrInternal, err)
    				}
    			}
    
    			err = writer.WriteRow(parsingResult, usr)
    			if err != nil {
    				return io.InlineError(web.ErrInternal, err)
    			}
    		}
    
    		return io.Render(web.NewFormData(formData, s, err), "eiffel.elicitation.form", "eiffel/_form-elicitation.go.html")
    	})
    }
    
    func outputSearchForm(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler {
    	return web.NewController(appCtx, webCtx, func(io web.IO) error {
    		request := io.Request()
    		err := request.ParseForm()
    		if err != nil {
    			return io.InlineError(web.ErrInternal, err)
    		}
    
    		rawDir := request.FormValue("output-dir")
    		dir := BuildDirPath(cfg.Output.BaseDir, rawDir)
    		rawFile := request.FormValue("output-file")
    		file := BuildFilename(rawFile)
    
    		path := filepath.Join(dir, file)
    		if path == "" {
    			return io.InlineError(ErrInvalidFilepath)
    		}
    
    		csv, created, err := CreateIfNotExists(dir, file, 0750)
    		if err != nil {
    			return io.InlineError(ErrCouldNotCreateFile, err)
    		}
    
    		if created {
    			writer := CSVWriter{file: csv}
    			err = writer.WriteHeaderRow()
    			if err != nil {
    				return io.InlineError(web.ErrInternal, err)
    			}
    		}
    
    		return io.Render(web.NewFormData(OutputFormData{
    			OutputDir:  rawDir,
    			OutputFile: rawFile,
    		}, []string{"eiffel.elicitation.output.file.success"}), "eiffel.elicitation.output-file.form", "eiffel/_form-output-file.go.html")
    	})
    }
    
    func outputSearchDir(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler {
    	return web.NewController(appCtx, webCtx, func(io web.IO) error {
    		request := io.Request()
    		err := request.ParseForm()
    		if err != nil {
    			return io.InlineError(web.ErrInternal, err)
    		}
    
    		query := request.FormValue("output-dir")
    		dirs, err := DirSearch(cfg.Output.BaseDir, query)
    		if err != nil {
    			return io.InlineError(web.ErrInternal, err)
    		}
    
    		return io.Render(dirs, "eiffel.elicitation.output.dir.search-result", "eiffel/_output-dir-search-result.go.html")
    	})
    }
    
    func outputFileSearch(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler {
    	return web.NewController(appCtx, webCtx, func(io web.IO) error {
    		request := io.Request()
    		err := request.ParseForm()
    		if err != nil {
    			return io.InlineError(web.ErrInternal, err)
    		}
    
    		query := request.FormValue("output-file")
    		dir := request.FormValue("output-dir")
    		files, err := FileSearch(BuildDirPath(cfg.Output.BaseDir, dir), query)
    		if err != nil {
    			return io.InlineError(web.ErrInternal, err)
    		}
    
    		return io.Render(files, "eiffel.elicitation.output.file.search-result", "eiffel/_output-file-search-result.go.html")
    	})
    }