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/rateis gone./v2/rateanswers the same question, but only when the request carries anX-Api-Versionheader.- The answer calls the number
valuewhere it used to sayrate.
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.