How a QA handoff turned into a weekend rabbit hole and eventually an open-source library
Tags: java, opensource, http, curl, backend
The Slack message that started it all
It's 4:47 PM on a Thursday. QA drops this in the channel:
curl -X POST https://api.internal.example.com/orders \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIs...' \
-H 'X-Trace-Id: 8f14e45f' \
-d '{"sku":"A-1001","count":2,"warehouse":"SH-03"}'
"Can you check why this fails in staging but works for me locally?"
Cool. Easy. I just need to reproduce this in our Java service. So I open the controller, and I start... translating.
-X POST → okay, HttpMethod.POST. -H lines → I build a HttpHeaders object, add each one by hand, careful not to typo Authorization. -d → I need a MultiValueMap or just serialize the JSON string directly into the body, depends on which client we're using this week. Then I remember we're on RestTemplate in this module but OkHttp in that other module, so I have to remember which boilerplate applies here.
Ten minutes later I've got a Java snippet that should be equivalent to the curl command. I run it. It fails differently than staging did. I stare at both side by side for a solid two minutes before I notice I dropped a header. Of course I did — I was hand-transcribing eight lines of shell syntax into a completely different language's HTTP client API, header by header, like a monk copying a manuscript.
And this wasn't a one-off. This was Tuesday. This was every integration week. Front-end hands me curl from their network tab, API docs ship curl examples, QA pastes curl into bug reports — curl is the lingua franca everyone actually uses to describe an HTTP call. Nobody writes a bug report in OkHttpClient Java syntax. They all just hit copy on the "Copy as cURL" button in devtools.
So I was translating the same universal language into Java, by hand, multiple times a day. And translating things by hand, repeatedly, is exactly the kind of problem that eventually makes you angry enough to fix it.
Why I didn't just reach for an existing tool
Before writing a single line, I did what everyone does — I went looking for something that already solved this.
Option 1: Just shell out to the real curl binary. Java's ProcessBuilder can absolutely call the system curl command and read stdout. I actually prototyped this first. It "worked," but I hated it almost immediately. It meant every machine running my service — dev laptops, CI runners, Docker images, production hosts — now had a hard dependency on a specific curl binary being installed and on PATH, with a specific version, potentially with different flag support across platforms. On Windows it was a mess. In a minimal Alpine-based Docker image, curl isn't even there by default. I didn't want my Java library to depend on the presence of a totally separate program written in C. It's not portable, it's fragile, and honestly it felt wrong to spawn an OS process just to make an HTTP call from inside the JVM.
Option 2: Use one of those "curl to code" online converters. These are fine for a single one-off translation, but they don't solve my actual problem — I don't want a Java code generator I paste output from once. I want the curl string itself to be the source of truth, permanently, so that when QA sends me an updated curl next sprint, I don't regenerate and re-paste new boilerplate again. I want to keep the curl command in my codebase, unmodified, and have it stay useful.
Option 3: Just write the RestTemplate/OkHttp code properly, like an adult. Yeah, obviously that's what I'd been doing. But it's exactly the boilerplate-per-endpoint problem — headers, method, body serialization, timeouts — repeated for every single API call in the codebase. None of that boilerplate teaches anyone anything; it's just friction between "I have a working curl command" and "I have a working Java call."
What I actually wanted was something that didn't exist: a way to parse curl syntax natively inside the JVM, with no external process, and turn it straight into an executable HTTP call. Not a code generator. Not a wrapper around a shell binary. An actual parser.
Designing jquick-curl
The core idea, once I let myself think about it properly, was almost stupidly simple: the curl command a person hands me should just work, unmodified, as the request definition. I shouldn't have to rewrite it into anything. I should be able to paste it straight from Chrome's "Copy as cURL," slap it on a Java interface method, and call that method.
That meant I needed a real grammar for curl syntax, not a pile of regex and String.split() hacks (I tried that first, for about a day, before giving up — curl's quoting rules, escaped characters inside -d payloads, and mixed short/long flags make naive string splitting fall apart fast). So I reached for ANTLR4 to define a proper grammar for the subset of curl I cared about: methods, headers, cookies, body flags, form/multipart uploads, auth, proxy settings, redirects. ANTLR runs entirely inside the JVM — no external process, no shelling out, no platform dependency. It parses the curl string into a syntax tree I can walk and turn into a structured request object. That single decision is what let me kill the "depend on a real curl binary" idea for good: jquick-curl runs identically on Windows, Linux, and Mac, and doesn't care whether curl is even installed on the machine.
Once I had a parsed request, I needed something to actually execute it, and there was no reason to reinvent that part — OkHttp underneath handles the real transport: connection pooling, timeouts, retries, redirects, interceptors. jquick-curl's job is purely to bridge "curl syntax" to "OkHttp request," not to replace a mature HTTP engine.
Then came the part that made it feel like an actual tool instead of a toy parser: how do you attach a curl string to your codebase in a way that fits how Java developers actually work? I ended up supporting two styles:
- An annotation,
@JCurlCommand, that you drop directly on an interface method — you paste the curl string as-is, and a dynamic proxy (JCurlInvoker.createProxy(...)) turns the interface into a real client at runtime. - An XML definition file (
apis.xml), for teams who'd rather centralize every API's curl template in one place instead of scattering them across annotations — handy when non-Java people (QA, API owners) are the ones maintaining the request definitions.
From there I kept adding the things that came up naturally once I started actually using it for real work: ${name} / #{name} placeholder substitution so a curl template isn't hard-coded per environment, <if test="..."> conditional rendering in the XML flavor for headers that only apply sometimes, cookie jar support (-b @file, -c file) because our session-based APIs needed it, multipart upload and file download via -F and -o, global interceptors for things like injecting a bearer token, and retry/timeout/connection-pool config through one JQuickCurlConfig singleton instead of per-call setup.
None of that was planned up front, honestly — it grew out of "oh, I need this for the thing I'm debugging right now" over a few weekends.
What it actually looks like
Add the dependency:
<dependency>
<groupId>io.github.paohaijiao</groupId>
<artifactId>jquick-curl</artifactId>
<version>2.5.0</version>
</dependency>
Take that curl command from earlier — the one from the Slack message — and just... put it on an interface:
import com.github.paohaijiao.anno.JCurlCommand;
import com.github.paohaijiao.domain.req.JQuickCurlReq;
import com.github.paohaijiao.executor.JCurlInvoker;
public interface OrderApi {
@JCurlCommand("curl -X POST https://api.internal.example.com/orders " +
"-H 'Content-Type: application/json' " +
"-H 'Authorization: Bearer ${token}' " +
"-H 'X-Trace-Id: ${traceId}' " +
"-d '{\"sku\":\"A-1001\",\"count\":2,\"warehouse\":\"SH-03\"}'")
String createOrder(JQuickCurlReq request);
}
class Demo {
public static void main(String[] args) throws Exception {
OrderApi api = JCurlInvoker.createProxy(OrderApi.class);
JQuickCurlReq req = new JQuickCurlReq();
req.put("token", System.getenv("API_TOKEN"));
req.put("traceId", "8f14e45f");
String body = api.createOrder(req);
System.out.println(body);
}
}
No HttpHeaders. No manually building a request body. No deciding which client's boilerplate applies this week. The curl string is the request definition — I literally paste what QA sent me, swap the hardcoded secrets for ${token} placeholders, and I have a working method.
Where I'd be lying if I said this solves everything
I want to be straight about this, because I've seen enough "this library changes everything" posts to be allergic to writing one myself.
jquick-curl is not trying to replace RestTemplate or OkHttp for general-purpose HTTP client code in your application. If you're building a service that needs fine-grained, programmatic control over request construction — conditional logic that's too complex for a template string, streaming request bodies, custom RequestBody implementations, deep integration with Spring's ecosystem (WebClient, reactive pipelines, circuit breakers via Resilience4j, etc.) — you're still better off writing that in actual OkHttp or RestTemplate code. jquick-curl is a thin layer that turns curl syntax into an OkHttp call; it's not trying to out-feature a mature HTTP client.
Where it genuinely earns its place, in my experience, is a fairly specific niche: reproducing and calling APIs that are described to you as curl commands — QA bug reports, third-party API docs that only ship curl examples, internal service-to-service calls you're debugging by pasting from a browser's network tab, or integration test suites where "does this curl still work" is literally the assertion you want to make. It's also handy for quick internal tooling where you want request definitions to be editable by non-Java teammates via the XML config, without touching Java code.
The honest limitations right now: the curl grammar covers a solid, tested subset of options (method, headers, cookies, data variants, form/multipart, basic auth, redirects, proxy, -k/-v/-s, HTTP/2) — but curl has a lot of flags, and I haven't chased every obscure one. If you hit something unsupported, the parser will tell you, not silently misbehave, but it's still a smaller surface than the real curl binary. Error messages from the ANTLR parser can also be more cryptic than I'd like when someone pastes malformed shell quoting — that's on my list to improve. And it's still a fairly young project, so I wouldn't bet a critical production path on it without your own test coverage around it yet.
Try it, break it, tell me
Repo's here: github.com/paohaijiao/jquick-curl — Apache 2.0, PRs welcome, and CONTRIBUTING.md has the setup if you want to poke at the ANTLR grammar yourself.
Two things I'm genuinely curious about from other backend folks:
- Do you hit the same "curl handed to me, translate it by hand" friction, or does your team already have some internal convention that avoids this? I'd love to know if this is a me-problem or an us-problem.
- If you have rolled your own curl-parsing tool before (I have to believe someone has), what broke first — quoting edge cases, multipart handling, something else?
Drop it in the comments — I read all of them, and half the feature list above exists because someone told me what they needed.
Want this saved as a downloadable Markdown file for dev.to, or is copy-pasting from here fine?
Top comments (0)