diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e89ba8..b5fb028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,12 @@ # New Features -- Added a new request block `[FormData]`, which enables to specify `multipart/form-data` request payloads. [Here](https://studio-b12.github.io/goat/goatfile/requests/formdata.html) you can read the documentation. [#55] +- Added raw data identifier `$` to directly use raw data from a response as request body. See the example in the + documentation for more information. [#66, #67] -- Added a new request option [`followredirects`](https://studio-b12.github.io/goat/goatfile/requests/options.html#followredirects). This is `true` by default, but can be set to `false` if redirects should not be followed transparently. [#61] +- Added a new flag `--retry-failed` (or `-R` for short). When a batch execution fails, failed files are now saved to a + temporary file. After that, goat can be executed again with only the flag which will re-run only the failed files. -- Added a new script builtin [`assert_eq`](https://studio-b12.github.io/goat/scripting/builtins.html#assert_eq), where you can pass two values which are compared and output for better error clarification. + -- Added a new script builtin [`jq`](https://studio-b12.github.io/goat/scripting/builtins.html#jq), to perform JQ commands on any object in script blocks. - -# Minor Changes and Bug Fixes - -- Fixed an issue when parsing file descriptors on Windows systems. \ No newline at end of file + \ No newline at end of file diff --git a/cmd/goat/main.go b/cmd/goat/main.go index 343eb91..ad68d84 100644 --- a/cmd/goat/main.go +++ b/cmd/goat/main.go @@ -4,10 +4,12 @@ import ( "bufio" "crypto/tls" "fmt" + "github.com/davecgh/go-spew/spew" "github.com/studio-b12/goat/pkg/errs" "io/fs" "net/http" "os" + "path" "path/filepath" "runtime" "strings" @@ -41,10 +43,11 @@ type Args struct { NoColor bool `arg:"--no-color,env:GOATARG_NOCOLOR" help:"Supress colored log output"` Params []string `arg:"-p,--params,separate,env:GOATARG_PARAMS" help:"Params file location(s)"` Profile []string `arg:"-P,--profile,separate,env:GOATARG_PROFILE" help:"Select a profile from your home config"` - ReducedErrors bool `arg:"--reduced-errors,-R,env:GOATARG_REDUCEDERRORS" help:"Hide template errors in teardown steps"` + ReducedErrors bool `arg:"-R,--reduced-errors,env:GOATARG_REDUCEDERRORS" help:"Hide template errors in teardown steps"` Secure bool `arg:"--secure,env:GOATARG_SECURE" help:"Validate TLS certificates"` Silent bool `arg:"-s,--silent,env:GOATARG_SILENT" help:"Disables all logging output"` Skip []string `arg:"--skip,separate,env:GOATARG_SKIP" help:"Section(s) to be skipped during execution"` + RetryFailed bool `arg:"--retry-failed,env:GOATARG_RETRYFAILED" help:"Retry files which have failed in the previous run"` } func main() { @@ -76,7 +79,24 @@ func main() { return } - if len(args.Goatfile) == 0 { + goatfiles := args.Goatfile + + if args.RetryFailed { + failed, err := loadLastFailedFiles() + if err != nil { + log.Fatal().Err(err).Msg("Failed loading last failed files") + return + } + if len(failed) == 0 { + log.Fatal().Msg("No failed files have been recorded in previous runs") + return + } + + goatfiles = failed + spew.Dump(goatfiles) + } + + if len(goatfiles) == 0 { argParser.Fail("Goatfile must be specified.") return } @@ -125,7 +145,7 @@ func main() { log.Debug().Msgf("Initial Params\n%s", state) - res, err := exec.Execute(args.Goatfile, state, !args.ReducedErrors) + res, err := exec.Execute(goatfiles, state, !args.ReducedErrors) res.Log() if err != nil { if args.ReducedErrors { @@ -134,7 +154,11 @@ func main() { entry := log.Fatal().Err(err) - if batchErr, ok := err.(executor.BatchResultError); ok { + if batchErr, ok := errs.As[*executor.BatchResultError](err); ok { + if sErr := storeLastFailedFiles(batchErr.FailedFiles()); err != nil { + log.Error().Err(sErr).Msg("failed storing latest failed files") + } + coloredMessages := batchErr.ErrorMessages() for i, p := range coloredMessages { coloredMessages[i] = clr.Print(clr.Format(p, clr.ColorFGRed)) @@ -227,10 +251,46 @@ func filterTeardownParamErrors(err error) error { newErrors = append(newErrors, e) } return newErrors - case executor.BatchResultError: + case *executor.BatchResultError: tErr.Inner = filterTeardownParamErrors(tErr.Inner).(errs.Errors) return tErr default: return err } } + +const lastFailedRunFileName = "goat_last_failed_run" + +func storeLastFailedFiles(paths []string) error { + failedRunPath := path.Join(os.TempDir(), lastFailedRunFileName) + f, err := os.Create(failedRunPath) + if err != nil { + return err + } + defer f.Close() + + _, err = f.WriteString(strings.Join(paths, "\n")) + return err +} + +func loadLastFailedFiles() (paths []string, err error) { + failedRunPath := path.Join(os.TempDir(), lastFailedRunFileName) + f, err := os.Open(failedRunPath) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if err = scanner.Err(); err != nil { + return nil, err + } + paths = append(paths, scanner.Text()) + } + + return paths, nil +} diff --git a/docs/book/src/goatfile/requests/body.md b/docs/book/src/goatfile/requests/body.md index e733e56..9e265f5 100644 --- a/docs/book/src/goatfile/requests/body.md +++ b/docs/book/src/goatfile/requests/body.md @@ -15,7 +15,7 @@ > *BlockDelimiter* : > `` ``` `` -## Example +## Examples ````toml [Body] @@ -32,6 +32,16 @@ ``` ```` +````toml +[Body] +@path/to/some/file +```` + +````toml +[Body] +$someVar +```` + ## Explanation Define the data to be transmitted with the request. @@ -53,3 +63,22 @@ Example: } ``` ```` + +### File Descriptor +With the `@` prefix in front of a file path, it is possible to send a file as a request body. + +Example: +````toml +[Body] +@path/to/some/file +```` + +### Raw Descriptor +With the `$` prefix in front of a variable name it is possible to pass raw byte arrays to the request body. +This can e.g. be used to send a `response.BodyRaw` to another endpoint. + +Example: +````toml +[Body] +$someVar +```` diff --git a/docs/book/src/goatfile/requests/formdata.md b/docs/book/src/goatfile/requests/formdata.md index 5e10f3c..fe997d6 100644 --- a/docs/book/src/goatfile/requests/formdata.md +++ b/docs/book/src/goatfile/requests/formdata.md @@ -13,6 +13,7 @@ someString = "some string" someInt = 42 someFile = @files/goat.png:image/png +someRaw = $someVar:application/zip ``` ## Explanation @@ -23,6 +24,9 @@ The format of the contents of this block is [`TOML`](https://toml.io/). The file's content type can be specified after the file descriptor, separated by a colon (`:`). Otherwise, the content type will default to `application/octet-stream`. +The `$` sign can be used in `FormData` values to directly pass byte array variables. This can e.g. be used when a +previous `response.BodyRaw` should be send to another endpoint as a `FormData` request. + > The example from above results in the following body content. > ``` > --e8b9253313450dbcf0d09df1a0f3ff36dd00e888339415a239ce167f279c @@ -39,6 +43,11 @@ the content type will default to `application/octet-stream`. > > some string > --e8b9253313450dbcf0d09df1a0f3ff36dd00e888339415a239ce167f279c-- +> Content-Disposition: form-data; name="someRaw" ; filename="binary-data" +> Content-Type: application/zip +> +> +> --e8b9253313450dbcf0d09df1a0f3ff36dd00e888339415a239ce167f279c-- > ``` Template parameters in parameter values will be substituted. diff --git a/e2e/cases/formdata/test.goat b/e2e/cases/formdata/test.goat index 7b11ce6..7c31edf 100644 --- a/e2e/cases/formdata/test.goat +++ b/e2e/cases/formdata/test.goat @@ -6,13 +6,13 @@ someInt = 42 file = @test-file.txt:text/plain [Script] -const [contentType, boundaryKV] = response.Body.headers["Content-Type"][0] +var [contentType, boundaryKV] = response.Body.headers["Content-Type"][0] .split(';') .map(v => v.trim()); assert(contentType, 'multipart/form-data', `Invalid Content-Type header: ${contentType}`); -const boundary = boundaryKV.split('=')[1]; -const bodyText = response.Body.body_string.replaceAll('\r', ''); -const bodyValues = bodyText +var boundary = boundaryKV.split('=')[1]; +var bodyText = response.Body.body_string.replaceAll('\r', ''); +var bodyValues = bodyText .substr(0, bodyText.length - 3) .split(`--${boundary}`) .map(v => v.trim()) @@ -34,3 +34,40 @@ const bodyValues = bodyText assert(v[1] === 'Content-Type: text/plain', `Invalid content type: ${v[1]}`); assert(v[2] === 'This is a test file!', `Invalid value: ${v[2]}`); } + + +--- +// Paramter Substitution +POST {{.instance}}/ + +[PreScript] +var someOtherString = "some other string"; +var someOtherInt = 43; + +[FormData] +someOtherString = "{{.someOtherString}}" +someOtherInt = {{.someOtherInt}} + +[Script] +var [contentType, boundaryKV] = response.Body.headers["Content-Type"][0] + .split(';') + .map(v => v.trim()); +assert(contentType, 'multipart/form-data', `Invalid Content-Type header: ${contentType}`); +var boundary = boundaryKV.split('=')[1]; +var bodyText = response.Body.body_string.replaceAll('\r', ''); +var bodyValues = bodyText + .substr(0, bodyText.length - 3) + .split(`--${boundary}`) + .map(v => v.trim()) + .filter(v => !!v) + .map(v => v.split('\n').map(l => l.trim()).filter(l => !!l)); +{ + const v = bodyValues.find(v => v[0].includes('name="someOtherString"')); + assert(v[0] === 'Content-Disposition: form-data; name="someOtherString"', `Invalid header: ${v[0]}`); + assert(v[1] === 'some other string', `[0] Invalid value: ${v[1]}`); +} +{ + const v = bodyValues.find(v => v[0].includes('name="someOtherInt"')); + assert(v[0] === 'Content-Disposition: form-data; name="someOtherInt"', `Invalid header: ${v[0]}`); + assert(v[1] === '43', `Invalid value: ${v[1]}`); +} \ No newline at end of file diff --git a/e2e/cases/rawdata-failing/body_fail.goat b/e2e/cases/rawdata-failing/body_fail.goat new file mode 100644 index 0000000..88f3322 --- /dev/null +++ b/e2e/cases/rawdata-failing/body_fail.goat @@ -0,0 +1,7 @@ +POST {{.instance}}/ + +[PreScript] +var number = 123.5 + +[Body] +$number \ No newline at end of file diff --git a/e2e/cases/rawdata-failing/formdata_fail.goat b/e2e/cases/rawdata-failing/formdata_fail.goat new file mode 100644 index 0000000..1b0a696 --- /dev/null +++ b/e2e/cases/rawdata-failing/formdata_fail.goat @@ -0,0 +1,7 @@ +POST {{.instance}}/ + +[PreScript] +var text = "test text" + +[FormData] +file = $text \ No newline at end of file diff --git a/e2e/cases/rawdata-failing/run.sh b/e2e/cases/rawdata-failing/run.sh new file mode 100644 index 0000000..587645a --- /dev/null +++ b/e2e/cases/rawdata-failing/run.sh @@ -0,0 +1,11 @@ +goat body_fail.goat && { + echo "should have failed but didn't" + exit 1 +} + +goat formdata_fail.goat && { + echo "should have failed but didn't" + exit 1 +} + +exit 0 \ No newline at end of file diff --git a/e2e/cases/rawdata/body.goat b/e2e/cases/rawdata/body.goat new file mode 100644 index 0000000..0b2190e --- /dev/null +++ b/e2e/cases/rawdata/body.goat @@ -0,0 +1,33 @@ +POST {{.instance}}/ + +[PreScript] +var binaryBody = new Uint8Array(4); +binaryBody[0] = 0x67; +binaryBody[1] = 0x6f; +binaryBody[2] = 0x61; +binaryBody[3] = 0x74; + +[Body] +$binaryBody + +[Script] +assert_eq(response.Body.body_string, '\x67\x6f\x61\x74'); + +--- +// With manually set Content-Type +POST {{.instance}}/ + +[PreScript] +var binaryBody = new Uint8Array(4); +binaryBody[0] = 0x67; +binaryBody[1] = 0x6f; +binaryBody[2] = 0x61; +binaryBody[3] = 0x74; + +[Body] +$binaryBody:text/plain + +[Script] +assert_eq(response.Body.body_string, '\x67\x6f\x61\x74'); +var contentType = response.Body.headers["Content-Type"]; +assert(contentType, 'text/plain', `Invalid Content-Type header: ${contentType}`); \ No newline at end of file diff --git a/e2e/cases/rawdata/formdata.goat b/e2e/cases/rawdata/formdata.goat new file mode 100644 index 0000000..4a03948 --- /dev/null +++ b/e2e/cases/rawdata/formdata.goat @@ -0,0 +1,60 @@ +POST {{.instance}}/ + +[PreScript] +var binaryBody = new Uint8Array(4); + +[FormData] +file = $binaryBody + +[Script] +info(JSON.stringify(response.Body,null,2)) +var [contentType, boundaryKV] = response.Body.headers["Content-Type"][0] + .split(';') + .map(v => v.trim()); +assert(contentType, 'multipart/form-data', `Invalid Content-Type header: ${contentType}`); +var boundary = boundaryKV.split('=')[1]; +var bodyText = response.Body.body_string.replaceAll('\r', ''); +var bodyValues = bodyText + .substr(0, bodyText.length - 3) + .split(`--${boundary}`) + .map(v => v.trim()) + .filter(v => !!v) + .map(v => v.split('\n').map(l => l.trim()).filter(l => !!l)); +{ + const v = bodyValues.find(v => v[0].includes('name="file"')); + assert(v[0] === 'Content-Disposition: form-data; name="file"; filename="binary-data"', `Invalid header: ${v[0]}`); + assert(v[1] === 'Content-Type: application/octet-stream', `Invalid content type: ${v[1]}`); + assert(v[2] === '\x00\x00\x00\x00', `Invalid value: ${v[2]}`); +} + + +--- +// With manually set Content-Type +POST {{.instance}}/ + +[PreScript] +var binaryBody = new Uint8Array(4); + +[FormData] +file = $binaryBody:text/plain + +[Script] +info(JSON.stringify(response.Body,null,2)) +var [contentType, boundaryKV] = response.Body.headers["Content-Type"][0] + .split(';') + .map(v => v.trim()); +assert(contentType, 'multipart/form-data', `Invalid Content-Type header: ${contentType}`); +var boundary = boundaryKV.split('=')[1]; +var bodyText = response.Body.body_string.replaceAll('\r', ''); +var bodyValues = bodyText + .substr(0, bodyText.length - 3) + .split(`--${boundary}`) + .map(v => v.trim()) + .filter(v => !!v) + .map(v => v.split('\n').map(l => l.trim()).filter(l => !!l)); +{ + const v = bodyValues.find(v => v[0].includes('name="file"')); + assert(v[0] === 'Content-Disposition: form-data; name="file"; filename="binary-data"', `Invalid header: ${v[0]}`); + assert(v[1] === 'Content-Type: text/plain', `Invalid content type: ${v[1]}`); + assert(v[2] === '\x00\x00\x00\x00', `Invalid value: ${v[2]}`); +} diff --git a/e2e/cases/rawdata/run.sh b/e2e/cases/rawdata/run.sh new file mode 100644 index 0000000..5669c2f --- /dev/null +++ b/e2e/cases/rawdata/run.sh @@ -0,0 +1,4 @@ +set -e + +goat body.goat +goat formdata.goat \ No newline at end of file diff --git a/pkg/errs/util.go b/pkg/errs/util.go index 3e17c59..aa2abcc 100644 --- a/pkg/errs/util.go +++ b/pkg/errs/util.go @@ -22,3 +22,15 @@ func IsOfType[T any](err error) bool { return false } + +// As applies errors.As() on the given err +// using the given type T as target for the +// unwrapping. +// +// Refer to the documentation of errors.As() +// for more details: +// https://pkg.go.dev/errors#As +func As[T error](err error) (t T, ok bool) { + ok = errors.As(err, &t) + return t, ok +} diff --git a/pkg/executor/errors.go b/pkg/executor/errors.go index 0188fce..8dea6ea 100644 --- a/pkg/executor/errors.go +++ b/pkg/executor/errors.go @@ -11,11 +11,11 @@ type BatchExecutionError struct { Path string } -func wrapBatchExecutionError(err error, path string) BatchExecutionError { +func wrapBatchExecutionError(err error, path string) *BatchExecutionError { var batchErr BatchExecutionError batchErr.Inner = err batchErr.Path = path - return batchErr + return &batchErr } // BatchResultError holds and array of errors @@ -25,21 +25,32 @@ type BatchResultError struct { Total int } -func (t BatchResultError) Error() string { +func (t *BatchResultError) Error() string { return fmt.Sprintf("%02d of %02d batches failed", len(t.Inner), t.Total) } -func (t BatchResultError) Unwrap() error { +func (t *BatchResultError) Unwrap() error { return t.Inner.Condense() } +// FailedFiles returns the list of files that have failed. +func (t *BatchResultError) FailedFiles() (files []string) { + files = make([]string, 0, len(t.Inner)) + for _, err := range t.Inner { + if batchErr, ok := errs.As[*BatchExecutionError](err); ok { + files = append(files, batchErr.Path) + } + } + return files +} + // ErrorMessages returns a list of the inner errors // as strings assambled from the path and error // message. -func (t BatchResultError) ErrorMessages() []string { +func (t *BatchResultError) ErrorMessages() []string { errMsgs := make([]string, 0, len(t.Inner)) for _, err := range t.Inner { - if batchErr, ok := err.(BatchExecutionError); ok { + if batchErr, ok := errs.As[*BatchExecutionError](err); ok { errMsgs = append(errMsgs, fmt.Sprintf("%s: %s", batchErr.Path, batchErr.Error())) } } diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index a1dbb14..1d2b12a 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -127,7 +127,7 @@ func (t *Executor) executeGoatfile( continue } - if !AbortOptionsFromMap(act.(goatfile.Request).Options).AlwaysAbort { + if !AbortOptionsFromMap(act.(*goatfile.Request).Options).AlwaysAbort { continue } } else { @@ -262,7 +262,7 @@ func (t *Executor) executeFromPathes(pathes []string, initialParams engine.State } if mErr.HasSome() { - err = BatchResultError{ + err = &BatchResultError{ Inner: mErr, Total: len(goatfiles), } @@ -335,9 +335,9 @@ func (t *Executor) executeAction( case goatfile.ActionRequest: res.Inc() - req := act.(goatfile.Request) + req := act.(*goatfile.Request) log.Trace().Fields("options", req.Options).Msg("Request Options") - err = t.executeRequest(eng, req, gf) + err = t.executeRequest(eng, *req, gf) if err != nil { res.IncFailed() err = errs.WithSuffix(err, fmt.Sprintf("(%s:%d)", req.Path, req.PosLine)) @@ -378,7 +378,7 @@ func (t *Executor) executeRequest(eng engine.Engine, req goatfile.Request, gf go state := eng.State() - err = req.PreSubstitudeWithParams(state) + err = req.PreSubstituteWithParams(state) if err != nil { return errs.WithPrefix("failed pre-substituting request with parameters:", err) } @@ -396,7 +396,7 @@ func (t *Executor) executeRequest(eng engine.Engine, req goatfile.Request, gf go state = eng.State() } - err = req.SubstitudeWithParams(state) + err = req.SubstituteWithParams(state) if err != nil { return errs.WithPrefix("failed substituting request with parameters:", NewParamsParsingError(err)) @@ -418,6 +418,18 @@ func (t *Executor) executeRequest(eng engine.Engine, req goatfile.Request, gf go t.Waiter.Wait() + err = req.InsertRawDataIntoBody(state) + if err != nil { + return errs.WithPrefix("failed inserting raw variable in body:", + NewParamsParsingError(err)) + } + + err = req.InsertRawDataIntoFormData(state) + if err != nil { + return errs.WithPrefix("failed reading raw variable:", + NewParamsParsingError(err)) + } + httpReq, err := req.ToHttpRequest() if err != nil { return errs.WithPrefix("failed transforming to http request:", err) diff --git a/pkg/goatfile/ast/ast.go b/pkg/goatfile/ast/ast.go index 47192eb..ded6516 100644 --- a/pkg/goatfile/ast/ast.go +++ b/pkg/goatfile/ast/ast.go @@ -136,6 +136,12 @@ type FileDescriptor struct { ContentType string } +type RawDescriptor struct { + VarName string + ContentType string + Data []byte +} + type NoContent struct{} type RequestHead struct { diff --git a/pkg/goatfile/data.go b/pkg/goatfile/data.go index 0b0f45c..60adacd 100644 --- a/pkg/goatfile/data.go +++ b/pkg/goatfile/data.go @@ -14,6 +14,7 @@ import ( "os/user" "path" "path/filepath" + "reflect" "strings" ) @@ -45,6 +46,16 @@ func DataFromAst(di ast.DataContent, filePath string) (data Data, header http.He } } return fc, header, nil + case ast.RawDescriptor: + rc := RawContent{ + varName: d.VarName, + } + if d.ContentType != "" { + header = http.Header{ + "Content-Type": []string{d.ContentType}, + } + } + return rc, header, nil case ast.FormData: boundary, err := randomBoundary() if err != nil { @@ -95,6 +106,22 @@ func (t FileContent) Reader() (r io.Reader, err error) { return r, err } +// RawContent can be used for reading byte +// array data +type RawContent struct { + varName string + value any +} + +func (t RawContent) Reader() (r io.Reader, err error) { + rv := reflect.ValueOf(t.value) + if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 { + r = bytes.NewReader(rv.Bytes()) + return r, nil + } + return nil, fmt.Errorf("variable is not a byte array: %v", t.varName) +} + // FormData writes the given key-value pairs into a Multipart Formdata // encoded reader stream. type FormData struct { @@ -147,6 +174,26 @@ func (t FormData) Reader() (io.Reader, error) { continue } + if rd, ok := v.(ast.RawDescriptor); ok { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + quoteEscaper.Replace(k), quoteEscaper.Replace("binary-data"))) + if rd.ContentType == "" { + rd.ContentType = http.DetectContentType(rd.Data) + } + h.Set("Content-Type", rd.ContentType) + fw, err := w.CreatePart(h) + if err != nil { + return nil, err + } + _, err = fw.Write(rd.Data) + if err != nil { + return nil, err + } + continue + } + fw, err := w.CreateFormField(k) if err != nil { return nil, err diff --git a/pkg/goatfile/errors.go b/pkg/goatfile/errors.go index 55c0da5..7e61ac4 100644 --- a/pkg/goatfile/errors.go +++ b/pkg/goatfile/errors.go @@ -34,6 +34,8 @@ var ( ErrSectionDefinedMultiple = errors.New("the section has been already defined") ErrUnclosedGroup = errors.New("group has not been closed") ErrMissingGroup = errors.New("missing group definition") + ErrVarNotFound = errors.New("variable not found") + ErrNotAByteArray = errors.New("not a byte array") ) // ParseError wraps an inner error with diff --git a/pkg/goatfile/goatfile.go b/pkg/goatfile/goatfile.go index 2abded6..634484a 100644 --- a/pkg/goatfile/goatfile.go +++ b/pkg/goatfile/goatfile.go @@ -80,9 +80,9 @@ func FromAst(astGf *ast.Goatfile) (gf Goatfile, err error) { return Goatfile{}, err } if gf.Defaults == nil { - gf.Defaults = &defReq + gf.Defaults = defReq } else { - gf.Defaults.Merge(&defReq) + gf.Defaults.Merge(defReq) } case ast.SectionSetup: for _, act := range s.Actions { diff --git a/pkg/goatfile/goatfile_test.go b/pkg/goatfile/goatfile_test.go index 7db26c8..a1b8f73 100644 --- a/pkg/goatfile/goatfile_test.go +++ b/pkg/goatfile/goatfile_test.go @@ -12,7 +12,7 @@ func TestMerge(t *testing.T) { getA := func() Goatfile { return Goatfile{ - Defaults: &def, + Defaults: def, Setup: []Action{ testRequest("A", "1"), testRequest("A", "2"), @@ -48,7 +48,7 @@ func TestMerge(t *testing.T) { assert.Equal(t, getB(), b) assert.Equal(t, Goatfile{ - Defaults: &def, + Defaults: def, Setup: []Action{ testRequest("A", "1"), testRequest("A", "2"), @@ -75,7 +75,7 @@ func TestMerge(t *testing.T) { assert.Equal(t, getA(), a) assert.Equal(t, Goatfile{ - Defaults: &def, + Defaults: def, Setup: []Action{ testRequest("B", "1"), testRequest("B", "2"), @@ -97,7 +97,7 @@ func TestMerge(t *testing.T) { // --- Helpers --- -func testRequest(method, uri string, opt ...int) Request { +func testRequest(method, uri string, opt ...int) *Request { r := newRequest() r.Method = method r.URI = uri @@ -109,7 +109,7 @@ func testRequest(method, uri string, opt ...int) Request { return r } -func testRequestWithPath(method, uri string, path string, opt ...int) Request { +func testRequestWithPath(method, uri string, path string, opt ...int) *Request { r := newRequest() r.Method = method r.URI = uri diff --git a/pkg/goatfile/lexer.go b/pkg/goatfile/lexer.go index f548962..19996b8 100644 --- a/pkg/goatfile/lexer.go +++ b/pkg/goatfile/lexer.go @@ -34,6 +34,7 @@ const ( tokCOMMA tokASSIGNMENT tokFILEDESC + tokRAW tokGROUPSTART tokGROUPEND @@ -143,6 +144,8 @@ func (t *scanner) scan() (tk token, lit string) { return tokASSIGNMENT, "" case '@': return tokFILEDESC, "" + case '$': + return tokRAW, "" case '\n': return tokLF, "" case eof: diff --git a/pkg/goatfile/parser.go b/pkg/goatfile/parser.go index db0ebe6..bc89aef 100644 --- a/pkg/goatfile/parser.go +++ b/pkg/goatfile/parser.go @@ -643,6 +643,9 @@ func (t *Parser) parseRaw() (ast.DataContent, error) { if r == '@' { return t.parseFileDescriptor() } + if r == '$' { + return t.parseRawDescriptor() + } t.s.unread() @@ -748,6 +751,10 @@ func (t *Parser) parseValue() (any, []ast.Comment, error) { case tokFILEDESC: v, err := t.parseFileDescriptor() return v, nil, err + case tokRAW: + v, err := t.parseRawDescriptor() + return v, nil, err + default: return nil, nil, errs.WithSuffix(ErrInvalidToken, "(value)") } @@ -810,6 +817,28 @@ func (t *Parser) parseFileDescriptor() (ast.DataContent, error) { return fd, nil } +func (t *Parser) parseRawDescriptor() (ast.DataContent, error) { + tk, varName := t.s.scanStringStopAt(':') + if tk != tokSTRING { + return ast.NoContent{}, ErrInvalidFileDescriptor + } + rd := ast.RawDescriptor{VarName: varName} + + if t.s.read() != ':' { + t.s.unread() + return rd, nil + } + + tk, lit := t.s.scanString() + if tk == tokILLEGAL { + return nil, ErrInvalidStringLiteral + } + + rd.ContentType = lit + + return rd, nil +} + func (t *Parser) astPos() ast.Pos { return ast.Pos{ Pos: t.s.pos, diff --git a/pkg/goatfile/request.go b/pkg/goatfile/request.go index 3b8eb3b..b4abe78 100644 --- a/pkg/goatfile/request.go +++ b/pkg/goatfile/request.go @@ -3,13 +3,14 @@ package goatfile import ( "errors" "fmt" + "github.com/studio-b12/goat/pkg/engine" + "github.com/studio-b12/goat/pkg/errs" "github.com/studio-b12/goat/pkg/goatfile/ast" + "github.com/studio-b12/goat/pkg/util" "io" "net/http" "net/url" - - "github.com/studio-b12/goat/pkg/errs" - "github.com/studio-b12/goat/pkg/util" + "reflect" ) const conditionOptionName = "condition" @@ -36,7 +37,8 @@ type Request struct { var _ Action = (*Request)(nil) -func newRequest() (t Request) { +func newRequest() (t *Request) { + t = new(Request) t.Header = http.Header{} t.Body = NoContent{} t.PreScript = NoContent{} @@ -44,9 +46,9 @@ func newRequest() (t Request) { return t } -func RequestFromAst(req *ast.Request, path string) (t Request, err error) { +func RequestFromAst(req *ast.Request, path string) (t *Request, err error) { if req == nil { - return Request{}, errors.New("request ast is nil") + return &Request{}, errors.New("request ast is nil") } t = newRequest() @@ -86,13 +88,13 @@ func RequestFromAst(req *ast.Request, path string) (t Request, err error) { } if err != nil { - return Request{}, err + return &Request{}, err } return t, nil } -func PartialRequestFromAst(req ast.PartialRequest, path string) (t Request, err error) { +func PartialRequestFromAst(req ast.PartialRequest, path string) (t *Request, err error) { var fullReq ast.Request fullReq.Pos = req.Pos @@ -101,14 +103,14 @@ func PartialRequestFromAst(req ast.PartialRequest, path string) (t Request, err return RequestFromAst(&fullReq, path) } -func (t Request) Type() ActionType { +func (t *Request) Type() ActionType { return ActionRequest } -// PreSubstitudeWithParams takes the given parameters and replaces placeholders +// PreSubstituteWithParams takes the given parameters and replaces placeholders // within specific parts of the request which shall be executed before the // actual request is substituted (like PreScript). -func (t *Request) PreSubstitudeWithParams(params any) error { +func (t *Request) PreSubstituteWithParams(params any) error { if t.preParsed { return ErrTemplateAlreadyPreParsed } @@ -130,10 +132,10 @@ func (t *Request) PreSubstitudeWithParams(params any) error { return nil } -// SubstitudeWithParams takes the given parameters +// SubstituteWithParams takes the given parameters // and replaces placeholders within the request // with values from the given params. -func (t *Request) SubstitudeWithParams(params any) error { +func (t *Request) SubstituteWithParams(params any) error { if t.parsed { return ErrTemplateAlreadyParsed } @@ -198,6 +200,12 @@ func (t *Request) SubstitudeWithParams(params any) error { return err } t.Body = body + case FormData: + err = ApplyTemplateToMap(body.fields, params) + if err != nil { + return err + } + t.Body = body } // Substitute Script @@ -216,9 +224,54 @@ func (t *Request) SubstitudeWithParams(params any) error { return nil } +// InsertRawDataIntoBody evaluates the raw bytes required for the request +func (t *Request) InsertRawDataIntoBody(state engine.State) error { + body, ok := t.Body.(RawContent) + if !ok { + return nil + } + v, ok := state[body.varName] + if !ok { + return ErrVarNotFound + } + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Slice || rv.Type().Elem().Kind() != reflect.Uint8 { + return errs.WithPrefix(fmt.Sprintf("$%v :", body.varName), ErrNotAByteArray) + } + + body.value = rv.Bytes() + t.Body = body + return nil +} + +// InsertRawDataIntoFormData evaluates the raw bytes required for the request +func (t *Request) InsertRawDataIntoFormData(state engine.State) error { + body, ok := t.Body.(FormData) + if !ok { + return nil + } + for k, v := range body.fields { + if vd, ok := v.(ast.RawDescriptor); ok { + valeFromState, ok := state[vd.VarName] + if !ok { + return ErrVarNotFound + } + rv := reflect.ValueOf(valeFromState) + if rv.Kind() != reflect.Slice || rv.Type().Elem().Kind() != reflect.Uint8 { + return errs.WithPrefix(fmt.Sprintf("$%v :", vd.VarName), ErrNotAByteArray) + } + vd.Data = rv.Bytes() + body.fields[k] = vd + } + } + + t.Body = body + return nil +} + // ToHttpRequest returns a *http.Request built from the // given Reuqest. -func (t Request) ToHttpRequest() (*http.Request, error) { +func (t *Request) ToHttpRequest() (*http.Request, error) { uri, err := url.Parse(t.URI) if err != nil { return nil, errs.WithPrefix("failed parsing URI:", err) @@ -299,7 +352,7 @@ func (t *Request) Merge(with *Request) { } } -func (t Request) String() string { +func (t *Request) String() string { return fmt.Sprintf("%s %s", t.Method, t.URI) } diff --git a/pkg/goatfile/request_test.go b/pkg/goatfile/request_test.go index 84e037c..f08a412 100644 --- a/pkg/goatfile/request_test.go +++ b/pkg/goatfile/request_test.go @@ -77,7 +77,7 @@ func TestToHttpRequest(t *testing.T) { } func TestPreSubstituteWithParams(t *testing.T) { - getReq := func() Request { + getReq := func() *Request { r := newRequest() r.URI = "{{.instance}}/api/v1/login" r.Header.Set("Content-Type", "{{.contentType}}") @@ -106,7 +106,7 @@ func TestPreSubstituteWithParams(t *testing.T) { } r := getReq() - err := r.PreSubstitudeWithParams(params) + err := r.PreSubstituteWithParams(params) assert.Nil(t, err, err) assert.Equal(t, @@ -137,7 +137,7 @@ func TestPreSubstituteWithParams(t *testing.T) { } func TestSubstituteWithParams(t *testing.T) { - getReq := func() Request { + getReq := func() *Request { r := newRequest() r.URI = "{{.instance}}/api/v1/login" r.Header.Set("Content-Type", "{{.contentType}}") @@ -166,7 +166,7 @@ func TestSubstituteWithParams(t *testing.T) { } r := getReq() - err := r.SubstitudeWithParams(params) + err := r.SubstituteWithParams(params) assert.Nil(t, err, err) assert.Equal(t, @@ -206,7 +206,7 @@ func TestMerge_request(t *testing.T) { req.Header.Add("bazz", "fuzz") req.Header.Add("hello", "moon") - req.Merge(&def) + req.Merge(def) assert.Equal(t, "bar", req.Header.Get("foo")) @@ -227,7 +227,7 @@ func TestMerge_request(t *testing.T) { req.QueryParams["bazz"] = "fuzz" req.QueryParams["hello"] = "moon" - req.Merge(&def) + req.Merge(def) assert.Equal(t, "bar", req.QueryParams["foo"]) @@ -248,7 +248,7 @@ func TestMerge_request(t *testing.T) { req.Options["bazz"] = "fuzz" req.Options["hello"] = "moon" - req.Merge(&def) + req.Merge(def) assert.Equal(t, "bar", req.Options["foo"]) @@ -262,25 +262,25 @@ func TestMerge_request(t *testing.T) { def := newRequest() def.Body = StringContent("foo bar") req := newRequest() - req.Merge(&def) + req.Merge(def) assert.Equal(t, StringContent("foo bar"), req.Body) def = newRequest() req = newRequest() req.Body = StringContent("foo bar") - req.Merge(&def) + req.Merge(def) assert.Equal(t, StringContent("foo bar"), req.Body) def = newRequest() def.Body = StringContent("hello world") req = newRequest() req.Body = StringContent("foo bar") - req.Merge(&def) + req.Merge(def) assert.Equal(t, StringContent("foo bar"), req.Body) def = newRequest() req = newRequest() - req.Merge(&def) + req.Merge(def) assert.Equal(t, NoContent{}, req.Body) }) @@ -288,25 +288,25 @@ func TestMerge_request(t *testing.T) { def := newRequest() def.PreScript = StringContent("foo bar") req := newRequest() - req.Merge(&def) + req.Merge(def) assert.Equal(t, StringContent("foo bar"), req.PreScript) def = newRequest() req = newRequest() req.PreScript = StringContent("foo bar") - req.Merge(&def) + req.Merge(def) assert.Equal(t, StringContent("foo bar"), req.PreScript) def = newRequest() def.PreScript = StringContent("hello world") req = newRequest() req.PreScript = StringContent("foo bar") - req.Merge(&def) + req.Merge(def) assert.Equal(t, StringContent("foo bar"), req.PreScript) def = newRequest() req = newRequest() - req.Merge(&def) + req.Merge(def) assert.Equal(t, NoContent{}, req.PreScript) }) @@ -314,25 +314,25 @@ func TestMerge_request(t *testing.T) { def := newRequest() def.Script = StringContent("foo bar") req := newRequest() - req.Merge(&def) + req.Merge(def) assert.Equal(t, StringContent("foo bar"), req.Script) def = newRequest() req = newRequest() req.Script = StringContent("foo bar") - req.Merge(&def) + req.Merge(def) assert.Equal(t, StringContent("foo bar"), req.Script) def = newRequest() def.Script = StringContent("hello world") req = newRequest() req.Script = StringContent("foo bar") - req.Merge(&def) + req.Merge(def) assert.Equal(t, StringContent("foo bar"), req.Script) def = newRequest() req = newRequest() - req.Merge(&def) + req.Merge(def) assert.Equal(t, NoContent{}, req.Script) }) }