Quick start

This takes about five minutes. You’ll run an old client that breaks against a new service, then fix it with two AfterPatch rules, without changing either side. Part two does the same for a whole API with AfterAPI, part three records real traffic and compares two versions, and part four changes a vendor’s config file without editing it.

1. Get the program

Download the archive for your system from the download page and put aftermarket somewhere on your PATH. Check it works:

aftermarket version

The example below also needs a copy of the code repository and Go 1.24 or newer, because the example service and client are small Go programs:

git clone https://github.com/aftermarket-dev/aftermarket
cd aftermarket

2. Start the new service

go run ./examples/legacy-customer/service -listen 127.0.0.1:9000

It only knows the new API, GET /api/v2/customers/{id}, and it nests the customer under "customer" with renamed fields.

3. Watch the old client fail

In a second terminal:

go run ./examples/legacy-customer/client -base http://127.0.0.1:9000

The old client asks for /api/v1/customer?id=7 and gets a 404. Even if the path were right, the JSON would be in the wrong shape.

4. Put AfterPatch in between

aftermarket patch run --config examples/legacy-customer/rules.yaml \
  --upstream http://127.0.0.1:9000 --listen 127.0.0.1:8080

Then point the old client at AfterPatch:

go run ./examples/legacy-customer/client -base http://127.0.0.1:8080

This time it prints old client got customer 7: Ada Lovelace. In the AfterPatch terminal, the audit log shows one line per change: the path rewrite on the way in, and the four JSON changes on the way out.

5. Read the rules

rules.yaml has two rules. The first sends /api/v1/customer?id=7 to /api/v2/customers/7. The second flattens the answer back into the shape the old client expects: customer.id becomes customer_id, customer.full_name becomes name, and so on.

6. Try it safely on your own traffic

Write your own rules, then check them:

aftermarket patch check --config my-rules.yaml

Run the proxy with --dry-run first. Traffic passes through unchanged, and the audit log shows what each rule would have changed. When the log looks right, drop the flag.

To lock a repair in, save a request and the answer you expect in a fixture file and run aftermarket patch test. It exits with an error if anything changes, so it fits in CI. The reference has the full rule and fixture format.

Part two: a whole API that changed

AfterPatch suits one small fix. When a whole API was replaced, use AfterAPI (aftermarket adapt) and describe each old endpoint in terms of the new one. The examples/customer-api folder has an old v1 service, a new v2 service and the old client’s test suite of twelve checks.

Start the new service and put adapt in front of it:

go run ./examples/customer-api/v2 -listen 127.0.0.1:9002 &
aftermarket adapt run --config examples/customer-api/adapt.yaml \
  --upstream http://127.0.0.1:9002 --listen 127.0.0.1:8081

Run the old test suite against the new service directly, then through adapt:

go run ./examples/customer-api/client -base http://127.0.0.1:9002   # 12 checks, 12 failed
go run ./examples/customer-api/client -base http://127.0.0.1:8081   # 12 checks, 0 failed

The suite creates and deletes a customer, so restart the v2 service before you run it again.

adapt.yaml has one entry per old endpoint. Each names the old call, the new call, and how to build one body from the other:

- id: get-customer
  old: GET /api/v1/customer?id={id}
  new: GET /api/v2/customers/{id}
  response:
    body:
      customer_id: $.customer.id
      name: $.customer.full_name
      email: $.customer.email
    error:
      message: $.error.message

Check a mapping file with aftermarket adapt check and lock it in with aftermarket adapt test, the same way as with patch. The AfterAPI reference has the full format.

Part three: record real traffic and compare two versions

AfterTest (aftermarket record) saves traffic as fixtures, and AfterDiff (aftermarket replay) sends them to two services and lists what’s different. Start a fresh v1 service and put record in front of it:

go run ./examples/customer-api/v1 -listen 127.0.0.1:9001 &
aftermarket record --upstream http://127.0.0.1:9001 --out recorded.yaml --listen 127.0.0.1:8090

Run the old client’s suite through it, then stop record with Ctrl-C:

go run ./examples/customer-api/client -base http://127.0.0.1:8090   # 12 checks, 0 failed

recorded.yaml now holds twelve cases, one per request. Login headers, cookies and keys such as api_key would read REDACTED. Restart both services so they start from the same data, keep adapt running in front of v2, and replay:

aftermarket replay --fixture recorded.yaml --a http://127.0.0.1:9001 --b http://127.0.0.1:8081 --include-writes
aftermarket replay --fixture recorded.yaml --a http://127.0.0.1:9001 --b http://127.0.0.1:9002 --include-writes

Through adapt, all twelve answers match. Against v2 directly, all twelve differ, and the report lists each difference by field, such as customer_id: only in A (7). Without --include-writes, replay sends only GET and HEAD requests, so it can’t change data on a live service.

A recording only holds what you sent while it ran. To test the new version on live traffic before switching, put shadow in front of v1 with v2 through adapt as the candidate. Restart both services first, as before:

aftermarket shadow --primary http://127.0.0.1:9001 --candidate http://127.0.0.1:8081 \
  --listen 127.0.0.1:8090 --include-writes --workers 1 --report shadow-report.md
go run ./examples/customer-api/client -base http://127.0.0.1:8090   # 12 checks, 0 failed

The client only ever gets v1’s answers, so it passes. Each request is copied to the candidate after v1 has answered, and shadow-report.md is rewritten as the comparisons come in. Stop shadow with Ctrl-C and the report reads 12 case(s): 12 same. With v2 directly as the candidate, it lists twelve differences, the same ones replay found. --workers 1 keeps the writes in order on the candidate. The AfterTest and AfterDiff reference has the full format.

Part four: change a vendor’s config without editing it

AfterConfig (aftermarket config) needs no Go and no services: the example ships in the download archive. From the unpacked folder:

aftermarket config diff  --overlay examples/vendor-app/overlay.yaml
aftermarket config apply --overlay examples/vendor-app/overlay.yaml --out config.json

The overlay makes seven changes to the vendor’s 1.0/config.json, which stays untouched, and writes the result to config.json. Now try it on the vendor’s next version:

aftermarket config apply --overlay examples/vendor-app/overlay.yaml \
  --base examples/vendor-app/1.1/config.json --out config.json

It stops at the two lines where version 1.1 changed a default the overlay relied on, and writes nothing.

The same upgrade with a TOML config is in examples/vendor-app-toml. There the changes are made on the vendor’s own lines, so its comments stay where they were:

aftermarket config apply --overlay examples/vendor-app-toml/overlay.yaml --out config.toml
diff examples/vendor-app-toml/1.0/config.toml config.toml

The case study walks through what to do next, and the AfterConfig reference has the full format.

Part five: get webhooks from an app that has none

AfterHook (aftermarket hook) watches a status file, an exports folder and a SQLite table in examples/legacy-app, and sends a signed webhook when one of them changes. You need the sqlite3 program, and Go to run the small receiver. In one terminal, from the unpacked folder:

cd examples/legacy-app
export HOOK_SECRET=any-long-random-string
go run receiver/main.go

In a second terminal, set the same secret, create the app’s database, and take a first look:

cd examples/legacy-app
export HOOK_SECRET=any-long-random-string
sqlite3 app.db "create table orders(id integer primary key, status text, total real); insert into orders values (1, 'open', 39.8);"
../../aftermarket hook run --config hook.yaml --once

The first run only sets a baseline and sends nothing. Now change all three: edit status.json so "status" reads "paid", drop any .csv file into exports, and add a row:

sqlite3 app.db "insert into orders values (2, 'open', 12);"
../../aftermarket hook run --config hook.yaml --once

The receiver prints three events, one per source, each with a checked signature. Run it once more and nothing is sent, since nothing changed. Stop the receiver, change something, and run again: the event waits in hook-state.json and goes out on the next run after the receiver is back. Leave out --once and AfterHook keeps checking on its own. The AfterHook reference has the full format.

Part six: put an old admin panel behind AfterGuard

AfterGuard (aftermarket guard) needs the stand-in panel from examples/legacy-admin. From a copy of this repository, start the panel on 127.0.0.1:9100, where only programs on the same machine can reach it, and AfterGuard in front of it:

go run ./examples/legacy-admin/app &
aftermarket guard run --config examples/legacy-admin/guard.yaml \
  --upstream http://127.0.0.1:9100 --listen 127.0.0.1:8080

Now ask for the same pages through AfterGuard:

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/debug/env      # 404: hidden from everyone
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/admin/users    # 401: needs the admin login
curl -s -u admin:admin-demo-password-change-me http://127.0.0.1:8080/admin/users
curl -s -H 'X-Api-Key: billing-demo-key-change-me' http://127.0.0.1:8080/reports/daily

Six wrong passwords in a row, and the sixth gets 429. Each decision is printed as a JSON line. aftermarket guard test --config examples/legacy-admin/guard.yaml --fixture examples/legacy-admin/fixture.yaml runs the same checks without a server, and works from the download archive too. Make your own hashes with aftermarket guard hash, then change the demo keys before real use. The AfterGuard reference has the full format.

Part seven: keep old SQL working after a schema change

AfterSchema (aftermarket schema) needs the sqlite3 command, 3.37 or later. The example is in the download archive. Shop 2.0 renamed and reshaped the tables that a dozen old reports read:

cd examples/legacy-shop
aftermarket schema check --queries reports.sql --new v2/shop.sql

Only 1 of the 12 reports compiles. Now add the views described in map.yaml:

aftermarket schema check --queries reports.sql --map map.yaml
aftermarket schema test  --queries reports.sql --map map.yaml

All 12 compile, and all 12 give the same rows on 2.0 as on 1.0. aftermarket schema diff --map map.yaml lists every change between the two versions, and aftermarket schema compat --map map.yaml --out compat.sql writes the views to a file for you to review and apply, for example with sqlite3 shop2.db < compat.sql. AfterSchema itself never writes a database. The AfterSchema reference has the full format.

Part eight: serve an old app’s exports as an API

AfterBridge (aftermarket bridge) needs nothing for CSV files, and the sqlite3 command for SQLite ones. The example in the download archive has two nights of orders exports and an inventory:

cd examples/legacy-exports
aftermarket bridge check --config bridge.yaml
aftermarket bridge serve --config bridge.yaml --listen 127.0.0.1:8080

Then, from another terminal:

curl -s 'http://127.0.0.1:8080/orders?paid=false'
curl -s http://127.0.0.1:8080/orders/1003
curl -s http://127.0.0.1:8080/products/BK-001

The orders come from the newest export, typed as bridge.yaml declares them, with the internal note left out. Drop a new orders-YYYY-MM-DD.csv into exports and it is served a few seconds after it is complete. aftermarket bridge test --config bridge.yaml --fixture fixture.yaml runs the same checks without a server. The AfterBridge reference has the full format.