> Markdown version of https://tuna.am/en/blog/ui-mocks-from-openapi/. Documentation index: [llms.txt](https://tuna.am/en/llms.txt), full text in one file: [llms-full.txt](https://tuna.am/en/llms-full.txt). Any page is available as Markdown: append `.md` to its URL without the trailing slash.

[Blog](https://tuna.am/en/blog.md)|[Under the hood](https://tuna.am/en/blog/tags/engineering)

# A dashboard on made-up data: an OpenAPI mock right inside Vite

November 12 · 8 min

The Tuna dashboard is a Vue app that fetches its data from our API under `/v1`. To open it locally and click through the pages, you'd normally need a running backend: the Go server, Postgres, Redis, S3, and seed data. That's a lot just for working on layout. So the dashboard has a lightweight mode: run `yarn serve:mock`, and the dev server answers every API request itself, making up data from the OpenAPI spec.

There's no separate mock server and not a single hand-written response: the plugin takes the shape of each response from `swagger.json` and generates the values on the fly.

> **In brief**
>
> * `yarn serve:mock` starts the dashboard without a backend: a Vite plugin intercepts `/v1` and answers on its own.
> * The plugin takes the response shape from the OpenAPI spec (path → 200 response schema) and generates values from the JSON Schema.
> * Every refresh brings a new random set of data that still matches the contract: lists of different lengths (empty ones included), statuses, roles, texts. It's a cheap way to surface states that fixed seed data rarely shows.
> * A side effect: the mock keeps the spec honest. An empty page means the endpoint isn't described, or is described differently from what the server returns.

The whole dashboard on the mock: tunnels, domains, ports, SSH recordings, team statistics, members, audit log, and bastion sessions. No backend is running.

## How it works

The dashboard is built with Vite. The Vite dev server has a middleware chain (plain connect), and the plugin hooks into the `/v1` prefix:

vite-plugins/spec-mock.js

```js
server.middlewares.use('/v1', (req, res, next) => {
    const spec = load();                       // swagger.json, loaded once
    const reqPath = '/v1' + req.url.split('?')[0];
    const schema = responseSchema(spec, req.method, reqPath);
    // ...generate a response from the schema and return JSON
});

```

From there, everything rests on the spec. Our server is written in Go, and go-swagger annotations turn into `swagger.json`, the full (admin) spec with every endpoint. The plugin finds the request path, taking path parameters into account (`/v1/domains/42` matches `/v1/domains/{id}`), picks the `200`/`201` response schema, and builds data from it.

Generation is a recursive walk over the JSON Schema:

* `enum` → a random value from the list;
* array → 0–3 items (for paginated lists, pagination goes through the `x-total` / `x-per-page` headers);
* object → each field in turn;
* `boolean` / `integer` / `number` / string.

To make the data look real rather than `string string`, strings are generated with heuristics based on the field name:

vite-plugins/spec-mock.js

```js
if (n.includes('email'))     return `${pick(NAMES, k).toLowerCase()}@example.com`;
if (n.includes('avatar'))    return `https://www.gravatar.com/avatar/...`;
if (n.includes('thumbnail')) return `https://picsum.photos/seed/${k % 1000}/320/200`;
if (n.includes('url'))       return `https://${pick(WORDS, k)}.tuna.am`;

```

There's no separate process: it's just another plugin in the same Vite dev server, and data is generated on every request.

Values are derived from the item index and a random "salt". The salt is picked anew for every response, so rows within one table differ, and a refresh changes the whole list.

## What you need

Almost nothing, and that's the point. You need an up-to-date OpenAPI spec (ours is generated from annotations anyway) and the middleware mechanism built into Vite. It starts with one command:

```shell
yarn serve:mock

```

The main requirement isn't about tooling, it's about discipline: **API responses must be described in the spec**. If an endpoint isn't described, the plugin has nothing to generate, and the page comes up empty.

## How it helps

**The dashboard opens in seconds, anywhere.** No backend, database, seed data, or external services. A frontend developer, designer, or reviewer checks out a branch and sees the UI right away.

**Every refresh is a different state.** Essentially, it's fuzzing the UI with data that matches the contract. With fixed seed data, edge cases have to be set up by hand; here they show up on their own after a couple of minutes of clicking around:

* empty lists and pagination (a list can have 0 rows, one page, or two);
* long text in fields designed for short text;
* every `enum` value (statuses, roles, types) mixed together;
* optional fields that are sometimes there and sometimes not.

Five refreshes of the same page: seven tunnels, then one, then five again. Different regions, services, and banners every time.

**The spec stays honest.** An empty or broken page in the mock almost always means the endpoint isn't described, or is described differently from what the server actually returns. So the mock doubles as a contract check.

## The hard parts

Data that's valid against the schema can easily be meaningless, and that gap is exactly where all the extra work lives:

* **Codes the UI turns into names.** The frontend maps fields like languages, roles, and locations to human-readable labels. A random string produces empty items in dropdowns, so for these fields the plugin has targeted overrides with real codes.
* **Related fields.** A domain with a redirect and the gateway that performs that redirect have to agree. Random generation would put them out of sync, so for domains and gateways the mock returns prepared responses with predefined table row states.
* **Recursion depth.** Generation is depth-limited; otherwise it would never stop on circular references. But the limit itself caused side effects, more on those below.
* **No state.** A spec-based mock is a stub: `GET` returns a fresh random invention, and `PATCH` doesn't "remember" anything. For endpoints where this matters (for example, the flags for banners the current member has already seen), we added a small in-memory state that lives as long as the dev server: `PATCH` stores the fields it receives, and `GET` applies them on top. In places, the stub effectively grows into a fake.

## What the mock has already caught

* **Undescribed endpoints.** Empty pages pointed to endpoints that were missing from the spec. We added annotations for them, and the spec became more complete.
* **A description that drifted from reality.** One list was described as an array, but the server returns a dictionary object for it. The mock dutifully generated an array, and the page behaved differently than it does with the real API. We fixed the description to match the actual response.
* **A fragile spot in the frontend.** The report screen crashed on `user.id`: because of the depth limit, a `null` item ended up in an array, and the code iterated over the list without checking. Technically this was a generator bug (which we fixed: `null` items are now filtered out), but it's also a signal that edge-case input found a place where the frontend trusts its data too much.
* **A heuristic false positive.** The "field name contains `port`" check matched `reports` and `new_reports`, so report counters showed "ports" in the 20000–60000 range. That's the price of heuristics based on field names. We narrowed the check to an exact `port` match and the `_port` suffix.

## Why we didn't use something off the shelf

* **A full local environment** (Go server + Postgres + Redis + seed data) exists and remains our main tool for real e2e and regression testing. But for "see the layout in every state" it's heavy: you have to start it, keep it running, and rebuild it, and the seed data is fixed, so rare states still have to be set up by hand.
* **Hand-written fixtures or handlers** (MSW, say) would have to be written for each of hundreds of endpoints and fixed with every API change. A mock built from the spec updates along with the spec.
* **Recording real responses** (record/replay) gives you a snapshot of a single state: no variety, no empty lists, and real data in the dumps on top of that.
* **An off-the-shelf OpenAPI mock server** (like Prism) is good as a standalone service. But we needed domain-specific tweaks (codes→names, consistent table rows, stateful endpoints, a fixed plan so a gated section opens), and we wanted to avoid an extra process: just another plugin in the same Vite dev server, with hot reload and data generated on the fly. A small plugin of our own turned out simpler than pulling in an external server and wiring it up.

This approach doesn't replace the test environment or tests. It covers a narrow but frequent task: quickly looking at the UI on varied data, while keeping the spec from drifting away from reality.

## Leave feedback

If you enjoy using Tuna, or on the contrary you are not happy with something, please [leave feedback](https://my.tuna.am/?show_feedback=1&lang=en).

![](/blog/img/2024-08-22/feedback.gif)

## News

We announce new features and posts on our [Telegram channel](https://t.me/tuna_am). Subscribe so you don't miss anything.

## Help

We value our users and carefully review every request. If you have any problems with tuna, please contact us in one of the following ways:

* [Email](mailto:info@tuna.am)
* [Telegram community](https://telegram.me/+Kgbtt0_gJwI5ZmEy)

Categories: [Under the hood](https://tuna.am/en/blog/tags/engineering)

[older →🔍 The new inspector, with an API for AI agents](https://tuna.am/en/blog/new-inspector.md)
