fun-refactor

Cross-language, contract first

A pet store, moved from Next.js to FastAPI without moving its API

A refactoring preserves what a function does. A translation preserves a signature. Rewriting a service in another language has to preserve the only thing anyone outside the repository can see: the HTTP contract. A caller does not import your functions. It sends PATCH /pets/42 with a JSON body and expects 200.

Everything below works one example: eight route files, thirteen operations, five request schemas, and every shape a CRUD API has. Those shapes are a collection, a member, a sub-collection, a sub-member, a whole-resource replacement, an action outside CRUD entirely, an aggregate, and a catch-all that matches across slashes.

Generated  Running fr over tests/petstore produced the contract and every translation on this page, and cargo test --test site_data captured them. Nobody typed any of it by hand, so none of it can drift from what the tool does.

The method

The two frameworks are not mirror images, and the difference decides the order of the steps.

FastAPI derives OpenAPI from the code. The path decorator gives the URL and the method, the annotated parameters give the path and query parameters, a Pydantic model gives the body schema, and /openapi.json falls out. The contract projects the types, instead of sitting in a document somebody maintains.

Next.js does not. The App Router has no equivalent. The URL is the file's position on disk, and the method is the exported function's name. A zod schema in some other module validates the body, and the handler reads the query parameters out of the URL by hand. Nothing declares any of it.

So the rewrite goes in one direction and the checking goes in the other:

  1. Derive the contract from the tree, before touching anything. fr openapi --yaml > contract.yaml. Keep that file as the baseline. Most teams skip this step, and without a baseline the rewrite that follows has nothing to be wrong about.
  2. Translate route by route. fr translate app/api/pets/[petId]/route.ts fastapi. Read the report each time: what carried, what did not, and which status codes are returned but not declared.
  3. Finish the handlers. The database calls and helpers the route imported have no counterpart and are yours. The tool did the mechanical, error-prone half, where a mistyped path segment costs you a week and a missing :path costs you the requests nobody reports.
  4. Run the service and diff. curl -s localhost:8000/openapi.json | … against the baseline. Every difference is a defect until argued otherwise.

This catches the failure nothing else catches: a contract that quietly got smaller. A smaller contract looks just like a correct one. Every test you have will pass, and the requests that stop working are the ones nobody sends often.

What the tool has to read that nobody declared

Contract elementWhere it lives in Next.jsHow it is read
URL templatethe file's path on diskfrom the tree, because nothing inside the file says it
Catch-all segment[...path]becomes {path:path}, which matches across slashes
Methodthe exported function's nameexport async function PATCH
Path parametersthe bracketed segmentstyped str, named the way Python names things
Query parametersnowhereread from req.nextUrl.searchParams.get("…") in the handler
Request bodya zod schema, usually in another modulethe tool finds the schema anywhere in the tree; the link comes from the .parse() call inside the handler
Response bodynowhereleft out of the contract, said so, and never invented
Status codesthe returned objectcarried into the code, reported for the contract

What changes

A Next.js handler receives (request, context) and digs the path parameter out of context.params.petId. FastAPI passes it as an argument. So the value arrives by a different route, and every use of it moves with the parameter. The behaviour redistributes itself while the URL it answers stays the same:

Next.js:

const pet = await db.pet.findUnique({ where: { id: context.params.petId } })

FastAPI:

pet = await db.pet.findUnique({"where": {"id": pet_id}})

Rewriting the declaration and leaving context.params.petId in the body would produce a file that parses, imports and starts, then answers every request with a NameError.

The contract

The tool derives this from the tree, before the rewrite. Paths, methods and path parameters come out exact, because they follow from where the files sit. Schemas are as good as what the source declared. Responses carry default only. Which status an endpoint returns is a fact about its code instead of its declaration. Writing 200 for everything would put fiction into the file you are about to diff against.

Every endpoint, both sides

One command each, in the order a reader meets them. fr translate produced the Python, and the report beside it says what the tool made of the parts it could not carry.

Did the contract survive?

The method says to run the finished service and diff its /openapi.json against the baseline. Check this half without running anything. fr openapi reads the other side too, the decorators and the signatures a FastAPI router declares, so one command answers the same question about the code the rewrite produced.

Thirteen operations went in and thirteen came out, with every URL, every method, every path parameter and every query parameter identical. Nothing was lost and nothing was invented.

The build holds to that claim: the site data test asserts the two documents agree, so a translation that started dropping endpoints or reshaping paths would fail the build, and not the page alone.

What it does not say

The two documents agreeing means the crossing was faithful. It does not mean the contract is complete, and the difference matters more than anything else on this page: both sides can be missing the same thing and agree perfectly.

So the baseline says what it could not read. A statement the tool carries whole is a statement it did not look inside, and a query parameter read in there goes missing from the document. The note exists for that, and it counts. Find it in the What it does not settle pane above, and watch the count. A gap that announces itself is a gap you can close; a gap that stays quiet is the thing this whole document exists to prevent.

This example closed twice while somebody was writing it. It named two statements the tool could not follow, and here they are:

const limit = Number(req.nextUrl.searchParams.get("limit") ?? "50")

const pets = await db.pet.findMany({
  where: species ? { species } : {},
  …
})

?? asks whether a value is absent, a question rather than an arithmetic operator. Zig spells it orelse, Rust reaches for Option::unwrap_or, Java for a static method, Python names the value twice, and Go cannot say it at all. { species } is the shorthand every modern TypeScript file uses, and it means { species: species }. Failing to read it refused the whole object, and with it the statement the object sat in.

The tool reads both now, the note is gone, and limit stands on both sides of the crossing. Closing a gap looks like this from here: the count goes down, and the contract gets bigger.

Where it stops

Not a proof. Preserving the addressing half of a contract, the URLs and the methods and the path parameters, is a syntactic property, and you can hold the tool to it. Preserving the shape half needs to know what the handlers do, and the tool carries the handlers across for a person to finish.

Not a migration. Authentication, database access, middleware ordering and every library the route imported have no counterpart, so the tool reports them instead of translating them.

Not the other direction. FastAPI → Next.js loses a machine-readable contract instead of gaining one: nothing in the target regenerates it, so you would export it before the rewrite and assert against it afterwards. This tool does not go that way.

Read the notes. The tool prints everything the document could not settle beside it, and guesses at none of it. A baseline that quietly invents an entry is the worst outcome available: the diff comes out clean and the contract still shrank.