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 all 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
213 changes: 213 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,219 @@ 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:

````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
}
}
]
````

### Using Data from JSON Requests

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).

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"
},
},
"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 }}",
"attributes": {
"name": "{{ .RequestBody.data.attributes.name }}",
"color": "{{ .RequestBody.data.attributes.gopherColor }}",
"age": {{ .RequestBody.data.attributes.gopherAge }}
}
}
}

````
````jsonc
// request body to POST /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b
{
"data": {
"type": "gophers",
"attributes": {
"name": "Natalissa",
"color": "Blue",
"age": 42
},
}
}
// response
{
"data": {
"type": "gophers",
"id": "bca49e8a-82dd-4c5d-b886-13a6ceb3744b",
"attributes": {
"name": "Natalissa",
"color": "Blue",
"age": 42
}
}
}
````

#### Using 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. Useful for including all data from a field.
- `stringsJoin`: Concatenates an array of strings using a separator.



```jsonc
// imposters/gophers_with_functions.imp.json
[
{
"request": {
"method": "POST",
"endpoint": "/gophers",
"schemaFile": "schemas/create_gopher_request.json",
"headers": {
"Content-Type": "application/json"
}
},
"response": {
"status": 201,
"headers": {
"Content-Type": "application/json"
},
"bodyFile": "responses/create_gopher_response_with_functions.json.tmpl"
}
}
]
````
````tmpl
// responses/create_gopher_response_with_functions.json.tmpl
{
"data": {
"type": "{{ .RequestBody.data.type }}",
"id": "{{ .PathParams.GopherID }}",
"timestamp": "{{ timeFormat (timeUTC (timeNow)) "2006-01-02 15:04" }}", // Current time in UTC
"birthday": "{{ timeFormat (timeAdd (timeNow) "24h") "2006-01-02" }}", // Always returns tomorrow's date
"attributes": {
"name": "{{ .RequestBody.data.attributes.name }}",
"color": "{{ stringsJoin .RequestBody.data.attributes.colors "," }}", // Concatenates the colors array
"age": {{ .RequestBody.data.attributes.age }}
},
"friends": {{ jsonMarshal .RequestBody.data.friends }} // Includes all data from the friends field
}
}

````
````jsonc
// request body to POST /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b
{
"data": {
"type": "gophers",
"attributes": {
"name": "Natalissa"
},
"friends": [
{
"name": "Zebediah",
"colors": ["Blue", "Purple"],
"age": 42
}
]
}
}
// 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"}]
}
}
````


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

Expand Down
121 changes: 111 additions & 10 deletions internal/server/http/handler.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package http

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

"github.com/friendsofgo/killgrave/internal/templating"
)

// ImposterHandler create specific handler for the received imposter
Expand All @@ -17,7 +23,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,27 +37,122 @@ 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 res.BodyFile != nil {
bodyFile := i.CalculateFilePath(*res.BodyFile)
bodyBytes = fetchBodyFromFile(bodyFile)
}

bodyStr := string(bodyBytes)

// early return if body does not contain templating
if !strings.Contains(bodyStr, "{{") {
w.Write([]byte(bodyStr))
return
}

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

templData := templating.TemplatingData{
RequestBody: structuredBody,
PathParams: extractPathParams(r, i.Request.Endpoint),
QueryParams: extractQueryParams(r),
}

if r.BodyFile != nil {
bodyFile := i.CalculateFilePath(*r.BodyFile)
wb = fetchBodyFromFile(bodyFile)
templateBytes, err := templating.ApplyTemplate(bodyStr, templData)
if err != nil {
log.Printf("error applying template: %v\n", err)
}
w.Write(wb)

w.Write(templateBytes)
}

func fetchBodyFromFile(bodyFile string) (bytes []byte) {
func fetchBodyFromFile(bodyFile string) []byte {
if _, err := os.Stat(bodyFile); os.IsNotExist(err) {
log.Printf("the body file %s not found\n", bodyFile)
return
return nil
}

f, _ := os.Open(bodyFile)
defer f.Close()
bytes, err := io.ReadAll(f)
if err != nil {
log.Printf("imposible read the file %s: %v\n", bodyFile, err)
return nil
}
return bytes
}

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(r *http.Request, endpoint string) map[string]string {
params := make(map[string]string)

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

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

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

// iterate over pathParts and endpointParts
for i := range endpointParts {
if strings.HasPrefix(endpointParts[i], ":") {
params[endpointParts[i][1:]] = pathParts[i]
}
if strings.HasPrefix(endpointParts[i], "{") && strings.HasSuffix(endpointParts[i], "}") {
params[endpointParts[i][1:len(endpointParts[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
return params
}
Loading