-
Notifications
You must be signed in to change notification settings - Fork 99
Add support for templating in response bodies #177
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
base: main
Are you sure you want to change the base?
Changes from 5 commits
94875bb
2fa497a
3ad1631
2466739
cd95984
c74d9e2
e552b0d
8926a98
2f9a63a
b934ff5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
||
| ````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.
|
||
| } | ||
| } | ||
| ] | ||
| ```` | ||
|
|
||
| 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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
|
||
|
|
||
| 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) { | ||
|
|
@@ -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) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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) { | ||
|
|
@@ -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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 We could have a package like: |
||
| 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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.