From Swagger URL to Instant API

From Swagger URL to Instant API

Slack publishes a specification describing 174 operations. GitHub publishes one describing 1,220. Stripe's runs to 589. Every one of those documents already contains the argument names, the types, the defaults, the enums, the required flags, and a paragraph of prose explaining what each field means.

So why does anybody still hand-write the wrapper?

That question is the whole of this article. Magic's Endpoint Generator grew a third tab this week: paste the URL of an OpenAPI specification, tick the operations you care about, and it writes Hyperlambda endpoints wrapping them — arguments and all, documentation and all. And because Magic publishes every endpoint over MCP, the API you just imported arrives on the other side as a set of tools your AI agent can call.

Same honesty as always: Claude wrote most of it, I reviewed and tested, and I caught the best bug — which this time was the model quietly adopting Stripe's bad habits and calling them the standard. More on that below.

Two fields and a checkbox

The Import API tab in the Magic Endpoint Generator, having read the Swagger Petstore specification, with eight of twenty operations selected

Paste, click Read, tick, click Import.

Magic fetches the specification, parses it, and shows you every operation it found — grouped by the specification's own tags, or by the first meaningful path segment for the many specifications that tag nothing at all. The module name and the base URL fill themselves in from the document. A terminal reports each endpoint as it is written, and tells you how many lines of Hyperlambda it produced.

That is the entire user interface. The interesting part is what comes out the other end.

The ledger

I did not test this against one specification and declare victory. Nine, across every dialect I could find:

SpecificationDialectOperationsWhat it proved
Swagger PetstoreSwagger 2.020form fields, multipart, three body encodings
Petstore v3OpenAPI 3.019relative server URLs
Petstore 3.1OpenAPI 3.13the newer dialect, mutualTLS
petstore-expanded3.0, YAML4YAML, and specifications with no summary
APIs.guru3.0, YAML7small and public
PokeAPI3.0, YAML100no authentication — live callable
Slack Web APISwagger 2.017491 form bodies, 101 header parameters
StripeOpenAPI 3.0589deeply nested form bodies
GitHub RESTOpenAPI 3.01,220scale

Every one of them parses. Most of the work of the last few days was in the gap between "parses" and "produces code that actually runs".

Arguments are the product

Here is the design decision that matters most, and it is not about HTTP at all.

A generated endpoint is not a dumb proxy. Every query parameter and every form field becomes a named, typed argument of the endpoint itself, carrying a comment that states its type, whether it is required, its default, the values it will accept, and the description the specification gave it. Required ones get a validators.mandatory invocation, so a missing argument is refused before the upstream API is ever contacted.

// Query parameter: per_page integer (optional) (default 30) - The number of results per page (max 100)…
per_page:long

add:x:../*/http.get/*/query
   get-nodes:x:@.arguments/*/q
   get-nodes:x:@.arguments/*/sort
   get-nodes:x:@.arguments/*/per_page

That add is doing something quietly elegant, and it is a Hyperlambda idiom worth stealing: it copies nothing at all for an argument the caller did not supply. Optional parameters need no conditionals, no null checks, no branching. Absent means absent.

Now the payoff. Magic publishes an endpoint's file comment as its MCP tool description, and the comment above each argument as that argument's description. So Slack's chat.postMessage does not arrive at your agent as one opaque payload blob. It arrives as fifteen individually typed, individually described tool argumentschannel marked required, link_names typed as a boolean, thread_ts explaining what it is for — every word of it lifted from Slack's own documentation.

Nobody wrote a tool definition. The tool definition was always in the specification; it was just in the wrong file.

I measured before I designed

The obvious question about all this is whether it is safe. Can every query parameter really become a named argument? Argument names have rules. Specifications are written by strangers.

I did not guess. I counted.

Across Stripe, GitHub, Slack and both Petstores — 5,324 parameters — exactly zero had a name unusable as an argument, and exactly zero collided with a path parameter on the same operation. So the conversion is safe, and I know it is safe rather than hoping.

Form fields were a different answer, and I am glad I asked. Slack: 293 of 293 convert cleanly. Stripe: 3,885 of its 5,548 form fields cannot, because a nested object has to be flattened into bracketed keys to survive form encoding — components[account_management][features][enabled] is not a name any argument can carry. So the rule became conditional, per operation: if every field name is a plain identifier, they all become named arguments; if any field needed brackets, that operation keeps a single payload argument and the bracketed keys are documented above it.

157 of Stripe's 293 form operations convert. The other 136 do not, and say so. That fallback exists because measurement found the case — not because somebody imagined it might exist one day.

The credential never touches the file

You choose how the generated endpoints authenticate against the upstream API — bearer token, custom header, query parameter, or nothing — and which configuration key holds the credential. The credential itself is never written into the generated file. It is read from your configuration at the moment the endpoint is invoked.

Which means the files are safe to commit, and the secret lives exactly where secrets should live. You also declare which of your roles may invoke the wrapper, so importing somebody's API does not silently publish it to the world.

The bug ledger

Every article I write about AI-built software gets one of these. This one has a centerpiece.

The model learned Stripe's bad habit and called it the standard. Stripe sends array query parameters as expand[0], expand[1]. That is Stripe's convention. It is not what OpenAPI says — the specification's default is style: form, explode: true, which means a repeated key. Claude generalised the bracket notation it had seen in Stripe to every specification on earth, and documented it as though it were the rule. I read one generated comment and asked why an array parameter was being described that way. The fix was deleting the invention: the generated comment now states the type the specification declared and leaves the layout to the API's own documentation, where it belongs. A model shown one example will cheerfully mistake it for the general case, and no amount of internal consistency will reveal that from the inside.

Declaring what you forward instead of what you accept. Magic endpoints can declare a Content-Type they consume. Claude wrote the upstream content type into that declaration — so a wrapper that accepted JSON and merely re-encoded it as a form on the way out claimed to consume form data. Hyper IDE promptly refused to let me invoke it, which was correct: the tool was right and the metadata was lying. And the deeper lesson, once it was fixed: application/json is the default, so writing it out at all is just repeating yourself. The declaration now appears only when the endpoint genuinely accepts something else.

Slack's dots./chat.postMessage became chat.postMessage.post.hl, and Magic reads a filename as name.verb.hl — so the file saved happily and the endpoint never existed. It generated, it reported success, and it resolved to nothing. Found by listing the endpoints afterwards and noticing one short.

Ninety-one missing bodies. Swagger 2.0 does not have requestBody; it declares form fields as parameters with in: formData. Unhandled, which meant most of Slack's API generated with no body at all — 91 operations that would have posted nothing.

The compiler got invited this time

The OAuth article's proudest number was zero recompiles. This one cannot make that claim, and the reason is worth the detour.

File uploads were corrupted. Magic serialised the multipart body to a string before sending it, which ran every byte through UTF-8 — and UTF-8 replaces anything that is not valid UTF-8 with U+FFFD. A 13,561-byte PNG arrived at the far end as 23,604 bytes of wreckage, its 89 50 4E 47 signature replaced by EF BF BD 50 4E 47. Text had always worked perfectly, which is presumably why nobody noticed for years.

That one needed C# — two files, one recompile.

And then it still was not right. The second run came back at 6,023 bytes against 6,000 sent. Twenty-three bytes too many. My test payload contained exactly twenty-three line feeds, and the MIME writer was faithfully rewriting each one as a carriage return and line feed pair — correct for text, quietly fatal for everything else. That fix needed no compiler at all: the generated file part now declares Content-Transfer-Encoding: binary, and the writer leaves the bytes alone.

Both bugs share a property worth dwelling on. Neither is visible by reading the code. Both are obvious the instant you compare checksums:

sentreceivedSHA-256
6,000 bytes, every byte value60006000identical
a real PNG13,56113,561identical

Round-trip a file and compare the hash. It is the cheapest test in the world and it finds what code review structurally cannot.

Does it actually work

Enough theory. PokeAPI publishes a specification, requires no key, and is happy to be called:

  • limit=3 → bulbasaur, ivysaur, venusaur, out of 1,351.
  • id=pikachu → id 25, weight 60.

And the Swagger Petstore, through a wrapper generated from its Swagger 2.0 document: 296 pets with status=sold, every one of them genuinely sold.

Endpoints written by reading a document, invoked against the real service, returning real data. No adapter written by hand.

The fine print

Big claims deserve precise edges. allOf and oneOf body composition are not handled — a body assembled from composed schemas is documented as best it can be, not decomposed. Remote $refs are not followed, only references within the document. Server variable templating is not resolved.A property that is itself a $ref is reported as an object rather than traversed, because following references across a specification can recurse forever and I would rather under-document than hang. And one file per multipart operation, because Magic hands every upload to Hyperlambda under the same node name, so two files in one request are indistinguishable.

None of those blocked a single operation in the nine specifications above. All of them will eventually bite somebody, and I would rather they read it here than discover it themselves.

Try it

One command:

curl -fsSL https://hyperlambda.dev/docker-compose.yaml | docker compose -f - up

Open localhost:5555, point it at localhost:4444, log in with root / root, and go to the Endpoint Generator's Import API tab. Paste this:

https://petstore.swagger.io/v2/swagger.json

Twenty operations will appear. Tick the ones you want, and read what comes out.

Magic is MIT-licensed and open source — the repository is at github.com/polterguy/magic, with documentation at docs.ainiro.io.

Your agent's next tool is already documented. It is sitting in somebody's OpenAPI file, waiting for a URL box.