Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for templating in response bodies #177

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,157 @@ In the following example, we have defined multiple imposters for the `POST /goph
]
````

#### Using Templating in Responses
Killgrave supports templating in responses, allowing you to create dynamic responses based on request data. This feature uses Go's `text/template` package to render templates.

In the following example, we define an imposter for the `GET /gophers/{id}` endpoint. The response body uses templating to include the id from the request URL.

````json
[
{
"request": {
"method": "GET",
"endpoint": "/gophers/{id}"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"id\": \"{{.PathParams.id}}\", \"name\": \"Gopher\"}"
}
}
]
````
In this example:

- The endpoint field uses a path parameter {id}.
- The body field in the response uses a template to include the id from the request URL.

You can also use other parts of the request in your templates, such query parameters and the request body.
Since query parameters can be used more than once, they are stored in an array and you can access them by index or use the `stringsJoin` function to concatenate them.

Here is an example that includes query parameters gopherColor and gopherAge in the response, one of which can be used more than once:
deerbone marked this conversation as resolved.
Show resolved Hide resolved

````jsonc
// expects a request to, for example, GET /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&gopherAge=42
[
{
"request": {
"method": "GET",
"endpoint": "/gophers/{id}",
"params": {
"gopherColor": "{v:[a-z]+}",
"gopherAge": "{v:[0-9]+}"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"id\": \"{{ .PathParams.id }}\", \"color\": \"{{ stringsJoin .QueryParams.gopherColor "," }}\", \"age\": {{ index .QueryParams.gopherAge 0 }}}"
joanlopez marked this conversation as resolved.
Show resolved Hide resolved
}
}
]
````

Templates can also include data from the request body, allowing you to create dynamic responses based on the request data. Currently only JSON bodies are supported. The request also needs to have the correct content type set (Content-Type: application/json).

This example also showcases the functions `timeNow`, `timeUTC`, `timeAdd`, `timeFormat`, `jsonMarshal` and `stringsJoin` that are available for use in templates.
Copy link
Member

@joanlopez joanlopez Oct 26, 2024

Choose a reason for hiding this comment

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

I'd love to have a two separated examples, please 🙏🏻 So, we can see how to use data from a JSON request in one, and how to use the functions in another one.

You already set another section for the functions, so we could perhaps duplicate the example, and simplify the one for using data from JSON requests to not use any function. Wdyt?


Here is an example that includes the request body in the response:

````jsonc
// imposters/gophers.imp.json
[
{
"request": {
"method": "POST",
"endpoint": "/gophers",
"schemaFile": "schemas/create_gopher_request.json",
"headers": {
"Content-Type": "application/json"
},
"params": {
"gopherColor": "{v:[a-z]+}",
"gopherAge": "{v:[0-9]+}"
}
},
"response": {
"status": 201,
"headers": {
"Content-Type": "application/json"
},
"bodyFile": "responses/create_gopher_response.json.tmpl"
}
}
]
````
````tmpl
// responses/create_gopher_response.json.tmpl
{
"data": {
"type": "{{ .RequestBody.data.type }}",
"id": "{{ .PathParams.GopherID }}",
"timestamp": "{{ timeFormat (timeUTC (timeNow)) "2006-01-02 15:04" }}",
"birthday": "{{ timeFormat (timeAdd (timeNow) "24h") "2006-01-02" }}",
"attributes": {
"name": "{{ .RequestBody.data.attributes.name }}",
"color": "{{ stringsJoin .QueryParams.gopherColor "," }}",
"age": {{ index .QueryParams.gopherAge 0 }}
},
"friends": {{ jsonMarshal .RequestBody.data.friends }}
}
}

````
````jsonc
// request body to POST /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&gopherAge=42
{
"data": {
"type": "gophers",
"attributes": {
"name": "Natalissa"
},
"friends": [
{
"name": "Zebediah",
"color": "Purple",
"age": 55
}
]
}
}
// response
{
"data": {
"type": "gophers",
"id": "bca49e8a-82dd-4c5d-b886-13a6ceb3744b",
"timestamp": "2006-01-02 15:04",
"birthday": "2006-01-03",
"attributes": {
"name": "Natalissa",
"color": "Blue,Purple",
"age": 42
},
"friends": [{"age":55,"color":"Purple","name":"Zebediah"}]
}
}
````

#### Available custom templating functions

These functions aren't part of the standard Go template functions, but are available for use in Killgrave templates:

- `timeNow`: Returns the current time. Returns RFC3339 formatted string.
- `timeUTC`: Converts a RFC3339 formatted string to UTC. Returns RFC3339 formatted string.
- `timeAdd`: Adds a duration to a RFC3339 formatted datetime string. Returns RFC3339 formatted string. Uses the [Go ParseDuration format](https://pkg.go.dev/time#ParseDuration).
- `timeFormat`: Formats a RFC3339 string using the provided layout. Uses the [Go time package layout](https://pkg.go.dev/time#pkg-constants).
- `jsonMarshal`: Marshals an object to a JSON string.
- `stringsJoin`: Concatenates an array of strings using a separator.


## Contributing
[Contributions](CONTRIBUTING.md) are more than welcome, if you are interested please follow our guidelines to help you get started.

Expand Down
176 changes: 169 additions & 7 deletions internal/server/http/handler.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
package http

import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"text/template"
"time"
)

type TemplatingData struct {
RequestBody map[string]interface{}
PathParams map[string]string
QueryParams map[string][]string
}

// ImposterHandler create specific handler for the received imposter
func ImposterHandler(i Imposter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -17,7 +28,7 @@ func ImposterHandler(i Imposter) http.HandlerFunc {
}
writeHeaders(res, w)
w.WriteHeader(res.Status)
writeBody(i, res, w)
writeBody(i, res, w, r)
}
}

Expand All @@ -31,14 +42,20 @@ func writeHeaders(r Response, w http.ResponseWriter) {
}
}

func writeBody(i Imposter, r Response, w http.ResponseWriter) {
wb := []byte(r.Body)
func writeBody(i Imposter, res Response, w http.ResponseWriter, r *http.Request) {
bodyBytes := []byte(res.Body)

if r.BodyFile != nil {
bodyFile := i.CalculateFilePath(*r.BodyFile)
wb = fetchBodyFromFile(bodyFile)
if res.BodyFile != nil {
bodyFile := i.CalculateFilePath(*res.BodyFile)
bodyBytes = fetchBodyFromFile(bodyFile)
}
w.Write(wb)

templateBytes, err := applyTemplate(i, bodyBytes, r)
if err != nil {
log.Printf("error applying template: %v\n", err)
}

w.Write(templateBytes)
}

func fetchBodyFromFile(bodyFile string) (bytes []byte) {
Expand All @@ -55,3 +72,148 @@ func fetchBodyFromFile(bodyFile string) (bytes []byte) {
}
return
}

func applyTemplate(i Imposter, bodyBytes []byte, r *http.Request) ([]byte, error) {
bodyStr := string(bodyBytes)

// check if the body contains a template
if !strings.Contains(bodyStr, "{{") {
return bodyBytes, nil
}

tmpl, err := template.New("body").
Funcs(template.FuncMap{
"stringsJoin": strings.Join,
"jsonMarshal": func(v interface{}) (string, error) {
Copy link
Member

@joanlopez joanlopez Oct 26, 2024

Choose a reason for hiding this comment

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

I'd prefer if we can have these functions declared somewhere else, with their own tests (at least the happy path), and just referenced here. Like with strings.Join, but for the custom ones.

We could have a package like: internal/template or internal/templating specifically for that. Even, defining the apply function and the types there, so we can have proper tests for them as well, and we only use that functionality from this internal/server/http package.

b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
},
"timeNow": func() string {
return time.Now().Format(time.RFC3339)
},
"timeUTC": func(t string) (string, error) {
parsedTime, err := time.Parse(time.RFC3339, t)
if err != nil {
return "", fmt.Errorf("error parsing time: %v", err)
}
return parsedTime.UTC().Format(time.RFC3339), nil
},
"timeAdd": func(t string, d string) (string, error) {
parsedTime, err := time.Parse(time.RFC3339, t)
if err != nil {
return "", fmt.Errorf("error parsing time: %v", err)
}
duration, err := time.ParseDuration(d)
if err != nil {
return "", fmt.Errorf("error parsing duration: %v", err)
}
return parsedTime.Add(duration).Format(time.RFC3339), nil
},
"timeFormat": func(t string, layout string) (string, error) {
parsedTime, err := time.Parse(time.RFC3339, t)
if err != nil {
return "", fmt.Errorf("error parsing time: %v", err)
}
return parsedTime.Format(layout), nil
},
}).
Parse(bodyStr)
if err != nil {
return nil, fmt.Errorf("error parsing template: %w", err)
}

extractedBody, err := extractBody(r)
if err != nil {
log.Printf("error extracting body: %v\n", err)
}

// parse request body in a generic way
tmplData := TemplatingData{
RequestBody: extractedBody,
PathParams: extractPathParams(i, r),
QueryParams: extractQueryParams(r),
}

var tpl bytes.Buffer
err = tmpl.Execute(&tpl, tmplData)
if err != nil {
return nil, fmt.Errorf("error applying template: %w", err)
}

return tpl.Bytes(), nil
}

func extractBody(r *http.Request) (map[string]interface{}, error) {
body := make(map[string]interface{})
if r.Body == http.NoBody {
return body, nil
}

bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
return body, fmt.Errorf("error reading request body: %w", err)
}

// Restore the body for further use
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

contentType := r.Header.Get("Content-Type")

switch {
case strings.Contains(contentType, "application/json"):
err = json.Unmarshal(bodyBytes, &body)
default:
return body, fmt.Errorf("unsupported content type: %s", contentType)
}

if err != nil {
return body, fmt.Errorf("error unmarshaling request body: %w", err)
}

return body, nil
}

func extractPathParams(i Imposter, r *http.Request) map[string]string {
Copy link
Member

Choose a reason for hiding this comment

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

Can we have a couple of tests for this function, please?

params := make(map[string]string)

path := r.URL.Path
if path == "" {
return params
}

endpoint := i.Request.Endpoint
// regex to split either path params using /:paramname or /{paramname}

// split path and endpoint by /
pathParts := strings.Split(path, "/")
imposterParts := strings.Split(endpoint, "/")

if len(pathParts) != len(imposterParts) {
log.Printf("request path and imposter endpoint parts do not match: %s, %s\n", path, endpoint)
return params
}

// iterate over pathParts and endpointParts
for i := range imposterParts {
if strings.HasPrefix(imposterParts[i], ":") {
params[imposterParts[i][1:]] = pathParts[i]
}
if strings.HasPrefix(imposterParts[i], "{") && strings.HasSuffix(imposterParts[i], "}") {
params[imposterParts[i][1:len(imposterParts[i])-1]] = pathParts[i]
}
}

return params
}

func extractQueryParams(r *http.Request) map[string][]string {
Copy link
Member

Choose a reason for hiding this comment

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

Also for this one, please 🙏🏻

params := make(map[string][]string)
query := r.URL.Query()
for k, v := range query {
params[k] = v
}
return params
}
Loading