Finish up better parsing

This commit is contained in:
Philipp Heckel 2022-04-27 09:51:23 -04:00
parent 574e72a974
commit 1f6118f068
7 changed files with 443 additions and 395 deletions

309
server/actions.go Normal file
View file

@ -0,0 +1,309 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/util"
"regexp"
"strings"
"unicode/utf8"
)
const (
actionIDLength = 10
actionEOF = rune(0)
actionsMax = 3
)
const (
actionView = "view"
actionBroadcast = "broadcast"
actionHTTP = "http"
)
var (
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
actionsWithURL = []string{actionView, actionHTTP}
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
)
type actionParser struct {
input string
pos int
}
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
func parseActions(s string) (actions []*action, err error) {
// Parse JSON or simple format
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "[") {
actions, err = parseActionsFromJSON(s)
} else {
actions, err = parseActionsFromSimple(s)
}
if err != nil {
return nil, err
}
// Add ID field, ensure correct uppercase/lowercase
for i := range actions {
actions[i].ID = util.RandomString(actionIDLength)
actions[i].Action = strings.ToLower(actions[i].Action)
actions[i].Method = strings.ToUpper(actions[i].Method)
}
// Validate
if len(actions) > actionsMax {
return nil, fmt.Errorf("only %d actions allowed", actionsMax)
}
for _, action := range actions {
if !util.InStringList(actionsAll, action.Action) {
return nil, fmt.Errorf("action '%s' unknown", action.Action)
} else if action.Label == "" {
return nil, fmt.Errorf("parameter 'label' is required")
} else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" {
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
} else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
}
}
return actions, nil
}
// parseActionsFromJSON converts a JSON array into an array of actions
func parseActionsFromJSON(s string) ([]*action, error) {
actions := make([]*action, 0)
if err := json.Unmarshal([]byte(s), &actions); err != nil {
return nil, err
}
return actions, nil
}
// parseActionsFromSimple parses the "simple" actions string (as described in
// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
//
// It can parse an actions string like this:
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
//
// It works by advancing the position ("pos") through the input string ("input").
//
// The parser is heavily inspired by https://go.dev/src/text/template/parse/lex.go (which
// is described by Rob Pike in this video: https://www.youtube.com/watch?v=HxaD_trXwRE),
// though it does not use state functions at all.
//
// Other resources:
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
func parseActionsFromSimple(s string) ([]*action, error) {
if !utf8.ValidString(s) {
return nil, errors.New("invalid string")
}
parser := &actionParser{
pos: 0,
input: s,
}
return parser.Parse()
}
// Parse loops trough parseAction() until the end of the string is reached
func (p *actionParser) Parse() ([]*action, error) {
actions := make([]*action, 0)
for !p.eof() {
a, err := p.parseAction()
if err != nil {
return nil, err
} else if a == nil {
return actions, err
}
actions = append(actions, a)
}
return actions, nil
}
// parseAction parses the individual sections of an action using parseSection into key/value pairs,
// and then uses populateAction to interpret the keys/values. The function terminates
// when EOF or ";" is reached.
func (p *actionParser) parseAction() (*action, error) {
a := newAction()
section := 0
for {
key, value, last, err := p.parseSection()
fmt.Printf("--> key=%s, value=%s, last=%t, err=%#v\n", key, value, last, err)
if err != nil {
return nil, err
}
if err := populateAction(a, section, key, value); err != nil {
return nil, err
}
p.slurpSpaces()
if last {
return a, nil
}
section++
}
}
// populateAction is the "business logic" of the parser. It applies the key/value
// pair to the action instance.
func populateAction(newAction *action, section int, key, value string) error {
// Auto-expand keys based on their index
if key == "" && section == 0 {
key = "action"
} else if key == "" && section == 1 {
key = "label"
} else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) {
key = "url"
}
// Validate
if key == "" {
return fmt.Errorf("term '%s' unknown", value)
}
// Populate
if strings.HasPrefix(key, "headers.") {
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
} else if strings.HasPrefix(key, "extras.") {
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
} else {
switch strings.ToLower(key) {
case "action":
newAction.Action = value
case "label":
newAction.Label = value
case "clear":
lvalue := strings.ToLower(value)
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
return fmt.Errorf("'clear=%s' not allowed", value)
}
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
case "url":
newAction.URL = value
case "method":
newAction.Method = value
case "body":
newAction.Body = value
default:
return fmt.Errorf("key '%s' unknown", key)
}
}
return nil
}
// parseSection parses a section ("key=value") and returns a key/value pair. It terminates
// when EOF or "," is reached.
func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
p.slurpSpaces()
key = p.parseKey()
r, w := p.peek()
if isSectionEnd(r) {
p.pos += w
last = isLastSection(r)
return
} else if r == '"' || r == '\'' {
value, last, err = p.parseQuotedValue(r)
return
}
value, last = p.parseValue()
return
}
// parseKey uses a regex to determine whether the current position is a key definition ("key =")
// and returns the key if it is, or an empty string otherwise.
func (p *actionParser) parseKey() string {
matches := actionsKeyRegex.FindStringSubmatch(p.input[p.pos:])
if len(matches) == 2 {
p.pos += len(matches[0])
return matches[1]
}
return ""
}
// parseValue reads the input until EOF, "," or ";" and returns the value string. Unlike parseQuotedValue,
// this function does not support "," or ";" in the value itself.
func (p *actionParser) parseValue() (value string, last bool) {
start := p.pos
for {
r, w := p.peek()
if isSectionEnd(r) {
last = isLastSection(r)
value = p.input[start:p.pos]
p.pos += w
return
}
p.pos += w
}
}
// parseQuotedValue reads the input until it finds an unescaped end quote character ("), and then
// advances the position beyond the section end. It supports quoting strings using backslash (\).
func (p *actionParser) parseQuotedValue(quote rune) (value string, last bool, err error) {
p.pos++
start := p.pos
var prev rune
for {
r, w := p.peek()
if r == actionEOF {
err = fmt.Errorf("unexpected end of input, quote started at position %d", start)
return
} else if r == quote && prev != '\\' {
value = p.input[start:p.pos]
p.pos += w
// Advance until section end (after "," or ";")
p.slurpSpaces()
r, w := p.peek()
last = isLastSection(r)
if !isSectionEnd(r) {
err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
return
}
p.pos += w
return
}
prev = r
p.pos += w
}
}
// slurpSpaces reads all space characters and advances the position
func (p *actionParser) slurpSpaces() {
for {
r, w := p.peek()
if r == actionEOF || !isSpace(r) {
return
}
p.pos += w
}
}
// peek returns the next run and its width
func (p *actionParser) peek() (rune, int) {
if p.eof() {
return actionEOF, 0
}
return utf8.DecodeRuneInString(p.input[p.pos:])
}
// eof returns true if the end of the input has been reached
func (p *actionParser) eof() bool {
return p.pos >= len(p.input)
}
func isSpace(r rune) bool {
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
}
func isSectionEnd(r rune) bool {
return r == actionEOF || r == ';' || r == ','
}
func isLastSection(r rune) bool {
return r == actionEOF || r == ';'
}

View file

@ -1,207 +0,0 @@
package server
import (
"errors"
"fmt"
"heckel.io/ntfy/util"
"regexp"
"strings"
"unicode/utf8"
)
// Heavily inspired by https://go.dev/src/text/template/parse/lex.go
// And thanks to Rob Pike (for Go, but also) for https://www.youtube.com/watch?v=HxaD_trXwRE
// action=view, label="Look ma, commas and \"quotes\" too", url=https://..
// "Look ma, a button",
// Look ma a button
// label=Look ma a=button
// label="Look ma, a button"
// "Look ma, \"quotes\""
// label="Look ma, \"quotes\""
// label=,
func parseActionsFromSimpleNew(s string) ([]*action, error) {
if !utf8.ValidString(s) {
return nil, errors.New("invalid string")
}
parser := &actionParser{
pos: 0,
input: s,
}
return parser.Parse()
}
type actionParser struct {
input string
pos int
}
const eof = rune(0)
func (p *actionParser) Parse() ([]*action, error) {
println("------------------------")
actions := make([]*action, 0)
for !p.eof() {
a, err := p.parseAction()
if err != nil {
return nil, err
} else if a == nil {
return actions, err
}
actions = append(actions, a)
}
return actions, nil
}
func (p *actionParser) parseAction() (*action, error) {
println("parseAction")
newAction := &action{
Headers: make(map[string]string),
Extras: make(map[string]string),
}
section := 0
for {
key, value, last, err := p.parseSection()
fmt.Printf("--> key=%s, value=%s, last=%t, err=%#v\n", key, value, last, err)
if err != nil {
return nil, err
} else if key == "" && section == 0 {
key = "action"
} else if key == "" && section == 1 {
key = "label"
} else if key == "" && section == 2 && util.InStringList([]string{"view", "http"}, newAction.Action) {
key = "url"
} else if key == "" {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "term '%s' unknown", value)
}
if strings.HasPrefix(key, "headers.") {
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
} else if strings.HasPrefix(key, "extras.") {
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
} else {
switch strings.ToLower(key) {
case "action":
newAction.Action = value
case "label":
newAction.Label = value
case "clear":
lvalue := strings.ToLower(value)
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "'clear=%s' not allowed", value)
}
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
case "url":
newAction.URL = value
case "method":
newAction.Method = value
case "body":
newAction.Body = value
default:
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "key '%s' unknown", key)
}
}
p.slurpSpaces()
if last {
return newAction, nil
}
section++
}
}
func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
fmt.Printf("parseSection, pos=%d, len(input)=%d, input[pos:]=%s\n", p.pos, len(p.input), p.input[p.pos:])
p.slurpSpaces()
key = p.parseKey()
r, w := p.peek()
if r == eof || r == ';' || r == ',' {
p.pos += w
last = r == ';' || r == eof
return
} else if r == '"' {
value, last, err = p.parseQuotedValue()
return
}
value, last = p.parseValue()
return
}
func (p *actionParser) parseValue() (value string, last bool) {
start := p.pos
for {
r, w := p.peek()
if r == eof || r == ';' || r == ',' {
last = r == ';' || r == eof
value = p.input[start:p.pos]
p.pos += w
return
}
p.pos += w
}
}
func (p *actionParser) parseQuotedValue() (value string, last bool, err error) {
p.pos++
start := p.pos
var prev rune
for {
r, w := p.peek()
if r == eof {
err = errors.New("unexpected end of input")
return
} else if r == '"' && prev != '\\' {
value = p.input[start:p.pos]
p.pos += w
// Advance until after "," or ";"
p.slurpSpaces()
r, w := p.peek()
last = r == ';' || r == eof
if r != eof && r != ';' && r != ',' {
err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
return
}
p.pos += w
return
}
prev = r
p.pos += w
}
}
var keyRegex = regexp.MustCompile(`^[-.\w]+=`)
func (p *actionParser) parseKey() string {
key := keyRegex.FindString(p.input[p.pos:])
if key != "" {
p.pos += len(key)
return key[:len(key)-1]
}
return key
}
func (p *actionParser) peek() (rune, int) {
if p.pos >= len(p.input) {
return eof, 0
}
return utf8.DecodeRuneInString(p.input[p.pos:])
}
func (p *actionParser) eof() bool {
return p.pos >= len(p.input)
}
func (p *actionParser) slurpSpaces() {
for {
r, w := p.peek()
if r == eof || !isSpace(r) {
return
}
p.pos += w
}
}
func isSpace(r rune) bool {
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
}

126
server/actions_test.go Normal file
View file

@ -0,0 +1,126 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestParseActions(t *testing.T) {
actions, err := parseActions("[]")
require.Nil(t, err)
require.Empty(t, actions)
// Basic test
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Open door", actions[0].Label)
require.Equal(t, "https://door.lan/open", actions[0].URL)
require.Equal(t, "view", actions[1].Action)
require.Equal(t, "Show portal", actions[1].Label)
require.Equal(t, "https://door.lan", actions[1].URL)
// JSON
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Open door", actions[0].Label)
require.Equal(t, "https://door.lan/open", actions[0].URL)
require.Equal(t, "view", actions[1].Action)
require.Equal(t, "Show portal", actions[1].Label)
require.Equal(t, "https://door.lan", actions[1].URL)
// Other params
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Open door", actions[0].Label)
require.Equal(t, "https://door.lan/open", actions[0].URL)
require.Equal(t, "PUT", actions[0].Method)
require.Equal(t, "this is a body", actions[0].Body)
// Extras with underscores
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "broadcast", actions[0].Action)
require.Equal(t, "Do a thing", actions[0].Label)
require.Equal(t, 2, len(actions[0].Extras))
require.Equal(t, "some command", actions[0].Extras["command"])
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
// Headers with dashes
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Send request", actions[0].Label)
require.Equal(t, 2, len(actions[0].Headers))
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
// Quotes
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `Look ma, \"quotes\"; and semicolons`, actions[0].Label)
require.Equal(t, `http://example.com`, actions[0].URL)
// Single quotes
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `"quotes" and \'single quotes\'`, actions[0].Label)
require.Equal(t, `http://example.com`, actions[0].URL)
// Out of order
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `Out of order!`, actions[0].Label)
require.Equal(t, `http://example.com`, actions[0].URL)
// Spaces
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `this is a label`, actions[0].Label)
require.Equal(t, `http://google.com`, actions[0].URL)
// Non-ASCII
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `Кохайтеся а не воюйте, 💙🫤`, actions[0].Label)
require.Equal(t, `http://google.com`, actions[0].URL)
// Invalid syntax
actions, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
require.EqualError(t, err, "unexpected character 'x' at position 22")
actions, err = parseActions(`label="", action="http", url=http://example.com`)
require.EqualError(t, err, "parameter 'label' is required")
actions, err = parseActions(`label=, action="http", url=http://example.com`)
require.EqualError(t, err, "parameter 'label' is required")
actions, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
require.EqualError(t, err, "term 'what is this anyway' unknown")
actions, err = parseActions(`fdsfdsf`)
require.EqualError(t, err, "action 'fdsfdsf' unknown")
actions, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
require.EqualError(t, err, "key 'aaa' unknown")
actions, err = parseActions(`action=http, label="omg the end quote is missing`)
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
}

View file

@ -539,7 +539,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
if actionsStr != "" {
m.Actions, err = parseActions(actionsStr)
if err != nil {
return false, false, "", false, err // wrapped errHTTPBadRequestActionsInvalid
return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
}
}
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!

View file

@ -56,6 +56,13 @@ type action struct {
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
}
func newAction() *action {
return &action{
Headers: make(map[string]string),
Extras: make(map[string]string),
}
}
// publishMessage is used as input when publishing as JSON
type publishMessage struct {
Topic string `json:"topic"`

View file

@ -1,17 +1,10 @@
package server
import (
"encoding/json"
"heckel.io/ntfy/util"
"net/http"
"strings"
)
const (
actionIDLength = 10
actionsMax = 3
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
value := strings.ToLower(readParam(r, names...))
if value == "" {
@ -47,103 +40,3 @@ func readQueryParam(r *http.Request, names ...string) string {
}
return ""
}
func parseActions(s string) (actions []*action, err error) {
// Parse JSON or simple format
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "[") {
actions, err = parseActionsFromJSON(s)
} else {
actions, err = parseActionsFromSimpleNew(s)
}
if err != nil {
return nil, err
}
// Add ID field, ensure correct uppercase/lowercase
for i := range actions {
actions[i].ID = util.RandomString(actionIDLength)
actions[i].Action = strings.ToLower(actions[i].Action)
actions[i].Method = strings.ToUpper(actions[i].Method)
}
// Validate
if len(actions) > actionsMax {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "only %d actions allowed", actionsMax)
}
for _, action := range actions {
if !util.InStringList([]string{"view", "broadcast", "http"}, action.Action) {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action '%s' unknown", action.Action)
} else if action.Label == "" {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'label' is required")
} else if util.InStringList([]string{"view", "http"}, action.Action) && action.URL == "" {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'url' is required for action '%s'", action.Action)
} else if action.Action == "http" && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'body' cannot be set if method is %s", action.Method)
}
}
return actions, nil
}
func parseActionsFromJSON(s string) ([]*action, error) {
actions := make([]*action, 0)
if err := json.Unmarshal([]byte(s), &actions); err != nil {
return nil, err
}
return actions, nil
}
func parseActionsFromSimple(s string) ([]*action, error) {
actions := make([]*action, 0)
rawActions := util.SplitNoEmpty(s, ";")
for _, rawAction := range rawActions {
newAction := &action{
Headers: make(map[string]string),
Extras: make(map[string]string),
}
parts := util.SplitNoEmpty(rawAction, ",")
if len(parts) < 3 {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action requires at least keys 'action', 'label' and one parameter: %s", rawAction)
}
for i, part := range parts {
key, value := util.SplitKV(part, "=")
if key == "" && i == 0 {
newAction.Action = value
} else if key == "" && i == 1 {
newAction.Label = value
} else if key == "" && util.InStringList([]string{"view", "http"}, newAction.Action) && i == 2 {
newAction.URL = value
} else if strings.HasPrefix(key, "headers.") {
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
} else if strings.HasPrefix(key, "extras.") {
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
} else if key != "" {
switch strings.ToLower(key) {
case "action":
newAction.Action = value
case "label":
newAction.Label = value
case "clear":
lvalue := strings.ToLower(value)
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "'clear=%s' not allowed", value)
}
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
case "url":
newAction.URL = value
case "method":
newAction.Method = value
case "body":
newAction.Body = value
default:
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "key '%s' unknown", key)
}
} else {
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "unknown term '%s'", part)
}
}
actions = append(actions, newAction)
}
return actions, nil
}

View file

@ -27,83 +27,3 @@ func TestReadBoolParam(t *testing.T) {
require.Equal(t, false, up)
require.Equal(t, true, firebase)
}
func TestParseActions(t *testing.T) {
actions, err := parseActions("[]")
require.Nil(t, err)
require.Empty(t, actions)
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Open door", actions[0].Label)
require.Equal(t, "https://door.lan/open", actions[0].URL)
require.Equal(t, "view", actions[1].Action)
require.Equal(t, "Show portal", actions[1].Label)
require.Equal(t, "https://door.lan", actions[1].URL)
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Open door", actions[0].Label)
require.Equal(t, "https://door.lan/open", actions[0].URL)
require.Equal(t, "view", actions[1].Action)
require.Equal(t, "Show portal", actions[1].Label)
require.Equal(t, "https://door.lan", actions[1].URL)
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Open door", actions[0].Label)
require.Equal(t, "https://door.lan/open", actions[0].URL)
require.Equal(t, "PUT", actions[0].Method)
require.Equal(t, "this is a body", actions[0].Body)
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "broadcast", actions[0].Action)
require.Equal(t, "Do a thing", actions[0].Label)
require.Equal(t, 2, len(actions[0].Extras))
require.Equal(t, "some command", actions[0].Extras["command"])
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Send request", actions[0].Label)
require.Equal(t, 2, len(actions[0].Headers))
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `Look ma, \"quotes\"; and semicolons`, actions[0].Label)
require.Equal(t, `http://example.com`, actions[0].URL)
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `Out of order!`, actions[0].Label)
require.Equal(t, `http://example.com`, actions[0].URL)
actions, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
require.EqualError(t, err, "unexpected character 'x' at position 22")
actions, err = parseActions(`label="", action="http", url=http://example.com`)
require.EqualError(t, err, "invalid request: actions invalid, parameter 'label' is required")
actions, err = parseActions(`label=, action="http", url=http://example.com`)
require.EqualError(t, err, "invalid request: actions invalid, parameter 'label' is required")
actions, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
require.EqualError(t, err, "invalid request: actions invalid, term 'what is this anyway' unknown")
}