From 9809e897a0c40fc27e87fe3cdc2abffd7a514e89 Mon Sep 17 00:00:00 2001 From: Anton Novojilov Date: Wed, 30 Oct 2024 14:46:17 +0300 Subject: [PATCH] [terminal/input] Add input validation feature --- CHANGELOG.md | 6 +- terminal/input/example_test.go | 30 ++++-- terminal/input/input.go | 165 ++++++++++++++++++++++++++++++--- terminal/input/input_stubs.go | 60 +++++++++++- version.go | 2 +- 5 files changed, 236 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d46c1c52..8d899ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ ## Changelog -### [13.9.3](https://kaos.sh/ek/13.9.3) +### [13.10.0](https://kaos.sh/ek/13.10.0) + +> [!CAUTION] +> This release contains breaking changes to the `input.Read`, `input.ReadPassword`, and `input.ReadPasswordSecure` methods. Prior to this release, all of these methods took a boolean argument to disallow empty input. Since we are adding input validators, you will need to use the `NotEmpty` validator for the same behaviour. - `[fmtutil/table]` Added automatic breaks feature +- `[terminal/input]` Added input validation feature ### [13.9.2](https://kaos.sh/ek/13.9.2) diff --git a/terminal/input/example_test.go b/terminal/input/example_test.go index 3db31def..5315a94f 100644 --- a/terminal/input/example_test.go +++ b/terminal/input/example_test.go @@ -16,25 +16,35 @@ import ( func ExampleRead() { // User must enter name - input, err := Read("Please enter user name", true) + userName, err := Read("Please enter user name", NotEmpty) if err != nil { fmt.Printf("Error: %v\n", err) return } - fmt.Printf("User name: %s\n", input) + fmt.Printf("User name: %s\n", userName) // You can read user input without providing any title fmt.Println("Please enter user name") - input, err = Read("", true) + userName, err = Read("") if err != nil { fmt.Printf("Error: %v\n", err) return } - fmt.Printf("User name: %s\n", input) + fmt.Printf("User name: %s\n", userName) + + // You can define many validators at once + userEmail, err := Read("Please enter user email", NotEmpty, IsEmail) + + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("User email: %s\n", userEmail) } func ExampleReadPassword() { @@ -44,7 +54,7 @@ func ExampleReadPassword() { NewLine = true // User must enter the password - password, err := ReadPassword("Please enter password", true) + password, err := ReadPassword("Please enter password", NotEmpty) if err != nil { fmt.Printf("Error: %v\n", err) @@ -60,7 +70,7 @@ func ExampleReadPasswordSecure() { MaskSymbolColorTag = "{s}" // User must enter the password - password, err := ReadPasswordSecure("Please enter password", true) + password, err := ReadPasswordSecure("Please enter password", NotEmpty) if err != nil { fmt.Printf("Error: %v\n", err) @@ -87,7 +97,7 @@ func ExampleReadAnswer() { } func ExampleAddHistory() { - input, err := Read("Please enter user name", true) + input, err := Read("Please enter user name", NotEmpty) if err != nil { fmt.Printf("Error: %v\n", err) @@ -101,7 +111,7 @@ func ExampleAddHistory() { } func ExampleSetHistoryCapacity() { - input, err := Read("Please enter user name", true) + input, err := Read("Please enter user name", NotEmpty) if err != nil { fmt.Printf("Error: %v\n", err) @@ -142,7 +152,7 @@ func ExampleSetCompletionHandler() { return "" }) - input, err := Read("Please enter command", true) + input, err := Read("Please enter command", NotEmpty) if err != nil { fmt.Printf("Error: %v\n", err) @@ -177,7 +187,7 @@ func ExampleSetHintHandler() { return "" }) - input, err := Read("Please enter command", true) + input, err := Read("Please enter command", NotEmpty) if err != nil { fmt.Printf("Error: %v\n", err) diff --git a/terminal/input/input.go b/terminal/input/input.go index d12d1fcd..fd7844f0 100644 --- a/terminal/input/input.go +++ b/terminal/input/input.go @@ -14,6 +14,7 @@ package input import ( "fmt" "os" + "strconv" "strings" "unicode/utf8" @@ -35,6 +36,9 @@ type CompletionHandler = func(input string) []string // HintHandler is hint handler type HintHandler = func(input string) string +// Validator is input validation function +type Validator = func(input string) (string, error) + // ////////////////////////////////////////////////////////////////////////////////// // // ErrKillSignal is error type when user cancel input @@ -71,13 +75,54 @@ var NewLine = false // ////////////////////////////////////////////////////////////////////////////////// // +var ( + // NotEmpty returns an error if input is empty + NotEmpty = validatorNotEmpty + + // IsNumber returns an error if the input is not a valid number + IsNumber = validatorIsNumber + + // IsFloat returns an error if the input is not a valid floating number + IsFloat = validatorIsFloat + + // IsEmail returns an error if the input is not a valid email + IsEmail = validatorIsEmail + + // IsURL returns an error if the input is not a valid URL + IsURL = validatorIsURL +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +var ( + // ErrInvalidAnswer is error for wrong answer for Y/N question + ErrInvalidAnswer = fmt.Errorf("Please enter Y or N") + + // ErrIsEmpty is error for empty input + ErrIsEmpty = fmt.Errorf("You must enter non-empty value") + + // ErrInvalidNumber is error for invalid number + ErrInvalidNumber = fmt.Errorf("Entered value is not a valid number") + + // ErrInvalidFloat is error for invalid floating number + ErrInvalidFloat = fmt.Errorf("Entered value is not a valid floating number") + + // ErrInvalidEmail is error for invalid email + ErrInvalidEmail = fmt.Errorf("Entered value is not a valid e-mail") + + // ErrInvalidURL is error for invalid URL + ErrInvalidURL = fmt.Errorf("Entered value is not a valid URL") +) + +// ////////////////////////////////////////////////////////////////////////////////// // + var oldTMUXFlag int8 // ////////////////////////////////////////////////////////////////////////////////// // // Read reads user input -func Read(title string, nonEmpty bool) (string, error) { - return readUserInput(title, nonEmpty, false) +func Read(title string, validators ...Validator) (string, error) { + return readUserInput(title, false, validators) } // ReadAnswer reads user's answer to yes/no question @@ -104,7 +149,7 @@ func ReadAnswer(title string, defaultAnswers ...string) (bool, error) { for { answer, err := readUserInput( - getAnswerTitle(title, defaultAnswer), false, false, + getAnswerTitle(title, defaultAnswer), false, nil, ) if err != nil { @@ -121,7 +166,7 @@ func ReadAnswer(title string, defaultAnswers ...string) (bool, error) { case "N": return false, nil default: - terminal.Warn("Please enter Y or N") + terminal.Warn(ErrInvalidAnswer) fmtc.NewLine() } } @@ -129,14 +174,14 @@ func ReadAnswer(title string, defaultAnswers ...string) (bool, error) { // ReadPassword reads password or some private input that will be hidden // after pressing Enter -func ReadPassword(title string, nonEmpty bool) (string, error) { - return readUserInput(title, nonEmpty, true) +func ReadPassword(title string, validators ...Validator) (string, error) { + return readUserInput(title, true, validators) } // ReadPasswordSecure reads password or some private input that will be hidden // after pressing Enter -func ReadPasswordSecure(title string, nonEmpty bool) (*secstr.String, error) { - password, err := readUserInput(title, nonEmpty, true) +func ReadPasswordSecure(title string, validators ...Validator) (*secstr.String, error) { + password, err := readUserInput(title, true, validators) if err != nil { return nil, err @@ -167,6 +212,93 @@ func SetHintHandler(h HintHandler) { // ////////////////////////////////////////////////////////////////////////////////// // +// validatorNotEmpty is validator for empty input +func validatorNotEmpty(input string) (string, error) { + if strings.TrimSpace(input) != "" { + return input, nil + } + + return input, ErrIsEmpty +} + +// validatorIsNumber is validator for number +func validatorIsNumber(input string) (string, error) { + input = strings.TrimSpace(input) + + if input == "" { + return input, nil // Empty imput is okay + } + + _, err := strconv.ParseInt(input, 10, 64) + + if err != nil { + return input, ErrInvalidNumber + } + + return input, nil +} + +// validatorIsFloat is validator for floating number +func validatorIsFloat(input string) (string, error) { + input = strings.TrimSpace(input) + + if input == "" { + return input, nil // Empty imput is okay + } + + _, err := strconv.ParseFloat(input, 64) + + if err != nil { + return input, ErrInvalidFloat + } + + return input, nil +} + +// validatorIsEmail is validator for email +func validatorIsEmail(input string) (string, error) { + input = strings.TrimSpace(input) + + if input == "" { + return input, nil // Empty imput is okay + } + + name, domain, ok := strings.Cut(input, "@") + + if !ok || strings.TrimSpace(name) == "" || + strings.TrimSpace(domain) == "" || !strings.Contains(domain, ".") { + return input, ErrInvalidEmail + } + + return input, nil +} + +// validatorIsURL is validator for URL +func validatorIsURL(input string) (string, error) { + input = strings.TrimSpace(input) + + if input == "" { + return input, nil // Empty imput is okay + } + + switch { + case strings.HasPrefix(input, "http://"), + strings.HasPrefix(input, "https://"), + strings.HasPrefix(input, "ftp://"): + // OK + default: + return input, ErrInvalidURL + } + + if !strings.Contains(input, ".") { + return input, ErrInvalidURL + } + + return input, nil +} + +// ////////////////////////////////////////////////////////////////////////////////// // + // getMask returns mask for password func getMask(message string) string { var masking string @@ -211,7 +343,7 @@ func getAnswerTitle(title, defaultAnswer string) string { } // readUserInput reads user input -func readUserInput(title string, nonEmpty, private bool) (string, error) { +func readUserInput(title string, private bool, validators []Validator) (string, error) { if title != "" { fmtc.Println(TitleColorTag + title + "{!}") } @@ -223,6 +355,7 @@ func readUserInput(title string, nonEmpty, private bool) (string, error) { linenoise.SetMaskMode(true) } +INPUT_LOOP: for { input, err = linenoise.Line(fmtc.Sprintf(Prompt)) @@ -234,9 +367,17 @@ func readUserInput(title string, nonEmpty, private bool) (string, error) { return "", err } - if nonEmpty && strings.TrimSpace(input) == "" { - terminal.Warn("\nYou must enter non-empty value\n") - continue + if len(validators) != 0 { + for _, validator := range validators { + input, err = validator(input) + + if err != nil { + fmtc.NewLine() + terminal.Warn(err.Error()) + fmtc.NewLine() + continue INPUT_LOOP + } + } } if private && input != "" { diff --git a/terminal/input/input_stubs.go b/terminal/input/input_stubs.go index f5d79924..0e2f966e 100644 --- a/terminal/input/input_stubs.go +++ b/terminal/input/input_stubs.go @@ -19,9 +19,22 @@ import ( // ////////////////////////////////////////////////////////////////////////////////// // +// CompletionHandler is completion handler +type CompletionHandler = func(input string) []string + +// HintHandler is hint handler +type HintHandler = func(input string) string + +// Validator is input validation function +type Validator = func(input string) (string, error) + +// ////////////////////////////////////////////////////////////////////////////////// // + // ❗ ErrKillSignal is error type when user cancel input var ErrKillSignal = errors.New("") +// ////////////////////////////////////////////////////////////////////////////////// // + // ❗ Prompt is prompt string var Prompt = "> " @@ -51,8 +64,49 @@ var NewLine = false // ////////////////////////////////////////////////////////////////////////////////// // +var ( + // NotEmpty returns an error if input is empty + NotEmpty = func(input string) (string, error) { return "", nil } + + // IsNumber returns an error if the input is not a valid number + IsNumber = func(input string) (string, error) { return "", nil } + + // IsFloat returns an error if the input is not a valid floating number + IsFloat = func(input string) (string, error) { return "", nil } + + // IsEmail returns an error if the input is not a valid email + IsEmail = func(input string) (string, error) { return "", nil } + + // IsURL returns an error if the input is not a valid URL + IsURL = func(input string) (string, error) { return "", nil } +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +var ( + // ErrInvalidAnswer is error for wrong answer for Y/N question + ErrInvalidAnswer = errors.New("") + + // ErrIsEmpty is error for empty input + ErrIsEmpty = errors.New("") + + // ErrInvalidNumber is error for invalid number + ErrInvalidNumber = errors.New("") + + // ErrInvalidFloat is error for invalid floating number + ErrInvalidFloat = errors.New("") + + // ErrInvalidEmail is error for invalid email + ErrInvalidEmail = errors.New("") + + // ErrInvalidURL is error for invalid URL + ErrInvalidURL = errors.New("") +) + +// ////////////////////////////////////////////////////////////////////////////////// // + // ❗ Read reads user input -func Read(title string, nonEmpty bool) (string, error) { +func Read(title string, validators ...Validator) (string, error) { panic("UNSUPPORTED") } @@ -63,13 +117,13 @@ func ReadAnswer(title string, defaultAnswers ...string) (bool, error) { // ❗ ReadPassword reads password or some private input that will be hidden // after pressing Enter -func ReadPassword(title string, nonEmpty bool) (string, error) { +func ReadPassword(title string, validators ...Validator) (string, error) { panic("UNSUPPORTED") } // ❗ ReadPasswordSecure reads password or some private input that will be hidden // after pressing Enter -func ReadPasswordSecure(title string, nonEmpty bool) (*secstr.String, error) { +func ReadPasswordSecure(title string, validators ...Validator) (*secstr.String, error) { panic("UNSUPPORTED") } diff --git a/version.go b/version.go index 47564c0a..31058bbe 100644 --- a/version.go +++ b/version.go @@ -8,4 +8,4 @@ package ek // ////////////////////////////////////////////////////////////////////////////////// // // VERSION is current ek package version -const VERSION = "13.9.3" +const VERSION = "13.10.0"