case studies

Case Study: Fixing a Moved Exchange Rate Endpoint With Two AfterPatch Rules

This is a worked case on a small stand-in service, built to look like a common kind of break. Every command and every line of output below comes from a real run of aftermarket 0.3.0.

What broke

An app shows exchange rates. It calls GET /v1/rate?from=EUR&to=USD and reads rate from the answer. It’s installed on devices nobody can update this week.

The rate provider shipped a new version, and three things changed at once:

  • /v1/rate is gone.
  • /v2/rate answers the same question, but only when the request carries an X-Api-Version header.
  • The answer calls the number value where it used to say rate.

Here’s what the app gets now:

$ curl -s "http://127.0.0.1:9100/v1/rate?from=EUR&to=USD"
{"error":"not found"}
$ curl -s "http://127.0.0.1:9100/v2/rate?from=EUR&to=USD"
{"error":"X-Api-Version header is required"}

Two rules

The first rule fixes the request on its way out: new path, plus the header. The second fixes the answer on its way back.

version: 1
rules:
  - id: rate-moved-to-v2
    description: The rate endpoint moved to v2 and needs a version header.
    when:
      method: GET
      path: /v1/rate
    request:
      rewrite_path:
        from: "^/v1/rate$"
        to: "/v2/rate"
      set_headers: {X-Api-Version: "2026-10"}

  - id: rate-field-renamed
    description: v2 calls the rate "value"; the old app reads "rate".
    when:
      method: GET
      path: /v1/rate
      status: 200
      content_type: application/json
    response:
      json:
        - rename: {from: value, to: rate}

when always looks at the request as the app sent it, so both rules match on /v1/rate even though the first one changes the path. patch check confirms the file before anything runs:

$ aftermarket patch check --config rates-rules.yaml
rates-rules.yaml: 2 rule(s), max_body 1048576 bytes, upstream_timeout 30s
  1. rate-moved-to-v2 (line 3, changes request)
  2. rate-field-renamed (line 14, changes response)
ok

Dry run first

With --dry-run, the proxy changes nothing and only logs what it would do. The app still gets the 404, and the log shows the request rule would fire:

{"event":"would_change","request":1,"rule":"rate-moved-to-v2","phase":"request","method":"GET","path":"/v1/rate","query_keys":["from","to"],"actions":["rewrite path","set header X-Api-Version"]}

The response rule doesn’t show up, and that’s correct. In a dry run the request really does go to /v1/rate, the real answer is still a 404, and a rule that wants a 200 has nothing to say about it. When one rule depends on another, the dry run shows the first step only. The fixture further down checks the whole chain.

Live

$ curl -s "http://127.0.0.1:8100/v1/rate?from=EUR&to=USD"
{"from":"EUR","to":"USD","rate":1.08,"as_of":"2026-10-20"}
$ curl -s "http://127.0.0.1:8100/v1/rate?from=GBP&to=JPY"
{"from":"GBP","to":"JPY","rate":1.08,"as_of":"2026-10-20"}

The query string goes along untouched, so every currency pair works, not just one. The new as_of field comes through as well, since the rules only touch what they name. The audit log has two lines per call, with no values in them:

{"event":"changed","request":1,"rule":"rate-moved-to-v2","phase":"request","method":"GET","path":"/v1/rate","query_keys":["from","to"],"actions":["rewrite path","set header X-Api-Version"]}
{"event":"changed","request":1,"rule":"rate-field-renamed","phase":"response","method":"GET","path":"/v1/rate","query_keys":["from","to"],"status":200,"actions":["rename value -> rate"]}

Locking it in

A fixture case says what goes in, what the stand-in upstream answers, and what must come out on both sides:

cases:
  - name: the old app's rate call reaches v2 and gets "rate" back
    request:
      method: GET
      path: /v1/rate?from=EUR&to=USD
    upstream:
      status: 200
      headers: {Content-Type: application/json}
      body: '{"from":"EUR","to":"USD","value":1.08,"as_of":"2026-10-20"}'
    expect:
      upstream_request:
        path: /v2/rate?from=EUR&to=USD
        headers: {X-Api-Version: "2026-10"}
      response:
        status: 200
        body: '{"from":"EUR","to":"USD","rate":1.08,"as_of":"2026-10-20"}'
$ aftermarket patch test --config rates-rules.yaml --fixture rates-fixture.yaml
PASS  the old app's rate call reaches v2 and gets "rate" back
1 case(s), 0 failed

It exits 1 when a case fails, so the repair can sit in CI next to the app’s other tests. When the app ships a version that calls /v2/rate itself, the two rules and the fixture can go.

The full rule format is in the AfterPatch reference. More short examples are on the use cases page.