Saturday, December 2, 2023

Mocking with AWS SDK for Go V2

I have been playing around with the AWS SDK for Go V2 recently. And I was reading their Developer Guide for Testing, and was a little shocked that I had to mock every AWS API that I intended to use.

For many applications this isn't horrible, but it does require adding a lot of abstraction which kinda only makes sense if you are building for multi-cloud already. For many small projects, that isn't worth the upfront cost.

Smithy

Smithy is an interface definition language. It isn't focused on the wire format. Instead it focuses on the shapes and traits of an API. It sounds very academic, but it makes sense in practice.

AWS uses Smithy to define the API concepts and then generate clients for different languages from there. There was a good Hacker News post on the topic a few years ago.

One of the nice things that Smithy brings to the AWS SDK is the ability to manipulate the request/response pipeline to do some interesting things. In this post I'm going to use it to add mocking to existing AWS SDK APIs.

Smithy-go Middleware

Smithy-go has a Middleware concept that it uses to perform all of the necessary transformations from Golang SDK objects into the wire format used by the AWS APIs. I found this diagram to be an easy way to digest the different stages of the middleware.

logical diagram of Smithy-go middleware stages.

For the purposes of adding mocking and avoiding actual calls to AWS, I decided to hook into the Initialize stack step.

InitializeMiddleware is Initializing Middleware

Making a middleware that executes a custom function and short-circuits the call to AWS is relatively trivial:

// MockingStackValue is the collection of values to return instead of executing
// the called service.
type MockingStackValue struct {
    Output   interface{}
    Metadata middleware.Metadata
    Error    error
}

// MockingMiddleware implements initiatlize and finalize middleware stages. It
// executes `Test` at at the end of the Initialize Stage to influence what is
// returned at the end of the finalize stage.
//
// See github.com/aws/smithy-go/middleware for more details on the stages.
type MockingMiddleware struct {
    Name string
    Test func(interface{}) (bool, MockingStackValue)
}

// ID returns the Name field.
//
// ID is required by the Middleware interfaces.
func (mocker *MockingMiddleware) ID() string { return mocker.Name }

// HandleInitialize runs the test function to configure how the middleware will
// handle the Finialize stage.
//
// HandleInitialize is required by the InitializeMiddleware interface.
func (mocker *MockingMiddleware) HandleInitialize(
    ctx context.Context,
    in middleware.InitializeInput,
    next middleware.InitializeHandler,
) (middleware.InitializeOutput, middleware.Metadata, error) {
    if passed, out := mocker.Test(in.Parameters); passed {
        return middleware.InitializeOutput{
            Result: out.Output,
        }, out.Metadata, out.Error
    }
    return next.HandleInitialize(ctx, in)
}

MockingStackValue isn't required, but I wanted it to simplify the signature of the Test function. The MockingMiddleware type wraps the Test function in way that allows it to make a decision to short-circuit the AWS call. The Method ID() just required by the InitializeMiddleware interface. As is HandleInitialize().

The simplest way to leverage this middle ware is to add the mocking object to the Config object. This ensures that the mocking object has an opportunity to intercept all AWS calls:

    ctx := context.TODO()
    cfg, err := config.LoadDefaultConfig(
        ctx,
        func(opts *config.LoadOptions) error {
            mocker := MockingMiddleware{
                Name: "dynamodb-mocker",
                Test: mockDDBResult,
            }
            opts.APIOptions = append(opts.APIOptions, func(stack *middleware.Stack) error {
                return stack.Initialize.Add(mocker, middleware.After)
            })
        },
    )
    if err != nil {
        log.Fatalf("Unable to configure AWS client %v", cfg)
    }

There is a lot of AWS/Smithy boilerplate there, but the main idea is that the mocking object is passed in as a configuration option function. If you prefer to add the configuration steps outside of the configuration load, you can follow the guidance in the AWS documentation.

In my application, I implemented something that allows for attaching steps per API call. The App is a lambda, so there isn't a lot of depth to the application, which makes updating the calls to pass the option functions easier:

type AppContext struct {
    AwsConfig config.Config
    Ddb       *dynamodb.Client
    DdbOptFns []func(*dynamodb.Options)
}

func NewAppContext(ctx context.Context) AppContext {
    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        log.Fatalf("Unable to configure AWS client %v", cfg)
    }
    return AppContext{
        AwsConfig: cfg,
        Ddb:       dynamodb.NewFromConfig(cfg),
    }
}

func (app AppContext) BusinessLogic(
    ctx context.Context,
    request events.APIGatewayProxyRequest,
) (events.APIGatewayProxyResponse, error) {
    ...
    result, err := app.Ddb.Query(ctx, &queryIn, app.DdbOptFns...)
    if err != nil {
        return events.APIGatewayProxyResponse{}, err
    }
    ...
}

func TestHandler(t *testing.T) {
    ctx := context.TODO()
    app := NewAppContext(ctx)
    app.DdbOptFns = []func(*dynamodb.Options){
        func(opts *dynamodb.Options) {
            mm := &MockingMiddleware{
                Name: "customer-query",
                Test: mockCustomerQuery,
            }

            opts.APIOptions = append(opts.APIOptions, func(stack *middleware.Stack) error {
                return stack.Initialize.Add(mm, middleware.After)
            })
        },
    }
    ...
    response, err := app.TimelineHandler(ctx, request)
}

I don't particularly like that app.DdbOptFns... hanging off the end of the call like that. I'll probably spend some time seeing if I can make it disappear without having to wrap every single client.

I prefer this approach as it allows me finer control over which API calls get mocked and which are treated more like an integration test.

Other Thoughts

Another approach I might investigate is making the decision to mock at the Initialize stage of the stack, and then changing the result at a different stage of the stack. One example of doing this would be to fake the wire protocol response in Deserialize stage. The advantage of manipulating the mock in this was is to preserve most of the expectations on what the AWS SDK normally does, in terms of authentication, etc. The AWS SDK has details for how to pass the metadata towards AWS.

I'm starting to like the "shape" approach used by smithy for defining APIs. It could be interesting to see what the effort is to implement my APIs in Smithy and generate the clients. We have a lot of conversations at Datadog about Services, Resources and Operations, and the bullet points in the Smithy Quickstart have given me some new perspective on those topics.

Friday, December 10, 2021

A New Vulkan-Go Bridge

I got frustrated with the state of the vulkan-go bridge. Mostly that it was seemingly unsupported. Additionally, the c-for-go library made everything difficult to update. As a result, I spent a week and wrote my own vulkan bindings generator.

Monday, November 1, 2021

Vulcan, Go, and A Triangle, Part 11 bis

In part 10, I mentioned trying to use go routines to create the command buffers, but that didn't work because the command pool and the command buffers must all be operated on in a single thread.

I had tried doing the command recording in parallel using go-routines, but that resulted validation layer threading errors. A command pool is apparently thread specific.

The rest of this part explores using a locked thread go routine associated with a command pool in order to multi-thread command recording.

Note This is basically building an abstraction layer on top of Vulkan, and isn't necessary to understand how vulkan works. As this code is a wholesale divergence from the Vulkan tutorial, I won't be using it when I start on the next section.

Saturday, October 30, 2021

Vulcan, Go, and A Triangle, Part 11

In this part, I used the previous created semaphores and fences to coordinate rendering and presentation. I also implement resizing of the window.

This part follows along with Drawing a triangle / Drawing / Rendering and presentation / Acquiring an image from the swap chain through to the end of Drawing a triangle / Swap chain recreation.

Thursday, October 28, 2021

Vulcan, Go, and A Triangle, Part 10

In this part, I created the command pool, the command buffers, and recorded the rendering to our framebuffers.

This part is a direct translation of Drawing a triangle / Drawing / Command buffers.

Tuesday, October 26, 2021

Vulcan, Go, and A Triangle, Part 9

In this part, I made the pipeline objects. This includes loading the shader modules, configuring the fixed functions, and creating the Vulkan pipelines. This part started with Drawing a triangle / Graphics pipeline basics / Shader modules, jumps into the Fixed functions, and ends with the Conclusion.

Sunday, October 24, 2021

Vulcan, Go, and A Triangle, Part 8

In this part, I made the image views, render pass, framebuffers and pipeline layout. All things that only modify the pipeline file. I found Drawing a triangle / Graphics pipeline basics / Introduction an excellent reminder about computer graphics in general and useful for understanding vulkan in particular.

This part doesn't relate to a single section in the Vulkan tutorial; it jumps around between a couple of different sections that were all pipeline specific and would need to be recreated if the pipeline needed to be recreated. It also references the Vulkan Tutorial almost constantly, as I didn't want to plagiarize their excellent explanations of these concepts.