diff --git a/core/templating/template_helpers.go b/core/templating/template_helpers.go index 2edd98729..3125e0466 100644 --- a/core/templating/template_helpers.go +++ b/core/templating/template_helpers.go @@ -116,8 +116,20 @@ func (t templateHelpers) split(target, separator string) []string { return strings.Split(target, separator) } -func (t templateHelpers) concat(val1, val2 string) string { - return val1 + val2 +// Concatenates any number of arguments together as strings. +func (t templateHelpers) concat(args ...interface{}) string { + var parts []string + for _, arg := range args { + // If arg is a slice, flatten it + if s, ok := arg.([]interface{}); ok { + for _, v := range s { + parts = append(parts, fmt.Sprint(v)) + } + } else { + parts = append(parts, fmt.Sprint(arg)) + } + } + return strings.Join(parts, "") } func (t templateHelpers) isNumeric(stringToCheck string) bool { @@ -498,6 +510,24 @@ func (t templateHelpers) setStatusCode(statusCode string, options *raymond.Optio return "" } +func (t templateHelpers) setHeader(headerName string, headerValue string, options *raymond.Options) string { + if headerName == "" { + log.Error("header name cannot be empty") + return "" + } + internalVars := options.ValueFromAllCtx("InternalVars").(map[string]interface{}) + var headers map[string][]string + if h, ok := internalVars["setHeaders"]; ok { + headers = h.(map[string][]string) + } else { + headers = make(map[string][]string) + } + // Replace or add the header + headers[headerName] = []string{headerValue} + internalVars["setHeaders"] = headers + return "" +} + func (t templateHelpers) sum(numbers []string, format string) string { return sumNumbers(numbers, format) } @@ -548,6 +578,13 @@ func (t templateHelpers) addToArray(key string, value string, output bool, optio } } +// Initializes (clears) an array in the template context under the given key. +func (t templateHelpers) initArray(key string, options *raymond.Options) string { + arrayData := options.ValueFromAllCtx("Kvs").(map[string]interface{}) + arrayData[key] = []string{} + return "" +} + func (t templateHelpers) getArray(key string, options *raymond.Options) []string { arrayData := options.ValueFromAllCtx("Kvs").(map[string]interface{}) if array, ok := arrayData[key]; ok { diff --git a/core/templating/template_helpers_test.go b/core/templating/template_helpers_test.go index 905272506..124587717 100644 --- a/core/templating/template_helpers_test.go +++ b/core/templating/template_helpers_test.go @@ -7,6 +7,18 @@ import ( . "github.com/onsi/gomega" ) +// mockRaymondOptions is a minimal mock for raymond.Options for testing +type mockRaymondOptions struct { + internalVars map[string]interface{} +} + +func (m *mockRaymondOptions) ValueFromAllCtx(key string) interface{} { + if key == "InternalVars" { + return m.internalVars + } + return nil +} + func testNow() time.Time { parsedTime, _ := time.Parse("2006-01-02T15:04:05Z", "2018-01-01T00:00:00Z") return parsedTime @@ -93,6 +105,7 @@ func Test_split(t *testing.T) { } func Test_concat(t *testing.T) { + RegisterTestingT(t) unit := templateHelpers{} @@ -100,6 +113,14 @@ func Test_concat(t *testing.T) { Expect(unit.concat("one", " two")).To(Equal("one two")) } +func Test_concatWithManyStrings(t *testing.T) { + RegisterTestingT(t) + + unit := templateHelpers{} + + Expect(unit.concat("one", " two", " three", " four")).To(Equal("one two three four")) +} + func Test_length(t *testing.T) { RegisterTestingT(t) diff --git a/core/templating/templating.go b/core/templating/templating.go index 801122d3e..52971235b 100644 --- a/core/templating/templating.go +++ b/core/templating/templating.go @@ -41,7 +41,6 @@ type Request struct { Host string } - type Templator struct { SupportedMethodMap map[string]interface{} TemplateHelper templateHelpers @@ -62,7 +61,7 @@ func NewEnrichedTemplator(journal *journal.Journal) *Templator { now: time.Now, fakerSource: gofakeit.New(0), TemplateDataSource: templateDataSource, - journal: journal, + journal: journal, } helperMethodMap["now"] = t.nowHelper @@ -105,11 +104,13 @@ func NewEnrichedTemplator(journal *journal.Journal) *Templator { helperMethodMap["journal"] = t.parseJournalBasedOnIndex helperMethodMap["hasJournalKey"] = t.hasJournalKey helperMethodMap["setStatusCode"] = t.setStatusCode + helperMethodMap["setHeader"] = t.setHeader helperMethodMap["sum"] = t.sum helperMethodMap["add"] = t.add helperMethodMap["subtract"] = t.subtract helperMethodMap["multiply"] = t.multiply helperMethodMap["divide"] = t.divide + helperMethodMap["initArray"] = t.initArray helperMethodMap["addToArray"] = t.addToArray helperMethodMap["getArray"] = t.getArray helperMethodMap["putValue"] = t.putValue @@ -146,11 +147,20 @@ func (t *Templator) RenderTemplate(tpl *raymond.Template, requestDetails *models ctx := t.NewTemplatingData(requestDetails, literals, vars, state) result, err := tpl.Exec(ctx) - if err == nil { - statusCode, ok := ctx.InternalVars["statusCode"] - if ok && response != nil { + if err == nil && response != nil { + // Set status code if present + if statusCode, ok := ctx.InternalVars["statusCode"]; ok { response.Status = statusCode.(int) } + // Set headers if present + if setHeaders, ok := ctx.InternalVars["setHeaders"]; ok { + if response.Headers == nil { + response.Headers = make(map[string][]string) + } + for k, v := range setHeaders.(map[string][]string) { + response.Headers[k] = v + } + } } return result, err } diff --git a/core/templating/templating_test.go b/core/templating/templating_test.go index cbbbb2bf1..90b2bc1fe 100644 --- a/core/templating/templating_test.go +++ b/core/templating/templating_test.go @@ -80,6 +80,18 @@ func Test_ApplyTemplate_MatchingRowsCsvAndReturnMatchedString(t *testing.T) { Expect(template).To(Equal(`Test2`)) } +func Test_ApplyTemplate_InitArray_ClearsArray(t *testing.T) { + RegisterTestingT(t) + + template, err := ApplyTemplate( + &models.RequestDetails{}, + make(map[string]string), + `{{addToArray 'myArray' 'one' false}}{{addToArray 'myArray' 'two' false}}{{addToArray 'myArray' 'three' false}}{{addToArray 'myArray' 'four' false}}{{addToArray 'myArray' 'five' false}}{{initArray 'myArray'}}{{addToArray 'myArray' 'six' false}}{{#each (getArray 'myArray')}}{{this}}{{/each}}`, + ) + + Expect(err).To(BeNil()) + Expect(template).To(Equal("six")) +} func Test_ApplyTemplate_MatchingRowsCsvMissingDataSource(t *testing.T) { RegisterTestingT(t) @@ -917,6 +929,22 @@ func Test_ApplyTemplate_setStatusCode_should_handle_nil_response(t *testing.T) { Expect(template).To(Equal("")) } +func Test_ApplyTemplate_setHeader(t *testing.T) { + RegisterTestingT(t) + + templator := templating.NewTemplator() + + template, err := templator.ParseTemplate(`{{ setHeader "X-Test-Header" "HeaderValue" }}`) + Expect(err).To(BeNil()) + + response := &models.ResponseDetails{Headers: map[string][]string{}} + result, err := templator.RenderTemplate(template, &models.RequestDetails{}, response, &models.Literals{}, &models.Variables{}, make(map[string]string)) + + Expect(err).To(BeNil()) + Expect(result).To(Equal("")) + Expect(response.Headers).To(HaveKeyWithValue("X-Test-Header", []string{"HeaderValue"})) +} + func toInterfaceSlice(arguments []string) []interface{} { argumentsArray := make([]interface{}, len(arguments)) diff --git a/docs/pages/keyconcepts/templating/templating.rst b/docs/pages/keyconcepts/templating/templating.rst index 20e460061..099794d5a 100644 --- a/docs/pages/keyconcepts/templating/templating.rst +++ b/docs/pages/keyconcepts/templating/templating.rst @@ -49,7 +49,7 @@ Currently, you can get the following data from request to the response via templ Helper Methods -------------- -Additional data can come from helper methods. These are the ones Hoverfly currently support: +Additional data can come from helper methods. These are the ones Hoverfly currently supports: +-----------------------------------------------------------+-----------------------------------------------------------+-----------------------------------------+ | Description | Example | Result | @@ -529,14 +529,19 @@ In this case, you can use the internal key value data store. The following helpe +----------------------------+--------------------------------------------+-----------------------+ | Get an entry | ``{{ getValue 'id' }}`` | 123 | +----------------------------+--------------------------------------------+-----------------------+ -| Add a value to an arra | ``{{ addToArray 'names' 'John' true }}`` | John | +| Add a value to an array | ``{{ addToArray 'names' 'John' true }}`` | John | +| Get an array | ``{{ getArray 'names' }}`` | ["John"] | +| Clear an array | ``{{ initArray 'names' }}`` | [] | +----------------------------+--------------------------------------------+-----------------------+ | Get an array | ``{{ getArray 'names' }}`` | []string{"John" | +----------------------------+--------------------------------------------+-----------------------+ + ``addToArray`` will create a new array if one doesn't exist. The boolean argument in ``putValue`` and ``addToArray`` is used to control whether the set value is returned. +``initArray`` will clear an existing array (set it to empty) or create a new empty array if it does not exist. This is useful for resetting arrays inside loops or before reusing them in a template. + .. note:: Each templating session has its own key value store, which means all the data you set will be cleared after the current response is rendered. @@ -601,7 +606,7 @@ You can use the following helper methods to join, split or replace string values +-----------------------------------------------------------+-----------------------------------------------------------+-----------------------------------------+ | Description | Example | Result | +===========================================================+===========================================================+=========================================+ -| String concatenate | ``{{ concat 'bee' 'hive' }}`` | beehive | +| String concatenate | ``{{ concat 'bee' 'hive' 'buzz' }}`` | beehivebuzz | +-----------------------------------------------------------+-----------------------------------------------------------+-----------------------------------------+ | String splitting | ``{{ split 'bee,hive' ',' }}`` | []string{"bee", "hive"} | +-----------------------------------------------------------+-----------------------------------------------------------+-----------------------------------------+ @@ -654,6 +659,51 @@ To learn about more advanced templating functionality, such as looping and condi Global Literals and Variables ----------------------------- +Setting properties on the response +---------------------------------- + +Hoverfly provides helper functions to set properties on the HTTP response directly from your templates. This allows you to dynamically control the status code and headers based on request data or logic in your template. + +Setting the Status Code +~~~~~~~~~~~~~~~~~~~~~~~ +You can set the HTTP status code of the response using the ``setStatusCode`` helper. This is useful for conditional logic, such as returning a 404 if a resource is not found, or a 200 if an operation succeeds. + +.. code:: handlebars + + {{ setStatusCode 404 }} + +You can use this helper inside conditional blocks: + +.. code:: handlebars + + {{#equal (csvDeleteRows 'pets' 'category' 'cats' true) '0'}} + {{ setStatusCode 404 }} + {"Message":"Error no cats found"} + {{else}} + {{ setStatusCode 200 }} + {"Message":"All cats deleted"} + {{/equal}} + +If you provide an invalid status code (e.g., outside the range 100-599), it will be ignored. + +Setting Response Headers +~~~~~~~~~~~~~~~~~~~~~~~~ +You can set or override HTTP response headers using the ``setHeader`` helper. This is useful for adding custom headers, controlling caching, or setting content types dynamically. + +.. code:: handlebars + + {{ setHeader "X-Custom-Header" "HeaderValue" }} + +You can use this helper multiple times to set different headers, or inside conditional blocks to set headers based on logic: + +.. code:: handlebars + + {{ setHeader "Content-Type" "application/json" }} + {{ setHeader "X-Request-Id" (randomUuid) }} + +If the header already exists, it will be overwritten with the new value. + +Both helpers do not output anything to the template result; they only affect the response properties. You can define global literals and variables for templated response. This comes in handy when you have a lot of templated responses that share the same constant values or helper methods.