Dev-time API mocking (registerApiMock)

Backend API not ready yet, but you want to build out pages, components, and interactions first? swift-web ships registerApiMock: declare "what a given request returns" during dev, let components call the real request() as usual, and once the API is ready, just delete the mocks to integrate.

This is a lightweight dev-time stopgap, not a full-blown mocking framework:

  • When to use: a new project whose backend API isn't ready, or when you want to run the UI / UX purely on the frontend.
  • When NOT to use: rewriting a legacy project with an existing backend API — you don't need it, don't introduce it. If registerApiMock is never called it simply doesn't exist (dev-only, stripped from production builds).
  • Boundary: for the odd case it can't handle, an applier can just hardcode something in the component for now. This tool doesn't aim for 100% coverage.

1. How it works — why intercept at the request layer, why not a third-party package

swift-web's request() sends the request body AES-CBC encrypted (dynamic key) + gzipped, and the response is ciphertext too. registerApiMock intercepts inside createApiFetch, before encryption: on a match it returns the plaintext data you provide; on a miss it passes through to the real backend.

This is exactly why the network-layer mock packages (MSW / vite-plugin-mock / MirageJS) don't fit this architecture well:

  • They all intercept at the network "wire", where the request / response is ciphertext — to mock there you'd have to re-implement the dynamic-key derivation + encryption in every handler. Tedious and fragile.
  • registerApiMock intercepts at the plaintext layer, so the handler just returns a plain object — encryption is bypassed by design.
  • Extra gotcha: MSW relies on a Service Worker in the browser, which would conflict with swift-web's PWA (workbox) service worker.

So shipping a thin built-in mechanism here is an architecture-driven decision, not reinventing the wheel.

2. Quick start

Put all mocks in a single dev-only plugin. Prefer a universal plugin (no .client suffix) — in dev it intercepts on both SSR and CSR, so ISR / SSR pages fetching at the top of setup with await useAsyncData(() => request(...)) also render mock data straight into the SSR HTML (the page is in its final, backend-ready form from day one):

// app/plugins/dev-mock.ts
export default defineNuxtPlugin(() => {
  registerApiMock('/movie/home', () => ({
    list: [
      { id: 1, title: 'Mock movie 1', cover: '/mock/1.jpg' },
      { id: 2, title: 'Mock movie 2', cover: '/mock/2.jpg' },
    ],
  }))

  registerApiMock('/movie/detail', ({ body }) => ({
    id: (body as { id: number }).id,
    title: 'Mock detail',
  }))
})

Components / composables call the real request() as usual — they never need to know whether a mock exists:

const home = await request('/movie/home')

When a mock is hit in dev, the console prints a line like [mock] SSR POST /movie/home (or CSR, indicating which side intercepted) so you can confirm the interception is working. SSR hits appear in the dev server terminal, CSR hits in the browser console.

Naming the file dev-mock.client.ts still works — that falls back to client-only interception (SSR still hits the real backend), useful when the backend is already up and you only want to swap a few endpoints in the browser.

To integrate the API: just delete app/plugins/dev-mock.ts — not a single line of component code changes, and requests go straight to the real backend. That's the clean cut for "build the UI first, integrate later". In the same step, delete the request.mock config block to stop the built-in fakes — see §7 Disabling and removing mock.

Zero-config built-ins (framework's own services)

When request.mock.enabled: true, framework also auto-mocks its own two services — no code needed:

  • Login /login/* → returns a built-in fake user, lands you in a logged-in state;
  • /system/info → injects a default skeleton (both SSR and CSR).

Override: use registerApiMock('/login/username', …) to override the built-in login; use request.mock.systemInfo in swift.config.json to override system/info fields. Set request.mock.builtins: false to keep the mechanism without the built-in fake data.

⚠️ The builtins fake data only activates when enabled is explicitly written as true — with enabled omitted, mock is still on by default in dev (the mechanism works, registerApiMock intercepts as usual), but the built-in presets do not activate — preventing them from hijacking a project's own dev mock data. This is the only "source-sensitive" switch in all of ModuleOptions.

3. API reference

registerApiMock(matcher, handler, options?)

Registers a mock and returns an unregister handle (call it to remove that entry).

matcher (three forms — decides which request is intercepted)

FormExampleNotes
Exact string'/login/username'Exact path match (the url's query string is ignored)
Object{ path: '/order/submit', method: 'POST' }Add method / query for finer matching; omit method to match any method
RegExp/^\/movie\/\d+$/Tested against the path

Priority: exact string > object > RegExp; within the same tier, first registered wins.

handler

(ctx: { url: string; method: string; body: unknown; query: Record<string, string> }) => data | Promise<data>

Returns the plaintext data — i.e. the T in request<T>() (the framework already unwraps the { status, data } envelope for you, so you just return data). May be async.

options

OptionEffect
delaySimulate network latency (ms), to exercise loading states
status: 'n'Simulate a business failure, going through the same failure branch as a real status:'n' backend response (throws)
errorCodeUsed with status:'n'; 2002 triggers the framework's "silent re-login + retry once" logic
errorThe error message when status:'n'; defaults to "请求失败"
onceAuto-unregister after the first hit
registerApiMock({ path: '/order/submit', method: 'POST' }, () => ({ orderId: 'x' }), { delay: 300 })
registerApiMock('/pay/confirm', () => ({}), { status: 'n', error: 'Insufficient balance' })

clearApiMocks()

Clears all registered mocks (handy in tests / resets).

The mock-layer switches (request.mock.enabled / builtins) plus the at-create-time vs existing-project ways to turn it off are all covered in §7 Disabling and removing mock below.

4. How to mock login (important)

With no backend, the most common need is "pretend login succeeded". Don't monkeypatch userStore.loginByUsername — that means understanding AES cookies and rewriting store internals, which is fragile and gets re-invented in every project.

The right way: just mock the login endpoint to return a fake user. Because interception happens before encryption and returns plaintext data, the framework's subsequent setInfo / cookie writes / isLoggedIn / route guards all run for real:

// app/plugins/dev-mock.ts
registerApiMock('/login/username', ({ body }) => ({
  token: 'mock-token',
  user_id: '1',
  nickname: 'Cloud Walker',
  avatar: '/mock/avatar.jpg',
}))

Now entering any username / password on the login page or dialog lands you in a real "logged in" state — the /mine guard lets you through, and logout clears state normally.

5. Safety model (why it never leaks to production)

A fake login reaching production = an "any credentials log you in" backdoor, which is a disaster. registerApiMock has three layers of protection:

  1. Dev-only: the interception logic is wrapped in import.meta.env.DEV, so production builds strip the whole block via Vite DCE (dead-code elimination) — the code never enters the production bundle.
  2. The same gate on both sides: the SSR side is also dev-only — in the production server build import.meta.env.DEV is statically false (eliminated at build time; even if a bundler keeps the code, registration and lookup are runtime no-ops).
  3. Master switch: request.mock.enabled can disable it explicitly.

Just write your mocks normally — you don't need to add your own "runtime-flag-guarded backdoor" the old way.

SSR notes: whether mocks cover SSR depends on where the plugin is registered — a universal plugin (dev-mock.ts) intercepts on both sides; .client.ts intercepts client-only. On the SSR side, once counts at the dev-server process level (one hit from any request removes it globally). system/info is the exception: the built-in presets inject its skeleton directly during SSR (zero requests, immune to plugin ordering — see "Built-in presets" above), so you never need to mock it.

6. Relationship to "data-layer composables"

Another common dev-mock approach: write a useXxx composable per feature that returns static data directly, then swap its body for a real request() once the API is ready. This "data-layer seam" works fine too and is complementary to registerApiMock:

  • registerApiMock: components call the real request() from day one, mocks live in one place and delete cleanly; requests that go through framework machinery (login / system-info) can only use this.
  • Data-layer composable: clear for business data with an unknown shape that you want strongly typed.

Pick by feel — they don't conflict.

7. Disabling and removing mock

Once the real backend is connected: deleting mock config is a checklist step, not an option

Global mock has a lifecycle: during scaffolding (no backend yet) request.mock.enabled: true plus the built-in fakes are perfectly legitimate; the moment the real backend is connected, delete the entire request.mock config block — treat it as a fixed item on your "connect the real backend" checklist, a mechanical step that doesn't rely on anyone remembering.

  • Why "delete the block" instead of enabled: false: an explicit false doesn't just turn off the built-in fakes — it kills the whole registerApiMock consumption layer, taking your single-endpoint mocks and the test fixtures that depend on them down with it. Deleting the block gives you exactly the state you want: built-in fakes stop (they only activate on an explicit enabled: true), while the registerApiMock mechanism stays available (on by default in dev when unset; no registrations = no side effects).
  • Endpoints that still need mocking afterwards: register them individually with registerApiMock, and file an issue for every registration marked "temporary — delete once the backend is ready". Single-endpoint mocks are explicit exceptions (a countable list); a global switch is implicit masking (it catches everything and shows you nothing).
  • After turning it off, dev depends on the backend being reachable. If it isn't, the fix is reachability (tunnel / .env / network) or a temporary single-endpoint mock plus an issue — not flipping the global switch back on. Tests that used to rely on the built-in fakes need two app-init states re-created by hand: login (run autoLogin after registering the fixture, going through the real setInfo) and system/info (replicate setInfo in the test).
  • Why so strict: with global mock ON next to a real backend, "the page works" looks identical under two hypotheses — "the API is fine" and "the API is broken but mock caught it" — so protocol drift and backend failures never surface; worse, built-in fakes can shadow real data and fabricate state. Real case: a site kept enabled: true after connecting its real backend; the built-in /system/info skeleton (deliberately minimal, no menus) was injected straight into the store, and the channel page 500'd because menus was forever empty — backend endpoints were all fine, the framework had no defect, and the problem stayed hidden in the mock layer until integration blew it open.

Five levels of turning it off

From a project-level switch all the way to deleting it for good, there are five levels — pick what you need:

What you wantHow
A new project that shouldn't have mock at all (backend ready / migrating an old project)At sw create, answer No to "Enable dev mock data?" — the CLI writes request.mock.enabled: false and deletes the business mock preset files that ship with starter
The real backend is connected (integration starts)Delete the entire request.mock config block (see the section above; don't use enabled: false — it also kills the registerApiMock single-endpoint channel)
Temporarily turn it all off in an existing project (hit the real backend during dev)sw config set framework.request.mock.enabled false (or edit swift.config.json by hand). Note: this also turns off the built-in /login/* and /system/info fakes and your own registered single-endpoint mocks — every request goes straight to the real backend
Turn off only the built-in fakes, keep the registerApiMock mechanismrequest.mock.builtins: false — the mechanism still intercepts the mocks you register, it just no longer ships login / system-info for you
API is ready — remove the business mock for goodDelete app/plugins/dev-mock.ts (or the relevant mock registrations). The request() calls in components / composables don't change a line — they go straight to the real backend

request.mock.enabled defaults to import.meta.env.DEV (on in dev / off in prod, and stripped entirely from prod via DCE), so most of the time you don't need to turn it off — the table above is for "turn it off early / migrate an old project / move into integration".

When integrating, after deleting the mock file there's one more step: check whether the real response shape matches what you assumed when mocking, and adjust field mapping as needed. Because the backend is a fixed framework and APIs are largely uniform across projects, this is usually just minor field tweaks rather than a rewrite.