case studies

Case Study: Moving an Old Client to a New Customer API With AfterAPI

This case uses the examples/customer-api folder from the code repository, so you can run every step yourself. The commands and output below come from a real run of aftermarket 0.3.0.

The situation

A customer service was rewritten, and its v1 API was switched off. One old client still calls v1, and it comes with its own test suite of twelve strict checks. Field names, extra fields, status codes and error messages all count.

The new API differs in almost every way the client can see:

  • A lookup moved from ?id=7 to /customers/7, and the customer is nested under "customer" with renamed fields.
  • Lists come wrapped in {"data": [...]}.
  • Search moved to a query parameter on the list.
  • A create answers 201 where v1 said 200, and a delete answers 204 with no body where v1 said 200 with {"deleted": true}.
  • Errors come as {"error": {"code": ..., "message": ...}}.

Against v2 directly, every check fails:

$ go run ./examples/customer-api/client -base http://127.0.0.1:9002
FAIL  get a customer
      GET /api/v1/customer?id=7: status 404, want 200 (body {"error":{"code":"not_found","message":"no such endpoint"}})
FAIL  missing customer is a 404 with a message
      got {"error":{"code":"not_found","message":"no such endpoint"}}, want {"message":"customer not found"}
...
12 checks, 12 failed

That’s too much for a few small patch rules. AfterAPI takes the other approach: write down what each old endpoint was, in terms of the new one.

Five mappings

adapt.yaml has one entry per old endpoint. The create, for example, builds the v2 request body from the v1 one, maps the status codes, and rebuilds the old answer:

  - id: create-customer
    old: POST /api/v1/customers
    new: POST /api/v2/customers
    request:
      body:
        full_name: $.name
        email: $.email
    response:
      status: {201: 200, 422: 400}
      body:
        customer_id: $.customer.id
        name: $.customer.full_name
        email: $.customer.email
      error:
        message: $.error.message

adapt check reads the whole file and sums up each mapping:

$ aftermarket adapt check --config examples/customer-api/adapt.yaml
examples/customer-api/adapt.yaml: 5 endpoint(s), max_body 1048576 bytes, upstream_timeout 30s
  1. get-customer (line 11): GET /api/v1/customer?id={id}  =>  GET /api/v2/customers/{id} (response body, error body, response headers)
  2. delete-customer (line 24): DELETE /api/v1/customer?id={id}  =>  DELETE /api/v2/customers/{id} (status 204->200, response body, error body)
  3. search-customers (line 34): GET /api/v1/customers/search?q={q}  =>  GET /api/v2/customers?search={q} (response body)
  4. list-customers (line 44): GET /api/v1/customers  =>  GET /api/v2/customers (response body)
  5. create-customer (line 54): POST /api/v1/customers  =>  POST /api/v2/customers (request body, status 201->200, 422->400, response body, error body)
ok

A dry run on the real client

Before changing any traffic, run adapt with --dry-run and point the client at it. The client still fails all twelve checks, since nothing is translated. The audit log is the useful part: it shows which mapping caught each call, and what it would have changed.

{"event":"would_change","request":1,"rule":"get-customer","phase":"request","method":"GET","path":"/api/v1/customer","query_keys":["id"],"actions":["rewrite path","rewrite query"]}
{"event":"would_change","request":1,"rule":"get-customer","phase":"response","method":"GET","path":"/api/v1/customer","query_keys":["id"],"status":404,"actions":["rebuild error body"],"not_applied":["remove header X-Api-Version (not present)"]}

Counted up, the dry run caught all ten requests the client sent: three lookups, a delete, two searches, two lists and two creates. All five mappings showed up. The suite makes twelve checks, but in a dry run nothing gets created, so the two checks that read back and delete the new customer stop before sending anything. A request no mapping catches would leave no line at all, and that’s the gap a dry run is for.

Live

$ go run ./examples/customer-api/client -base http://127.0.0.1:8080
...
PASS  a deleted customer is gone
PASS  deleting a missing customer is a 404 with a message
12 checks, 0 failed

The run left 24 lines in the audit log, one for each request and one for each answer. The create shows both sides:

{"event":"changed","request":7,"rule":"create-customer","phase":"request","method":"POST","path":"/api/v1/customers","actions":["rewrite path","rebuild body"]}
{"event":"changed","request":7,"rule":"create-customer","phase":"response","method":"POST","path":"/api/v1/customers","status":201,"actions":["status 201 -> 200","rebuild body"]}

The log never holds values. Path parameters stay out of it too, because adapt logs the endpoint’s pattern, not the path with the customer number in it.

In CI

The example ships with six fixture cases that cover a lookup, an error, a create, a delete, the search and a path no mapping touches:

$ aftermarket adapt test --config examples/customer-api/adapt.yaml --fixture examples/customer-api/fixture.yaml
PASS  a v1 lookup reaches v2 and comes back in the v1 shape
PASS  a v2 error becomes a v1 error
PASS  create maps the body both ways and 201 becomes 200
PASS  delete turns 204 into 200 with a body
PASS  search unwraps the list
PASS  paths no mapping covers pass through untouched
6 case(s), 0 failed

The repository’s own tests go one step further. They take each of the five mappings out in turn and run the client again, and something has to fail every time, which proves none of the five is dead weight. The release check case study shows how to prove a mapping like this on recorded traffic as well. The full format is in the AfterAPI reference.