The joy of pattern matching – part V

Ionbeam – an HTTP testing framework

Exercising an HTTP server, in order to test it, typically involves sending a request, extracting some information in the response, validating the response and, if the response is valid, use the extracted information to form the next request to the server (or to some other server if indicated by the information).

“Extracting some information” is in the context of this series simply “pattern matching” (template:match if we want to refer to the template library[1] we built in part II) and “forming the next request” is to fill in the values of a template (template:replace) before sending it to some server again. This means that, if we want to do some testing of some HTTP endpoint that we have developed we could use our template library, add some HTTP client code and we are good to go. That is what we will do in this blog post and the end result, called Ionbeam (for lack of a better name, where “beam”, at least, could be seen as Erlang related), can be found on github[2] as before.

Forming the Ionbeam

A test suite consists of a sequence of tasks where each task is defined as having a request template which is filled in with values from some input context. Performing the task and validating and extracting information from the response will produce an output context that can be used as input context for other tasks further down the sequence.

[
    %% do login
    {LoginTask, #{"USER_NAME" => "alice",
                  "PASSWORD" => "secret"}, 'LoginCtx'},

    %% do list items
    {ListItemsTask, 'LoginCtx', 'ListItemsCtx'}
]

An Ionbeam example testsuite consists of a list of tuples where each tuple consists of a task, a reference to an input context and a reference to an output context. The input context an either be a direct map, the name of a previously used context or a function returning a context.

A task, in Ionbeam, is represented by an Erlang map with fields of two categories – one that describes the request (such as method, host, path and headers fields) and one that details how the response is to be validated and what information should be passed to the output context. Another way of looking at the response fields is that they put constraints on the response.

LoginTask = #{

   request => #{
     method =>  "POST",
     host =>    "localhost",
     port =>    "8080",
     path =>    "/api/login",
     headers => "Accept: application/json\r\nContent-Type: application/json\r\n\r\n",
     body =>    "{\"uname\":\"$(USER_NAME)\",\"password\":\"$(PASSWORD)\"}"
   },

   response => #{
     status => [200],
     headers => #{
       match => ["Content-Type: application/json"]
     },
     body => #{
       match => "$(_A)\"token\":\"$(TOKEN)\"$(_C)"
     }
  }
}

An example task that will perform a login according to some made up API. The body references the variables USER_NAME and PASSWORD so these two variables need to be present in the input context. The response must have a status of 200, it must have at least one header Content-Type with the value application/json and the body must match the pattern shown. If the body matches, the TOKEN variable will be put into the output context. If the TOKEN is not found, or if any of the other matches fail, the task will fail.

If we then imagine, to continue the example, that the ListItemsTask requires authentication and that this authentication is done by inserting a header X-Token with the TOKEN value and that it will return json document in response to a GET request, its definition becomes something like:

ListItemsTask = #{

   request => #{
     host => "localhost",
     port => "8080", 
     headers => "X-Token: $(TOKEN)\r\nAccept: application/json\r\n\r\n",
     path => "/api/items"
   },

   response => #{
     headers => #{
       match => ["Content-Type: application/json"]
     }
   }
}

Defining the ListItem task which uses the TOKEN value obtained in the Login task. You see that method is not defined as so it will be a GET per default. There is no constraints on the body of the document but the default constraint on the status is 200 and the content type must be application/json

Suppose now that we WOULD like to put constraints on the body, but we know that the body could be huge and might not be fit to be parsed by the generic matcher library (or that the validation logic might not easily be expressed by a matching function). In this case we would, instead of specifying a match template, specify a validation function that can, given some domain knowledge, in a more efficient way parse and validate the body.

          body =>
              fun(Body, Ctx) ->
                      #{<<"members">> := Members} = jiffy:decode(Body, [return_maps]),
                      case lists:member(<<"stefan">>, Members) of
                          true ->
                              Ctx;
                          _ ->
                              {error, "stefan must be a member, but was not"}
                      end
              end

Instead of expressing validation of the body in terms of matching, one can
express validation as an Erlang function instead. This function takes two arguments - the body and the current context - and it returns a new context in the form of a map, or an error tuple (if the validation fails). In this example we parse the body using jiffy and check that the members array contains the required string.

Running it

When you have written all your tasks, and put them together in a sequence, you can then run them using the ionbeam:run_script function. This function will manage your input and output contexts for you so that you use them throughout the sequence and it will catch and report errors occurring.

ionbeam:run_script([
  %% do login
  {LoginTask, #{"USER_NAME" => "alice",
                "PASSWORD" => "secret"}, 'LoginCtx'},

  %% do list items
  {ListItemsTask, 'LoginCtx', 'ListItemsCtx'}, 

  ...
])

Summary

Pattern matching is a core concept in programming as it provides powerful tools that can be used within many different contexts. Throughout this series of posts about pattern matching I have tried to show how diverse use cases one can have for it, such as implementing some simple language processing (like Eliza in part III) or by implementing an HTTP server testing framework like in this post. Both problems are very different in context, but essentially solved in the same way – by matching patterns against literal strings. And it can be iterated once more, that pattern matching is not only a powerful way of expressing yourself, it also enhances the creative energy while programming, thus making programming more fun and rewarding.

References
  1. https://github.com/peffis/template
  2. https://github.com/peffis/ionbeam

Author: peffis

I was Slashdotted in 2004. After that, not much.