Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/openapi-mock_initial-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@equinor/fusion-openapi-mock": minor
---

Add `@equinor/fusion-openapi-mock`: fakes OpenAPI 3 responses straight from a parsed spec document, so testing an API-shaped client needs no hand-written fixtures until a specific edge case needs overriding.

```typescript
import { createOpenApiMock, fetchOpenApiDocument } from '@equinor/fusion-openapi-mock';

const openapi = await fetchOpenApiDocument('https://api.example.com/openapi.json');
const mock = createOpenApiMock(openapi, { seed: 42 });

const response = await mock.resolve({ method: 'GET', path: '/pets/1' });
// response.mock is already shaped like the operation's declared response schema
```

Highlights:

- Every operation with an `operationId` is faked from its declared success response schema, `$ref`s resolved against the document — no hand-written mock data needed to get started.
- `overrides` (at construction) and `.register(operationId, handler)` (afterwards) replace the faked response for just the operations an edge case cares about.
- `seed` makes faked output repeatable across runs, for assertions against concrete expected values instead of `expect.any(...)`.
- `fetchOpenApiDocument(url, options?)` fetches and parses a JSON or YAML spec from a URL, so there's no need to download and commit a copy that can drift out of sync.
- `fields`, a `FieldFakerMap` keyed `"<ModelName>.<field>"`, fakes specific fields with a `@faker-js/faker` path string or a real function — without editing the spec itself. `loadFakerMap(source)` loads one from a `.json`/`.yml`/`.yaml`/`.ts`/`.js` sidecar file (functions require `.ts`/`.js`), or accepts an already-built map.
- No dependency on any HTTP or routing framework: `resolve({ method, path, query })` returns a plain `{ status, mock }`, so it drops into `@equinor/fusion-framework-module-http`'s mock router, `openapi-backend`, Express, or a hand-rolled server equally easily.
3 changes: 2 additions & 1 deletion CODEMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ search to rediscover it.

| Path | Contains | Published? |
| --- | --- | --- |
| `packages/*` | Framework libraries (58 packages) | Yes, via Changesets |
| `packages/*` | Framework libraries (59 packages) | Yes, via Changesets |
| `cookbooks/*` | Runnable example apps and portals | Yes (versioned, but examples) |
| `eds-content/`, `eds/` | EDS design-system content and token tooling | No |
| `vue-press/` | Documentation site | Partly |
Expand Down Expand Up @@ -91,6 +91,7 @@ Format: `package name` → path → role.
| `@equinor/fusion-log` | `packages/utils/log` | Logging utilities |
| `@equinor/fusion-imports` | `packages/utils/imports` | Import resolution helpers |
| `@equinor/fusion-load-env` | `packages/utils/load-env` | `.env` loading |
| `@equinor/fusion-openapi-mock` | `packages/utils/openapi-mock` | Fakes OpenAPI 3 responses from a parsed spec document |

### CLI and tooling

Expand Down
162 changes: 162 additions & 0 deletions packages/utils/openapi-mock/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# @equinor/fusion-openapi-mock

Fakes OpenAPI 3 responses straight from a spec document, so testing an API-shaped client needs no hand-written mock data until a specific edge case needs one.

## When to use this package

- You have an `openapi.json`/`openapi.yaml` for a service and want a test double that already matches its shape, without writing fixtures by hand.
- You want most operations faked automatically, but a handful overridden for specific test scenarios (a `404`, a particular id, a boundary value).
- You want to wire generated mocks into an HTTP mock router (e.g. `@equinor/fusion-framework-module-http`'s `mock` entry point), `openapi-backend`, Express, or a hand-rolled server — this package has no opinion on any of them.

## Installation

```bash
pnpm add @equinor/fusion-openapi-mock
```

## Quick start

```ts
import { createOpenApiMock, fetchOpenApiDocument } from '@equinor/fusion-openapi-mock';

// Fetch the spec straight from wherever it's published — most are already public,
// so there's no point downloading a copy and committing it to the repository.
const openapi = await fetchOpenApiDocument('https://api.example.com/openapi.json');
const mock = createOpenApiMock(openapi);

const response = await mock.resolve({ method: 'GET', path: '/pets/1' });
// response.status -> the operation's declared success status
// response.mock -> a value shaped like the operation's response schema
// response.params -> { petId: '1' }, extracted from the /pets/{petId} template
```

A document already available locally works the same way, with no need for `fetchOpenApiDocument`:

```ts
import openapi from './openapi.json' with { type: 'json' };

const mock = createOpenApiMock(openapi);
```

## Key concepts

### Fetching the spec instead of committing it

`fetchOpenApiDocument(url, options?)` fetches and parses a spec — JSON or YAML — from a URL, so a test suite always mocks against the real, currently-published contract instead of a copy that can drift out of sync. Pass a custom `fetch` (e.g. one that attaches an auth header) through `options.fetch`.

### Zero-friction baseline

Every operation with an `operationId` is indexed from the document's `paths`. The first time it's requested, its declared "success" response (the lowest `2xx` status code, falling back to `default`) is faked from that response's JSON schema — `$ref`s included, resolved against the same document.

### Overriding an edge case

Pass `overrides` at construction, or call `.register(operationId, handler)` afterwards, to replace the faked response for one operation:

```ts
const mock = createOpenApiMock(openapi, {
overrides: {
getPetById: ({ params }) => ({
status: 404,
mock: { message: `Pet ${params.petId} not found` },
}),
},
});
```

An override can also start from the generated baseline and tweak just the field a test cares about:

```ts
mock.register('getPetById', async ({ params, mockResponseForOperation }) => {
const baseline = await mockResponseForOperation();
return { ...baseline, mock: { ...baseline.mock, id: params.petId, status: 'sold' } };
});
```

### Repeatable tests with `seed`

```ts
const mock = createOpenApiMock(openapi, { seed: 42 });
```

The same document and seed always fake the same values, so a test can assert against a concrete expected value instead of `expect.any(...)`.

### Faking specific fields with `@faker-js/faker`

Add a `faker: "module.method"` keyword to any property in the OpenAPI schema (this package's own extension to the schema, ignored by every OpenAPI tool that doesn't know about it) to get a realistic value instead of a generic one:

```json
{
"type": "object",
"properties": {
"name": { "type": "string", "faker": "person.fullName" },
"email": { "type": "string", "faker": "internet.email" }
}
}
```

Everything else — `format`, `pattern`, ranges, `enum`, composition keywords — is faked by `json-schema-faker`'s own built-in generators.

### Faking fields without editing the schema, via a `fields` sidecar

The `faker: "..."` keyword above requires editing the schema — not always possible when a spec is fetched from a URL and isn't yours to change. `fields` describes the same thing from the outside, keyed `"<ModelName>.<field>"` against `#/components/schemas` names instead:

```ts
const mock = createOpenApiMock(openapi, {
fields: {
'User.email': 'internet.email', // a faker path — same lookup as the schema keyword
'User.avatarUrl': 'image.avatar',
'User.id': ({ modelName, path }) => `usr_${modelName}_${path.join('-')}_${crypto.randomUUID()}`,
},
});
```

A nested (inline, non-`$ref`) field dots further — `'User.address.city'`. Once a nested field is itself a named component schema, its own fields key off *that* schema's name instead (`'Address.city'`, not `'User.address.city'`), since that's the model a real `$ref` in the spec actually points at.

Build the map in code, or load it from a **sidecar file** with `loadFakerMap` — so the mapping lives next to your tests instead of inside the spec:

```ts
import { createOpenApiMock, loadFakerMap } from '@equinor/fusion-openapi-mock';

const fields = await loadFakerMap('./fields.faker.ts');
const mock = createOpenApiMock(openapi, { fields });
```

`loadFakerMap` resolves the sidecar by extension:

| Format | Can hold |
| --- | --- |
| `.json` | Faker-path strings only |
| `.yml` / `.yaml` | Faker-path strings only |
| `.ts` / `.js` / `.mjs` | Faker-path strings **and** real functions — its `default` export is used as the map, resolved through [`@equinor/fusion-imports`](../imports)' `importConfig`, so no build step is required |

```ts
// fields.faker.ts
import type { FieldFakerMap } from '@equinor/fusion-openapi-mock';

export default {
'User.email': 'internet.email',
'User.id': ({ path }) => `usr_${path.join('-')}`,
} satisfies FieldFakerMap;
```

`loadFakerMap` also accepts an already-built map (returned as-is), so code that builds one dynamically doesn't need a file at all.

## API reference

| Export | Description |
| ------------------------ | ----------- |
| `createOpenApiMock(document, options?)` | Builds an `OpenApiMock` for one parsed OpenAPI document. `options.seed` makes faked output repeatable, `options.fields` applies a `FieldFakerMap`. |
| `OpenApiMock.resolve({ method, path, query? })` | Matches a request against the document's paths, returning `{ status, mock, operationId, params }` or `undefined`. |
| `OpenApiMock.mockResponseForOperation(operationId)` | Fakes a response for one operation directly, ignoring request matching. |
| `OpenApiMock.register(operationId, handler)` | Registers (or replaces) the override for one operation. |
| `fetchOpenApiDocument(url, options?)` | Fetches and parses a JSON or YAML OpenAPI document from a URL. |
| `dereferenceSchema(schema, document)` | Inlines every `$ref` JSON pointer in a schema against a document. |
| `generateMockFromSchema(schema, options?)` | Fakes one value from an already-dereferenced schema. `options.seed` makes it repeatable. |
| `loadFakerMap(source, options?)` | Loads a `FieldFakerMap` from a `.json`/`.yml`/`.yaml`/`.ts`/`.js` sidecar file, or returns an already-built map as-is. |
| `applyFieldFakers(schema, document, fields)` | Dereferences a schema while annotating fields matched by a `FieldFakerMap`; used internally by `createOpenApiMock`'s `fields` option. |

## Notes

- Operations without an `operationId` are not routable or overridable — they're skipped entirely, since there's nothing to key an override on.
- A path parameter (`{petId}`) always matches exactly one path segment, matching OpenAPI's own path templating.
- A schema that (indirectly) references itself would recurse forever when faked; the second time one `$ref` is seen along a branch, `dereferenceSchema` substitutes an empty (permissive) schema instead.
58 changes: 58 additions & 0 deletions packages/utils/openapi-mock/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@equinor/fusion-openapi-mock",
"version": "0.1.0",
"description": "Fakes OpenAPI 3 responses straight from a spec, so testing an API-shaped client needs no hand-written mock data until an edge case needs overriding",
"keywords": [
"openapi",
"mock",
"faker",
"testing",
"typescript",
"equinor",
"fusion"
],
"license": "ISC",
"type": "module",
"main": "dist/esm/index.js",
"exports": {
".": {
"import": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts"
}
},
"types": "dist/types/index.d.ts",
"directories": {
"dist": "dist"
},
"files": [
"dist",
"src"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/equinor/fusion-framework.git",
"directory": "packages/utils/openapi-mock"
},
"scripts": {
"build": "tsc -b",
"prepack": "pnpm build",
"test": "vitest",
"test:run": "vitest --run"
},
"bugs": {
"url": "https://github.com/equinor/fusion-framework/issues"
},
"dependencies": {
"@equinor/fusion-imports": "workspace:^",
"@faker-js/faker": "^10.1.0",
"json-schema-faker": "^0.6.3",
"yaml": "^2.9.0"
},
"devDependencies": {
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
Loading
Loading