Skip to content

Commit

Permalink
feat(#19): allow external filename functions and give access to reque…
Browse files Browse the repository at this point in the history
…st body
  • Loading branch information
StephanHCB committed Jun 8, 2023
1 parent 74611aa commit 9ca241a
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 73 deletions.
132 changes: 73 additions & 59 deletions implementation/playback/playback.go
Expand Up @@ -14,84 +14,98 @@ import (
const PlaybackRewritePathEnvVariable = "GO_AUTUMN_RESTCLIENT_PLAYBACK_REWRITE_PATH"

type PlaybackImpl struct {
RecorderPath string
RecorderRewritePath string
// Now is exposed so tests can fixate the time by overwriting this field
Now func() time.Time
RecorderPath string
RecorderRewritePath string
ConstructFilenameCandidates []aurestrecorder.ConstructFilenameFunction
Now func() time.Time
}

type PlaybackOptions struct {
// ConstructFilenameCandidates contains filename constructor functions.
//
// The first one is considered "canonical", for all others, a log entry is printed that instructs the user
// to rename the file.
ConstructFilenameCandidates []aurestrecorder.ConstructFilenameFunction
NowFunc func() time.Time
}

// New builds a new http client simulator based on playback.
//
// Use this in your tests.
func New(recorderPath string) aurestclientapi.Client {
//
// You can optionally add a PlaybackOptions instance to your call. The ... is really just so it's an optional argument.
func New(recorderPath string, additionalOptions ...PlaybackOptions) aurestclientapi.Client {
filenameCandidates := []aurestrecorder.ConstructFilenameFunction{
aurestrecorder.ConstructFilenameV3WithBody,
aurestrecorder.ConstructFilenameWithBody,
aurestrecorder.ConstructFilenameV2WithBody,
}
nowFunc := time.Now
for _, o := range additionalOptions {
if len(o.ConstructFilenameCandidates) > 0 {
filenameCandidates = o.ConstructFilenameCandidates
}
if o.NowFunc != nil {
nowFunc = o.NowFunc
}
}
return &PlaybackImpl{
RecorderPath: recorderPath,
RecorderRewritePath: os.Getenv(PlaybackRewritePathEnvVariable),
Now: time.Now,
RecorderPath: recorderPath,
RecorderRewritePath: os.Getenv(PlaybackRewritePathEnvVariable),
ConstructFilenameCandidates: filenameCandidates,
Now: nowFunc,
}
}

func (c *PlaybackImpl) Perform(ctx context.Context, method string, requestUrl string, _ interface{}, response *aurestclientapi.ParsedResponse) error {
filename, err := aurestrecorder.ConstructFilenameV3(method, requestUrl)
if err != nil {
return err
}
func (c *PlaybackImpl) Perform(ctx context.Context, method string, requestUrl string, requestBody interface{}, response *aurestclientapi.ParsedResponse) error {
canonicalFilename := ""
var originalError error
for i, constructFilenameCandidate := range c.ConstructFilenameCandidates {
filename, err := constructFilenameCandidate(method, requestUrl, requestBody)
if err != nil {
return err
}
if i == 0 {
canonicalFilename = filename
}

jsonBytes, err := os.ReadFile(filepath.Join(c.RecorderPath, filename))
if err != nil {
// try old filename for compatibility (cannot fail if ConstructFilenameV2 didn't)
filenameOldV1, _ := aurestrecorder.ConstructFilename(method, requestUrl)
jsonBytes, err := os.ReadFile(filepath.Join(c.RecorderPath, filename))
if err != nil {
if i == 0 {
originalError = err
}
} else {
// successfully read
if i > 0 {
aulogging.Logger.Ctx(ctx).Info().Printf("use of deprecated recorder filename '%s', please move to '%s'", filename, canonicalFilename)
}

jsonBytesOldV1, errWithOldFilenameV1 := os.ReadFile(filepath.Join(c.RecorderPath, filenameOldV1))
if errWithOldFilenameV1 != nil {
// try old filename for compatibility (cannot fail if ConstructFilenameV2 didn't)
filenameOldV2, _ := aurestrecorder.ConstructFilenameV2(method, requestUrl)
_ = c.rewriteFileIfConfigured(ctx, filename, canonicalFilename)

jsonBytesOldV2, errWithOldFilenameV2 := os.ReadFile(filepath.Join(c.RecorderPath, filenameOldV2))
if errWithOldFilenameV2 != nil {
// but return original error if that also fails
recording := aurestrecorder.RecorderData{}
err = json.Unmarshal(jsonBytes, &recording)
if err != nil {
return err
} else {
aulogging.Logger.Ctx(ctx).Info().Printf("use of deprecated recorder filename (v2) '%s', please move to '%s'", filenameOldV2, filename)
}

_ = c.rewriteFileIfConfigured(ctx, filenameOldV2, filename)
response.Header = recording.ParsedResponse.Header
response.Status = recording.ParsedResponse.Status
response.Time = c.Now()

filename = filenameOldV2
jsonBytes = jsonBytesOldV2
// cannot just assign the body, need to re-parse into the existing pointer - using a json round trip
bodyJsonBytes, err := json.Marshal(recording.ParsedResponse.Body)
if err != nil {
return err
}
err = json.Unmarshal(bodyJsonBytes, response.Body)
if err != nil {
return err
}
} else {
aulogging.Logger.Ctx(ctx).Info().Printf("use of deprecated recorder filename (v1) '%s', please move to '%s'", filenameOldV1, filename)

_ = c.rewriteFileIfConfigured(ctx, filenameOldV1, filename)

filename = filenameOldV1
jsonBytes = jsonBytesOldV1
return recording.Error
}
} else {
_ = c.rewriteFileIfConfigured(ctx, filename, filename)
}

recording := aurestrecorder.RecorderData{}
err = json.Unmarshal(jsonBytes, &recording)
if err != nil {
return err
}

response.Header = recording.ParsedResponse.Header
response.Status = recording.ParsedResponse.Status
response.Time = c.Now()

// cannot just assign the body, need to re-parse into the existing pointer - using a json round trip
bodyJsonBytes, err := json.Marshal(recording.ParsedResponse.Body)
if err != nil {
return err
}
err = json.Unmarshal(bodyJsonBytes, response.Body)
if err != nil {
return err
}

return recording.Error
return originalError
}

func (c *PlaybackImpl) rewriteFileIfConfigured(ctx context.Context, fileNameFrom string, fileNameTo string) error {
Expand Down
40 changes: 34 additions & 6 deletions implementation/recorder/recorder.go
Expand Up @@ -12,30 +12,46 @@ import (
"strings"
)

type ConstructFilenameFunction func(method string, requestUrl string, requestBody interface{}) (string, error)

type RecorderImpl struct {
Wrapped aurestclientapi.Client
RecorderPath string
Wrapped aurestclientapi.Client
RecorderPath string
ConstructFilenameFunc ConstructFilenameFunction
}

const RecorderPathEnvVariable = "GO_AUTUMN_RESTCLIENT_RECORDER_PATH"

type RecorderOptions struct {
ConstructFilenameFunc ConstructFilenameFunction
}

// New builds a new http recorder.
//
// Insert this into your stack just above the actual http client.
//
// Normally it does nothing, but if you set the environment variable RecorderPathEnvVariable to a path to a directory,
// it will write response recordings for your requests that you can then play back using aurestplayback.PlaybackImpl
// in your tests.
func New(wrapped aurestclientapi.Client) aurestclientapi.Client {
//
// You can optionally add a RecorderOptions instance to your call. The ... is really just so it's an optional argument.
func New(wrapped aurestclientapi.Client, additionalOptions ...RecorderOptions) aurestclientapi.Client {
recorderPath := os.Getenv(RecorderPathEnvVariable)
if recorderPath != "" {
if !strings.HasSuffix(recorderPath, "/") {
recorderPath += "/"
}
}
filenameFunc := ConstructFilenameV3WithBody
for _, o := range additionalOptions {
if o.ConstructFilenameFunc != nil {
filenameFunc = o.ConstructFilenameFunc
}
}
return &RecorderImpl{
Wrapped: wrapped,
RecorderPath: recorderPath,
Wrapped: wrapped,
RecorderPath: recorderPath,
ConstructFilenameFunc: filenameFunc,
}
}

Expand All @@ -50,7 +66,7 @@ type RecorderData struct {
func (c *RecorderImpl) Perform(ctx context.Context, method string, requestUrl string, requestBody interface{}, response *aurestclientapi.ParsedResponse) error {
responseErr := c.Wrapped.Perform(ctx, method, requestUrl, requestBody, response)
if c.RecorderPath != "" {
filename, err := ConstructFilenameV3(method, requestUrl)
filename, err := c.ConstructFilenameFunc(method, requestUrl, requestBody)
if err == nil {
recording := RecorderData{
Method: method,
Expand Down Expand Up @@ -88,6 +104,10 @@ func ConstructFilename(method string, requestUrl string) (string, error) {
return filename, nil
}

func ConstructFilenameWithBody(method string, requestUrl string, _ interface{}) (string, error) {
return ConstructFilename(method, requestUrl)
}

func ConstructFilenameV2(method string, requestUrl string) (string, error) {
parsedUrl, err := url.Parse(requestUrl)
if err != nil {
Expand All @@ -112,6 +132,10 @@ func ConstructFilenameV2(method string, requestUrl string) (string, error) {
return filename, nil
}

func ConstructFilenameV2WithBody(method string, requestUrl string, _ interface{}) (string, error) {
return ConstructFilenameV2(method, requestUrl)
}

func ConstructFilenameV3(method string, requestUrl string) (string, error) {
parsedUrl, err := url.Parse(requestUrl)
if err != nil {
Expand All @@ -135,3 +159,7 @@ func ConstructFilenameV3(method string, requestUrl string) (string, error) {
filename := fmt.Sprintf("request_%s_%s_%s.json", m, p, q)
return filename, nil
}

func ConstructFilenameV3WithBody(method string, requestUrl string, _ interface{}) (string, error) {
return ConstructFilenameV3(method, requestUrl)
}
16 changes: 8 additions & 8 deletions implementation/recorder/recorder_test.go
Expand Up @@ -7,58 +7,58 @@ import (

func TestConstructFilenameLong(t *testing.T) {
requestUrl := "https://some.super.long.server.name.that.hopefully.does.not.exist/api/rest/v1/v2/v3/v4/this/is/intentionally/very/very/very/very/long/djkfjhdalsfhdsahjflkdjsahfkjlsdhafjkdshafkjlsdahf/asdfjkldsahfkjlfad/dskjfhjkdsfahlk/sdafjkhsdafklhreuih/dfgjgkjlhgjlkhg?asjdfhlkhewuirfhkjsdhlk=kjhrelrukihsjd&fsdkjhfdjklhsdf=werjkyewuiryuweiry&sfuyfddsjkhjkldsfhldkfs=sdjhdflksjhfdhsf"
actual, err := ConstructFilename("GET", requestUrl)
actual, err := ConstructFilenameWithBody("GET", requestUrl, nil)
expected := "request_get_%2Fapi%2Frest%2Fv1%2Fv2%2Fv3%2Fv4%2Fthis%2Fis%2Fintentionally%2Fvery%2Fvery%2Fvery%2Fvery%2Flong%2Fdjkfjhdalsfhdsahjflkd_fb2e3656d88910ffc49023f99f5e0df6.json"
require.Nil(t, err)
require.Equal(t, expected, actual)
}

func TestConstructFilenameShort(t *testing.T) {
requestUrl := "https://some.short.server.name/api/rest/v1/kittens"
actual, err := ConstructFilename("GET", requestUrl)
actual, err := ConstructFilenameWithBody("GET", requestUrl, nil)
expected := "request_get_%2Fapi%2Frest%2Fv1%2Fkittens_d41d8cd98f00b204e9800998ecf8427e.json"
require.Nil(t, err)
require.Equal(t, expected, actual)
}

func TestConstructFilenameV2Long(t *testing.T) {
requestUrl := "https://some.super.long.server.name.that.hopefully.does.not.exist/api/rest/v1/v2/v3/v4/this/is/intentionally/very/very/very/very/long/djkfjhdalsfhdsahjflkdjsahfkjlsdhafjkdshafkjlsdahf/asdfjkldsahfkjlfad/dskjfhjkdsfahlk/sdafjkhsdafklhreuih/dfgjgkjlhgjlkhg?asjdfhlkhewuirfhkjsdhlk=kjhrelrukihsjd&fsdkjhfdjklhsdf=werjkyewuiryuweiry&sfuyfddsjkhjkldsfhldkfs=sdjhdflksjhfdhsf"
actual, err := ConstructFilenameV2("GET", requestUrl)
actual, err := ConstructFilenameV2WithBody("GET", requestUrl, nil)
expected := "request_get_api-rest-v1-v2-v3-v4-this-is-intentionally-very-very-very-very-long-djkfjhdalsfhdsahjflkd_fb2e3656.json"
require.Nil(t, err)
require.Equal(t, expected, actual)
}

func TestConstructFilenameV2Short(t *testing.T) {
requestUrl := "https://some.short.server.name/api/rest/v1/kittens"
actual, err := ConstructFilenameV2("GET", requestUrl)
actual, err := ConstructFilenameV2WithBody("GET", requestUrl, nil)
expected := "request_get_api-rest-v1-kittens_d41d8cd9.json"
require.Nil(t, err)
require.Equal(t, expected, actual)
}

func TestConstructFilenameV3Long(t *testing.T) {
requestUrl := "https://some.super.long.server.name.that.hopefully.does.not.exist/api/rest/v1/v2/v3/v4/this/is/intentionally/very/very/very/very/long/djkfjhdalsfhdsahjflkdjsahfkjlsdhafjkdshafkjlsdahf/asdfjkldsahfkjlfad/dskjfhjkdsfahlk/sdafjkhsdafklhreuih/dfgjgkjlhgjlkhg?asjdfhlkhewuirfhkjsdhlk=kjhrelrukihsjd&fsdkjhfdjklhsdf=werjkyewuiryuweiry&sfuyfddsjkhjkldsfhldkfs=sdjhdflksjhfdhsf"
actual, err := ConstructFilenameV3("GET", requestUrl)
actual, err := ConstructFilenameV3WithBody("GET", requestUrl, nil)
expected := "request_get_api-rest-v1-v2-v3-v4-this-is-intentionally-very-very-very-very-long-djkfjhdalsfhdsahjflkd_fb2e3656.json"
require.Nil(t, err)
require.Equal(t, expected, actual)
}

func TestConstructFilenameV3Short(t *testing.T) {
requestUrl := "https://some.short.server.name/api/rest/v1/kittens"
actual, err := ConstructFilenameV3("GET", requestUrl)
actual, err := ConstructFilenameV3WithBody("GET", requestUrl, nil)
expected := "request_get_api-rest-v1-kittens_d41d8cd9.json"
require.Nil(t, err)
require.Equal(t, expected, actual)
}

func TestConstructEqualFilenameV3ForDifferentQueryParameterOrder(t *testing.T) {
requestUrl1 := "https://some.short.server.name/api/rest/v1/kittens?a=123&z=o&v=666"
actual1, _ := ConstructFilenameV3("GET", requestUrl1)
actual1, _ := ConstructFilenameV3WithBody("GET", requestUrl1, nil)

requestUrl2 := "https://some.short.server.name/api/rest/v1/kittens?z=o&v=666&a=123"
actual2, _ := ConstructFilenameV3("GET", requestUrl2)
actual2, _ := ConstructFilenameV3WithBody("GET", requestUrl2, nil)

require.Equal(t, actual1, actual2)
}

0 comments on commit 9ca241a

Please sign in to comment.