Riposte—Scripting Language for JSON-based HTTP APIs
1 Brief introduction
2 Installation
3 Where Riposte came from
4 Usage
4.1 Using the interpreter
4.2 Direct execution
5 Language
5.1 Imports
5.2 Assignments
5.2.1 Variable assignments
5.2.2 Header assignments
5.2.2.1 Request header normalization
5.2.2.2 Resetting headers
5.2.3 Parameters
5.2.3.1 Base URI
5.2.3.2 Timeout
5.3 Commands
5.3.1 HTTP methods
5.3.2 Payload
5.3.3 URI-TEMPLATE
5.3.4 HEADERS
5.3.5 HTTP-RESPONSE-CODE
5.3.6 SCHEMA
5.4 Assertions
5.4.1 Equations & their discontents (disequations, inequalities)
5.4.2 Type checks
5.4.3 Negation
5.4.4 Adjectives
5.4.4.1 Numeric adjectives
5.4.4.2 String adjectives
5.4.4.3 Array adjectives
5.4.4.4 Object adjectives
6 Limitations
7 Miscellanea
7.1 File extension
7.2 JSON-only
7.3 Draft 07 Schema only
8 Wishlist
7.0

Riposte—Scripting Language for JSON-based HTTP APIs

Jesse Alama <[email protected]>

Riposte is a scripting language for evaluating JSON-bearing HTTP responses. The intended use case is a JSON-based HTTP API. It comes with a commandline tool, riposte, which executes Riposte scripts. Using Riposte, one writes a sequence of commands—which amount to HTTP requests—and assertions, which require that the response meets certain conditions.

Riposte is intended to be a language for system (or integration) tests. As such, it is not intended to be a general purpose programmign langauge. There is no notion of branching (“if-then-else”) in a single script. The idea is that a Riposte script consists of a series of assertions, all of which must succeed. If, during evaluation, a command or assertion fails, evaluation will be aborted. There’s no fallback.

In other words, Riposte was made to help you get more serious about testing your APIs. It helped me up my testing game, and I hope it can do the same for you.

1 Brief introduction

Let’s take a look at some little Riposte scripts.

# set a header, to be added to all requests going forward
# the # character is the to-end-of-line comment character
 
^Content-Type := "application/json" # set a base URL; we will merge all URIs going forward # with this %base := https://api.example.com:8441/v1/   $uuid := @UUID # @ is how you access environment variables

Normal variables (here, $uuid) have the $ sigil in front of them. There are a handful of global variables which use % as their sigil (here, %base). Headers form their own kind of “namespace“ for variables; their sigil is ^ (here, ^Content-Type). In all cases, := is the way you assign a value to a variable.

# -----------------------
# Start sending requests!
# -----------------------
 
GET cart/{uuid} responds with 2XX

Riposte uses URI Template as its language for URIs. URI Template extends URIs with a kind of template syntax. Values get plugged in between open and close curly braces. Here, we plug in the value of the uuid variable, whose value came from the environment. The 2XX means: we expect a 200-class response; the precise response code doesn’t matter. It could be 200, 201, 204...

Riposte supports GET, HEAD, POST, PATCH and DELETE as HTTP request methods.

# ----------------------------------------------------------------------
# Now add something to the cart:
# ----------------------------------------------------------------------
 
$productId := 41966 $qty := 5 $campaignId := 1   $payload := { "product_id": $productId, # we extend the JSON syntax here "campaign_id": $campaignId, # in that you can use Riposte variables "qty": $qty # and you can add comments to JSON, too }   POST $payload cart/{uuid}/items responds with 200   $itemId := /items/0/cart_item_id # extract the item ID

Here, we define some data and build a JSON object with three properties. We then submit that to (what works out to) https://api.example.com/cart/28f896e6-98df-497b-b95f-c34d39c2a368/items. (I just picked a random UUID.) Notice that we expect a concrete response code here. The response had better be 200. Anything else would be an error.

We also extract data from the response. Riposte builds on JSON Pointer, an IETF standard notation for referring to parts of JSON documents.

GET cart responds with 2XX   /tax_total exists

After making a request, you can make assertions about it. Here, with exists, we’re asserting that (1) the response is a JSON object, and (2) it has a property, "tax_total". We don’t care what that value is. (Riposte comes with a ton of assertions; exists is just one.)

Here’s another Riposte script that logs in to our iOS API:

$loginPayload := { "email": @EMAIL, "password": @PASSWORD }   POST $loginPayload auth/login responds with 2XX   # extract a value from the response body # and use it as the value of an # HTTP header, going forward: ^Apikey := /key

Welcome to Riposte. En garde!

2 Installation

Riposte is written in Racket and depends on an installation of it. (The previous link takes you to the official Racket download page. You may be able to install Racket using some package manager on your system, such as Homebrew on macOS, apt-get on Debian-based Linuxes, pkg_add on OpenBSD, and so on and so forth.)

Once you’ve got Racket installed, use raco (the Racket commandline tool) as follows:

$ raco pkg install --auto riposte

(The auto bit there automatically installs anything that Riposte depends upon, in case you don’t have it. You can omit it if you like and be prompted whether you want to install each of the dependencies.)

Once Riposte (the Racket package) is installed, you ought to have a riposte executable in the place where your Racket executables are stored. On my machine (a Mac), riposte is residing at /Users/jessealama/Library/Racket/7.0/bin. I’ve added this to my $PATH environment variable to make sure that I can just use riposte straight away.

3 Where Riposte came from

Riposte was born out of a need to do more serious testing of HTTP APIs.

I envied the little langauges that I found in developer tools such as restclient.el for Emacs and the REST Client in the JetBrains IDEs. (There are surely many more such tools out there.) I wanted to use these kinds of things for automated testing, but couldn’t find anything satisfactory. Formats like API Blueprint are a decent start, but I see these more like documentation than an automated test suite. Combining API Blueprint with a tool like Dredd is good, but I found it didn’t quite have the features that I was looking for.

So I built Riposte to fill a gap. The requirement was that it needed to have a straightforward language that is more or less instantly recognizable. Riposte is made with Racket, but it was important that the language look like those cool little languages that I alluded to earlier, and not like Lisp. Indeeed, Riposte was initially built to be used in an environment where Lisp of any sort was not the language of choice.

In the end, as with many tools in the tech world, I cannot claim that Riposte is totally unique. I was inspired by some good tools, but didn’t quite fit the need I had. Perhaps I just needed to RTFM and figure out how to use these tools more intelligently, or perhaps strap some kind of DSL on top of them. Maybe there are tools out there just like Riposte.

But for now, I’m sticking with it. Riposte has proved its usefulness to me and my teammates many times. I hope you find it useful, too.

4 Usage

There are two ways of executing a Riposte script: using riposte or directly executing them.

4.1 Using the interpreter

Do this:

$ riposte path/to/script.rip

4.2 Direct execution

Use a shebang. Start your Riposte script, say let-er.rip, like this:

#!/usr/local/bin/riposte

#

GET whatever responds with 2XX

Make sure your Riposte script is executable. Then—assuming that Riposte really is at /usr/local/bin/riposte, as indicated in the script—you ought to be able to just to do

$ ./let-er.rip

to GET whatever.

5 Language

The Riposte language consists of three basic elements:

Here’s a simple Riposte script contains all three ingredients:

^Content-Type := "application/json" $payload := { "a": 5, "b": [] } POST $payload https://whatever.test responds with 201 /foo is positive integer

Lines 1 and 2 are assignments; the first assigns a value to a header, and the second assigns a value to a variable. Line 3 is a command: here, we send a POST request, with a payload—a JSON object specified in line 2. We expect that the response we receive has an exit code of 201. Line 4 is an assertion. We’re saying there that the response body had better be well-formed JSON, that that JSON had better be a JSON object, and that that object had better have the property foo, and the value of the foo property of that object is a positive integer.

The following sections give a more thorough discussion of the different kinds of top-level syntatic ingredients.

5.1 Imports

Imports are a way of putting some Riposte code into one file and using it in another. It gives a way to pass variables from one script to another. They allow you to carry out some requests, gathering a bit of state, and then using that state in another file.

Here’s an example of this. Let’s call this file headers.rip:

^Content-Type := "application/json" ^Cache-Control := "no-cache"

Consider setting up a base URI in a separate file, parameters.rip:

%base := "https://cool.test/api/v2/" %timeout := 30

Then, in another Riposte script, login.rip, we can import these two to set up a kind of basis:

import "parameters.rip" import "headers.rip"   $payload := { "username": @USERNAME, "password": @PASSWORD }   POST $payload login responds with 200   $key := /apikey ^APIKey := $key

The POST command here gets executed here with the parameters and headers set up in the other files. Upon executing login.rip, a header, APIKey, gets set. login.rip also “exports” a variable, $key. If you were to keep going an import login.rip, you’d have that variable, and the header assignment would also be in place:

import "login.rip"   # You can refer to $key   $load := { "key": $key, # comes from login.rip "zoom": "boom" }   # APIKey request header will be present here: PATCH $load dump/truck responds with 204

5.2 Assignments

With assignments, the idea is that we intend to modify a namespace. There are a few different namespaces in play with Riposte: the variable namespace, HTTP headers, and parameters.

5.2.1 Variable assignments

A normal definition means that we give a value to a variable.

5.2.2 Header assignments

Header assignment means that we assign a value to an HTTP header. Until unset, every request will have that header, and that header will have that value. Here’s an example:

^Content-Type := "application/json"

The effect of evaluating this assignment is to indicate that all commands (HTTP requests) executed after this assignment will have a Content-Type header, and the value of the header will be application/json.

Only strings are allowed as the values for an assignment. The empty string is fine.

5.2.2.1 Request header normalization

Riposte’s approach to HTTP headers is simple: a header can show up only once in a request. Thus, when assigning a value to a request header, any previously existing value for that header will be discarded.

5.2.2.2 Resetting headers

To ensure that a header does not show up in HTTP requests, use unset. Example:

unset ^Content-Type

Evaluating this means that the next request—and all following ones—will not have a Content-Type header.

5.2.3 Parameters

There are two parameters (AKA global variables) that steer the way the evaluation of Riposte works.

5.2.3.1 Base URI

Sure, you can write out every URL you want, completely. Or you can save yourself some time, and make your scripts more readable to boot, by using a base URI.

Here’s how you can set that:

%base := "https://whatever.test/v2/" GET big/boy responds with 2XX

When it comes time to build that GET request, we’ll use https://whatever.test/v2/big/boy as the URI.

5.2.3.2 Timeout

Usually we want to wait only a ceratin amount of time before we give up on the server that we’re working with. To control that, use %timeout, like so:

%timeout := 5 GET /what/ever responds with 2XX

The value should be a positive integer. (If it isn’t, Riposte will die.) We will have

Riposte does not offer a way of disabling a timeout. Just use a big number if you want to give your requests a lot of time.

%timeout := 10000000000 # more than 300 years! GET /what/ever responds with 2XX

The default value for this parameter is 10 seconds.

5.3 Commands

With commands, you send out your HTTP requests.

Optionally, you can check that

Those two parts are optional; you can check the response code without specifying a schema, and you can specify a schema without saying anything about the response code. You can leave out both, or supply both.

(The Riposte langauge has a concept of an assertion, which is all about checking things. When a command includes a response code check, or a schema check, then commands has the effect of performing an assertion.)

Formally, a command has one of these these structures, depending on whether you want to check the response code and assert that the response satisfies a schema.

HTTP-METHOD [ PAYLOAD ] URI-TEMPLATE [ "with" "headers" HEADERS ]

HTTP-METHOD [ PAYLOAD ] URI-TEMPLATE [ "with" "headers" HEADERS ] "responds" "with" HTTP-RESPONSE-CODE [ "and" "satisfies" "schema" SCHEMA ]

HTTP-METHOD [ PAYLOAD ] URI-TEMPLATE [ "with" "headers" HEADERS ] "satisfies" "schema" SCHEMA

A command need not be on one line.

5.3.1 HTTP methods

HTTP-METHOD consists of a non-empty sequence of uppercase letters. Typical examples:

However, you can use whatever you like, e.g., CANCEL, BREW, and so on.

5.3.2 Payload

PAYLOAD, if present, is supposed to be a variable reference. Here’s an example:

$payload := { "a" : 4, "b": true, "c": [] } POST $payload api/flub responds with 2XX

When executing the command, the payload will become the request body.

5.3.3 URI-TEMPLATE

HTTP requests need to have a URI. (Of course.) The URI could be either fixed (that is, involve no variables)

The URI that will ultimately be built will be glommed onto whatever the base URI is. By default, there is no base URI, so your URI will be used as-is. If a base URI is specified (assign a string value to the global variable %base for that purpose), then it will be the prefix for your specified URI. If you wish to go around the base URI, specify an absolute URI, like https://whatever.test/api/grub or, before the command, unset %base.

If you’re not so static, the URI you give here might be built up from URI Templates. Here’s a typical example:

$payload := { "a" : 4, "b": true, "c": [] } $id := 1 POST $payload api/flub/{id} responds with 2XX

A URI is, at the end of the day, a kind of string. In this example, notice that we’ve used an integer and plugged it into the URI.

5.3.4 HEADERS

For a single request, one can specify headers that should be used, in just this request, in addition to the ones that are already set. Here’s a typical example:

$heads := { "Accept-Language": "jp" } GET $payload api/flub with headers $heads responds with 2XX

Notice here that we use a JSON object to specify the headers. The headers that are ultimately generated are normalized. (If you have an application that is sensitive to normalization—if it behaves one way when headers are normalized and another if headers are not normalized, I’m afraid Riposte cannot currently build such HTTP requests.)

5.3.5 HTTP-RESPONSE-CODE

HTTP response codes are supposed to be three-digit integers. There aren’t lots of possibilities.

If you don’t care about the precise response code, you can use response code patterns. Example: 2XX means: any 200-class is OK. It could be 200, 201, 204, you name it. You can also say things like 20X to mean that 200, 201, … 209 would be OK, but 210 wouldn’t be.

5.3.6 SCHEMA

There are two forms that SCHEMA can take:

The two forms are for referring directly to a JSON Schema as a JSON value or in an external file. Here are examples of the two kinds of forms:

$schema := { "type": "object", "requiredProperties": [ "age", "weight" ] } GET $payload api/flub satisfies schema $schema

Example of using a schema specified in a file:

GET $payload api/flub satisfies schema in schema.json

5.4 Assertions

Assertions are checks that succeed or fail. When executing an assertion, Riposte will see whether what’s being asserted is true. If it’s true, it moves on to the next thing (command, assertion, etc.). If the check fails, Riposte bails out.

There are a few different kinds of assertions:

5.4.1 Equations & their discontents (disequations, inequalities)

Write an equation by writing two expressions separated by =.

Write a disequation by writing !=

5.4.2 Type checks

You can use is and the JSON type keywords to assert that a value has a certain type. The types are:

You can use these words as-is. Thus:

$foo is integer

is an assertion that succeeds provided that the value held by $foo is indeed an integer.

5.4.3 Negation

You can assert that a value does not have a certain type by using not. Thus:

$bar is not array

works provided that $bar is not an array.

5.4.4 Adjectives

One can tweak checks with adjectives. The set of adjectives available depends on the type.

Use non to say that a value does not meet a certain adjective.

5.4.4.1 Numeric adjectives
5.4.4.2 String adjectives
5.4.4.3 Array adjectives
5.4.4.4 Object adjectives

6 Limitations

All sorts of HTTP scenarios cannot be modeled at all with Riposte. Riposte is essnetially a scriptable headless browser, but you can’t do everything at all.

7 Miscellanea

Here’s where you can find some information for which I couldn’t find a better place.

7.1 File extension

I suggest using .rip for your Riposte scripts. But, in truth, it doesn’t matter. Riposte scripts are supposed to be plain text, and there’s no check that a script has the file extension .rip.

7.2 JSON-only

Riposte works with JSON values and assumes that the responses it receives (more precisely, the response bodies) are JSON.

There’s one exception to this. Some HTTP responses (again, their bodies) are empty. There’s even an HTTP response code for this case (204). Riposte can handle such responses, even though the empty string is not valid JSON.

If, however, a non-empty response is returned, Riposte attempts to parse the body as JSON. If if can’t, Riposte will die.

This means that Riposte, currently, can’t work with HTML, arbitrary plain text, images, and so on. If you request a URI and get a response that contains, say, HTML, Riposte will bail out (unless, by chance, the response turns out to be well-formed JSON!).

7.3 Draft 07 Schema only

Currently, schema validation with Riposte is done according to JSON Schema draft 07. There is, at the moment, no support for any other draft version of JSON Schema. Sorry.

8 Wishlist

A number of features could be added to Riposte but have so far not made the cut.