diff --git a/blog/generate-openapi-from-traffic.md b/blog/generate-openapi-from-traffic.md new file mode 100644 index 000000000..6d96e3643 --- /dev/null +++ b/blog/generate-openapi-from-traffic.md @@ -0,0 +1,385 @@ +--- +template: ../@theme/templates/BlogPost +title: Generate OpenAPI from real traffic (with AI) +description: The new generate-spec command infers an OpenAPI description from recorded HTTP traffic - with AI refinement. +seo: + title: Generate OpenAPI from real traffic (with AI) + description: The new generate-spec command infers an OpenAPI description from recorded HTTP traffic - with AI refinement. +author: adam-sobaniec +publishedDate: "2026-08-05" +categories: + - redocly:redocly-cli + - redocly:product-updates + - api-specifications:openapi +--- + +Plenty of production APIs have no OpenAPI description at all. +The endpoints live in code, the knowledge lives in people's heads, and the closest thing to documentation is a wiki page nobody fully trusts. + +Human developers cope with that - they read the source, ask a colleague, poke around with curl. +AI agents can't. +They learn what your API can do from a machine-readable description of it, and without one its capabilities are invisible to them. + +So the blank page is worth removing, and it is usually the only thing in the way: writing a description by hand for dozens of existing endpoints is exactly the kind of backfill work that never makes it into a sprint. +The new `generate-spec` command in Redocly CLI removes it. +Your API already produces the raw material every time someone uses it: traffic. +`generate-spec` reads recorded HTTP traffic and infers an OpenAPI 3.2 description from it. + +## From traffic to description + +The command accepts HAR files, Kong logs, Nginx/Apache JSON logs, and NDJSON - a file or a whole folder of them. +Traffic parsing is shared with the [`drift` command](./catch-api-drift.md), so any log that works with `drift` works here too. + +From the recorded exchanges it builds a baseline deterministically - no AI involved yet, same traffic in, same description out: + +- Identifier-like path segments (numeric IDs, UUIDs, prefixed tokens like `prd_…`) become named path parameters, so a hundred URLs collapse into one templated path. +- Request and response schemas are merged across all observations; a property becomes optional as soon as one sample omits it. +- Alternative body shapes for the same operation are preserved as `oneOf` variants, and object shapes that repeat across the document are extracted into `components/schemas`. +- String values are analyzed conservatively: consistent well-known patterns get a `format` (`uuid`, `date-time`, `email`, `uri`), and strings that only ever take a small set of repeated values become an `enum`. + +## Example: the Cafe API + +Here is what that looks like end to end, on Redocly Cafe - our public demo API, so you can follow along on the same traffic if you want. +Pretend for a moment that its OpenAPI description doesn't exist, and let's reconstruct it from traffic. + +First, record some. +The `proxy` command starts a local reverse proxy that captures everything passing through into a HAR file: + +```bash +redocly proxy --target https://api.cafe.redocly.com --har ./cafe.har +``` + +```sh +Proxy listening on http://127.0.0.1:4040 → forwarding to https://api.cafe.redocly.com/ +Recording traffic to ./cafe.har +Press Ctrl+C to stop. +``` + +{% admonition type="warning" name="Record in a sandboxed environment" %} +The proxy records complete exchanges - URLs, headers, cookies, and request and response bodies - so a capture taken against production holds whatever that traffic held, including credentials and personal data. +Record against a test environment with synthetic data. +This matters in every mode, not only with AI: observed values reach the generated description as enums and examples, so a description inferred from real user data isn't safe to pass around either. +{% /admonition %} + +Send a few requests through it - browse the menu, filter it, download some menu item photos: + +```bash +curl http://127.0.0.1:4040/menu +curl "http://127.0.0.1:4040/menu?category=dessert" +curl "http://127.0.0.1:4040/menu?category=beverage" +curl -o tiramisu.png http://127.0.0.1:4040/menu-item-images/prd_0000000000seedtrams0000000 +curl -o tea.png http://127.0.0.1:4040/menu-item-images/prd_0000000000seedteabv0000000 +curl -o cheesecake.png http://127.0.0.1:4040/menu-item-images/prd_0000000000seedchesc0000000 +``` + +Press `Ctrl` + `C` to stop the proxy and write the HAR file: + +```sh +Captured 6 exchange(s) to ./cafe.har +``` + +Now ask for a description: + +```bash +redocly generate-spec ./cafe.har --title "Cafe API" -o cafe-openapi.yaml +``` + +```sh +Inferred a baseline OpenAPI description from traffic: 2 operation(s). +Written to: cafe-openapi.yaml +Done in 0s. +``` + +The result is a valid OpenAPI 3.2 description - about a hundred lines of it, from six requests. +The frame: a server URL inferred from the capture, and one path per discovered endpoint: + +```yaml +openapi: 3.2.0 +info: + title: Cafe API + version: 1.0.0 +servers: + - url: https://api.cafe.redocly.com +paths: + /menu: + # … + /menu-item-images/{menu-item-imageId}: + # … +``` + +Rather than paste the whole file, let's zoom in on what the inference did. + +Start with the paths: the three photo URLs collapsed into one templated path, because the `prd_…` identifiers were recognized as IDs and became a required path parameter: + +```yaml +/menu-item-images/{menu-item-imageId}: + get: + operationId: get-menu-item-images-menu-item-imageId + responses: + '200': + description: OK + parameters: + - name: menu-item-imageId + in: path + required: true + schema: + type: string +``` + +Inside the `/menu` response schema, every menu item property got a type, and the observed values were mined for more: + +```yaml +properties: + # … + price: + type: integer + category: + type: string + enum: + - beverage + - dessert + createdAt: + type: string + format: date-time + photoUrl: + type: string + format: uri +``` + +`category` became an enum because every observed value was one of the two, and `createdAt` and `photoUrl` matched well-known patterns in every sample. +The `category` query parameter on the same operation stayed a plain string - two observations aren't enough evidence, so the inference stays conservative rather than guessing: + +```yaml +parameters: + - name: category + in: query + required: false + schema: + type: string +``` + +The merge across samples also noticed which properties come and go. +Beverages have volume, desserts have calories, so `volume`, `containsCaffeine`, and `calories` are typed but absent from the `required` list: + +```yaml +properties: + # … + volume: + type: integer + containsCaffeine: + type: boolean + calories: + type: integer +required: + - id + - name + - price + - photoTextDescription + - category + - createdAt + - updatedAt + - object + - photoUrl +``` + +It's still a hypothesis, though - the description only knows what the traffic showed. +`price` is an integer because every observed price happened to be whole. +`id` and `name` came out as enums of the five menu items the capture contained, which is over-fitting rather than a rule of the API - the same mechanism that turns real values into published enums if you record real traffic. +The photo response has no schema because its body is a PNG, not JSON. +Endpoints nobody called don't exist, nothing has a human-readable description, and names like `{menu-item-imageId}` are derived mechanically - rename them when you review. +More traffic tightens the hypothesis, and your e2e test suite is a free source of it: route the tests through `proxy` once, and the whole run becomes input. + +## Refine it with AI + +For everything determinism can't reach, add `--with-ai`: + +```bash +redocly generate-spec ./cafe.har --title "Cafe API" --with-ai --ai-provider claude -o cafe-openapi.yaml +``` + +The command refines the baseline one operation at a time, sending each operation together with a shape-diverse sample of its real recorded exchanges to an AI provider. +The AI narrows types, adds formats, enums, descriptions, and examples, and models alternative payloads explicitly with `oneOf` and `allOf`. + +Three providers are supported - `claude` (Claude Code), `codex` (Codex CLI), and `cursor` (Cursor CLI). +Each runs the corresponding locally installed CLI in non-interactive mode, so the provider you already use and pay for is the one doing the work. +`--ai-provider` is optional and defaults to `claude`; pick a model with `--ai-model` or let the provider use its default. + +The AI's answer is never trusted blindly. +A refined operation is only accepted when it keeps the operation's path and method, keeps every response status code observed in the traffic, and passes validation with the `spec` ruleset. +A rejected refinement keeps its deterministic baseline, and if refinement fails entirely, the command falls back to the baseline description - you never end up with less than the deterministic run would have given you. + +{% admonition type="warning" name="Traffic leaves your machine" %} +`--with-ai` sends samples of the recorded traffic - URLs, query strings, request and response bodies - to the selected AI provider. +Beyond recording in a sandbox, make sure the traffic holds no secrets or personal data you are not allowed to share with that provider. +{% /admonition %} + +## How much does `--with-ai` actually add? + +Fair question, and the Cafe API can answer it with ground truth: its real, handwritten [OpenAPI description](https://cafe.redocly.com/openapi/cafe) exists - we only pretended it doesn't. +Whatever `generate-spec` reconstructs from traffic can be scored against what the API team actually wrote. + +So we recorded a fuller session than the six-request capture above - 30 requests covering every endpoint: the OAuth2 client registration dance, menu items created in both categories, orders placed, updated, and deleted, photo downloads, and the errors a real session produces along the way (a `400`, a few `404`s, even a `409`). +Then we generated a description twice from that one capture - once deterministically, once with `--with-ai` - and scored both against the handwritten description. + +Two things are worth measuring separately: + +- **Precision** - of every checkable claim the generated description makes about a property the handwritten description also documents (its type, its format, its enum values, whether it's required), how many agree. + This is the "can I trust what it says" number. +- **Recall** - of everything the handwritten description documents, how much the generated one recovered. + This is the "how much work is left" number. + +For response schemas: + +| Metric | Deterministic | `--with-ai` | +| -------------------------------------- | ------------- | ----------- | +| Response properties recovered | 97.5% | 98.3% | +| Correct types | 100% | 100% | +| Correct formats | 100% | 100% | +| Correct enum values | 100% | 100% | +| Correct `required` | 69.2% | 72.2% | +| **Precision, all claims** | **87.8%** | **89.2%** | +| Formats documented, recovered | 53.1% | 62.5% | +| Enums documented, recovered | 66.7% | 66.7% | +| `required` documented, recovered | 91.3% | 94.2% | +| Properties carrying a description | 0% | 97.5% | +| Numeric and length constraints | 0 | 21 | +| Run time | under 1s | 6-15 min | + +Responses tell the same story as the walkthrough: the deterministic baseline is already *correct* - it never contradicted the handwritten description on a single type, format, or enum value - and what `--with-ai` adds there is everything determinism structurally cannot produce: descriptions on nearly every property, constraints, examples, and formats inferred from context rather than repetition. + +Request bodies are a different story: + +| Metric | Deterministic | `--with-ai` | +| ----------------------------------- | ------------- | ----------- | +| Request properties recovered | 55.9% | 61.8% | +| Correct types | 78.9% | **100%** | +| Correct `required` | 81.8% | **100%** | +| **Precision, all claims** | **80.0%** | **100%** | +| Properties carrying a description | 0% | 85.7% | + +## What actually changed + +`POST /menu` accepts `multipart/form-data`, and in a multipart form every value is a string on the wire. +The deterministic baseline can only write down what it saw: + +```yaml +requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + name: + type: string + price: + type: string + category: + type: string + volume: + type: string + containsCaffeine: + type: string + calories: + type: string + required: + - name + - price + - category +``` + +This is the one place in the whole experiment where the baseline was *wrong* rather than merely incomplete - `price`, `volume`, `calories`, and `containsCaffeine` are not strings - and it is exactly what the AI fixed. +The same request body after `--with-ai`: + +```yaml +requestBody: + content: + multipart/form-data: + schema: + oneOf: + - $ref: '#/components/schemas/BeverageCreate' + - $ref: '#/components/schemas/DessertCreate' + discriminator: + propertyName: category + mapping: + beverage: '#/components/schemas/BeverageCreate' + dessert: '#/components/schemas/DessertCreate' +``` + +```yaml +BeverageCreate: + description: Creation request for a beverage menu item. + allOf: + - $ref: '#/components/schemas/MenuItemCreateBase' + - type: object + properties: + category: + type: string + enum: + - beverage + volume: + type: integer + minimum: 0 + description: Serving volume in millilitres. + example: 180 + containsCaffeine: + type: boolean + description: Whether the beverage contains caffeine. + example: true +MenuItemCreateBase: + type: object + description: Attributes shared by every menu item creation request. + properties: + name: + type: string + description: Human-readable name of the menu item. + example: flat-white + price: + type: integer + minimum: 0 + description: Price in the smallest currency unit (for example cents). + example: 450 + # … + required: + - name + - price + - category +``` + +Every type is corrected, and constraints, descriptions, and examples appeared - but the bigger change is the shape itself. +The AI noticed from the samples that beverages and desserts carry different fields and modeled the union explicitly: `allOf` composition over the shared attributes, selected by a `category` discriminator. +The handwritten description models menu items exactly the same way - `oneOf` beverage or dessert, discriminated by `category`. +Traffic plus AI converged on the design the API team chose by hand, where the baseline could only offer one merged object with everything optional. + +The rest of the delta, briefly: + +- Descriptions went from zero to 97.5% of response properties, plus a summary and description on every refined operation. +- 21 numeric and length constraints appeared, along with realistic `example` values. +- Identifier patterns were inferred on path parameters, such as `pattern: ^ord_[0-9a-z]+$` on the order ID. +- The over-fitting the walkthrough warned about got cleaned up: the baseline turned the two observed order IDs into an `enum`, and the AI demoted it to a plain string with a description and a realistic `example` - while real enums like `category` and order `status` stayed. +- `required` was cleaned up where the traffic over-claimed it - optional client-registration fields that every sample happened to include. + +The guardrails did real work during this run, too: one operation's refinement failed on a transient provider error and kept its deterministic baseline, so the output stayed valid and complete. + +One caveat travels from API to API: path parameters. +Every Cafe path parameter was recognized, because its identifiers are prefixed tokens (`prd_…`, `ord_…`) that the deterministic inference detects. +On APIs whose path segments are ordinary words - organization names, repository names, branches - those segments stay hardcoded, and AI refinement can't rescue them because a refined operation must keep its path. +Reviewing paths by hand is the one non-optional step. + +{% admonition type="info" name="About these numbers" %} +They come from one capture of one small API, scored by a script we wrote for the occasion, so treat them as an illustration rather than a benchmark. +The handwritten description isn't perfect ground truth either: the traffic hit a `409` on `DELETE /menu/{menuItemId}` and a live `/health` endpoint that the handwritten description doesn't document - real API behavior that counts against the generated description as "not in the spec". +{% /admonition %} + +{% admonition type="warning" name="Experimental" %} +The `generate-spec` command is experimental. +Flags, output, and behavior may change - including breaking changes - in upcoming releases while we shape it with your feedback. +{% /admonition %} + +## Get started + +The `generate-spec` command is available now in the latest [Redocly CLI](https://redocly.com/docs/cli) - see the [command reference](https://redocly.com/docs/cli/commands/generate-spec) for all options. +Run it on your traffic, review what comes out, lint it with your own ruleset, and tell us what you think on the [Redocly CLI GitHub repository](https://github.com/Redocly/redocly-cli/issues). + +And once you have a description, don't let it rot. +Point the [`drift` command](./catch-api-drift.md) at next week's traffic and it tells you the moment reality moves. +Generate, then guard.