Skip to content

Commit

Permalink
Add support for templating in response bodies
Browse files Browse the repository at this point in the history
Signed-off-by: Adam Ludes <[email protected]>
  • Loading branch information
deerbone committed Oct 15, 2024
1 parent bca67fd commit 94875bb
Show file tree
Hide file tree
Showing 6 changed files with 394 additions and 7 deletions.
123 changes: 123 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,129 @@ 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 as headers and query parameters.
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 a query parameter gopherColor in the response:

````json
[
{
"request": {
"method": "GET",
"endpoint": "/gophers/{id}",
"params": {
"gopherColor": "{v:[a-z]+}",
"age": "{v:[0-9]+}"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"id\": \"{{ .PathParams.id }}\", \"color\": \"{{ stringsJoin .QueryParams.gopherColor "," }}\", \"age\": {{ index .QueryParams.age 0 }}}"
}
}
]
````

Templates can also include data from the request, allowing you to create dynamic responses based on the request data. Currently only JSON bodies are supported. They also need to have the correct content type set.

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

````json
// 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]+}",
"age": "{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 }}",
"attributes": {
"name": "{{ .RequestBody.data.attributes.name }}",
"color": "{{ stringsJoin .QueryParams.gopherColor "," }}",
"age": {{ index .QueryParams.age 0 }}
}
}
}
````
````json
// request body to POST /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&age=42
{
"data": {
"type": "gophers",
"attributes": {
"name": "Natalissa",
}
}
}
// response
{
"data": {
"type": "gophers",
"id": "bca49e8a-82dd-4c5d-b886-13a6ceb3744b",
"attributes": {
"name": "Natalissa",
"color": "Blue,Purple",
"age": 42
}
}
}
````


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

Expand Down
139 changes: 132 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,111 @@ 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}).
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 == nil {
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 {
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 {
params := make(map[string][]string)
query := r.URL.Query()
for k, v := range query {
params[k] = v
}
return params
}
68 changes: 68 additions & 0 deletions internal/server/http/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,74 @@ func TestImposterHandler(t *testing.T) {
}
}

func TestImposterHandlerTemplating(t *testing.T) {
bodyRequest := []byte(`{
"data": {
"type": "gophers",
"attributes": {
"name": "Natalissa"
}
}
}`)
var headers = make(map[string]string)
headers["Content-Type"] = "application/json"

schemaFile := "test/testdata/imposters_templating/schemas/create_gopher_request.json"
bodyFile := "test/testdata/imposters_templating/responses/create_gopher_response.json.tmpl"
bodyFileFake := "test/testdata/imposters_templating/responses/create_gopher_response_fail.json"
body := `{"test":true}`

validRequest := Request{
Method: "POST",
Endpoint: "/gophers/{GopherID}",
SchemaFile: &schemaFile,
Headers: &headers,
}

f, _ := os.Open(bodyFile)
defer f.Close()

expectedBody := `{
"data": {
"type": "gophers",
"id": "bca49e8a-82dd-4c5d-b886-13a6ceb3744b",
"attributes": {
"name": "Natalissa",
"color": "Blue,Purple",
"age": 42
}
}
}
`

var dataTest = []struct {
name string
imposter Imposter
expectedBody string
statusCode int
}{
{"valid imposter with body", Imposter{Request: validRequest, Response: Responses{{Status: http.StatusOK, Headers: &headers, Body: body}}}, body, http.StatusOK},
{"valid imposter with bodyFile", Imposter{Request: validRequest, Response: Responses{{Status: http.StatusOK, Headers: &headers, BodyFile: &bodyFile}}}, expectedBody, http.StatusOK},
{"valid imposter with not exists bodyFile", Imposter{Request: validRequest, Response: Responses{{Status: http.StatusOK, Headers: &headers, BodyFile: &bodyFileFake}}}, "", http.StatusOK},
}

for _, tt := range dataTest {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest("POST", "/gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&gopherAge=42", bytes.NewBuffer(bodyRequest))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")

rec := httptest.NewRecorder()
handler := ImposterHandler(tt.imposter)

handler.ServeHTTP(rec, req)
assert.Equal(t, rec.Code, tt.statusCode)
assert.Equal(t, tt.expectedBody, rec.Body.String())

})
}
}

func TestInvalidRequestWithSchema(t *testing.T) {
validRequest := []byte(`{
"data": {
Expand Down
Loading

0 comments on commit 94875bb

Please sign in to comment.