From 94875bb4c5222886c8d982f05e811391614b8297 Mon Sep 17 00:00:00 2001 From: Adam Ludes Date: Tue, 15 Oct 2024 14:34:09 +0200 Subject: [PATCH 01/10] Add support for templating in response bodies Signed-off-by: Adam Ludes --- README.md | 123 ++++++++++++++++ internal/server/http/handler.go | 139 +++++++++++++++++- internal/server/http/handler_test.go | 68 +++++++++ .../create_gopher.imp.json | 26 ++++ .../create_gopher_response.json.tmpl | 11 ++ .../schemas/create_gopher_request.json | 34 +++++ 6 files changed, 394 insertions(+), 7 deletions(-) create mode 100644 internal/server/http/test/testdata/imposters_templating/create_gopher.imp.json create mode 100644 internal/server/http/test/testdata/imposters_templating/responses/create_gopher_response.json.tmpl create mode 100644 internal/server/http/test/testdata/imposters_templating/schemas/create_gopher_request.json diff --git a/README.md b/README.md index 8b12570..b1341c4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index cab0ae0..54bc083 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -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,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 +} diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index ba87c9e..01094b8 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -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": { diff --git a/internal/server/http/test/testdata/imposters_templating/create_gopher.imp.json b/internal/server/http/test/testdata/imposters_templating/create_gopher.imp.json new file mode 100644 index 0000000..ebd69b3 --- /dev/null +++ b/internal/server/http/test/testdata/imposters_templating/create_gopher.imp.json @@ -0,0 +1,26 @@ +[ + { + "request": { + "method": "POST", + "endpoint": "/gophers/{GopherID}", + "schemaFile": "schemas/create_gopher_request.json", + "headers": { + "Content-Type": "application/json" + }, + "params": { + "gopherColor": "{v:[a-z]+}", + "gopherAge": "{v:[0-9]+}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFile": "responses/create_gopher_response.json.tmpl" + } + }, + { + "t": "random_text" + } +] diff --git a/internal/server/http/test/testdata/imposters_templating/responses/create_gopher_response.json.tmpl b/internal/server/http/test/testdata/imposters_templating/responses/create_gopher_response.json.tmpl new file mode 100644 index 0000000..316b1fa --- /dev/null +++ b/internal/server/http/test/testdata/imposters_templating/responses/create_gopher_response.json.tmpl @@ -0,0 +1,11 @@ +{ + "data": { + "type": "{{ .RequestBody.data.type }}", + "id": "{{ .PathParams.GopherID }}", + "attributes": { + "name": "{{ .RequestBody.data.attributes.name }}", + "color": "{{ stringsJoin .QueryParams.gopherColor "," }}", + "age": {{ index .QueryParams.gopherAge 0 }} + } + } +} diff --git a/internal/server/http/test/testdata/imposters_templating/schemas/create_gopher_request.json b/internal/server/http/test/testdata/imposters_templating/schemas/create_gopher_request.json new file mode 100644 index 0000000..4a1c0f7 --- /dev/null +++ b/internal/server/http/test/testdata/imposters_templating/schemas/create_gopher_request.json @@ -0,0 +1,34 @@ +{ + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "gophers" + ] + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + }, + "required": [ + "type", + "attributes" + ] + } + }, + "required": [ + "data" + ] +} From 2fa497a21128febb037432326e3c40882fab3e54 Mon Sep 17 00:00:00 2001 From: Adam Ludes Date: Tue, 15 Oct 2024 17:07:53 +0200 Subject: [PATCH 02/10] Edit README.md Signed-off-by: Adam Ludes --- README.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b1341c4..6a962e2 100644 --- a/README.md +++ b/README.md @@ -561,7 +561,7 @@ 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. +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. @@ -587,12 +587,13 @@ 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. +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 a query parameter gopherColor in the response: +Here is an example that includes query parameters gopherColor and gopherAge in the response, one of which can be used more than once: ````json +// expects a request to, for example, GET /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&gopherAge=42 [ { "request": { @@ -600,7 +601,7 @@ Here is an example that includes a query parameter gopherColor in the response: "endpoint": "/gophers/{id}", "params": { "gopherColor": "{v:[a-z]+}", - "age": "{v:[0-9]+}" + "gopherAge": "{v:[0-9]+}" } }, "response": { @@ -608,13 +609,13 @@ Here is an example that includes a query parameter gopherColor in the response: "headers": { "Content-Type": "application/json" }, - "body": "{\"id\": \"{{ .PathParams.id }}\", \"color\": \"{{ stringsJoin .QueryParams.gopherColor "," }}\", \"age\": {{ index .QueryParams.age 0 }}}" + "body": "{\"id\": \"{{ .PathParams.id }}\", \"color\": \"{{ stringsJoin .QueryParams.gopherColor "," }}\", \"age\": {{ index .QueryParams.gopherAge 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. +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: @@ -631,7 +632,7 @@ Here is an example that includes the request body in the response: }, "params": { "gopherColor": "{v:[a-z]+}", - "age": "{v:[0-9]+}" + "gopherAge": "{v:[0-9]+}" } }, "response": { @@ -653,13 +654,13 @@ Here is an example that includes the request body in the response: "attributes": { "name": "{{ .RequestBody.data.attributes.name }}", "color": "{{ stringsJoin .QueryParams.gopherColor "," }}", - "age": {{ index .QueryParams.age 0 }} + "age": {{ index .QueryParams.gopherAge 0 }} } } } ```` ````json -// request body to POST /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&age=42 +// request body to POST /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&gopherAge=42 { "data": { "type": "gophers", From 3ad1631dc541153d131eee62f6e7eaf6e3c5e225 Mon Sep 17 00:00:00 2001 From: Adam Ludes Date: Wed, 16 Oct 2024 18:30:19 +0200 Subject: [PATCH 03/10] extend templating with useful functions Signed-off-by: Adam Ludes --- README.md | 35 ++++++++++++++-- internal/server/http/handler.go | 41 ++++++++++++++++++- internal/server/http/handler_test.go | 24 ++++++++--- .../create_gopher_response.json.tmpl | 5 ++- 4 files changed, 92 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6a962e2..aab3bfe 100644 --- a/README.md +++ b/README.md @@ -617,6 +617,8 @@ Here is an example that includes query parameters gopherColor and gopherAge in t 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. + Here is an example that includes the request body in the response: ````json @@ -651,13 +653,17 @@ Here is an example that includes the request body in the response: "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 }} } } + ```` ````json // request body to POST /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&gopherAge=42 @@ -665,8 +671,15 @@ Here is an example that includes the request body in the response: "data": { "type": "gophers", "attributes": { - "name": "Natalissa", - } + "name": "Natalissa" + }, + "friends": [ + { + "name": "Zebediah", + "color": "Purple", + "age": 55 + } + ] } } // response @@ -674,15 +687,29 @@ Here is an example that includes the request body in the 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 (in RFC3339 format). +- `timeUTC`: Returns the current time in UTC (in RFC3339 format). +- `timeAdd`: Adds a duration to a time.Time object. 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. diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index 54bc083..1d9740d 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -82,7 +82,44 @@ func applyTemplate(i Imposter, bodyBytes []byte, r *http.Request) ([]byte, error } tmpl, err := template.New("body"). - Funcs(template.FuncMap{"stringsJoin": strings.Join}). + Funcs(template.FuncMap{ + "stringsJoin": strings.Join, + "jsonMarshal": func(v interface{}) (string, error) { + 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) @@ -111,7 +148,7 @@ func applyTemplate(i Imposter, bodyBytes []byte, r *http.Request) ([]byte, error func extractBody(r *http.Request) (map[string]interface{}, error) { body := make(map[string]interface{}) - if r.Body == nil { + if r.Body == http.NoBody { return body, nil } diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index 01094b8..19b6795 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -72,12 +73,19 @@ func TestImposterHandler(t *testing.T) { func TestImposterHandlerTemplating(t *testing.T) { bodyRequest := []byte(`{ "data": { - "type": "gophers", - "attributes": { - "name": "Natalissa" - } + "type": "gophers", + "attributes": { + "name": "Natalissa" + }, + "friends": [ + { + "name": "Zebediah", + "color": "Purple", + "age": 55 + } + ] } - }`) + }`) var headers = make(map[string]string) headers["Content-Type"] = "application/json" @@ -100,11 +108,14 @@ func TestImposterHandlerTemplating(t *testing.T) { "data": { "type": "gophers", "id": "bca49e8a-82dd-4c5d-b886-13a6ceb3744b", + "timestamp": "` + time.Now().UTC().Format("2006-01-02 15:04") + `", + "birthday": "` + time.Now().UTC().Add(time.Hour*24).Format("2006-01-02") + `", "attributes": { "name": "Natalissa", "color": "Blue,Purple", "age": 42 - } + }, + "friends": [{"age":55,"color":"Purple","name":"Zebediah"}] } } ` @@ -127,6 +138,7 @@ func TestImposterHandlerTemplating(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() + tt.imposter.PopulateBodyData() handler := ImposterHandler(tt.imposter) handler.ServeHTTP(rec, req) diff --git a/internal/server/http/test/testdata/imposters_templating/responses/create_gopher_response.json.tmpl b/internal/server/http/test/testdata/imposters_templating/responses/create_gopher_response.json.tmpl index 316b1fa..b08a04d 100644 --- a/internal/server/http/test/testdata/imposters_templating/responses/create_gopher_response.json.tmpl +++ b/internal/server/http/test/testdata/imposters_templating/responses/create_gopher_response.json.tmpl @@ -2,10 +2,13 @@ "data": { "type": "{{ .RequestBody.data.type }}", "id": "{{ .PathParams.GopherID }}", + "timestamp": "{{ timeFormat (timeUTC (timeNow)) "2006-01-02 15:04" }}", + "birthday": "{{ timeFormat (timeAdd (timeUTC (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 }} } } From 246673998f7abfd62c6a905b36cc2bfec7bb327c Mon Sep 17 00:00:00 2001 From: Adam Ludes Date: Wed, 16 Oct 2024 18:39:49 +0200 Subject: [PATCH 04/10] fix README.md Signed-off-by: Adam Ludes --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aab3bfe..f3f1b23 100644 --- a/README.md +++ b/README.md @@ -703,9 +703,9 @@ Here is an example that includes the request body in the response: These functions aren't part of the standard Go template functions, but are available for use in Killgrave templates: -- `timeNow`: Returns the current time (in RFC3339 format). -- `timeUTC`: Returns the current time in UTC (in RFC3339 format). -- `timeAdd`: Adds a duration to a time.Time object. Uses the [Go ParseDuration format](https://pkg.go.dev/time#ParseDuration). +- `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. From cd9598468af1eb5cc4db690492886174cce067af Mon Sep 17 00:00:00 2001 From: Adam Ludes Date: Wed, 16 Oct 2024 18:50:26 +0200 Subject: [PATCH 05/10] use jsonc in code blocks Signed-off-by: Adam Ludes --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f3f1b23..a7b31b3 100644 --- a/README.md +++ b/README.md @@ -592,7 +592,7 @@ Since query parameters can be used more than once, they are stored in an array a Here is an example that includes query parameters gopherColor and gopherAge in the response, one of which can be used more than once: -````json +````jsonc // expects a request to, for example, GET /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&gopherAge=42 [ { @@ -621,7 +621,7 @@ This example also showcases the functions `timeNow`, `timeUTC`, `timeAdd`, `time Here is an example that includes the request body in the response: -````json +````jsonc // imposters/gophers.imp.json [ { @@ -665,7 +665,7 @@ Here is an example that includes the request body in the response: } ```` -````json +````jsonc // request body to POST /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b?gopherColor=Blue&gopherColor=Purple&gopherAge=42 { "data": { From c74d9e2e726943dfff847bebd9d7a9507b395ca6 Mon Sep 17 00:00:00 2001 From: Adam Ludes <141125633+deerbone@users.noreply.github.com> Date: Tue, 29 Oct 2024 16:33:01 +0100 Subject: [PATCH 06/10] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joan López de la Franca Beltran <5459617+joanlopez@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7b31b3..bd47571 100644 --- a/README.md +++ b/README.md @@ -590,7 +590,7 @@ In this example: 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: +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 From e552b0db7ffc5f9f6a5b62cd415c9e04ee601492 Mon Sep 17 00:00:00 2001 From: Adam Ludes Date: Tue, 29 Oct 2024 17:09:58 +0100 Subject: [PATCH 07/10] Update README.md Signed-off-by: Adam Ludes --- README.md | 114 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 88 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index bd47571..ae84dfa 100644 --- a/README.md +++ b/README.md @@ -615,13 +615,14 @@ Here is an example that includes query parameters `gopherColor` and `gopherAge` ] ```` -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). +### Using Data from JSON Requests -This example also showcases the functions `timeNow`, `timeUTC`, `timeAdd`, `timeFormat`, `jsonMarshal` and `stringsJoin` that are available for use in templates. +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 +```jsonc // imposters/gophers.imp.json [ { @@ -632,10 +633,6 @@ Here is an example that includes the request body in the response: "headers": { "Content-Type": "application/json" }, - "params": { - "gopherColor": "{v:[a-z]+}", - "gopherAge": "{v:[0-9]+}" - } }, "response": { "status": 201, @@ -653,20 +650,96 @@ Here is an example that includes the request body in the response: "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 }} + "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" }, - "friends": {{ jsonMarshal .RequestBody.data.friends }} + "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?gopherColor=Blue&gopherColor=Purple&gopherAge=42 +// request body to POST /gophers/bca49e8a-82dd-4c5d-b886-13a6ceb3744b { "data": { "type": "gophers", @@ -676,8 +749,8 @@ Here is an example that includes the request body in the response: "friends": [ { "name": "Zebediah", - "color": "Purple", - "age": 55 + "colors": ["Blue", "Purple"], + "age": 42 } ] } @@ -699,17 +772,6 @@ Here is an example that includes the request body in the response: } ```` -#### 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. From 8926a989509551fb0f4e3750ddcc20199fab289f Mon Sep 17 00:00:00 2001 From: Adam Ludes <141125633+deerbone@users.noreply.github.com> Date: Tue, 29 Oct 2024 17:18:23 +0100 Subject: [PATCH 08/10] Update internal/server/http/handler_test.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joan López de la Franca Beltran <5459617+joanlopez@users.noreply.github.com> --- internal/server/http/handler_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index 19b6795..5d84b02 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -134,7 +134,7 @@ func TestImposterHandlerTemplating(t *testing.T) { 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) + require.NoError(t, err) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() From 2f9a63a289012f3e6138308826a8867adcab3ebd Mon Sep 17 00:00:00 2001 From: Adam Ludes <141125633+deerbone@users.noreply.github.com> Date: Tue, 29 Oct 2024 17:18:30 +0100 Subject: [PATCH 09/10] Update internal/server/http/handler_test.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joan López de la Franca Beltran <5459617+joanlopez@users.noreply.github.com> --- internal/server/http/handler_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index 5d84b02..69d1dec 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -101,7 +101,8 @@ func TestImposterHandlerTemplating(t *testing.T) { Headers: &headers, } - f, _ := os.Open(bodyFile) + f, err := os.Open(bodyFile) + require.NoError(t, err) defer f.Close() expectedBody := `{ From b934ff50c7db3754b16be263aae69534c3162a75 Mon Sep 17 00:00:00 2001 From: Adam Ludes Date: Wed, 30 Oct 2024 12:52:05 +0100 Subject: [PATCH 10/10] refactor templating with added tests Signed-off-by: Adam Ludes --- internal/server/http/handler.go | 131 ++++--------- internal/server/http/handler_test.go | 176 +++++++++++++++++- internal/templating/custom_functions.go | 47 +++++ internal/templating/custom_functions_test.go | 182 +++++++++++++++++++ internal/templating/templating.go | 38 ++++ internal/templating/templating_test.go | 114 ++++++++++++ 6 files changed, 591 insertions(+), 97 deletions(-) create mode 100644 internal/templating/custom_functions.go create mode 100644 internal/templating/custom_functions_test.go create mode 100644 internal/templating/templating.go create mode 100644 internal/templating/templating_test.go diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index 1d9740d..0166834 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -9,15 +9,10 @@ import ( "net/http" "os" "strings" - "text/template" "time" -) -type TemplatingData struct { - RequestBody map[string]interface{} - PathParams map[string]string - QueryParams map[string][]string -} + "github.com/friendsofgo/killgrave/internal/templating" +) // ImposterHandler create specific handler for the received imposter func ImposterHandler(i Imposter) http.HandlerFunc { @@ -50,7 +45,26 @@ func writeBody(i Imposter, res Response, w http.ResponseWriter, r *http.Request) bodyBytes = fetchBodyFromFile(bodyFile) } - templateBytes, err := applyTemplate(i, bodyBytes, r) + 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), + } + + templateBytes, err := templating.ApplyTemplate(bodyStr, templData) if err != nil { log.Printf("error applying template: %v\n", err) } @@ -58,10 +72,10 @@ func writeBody(i Imposter, res Response, w http.ResponseWriter, r *http.Request) 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) @@ -69,81 +83,9 @@ func fetchBodyFromFile(bodyFile string) (bytes []byte) { bytes, err := io.ReadAll(f) if err != nil { log.Printf("imposible read the file %s: %v\n", bodyFile, err) + return nil } - 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) { - 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 + return bytes } func extractBody(r *http.Request) (map[string]interface{}, error) { @@ -176,7 +118,7 @@ func extractBody(r *http.Request) (map[string]interface{}, error) { return body, nil } -func extractPathParams(i Imposter, r *http.Request) map[string]string { +func extractPathParams(r *http.Request, endpoint string) map[string]string { params := make(map[string]string) path := r.URL.Path @@ -184,25 +126,22 @@ func extractPathParams(i Imposter, r *http.Request) map[string]string { 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, "/") + endpointParts := strings.Split(endpoint, "/") - if len(pathParts) != len(imposterParts) { - log.Printf("request path and imposter endpoint parts do not match: %s, %s\n", path, 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 imposterParts { - if strings.HasPrefix(imposterParts[i], ":") { - params[imposterParts[i][1:]] = pathParts[i] + for i := range endpointParts { + if strings.HasPrefix(endpointParts[i], ":") { + params[endpointParts[i][1:]] = pathParts[i] } - if strings.HasPrefix(imposterParts[i], "{") && strings.HasSuffix(imposterParts[i], "}") { - params[imposterParts[i][1:len(imposterParts[i])-1]] = pathParts[i] + if strings.HasPrefix(endpointParts[i], "{") && strings.HasSuffix(endpointParts[i], "}") { + params[endpointParts[i][1:len(endpointParts[i])-1]] = pathParts[i] } } diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index 69d1dec..dd87d29 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -139,7 +139,6 @@ func TestImposterHandlerTemplating(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() - tt.imposter.PopulateBodyData() handler := ImposterHandler(tt.imposter) handler.ServeHTTP(rec, req) @@ -242,3 +241,178 @@ func TestImposterHandler_MultipleRequests(t *testing.T) { assert.Equal(t, "Accepted", rec.Body.String()) }) } + +func TestExtractPathParams(t *testing.T) { + tests := []struct { + name string + endpoint string + url string + expected map[string]string + }{ + { + name: "simple path params", + endpoint: "/gophers/{id}", + url: "/gophers/123", + expected: map[string]string{ + "id": "123", + }, + }, + { + name: "no path params", + endpoint: "/gophers", + url: "/gophers", + expected: map[string]string{}, + }, + { + name: "mismatched path parts", + endpoint: "/gophers/{id}/details", + url: "/gophers/123", + expected: map[string]string{}, + }, + { + name: "colon path params", + endpoint: "/gophers/:id", + url: "/gophers/123", + expected: map[string]string{ + "id": "123", + }, + }, + { + name: "multiple path params", + endpoint: "/gophers/{id}/friends/{friendID}", + url: "/gophers/123/friends/456", + expected: map[string]string{ + "id": "123", + "friendID": "456", + }, + }, + { + name: "URL with extra slashes", + endpoint: "/gophers/{id}", + url: "/gophers//123", + expected: map[string]string{}, + }, + { + name: "URL with special characters", + endpoint: "/gophers/{id}", + url: "/gophers/123@!$", + expected: map[string]string{ + "id": "123@!$", + }, + }, + { + name: "URL with missing path parts", + endpoint: "/gophers/{id}/friends/{friendID}", + url: "/gophers/123/friends", + expected: map[string]string{}, + }, + { + name: "URL with query parameters", + endpoint: "/gophers/{id}", + url: "/gophers/123?color=blue", + expected: map[string]string{ + "id": "123", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", tt.url, nil) + if err != nil { + t.Fatal(err) + } + params := extractPathParams(req, tt.endpoint) + if len(params) != len(tt.expected) { + t.Errorf("expected %d params, got %d", len(tt.expected), len(params)) + } + for k, v := range tt.expected { + if params[k] != v { + t.Errorf("expected param %s to be %s, got %s", k, v, params[k]) + } + } + }) + } +} + +func TestExtractQueryParams(t *testing.T) { + tests := []struct { + name string + url string + expected map[string][]string + }{ + { + name: "simple query params", + url: "/gophers?color=blue&age=5", + expected: map[string][]string{ + "color": {"blue"}, + "age": {"5"}, + }, + }, + { + name: "no query params", + url: "/gophers", + expected: map[string][]string{}, + }, + { + name: "multiple values for a query param", + url: "/gophers?color=blue&color=green", + expected: map[string][]string{ + "color": {"blue", "green"}, + }, + }, + { + name: "empty query parameter", + url: "/gophers?color=", + expected: map[string][]string{ + "color": {""}, + }, + }, + { + name: "query parameter with no value", + url: "/gophers?color", + expected: map[string][]string{ + "color": {""}, + }, + }, + { + name: "query parameter with special characters", + url: "/gophers?color=blue&name=John%20Doe", + expected: map[string][]string{ + "color": {"blue"}, + "name": {"John Doe"}, + }, + }, + { + name: "query parameter with mixed case", + url: "/gophers?Color=blue&color=green", + expected: map[string][]string{ + "Color": {"blue"}, + "color": {"green"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", tt.url, nil) + if err != nil { + t.Fatal(err) + } + params := extractQueryParams(req) + if len(params) != len(tt.expected) { + t.Errorf("expected %d params, got %d", len(tt.expected), len(params)) + } + for k, v := range tt.expected { + if len(params[k]) != len(v) { + t.Errorf("expected %d values for param %s, got %d", len(v), k, len(params[k])) + } + for i := range v { + if params[k][i] != v[i] { + t.Errorf("expected param %s to be %s, got %s", k, v[i], params[k][i]) + } + } + } + }) + } +} diff --git a/internal/templating/custom_functions.go b/internal/templating/custom_functions.go new file mode 100644 index 0000000..e5982c6 --- /dev/null +++ b/internal/templating/custom_functions.go @@ -0,0 +1,47 @@ +package templating + +import ( + "encoding/json" + "fmt" + "time" +) + +func JsonMarshal(v interface{}) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +func TimeNow() string { + return time.Now().Format(time.RFC3339) +} + +func TimeUTC(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 +} + +func TimeAdd(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 +} + +func TimeFormat(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 +} diff --git a/internal/templating/custom_functions_test.go b/internal/templating/custom_functions_test.go new file mode 100644 index 0000000..32d22a7 --- /dev/null +++ b/internal/templating/custom_functions_test.go @@ -0,0 +1,182 @@ +package templating + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJsonMarshal(t *testing.T) { + tests := []struct { + name string + input interface{} + expected string + }{ + { + name: "simple map", + input: map[string]string{"key": "value"}, + expected: `{"key":"value"}`, + }, + { + name: "nested map", + input: map[string]interface{}{"key": map[string]string{"nestedKey": "nestedValue"}}, + expected: `{"key":{"nestedKey":"nestedValue"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := JsonMarshal(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestJsonMarshal_Invalid(t *testing.T) { + input := make(chan int) + _, err := JsonMarshal(input) + assert.Error(t, err) +} + +func TestTimeNow(t *testing.T) { + before := time.Now().Truncate(time.Second) + got := TimeNow() + after := time.Now().Truncate(time.Second) + + parsedTime, err := time.Parse(time.RFC3339, got) + assert.NoError(t, err, "TimeNow() returned invalid time format") + + assert.True(t, parsedTime.After(before) || parsedTime.Equal(before)) + assert.True(t, parsedTime.Before(after) || parsedTime.Equal(after)) +} + +func TestTimeUTC(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "valid RFC3339 time", + input: "2023-10-15T13:34:02Z", + expected: "2023-10-15T13:34:02Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := TimeUTC(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestTimeUTC_Invalid(t *testing.T) { + tests := []struct { + name string + input string + errMsg string + }{ + { + name: "invalid time format", + input: "invalid-time", + errMsg: "parsing time", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := TimeUTC(tt.input) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestTimeAdd(t *testing.T) { + tests := []struct { + name string + time string + duration string + expected string + }{ + { + name: "add 1 hour", + time: "2023-10-15T13:34:02Z", + duration: "1h", + expected: "2023-10-15T14:34:02Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := TimeAdd(tt.time, tt.duration) + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestTimeAdd_Invalid(t *testing.T) { + tests := []struct { + name string + time string + duration string + errMsg string + }{ + { + name: "invalid time format", + time: "invalid-time", + duration: "1h", + errMsg: "parsing time", + }, + { + name: "invalid duration format", + time: "2023-10-15T13:34:02Z", + duration: "invalid-duration", + errMsg: "time: invalid duration", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := TimeAdd(tt.time, tt.duration) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestTimeFormat(t *testing.T) { + tests := []struct { + name string + time string + layout string + expected string + }{ + { + name: "valid time and layout", + time: "2023-10-15T13:34:02Z", + layout: "2006-01-02 15:04:05", + expected: "2023-10-15 13:34:02", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := TimeFormat(tt.time, tt.layout) + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestTimeFormat_Invalid(t *testing.T) { + _, err := TimeFormat("invalid-time", "2006-01-02 15:04:05") + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing time") +} diff --git a/internal/templating/templating.go b/internal/templating/templating.go new file mode 100644 index 0000000..8ca479e --- /dev/null +++ b/internal/templating/templating.go @@ -0,0 +1,38 @@ +package templating + +import ( + "bytes" + "fmt" + "strings" + "text/template" +) + +type TemplatingData struct { + RequestBody map[string]interface{} + PathParams map[string]string + QueryParams map[string][]string +} + +func ApplyTemplate(bodyStr string, templData TemplatingData) ([]byte, error) { + tmpl, err := template.New("body"). + Funcs(template.FuncMap{ + "stringsJoin": strings.Join, + "jsonMarshal": JsonMarshal, + "timeNow": TimeNow, + "timeUTC": TimeUTC, + "timeAdd": TimeAdd, + "timeFormat": TimeFormat, + }). + Parse(bodyStr) + if err != nil { + return nil, fmt.Errorf("error parsing template: %w", err) + } + + var tpl bytes.Buffer + err = tmpl.Execute(&tpl, templData) + if err != nil { + return nil, fmt.Errorf("error applying template: %w", err) + } + + return tpl.Bytes(), nil +} diff --git a/internal/templating/templating_test.go b/internal/templating/templating_test.go new file mode 100644 index 0000000..9108e07 --- /dev/null +++ b/internal/templating/templating_test.go @@ -0,0 +1,114 @@ +package templating + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyTemplate(t *testing.T) { + now := time.Now() + tests := []struct { + name string + bodyStr string + templData TemplatingData + expected string + }{ + { + name: "simple template", + bodyStr: `{"message": "Hello, {{ .PathParams.name }}!"}`, + templData: TemplatingData{ + PathParams: map[string]string{ + "name": "World", + }, + }, + expected: `{"message": "Hello, World!"}`, + }, + { + name: "template with JSON marshaling", + bodyStr: `{"data": {{ jsonMarshal .RequestBody.data }}}`, + templData: TemplatingData{ + RequestBody: map[string]interface{}{ + "data": map[string]string{ + "key": "value", + }, + }, + }, + expected: `{"data": {"key":"value"}}`, + }, + { + name: "template with time functions", + bodyStr: `{ + "timestamp": "{{ timeFormat (timeUTC (timeNow)) "2006-01-02 15:04" }}", + "future": "{{ timeFormat (timeAdd (timeUTC (timeNow)) "24h") "2006-01-02" }}" + }`, + templData: TemplatingData{ + RequestBody: map[string]interface{}{}, + PathParams: map[string]string{}, + QueryParams: map[string][]string{}, + }, + expected: `{ + "timestamp": "` + now.UTC().Format("2006-01-02 15:04") + `", + "future": "` + now.Add(24*time.Hour).UTC().Format("2006-01-02") + `" + }`, + }, + { + name: "template with string join", + bodyStr: `{"colors": "{{ stringsJoin .QueryParams.colors "," }}"}`, + templData: TemplatingData{ + QueryParams: map[string][]string{ + "colors": {"red", "green", "blue"}, + }, + }, + expected: `{"colors": "red,green,blue"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ApplyTemplate(tt.bodyStr, tt.templData) + require.NoError(t, err) + assert.JSONEq(t, tt.expected, string(got)) + }) + } +} + +func TestApplyTemplate_Error(t *testing.T) { + tests := []struct { + name string + bodyStr string + templData TemplatingData + errMsg string + }{ + { + name: "invalid template directive", + bodyStr: `{"message": "Hello, {{ .PathParams.name | invalidFunc }}`, + templData: TemplatingData{ + PathParams: map[string]string{ + "name": "World", + }, + }, + errMsg: "function \"invalidFunc\" not defined", + }, + { + name: "invalid template", + bodyStr: `{"message": "Hello, {{ .InvalidField }}`, + templData: TemplatingData{ + PathParams: map[string]string{ + "name": "World", + }, + }, + errMsg: "error applying template", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ApplyTemplate(tt.bodyStr, tt.templData) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +}