Skip to content
Open
41 changes: 39 additions & 2 deletions core/templating/template_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions core/templating/template_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -93,13 +105,22 @@ func Test_split(t *testing.T) {
}

func Test_concat(t *testing.T) {

RegisterTestingT(t)

unit := templateHelpers{}

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)

Expand Down
26 changes: 20 additions & 6 deletions core/templating/templating.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ type Request struct {
Host string
}


type Templator struct {
SupportedMethodMap map[string]interface{}
TemplateHelper templateHelpers
Expand All @@ -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
Expand All @@ -79,7 +78,11 @@ func NewEnrichedTemplator(journal *journal.Journal) *Templator {
helperMethodMap["randomUuid"] = t.randomUuid
helperMethodMap["replace"] = t.replace
helperMethodMap["split"] = t.split
helperMethodMap["concat"] = t.concat
// Register concatMany to accept a slice of interface{}
raymond.RemoveHelper("concat") //This is here to allow testing to deregister and re-register concat if called multiple times
raymond.RegisterHelper("concat", func(args ...interface{}) string {
return t.concat(args...)
})
Comment on lines +81 to +85
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not needed, you can just do helperMethodMap["concat"] = t.concat

helperMethodMap["length"] = t.length
helperMethodMap["substring"] = t.substring
helperMethodMap["rightmostCharacters"] = t.rightmostCharacters
Expand All @@ -105,11 +108,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
Expand Down Expand Up @@ -146,11 +151,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
}
Expand Down
28 changes: 28 additions & 0 deletions core/templating/templating_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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))

Expand Down
56 changes: 53 additions & 3 deletions docs/pages/keyconcepts/templating/templating.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"} |
+-----------------------------------------------------------+-----------------------------------------------------------+-----------------------------------------+
Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions functional-tests/core/bin/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading
Loading