nuxt-api-contract 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -2
- package/dist/chunks/server.mjs +3 -4
- package/dist/client.mjs +2 -3
- package/dist/composables.mjs +1 -2
- package/dist/mock.mjs +3 -4
- package/dist/server.mjs +5 -7
- package/dist/shared/{nuxt-api-contract.Bd2Y7Lx0.mjs → nuxt-api-contract.BWgzVTNN.mjs} +1 -1
- package/dist/shared/{nuxt-api-contract.DDpgZj2g.mjs → nuxt-api-contract.CAIOgncy.mjs} +1 -1
- package/dist/shared/nuxt-api-contract.CHdRLlU7.mjs +152 -0
- package/dist/shared.mjs +2 -3
- package/dist/testing.d.mts +111 -6
- package/dist/testing.d.ts +111 -6
- package/dist/testing.mjs +160 -3
- package/package.json +1 -1
- package/dist/shared/nuxt-api-contract.V15soI_f.mjs +0 -80
- package/dist/shared/nuxt-api-contract.obS6uV8A.mjs +0 -73
package/README.md
CHANGED
|
@@ -282,10 +282,75 @@ expect(error).toBeNull()
|
|
|
282
282
|
expect(data.id).toBe('1')
|
|
283
283
|
```
|
|
284
284
|
|
|
285
|
-
|
|
286
|
-
See `test/integration/playground.test.ts` for full-stack tests with
|
|
285
|
+
`callContract` runs validation → handler → response-validation exactly like
|
|
286
|
+
production. See `test/integration/playground.test.ts` for full-stack tests with
|
|
287
287
|
`@nuxt/test-utils`.
|
|
288
288
|
|
|
289
|
+
### Contract test suites
|
|
290
|
+
|
|
291
|
+
`testContract()` wraps a contract + handler into an assertion suite. It is
|
|
292
|
+
framework-agnostic (works with Vitest, Jest, `node:assert` — any runner that
|
|
293
|
+
treats thrown errors as failures):
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
import { testContract } from 'nuxt-api-contract/testing'
|
|
297
|
+
|
|
298
|
+
const suite = testContract(GetUser, handler)
|
|
299
|
+
|
|
300
|
+
it('returns the user', async () => {
|
|
301
|
+
const data = await suite.expectSuccess({ params: { id: '1' } })
|
|
302
|
+
expect(data.name).toBe('John')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('returns 404 for missing user', async () => {
|
|
306
|
+
const error = await suite.expectError(
|
|
307
|
+
{ params: { id: 'missing' } },
|
|
308
|
+
'USER_NOT_FOUND',
|
|
309
|
+
404,
|
|
310
|
+
)
|
|
311
|
+
})
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Available assertions:
|
|
315
|
+
|
|
316
|
+
| method | meaning |
|
|
317
|
+
| --- | --- |
|
|
318
|
+
| `expectSuccess(input?)` | call succeeds; returns typed response data |
|
|
319
|
+
| `expectError(input?, code?, statusCode?)` | call fails with (optionally) the given code/status |
|
|
320
|
+
| `expectValidationError(input?, issuePaths?)` | fails with `VALIDATION_ERROR`; optionally checks issue paths |
|
|
321
|
+
| `expectResponseValidationError(input, badResponse)` | handler returns bad data caught by response validation |
|
|
322
|
+
| `validateResponse(value)` | validates an arbitrary value against the contract's response schema |
|
|
323
|
+
|
|
324
|
+
### Contract coverage
|
|
325
|
+
|
|
326
|
+
Record which registered contracts are exercised in tests and print a report:
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import {
|
|
330
|
+
startContractCoverage,
|
|
331
|
+
stopContractCoverage,
|
|
332
|
+
formatContractCoverage,
|
|
333
|
+
} from 'nuxt-api-contract/testing'
|
|
334
|
+
|
|
335
|
+
beforeAll(() => startContractCoverage())
|
|
336
|
+
afterAll(() => console.log(formatContractCoverage(stopContractCoverage())))
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Output:
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
Contract coverage
|
|
343
|
+
5/6 contracts covered (83%)
|
|
344
|
+
Covered:
|
|
345
|
+
✓ GET /api/users/:id 3 ok
|
|
346
|
+
✓ POST /api/users 2 ok (1 failed)
|
|
347
|
+
Uncovered:
|
|
348
|
+
✗ DELETE /api/users/:id (DeleteUser)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Coverage is driven by the same `callContract` pipeline, so it works for both
|
|
352
|
+
unit tests and integration tests.
|
|
353
|
+
|
|
289
354
|
## DevTools
|
|
290
355
|
|
|
291
356
|
When `apiContract.devtools` is enabled in development, a panel lists all
|
package/dist/chunks/server.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import { B as BUILT_IN_ERROR_CODES, A as ApiError, s as serializeApiError } from '../shared/nuxt-api-contract.
|
|
3
|
-
import { v as validateContractInput, a as validateContractResponse } from '../shared/nuxt-api-contract.
|
|
4
|
-
import { g as generateMockResponse } from '../shared/nuxt-api-contract.
|
|
2
|
+
import { B as BUILT_IN_ERROR_CODES, A as ApiError, s as serializeApiError } from '../shared/nuxt-api-contract.CHdRLlU7.mjs';
|
|
3
|
+
import { v as validateContractInput, a as validateContractResponse } from '../shared/nuxt-api-contract.CAIOgncy.mjs';
|
|
4
|
+
import { g as generateMockResponse } from '../shared/nuxt-api-contract.BWgzVTNN.mjs';
|
|
5
5
|
import 'zod';
|
|
6
6
|
import '../shared/nuxt-api-contract.DCAU2j7t.mjs';
|
|
7
|
-
import '../shared/nuxt-api-contract.V15soI_f.mjs';
|
|
8
7
|
|
|
9
8
|
function pathToRegex(path) {
|
|
10
9
|
const names = [];
|
package/dist/client.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
export { b as buildRequestPath, c as clearContractRegistry, d as defineApiContract, g as getContractByName,
|
|
2
|
-
export { A as ApiError, B as BUILT_IN_ERROR_CODES, c as createApiError, i as isApiError, p as parseApiErrorPayload, s as serializeApiError, t as toApiError } from './shared/nuxt-api-contract.obS6uV8A.mjs';
|
|
1
|
+
export { A as ApiError, B as BUILT_IN_ERROR_CODES, b as buildRequestPath, c as clearContractRegistry, a as createApiError, d as defineApiContract, g as getContractByName, e as getContractMock, i as isApiContract, f as isApiError, l as listRegisteredContracts, m as mockContract, p as parseApiErrorPayload, r as registerContract, s as serializeApiError, t as toApiError } from './shared/nuxt-api-contract.CHdRLlU7.mjs';
|
|
3
2
|
export { f as formatValidationMessage, s as sanitizeIssues, t as toValidationIssues } from './shared/nuxt-api-contract.DCAU2j7t.mjs';
|
|
4
3
|
export { s as serializeQuery, a as serializeQueryValue, b as stableStringify } from './shared/nuxt-api-contract.Dr4tPqGB.mjs';
|
|
5
|
-
export { a as autoMockContract, g as generateMockResponse } from './shared/nuxt-api-contract.
|
|
4
|
+
export { a as autoMockContract, g as generateMockResponse } from './shared/nuxt-api-contract.BWgzVTNN.mjs';
|
package/dist/composables.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useAsyncData, useNuxtApp, useRequestEvent } from '#imports';
|
|
2
|
-
import { b as buildRequestPath } from './shared/nuxt-api-contract.
|
|
3
|
-
import { p as parseApiErrorPayload, A as ApiError, t as toApiError } from './shared/nuxt-api-contract.obS6uV8A.mjs';
|
|
2
|
+
import { b as buildRequestPath, p as parseApiErrorPayload, A as ApiError, t as toApiError } from './shared/nuxt-api-contract.CHdRLlU7.mjs';
|
|
4
3
|
import { b as stableStringify, s as serializeQuery } from './shared/nuxt-api-contract.Dr4tPqGB.mjs';
|
|
5
4
|
|
|
6
5
|
function createRequestKey(contract, options) {
|
package/dist/mock.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
export { a as autoMockContract, c as createRng, g as generateMockResponse, b as generateMockValue } from './shared/nuxt-api-contract.
|
|
1
|
+
export { a as autoMockContract, c as createRng, g as generateMockResponse, b as generateMockValue } from './shared/nuxt-api-contract.BWgzVTNN.mjs';
|
|
2
2
|
export { buildMockMatchers, createMockServer, startMockServer } from './chunks/server.mjs';
|
|
3
|
-
import './shared/nuxt-api-contract.
|
|
3
|
+
import './shared/nuxt-api-contract.CHdRLlU7.mjs';
|
|
4
4
|
import 'node:http';
|
|
5
|
-
import './shared/nuxt-api-contract.
|
|
6
|
-
import './shared/nuxt-api-contract.DDpgZj2g.mjs';
|
|
5
|
+
import './shared/nuxt-api-contract.CAIOgncy.mjs';
|
|
7
6
|
import 'zod';
|
|
8
7
|
import './shared/nuxt-api-contract.DCAU2j7t.mjs';
|
package/dist/server.mjs
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { b as buildRequestPath, c as clearContractRegistry, d as defineApiContract, g as getContractByName, i as isApiContract, l as listRegisteredContracts, m as mockContract, r as registerContract } from './shared/nuxt-api-contract.
|
|
3
|
-
import { A as ApiError, s as serializeApiError, B as BUILT_IN_ERROR_CODES } from './shared/nuxt-api-contract.obS6uV8A.mjs';
|
|
4
|
-
export { c as createApiError, i as isApiError, p as parseApiErrorPayload, t as toApiError } from './shared/nuxt-api-contract.obS6uV8A.mjs';
|
|
1
|
+
import { e as getContractMock, A as ApiError, s as serializeApiError, B as BUILT_IN_ERROR_CODES } from './shared/nuxt-api-contract.CHdRLlU7.mjs';
|
|
2
|
+
export { b as buildRequestPath, c as clearContractRegistry, a as createApiError, d as defineApiContract, g as getContractByName, i as isApiContract, f as isApiError, l as listRegisteredContracts, m as mockContract, p as parseApiErrorPayload, r as registerContract, t as toApiError } from './shared/nuxt-api-contract.CHdRLlU7.mjs';
|
|
5
3
|
export { f as formatValidationMessage, s as sanitizeIssues, t as toValidationIssues } from './shared/nuxt-api-contract.DCAU2j7t.mjs';
|
|
6
4
|
export { s as serializeQuery, a as serializeQueryValue, b as stableStringify } from './shared/nuxt-api-contract.Dr4tPqGB.mjs';
|
|
7
5
|
import { defineEventHandler, getRequestHeaders, getQuery, readValidatedBody, setResponseStatus } from 'h3';
|
|
8
|
-
import { g as generateMockResponse } from './shared/nuxt-api-contract.
|
|
9
|
-
import { v as validateContractInput, r as readRuntimeConfig, s as shouldValidateResponse, a as validateContractResponse } from './shared/nuxt-api-contract.
|
|
10
|
-
export { b as rawQuerySchema } from './shared/nuxt-api-contract.
|
|
6
|
+
import { g as generateMockResponse } from './shared/nuxt-api-contract.BWgzVTNN.mjs';
|
|
7
|
+
import { v as validateContractInput, r as readRuntimeConfig, s as shouldValidateResponse, a as validateContractResponse } from './shared/nuxt-api-contract.CAIOgncy.mjs';
|
|
8
|
+
export { b as rawQuerySchema } from './shared/nuxt-api-contract.CAIOgncy.mjs';
|
|
11
9
|
import 'zod';
|
|
12
10
|
|
|
13
11
|
const CONTRACT_HANDLER_META = Symbol.for("nuxt-api-contract.contractHandlerMeta");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { a as createApiError, B as BUILT_IN_ERROR_CODES } from './nuxt-api-contract.CHdRLlU7.mjs';
|
|
3
3
|
import { t as toValidationIssues, s as sanitizeIssues, f as formatValidationMessage } from './nuxt-api-contract.DCAU2j7t.mjs';
|
|
4
4
|
|
|
5
5
|
function readRuntimeConfig(getConfig) {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const API_CONTRACT_KIND = "api-contract";
|
|
2
|
+
|
|
3
|
+
const REGISTRY_KEY = Symbol.for("nuxt-api-contract.registry");
|
|
4
|
+
function getStore() {
|
|
5
|
+
const globalThis_ = globalThis;
|
|
6
|
+
if (!globalThis_[REGISTRY_KEY]) {
|
|
7
|
+
globalThis_[REGISTRY_KEY] = { contracts: /* @__PURE__ */ new Map() };
|
|
8
|
+
}
|
|
9
|
+
return globalThis_[REGISTRY_KEY];
|
|
10
|
+
}
|
|
11
|
+
function registerContract(contract) {
|
|
12
|
+
if (!contract.name) return;
|
|
13
|
+
const store = getStore();
|
|
14
|
+
const existing = store.contracts.get(contract.name);
|
|
15
|
+
if (existing && existing !== contract) {
|
|
16
|
+
console.warn(
|
|
17
|
+
`[nuxt-api-contract] Duplicate contract name "${contract.name}" registered. The latest definition wins.`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
store.contracts.set(contract.name, contract);
|
|
21
|
+
}
|
|
22
|
+
function getContractByName(name) {
|
|
23
|
+
return getStore().contracts.get(name);
|
|
24
|
+
}
|
|
25
|
+
function listRegisteredContracts() {
|
|
26
|
+
return [...getStore().contracts.values()];
|
|
27
|
+
}
|
|
28
|
+
function clearContractRegistry() {
|
|
29
|
+
getStore().contracts.clear();
|
|
30
|
+
}
|
|
31
|
+
const MOCK_STORE_KEY = Symbol.for("nuxt-api-contract.mocks");
|
|
32
|
+
const mockStore = globalThis[MOCK_STORE_KEY] ??= /* @__PURE__ */ new Map();
|
|
33
|
+
function mockContract(contract, mock) {
|
|
34
|
+
mockStore.set(contract, mock);
|
|
35
|
+
}
|
|
36
|
+
function getContractMock(contract) {
|
|
37
|
+
return mockStore.get(contract);
|
|
38
|
+
}
|
|
39
|
+
function defineApiContract(definition) {
|
|
40
|
+
const contract = {
|
|
41
|
+
kind: API_CONTRACT_KIND,
|
|
42
|
+
name: definition.name,
|
|
43
|
+
version: definition.version,
|
|
44
|
+
method: definition.method,
|
|
45
|
+
path: definition.path,
|
|
46
|
+
params: definition.params,
|
|
47
|
+
query: definition.query,
|
|
48
|
+
body: definition.body,
|
|
49
|
+
headers: definition.headers,
|
|
50
|
+
response: definition.response,
|
|
51
|
+
errors: definition.errors ? Object.freeze({ ...definition.errors }) : void 0,
|
|
52
|
+
summary: definition.summary,
|
|
53
|
+
description: definition.description,
|
|
54
|
+
tags: definition.tags ? Object.freeze([...definition.tags]) : void 0,
|
|
55
|
+
auth: definition.auth,
|
|
56
|
+
metadata: definition.metadata ? Object.freeze({ ...definition.metadata }) : void 0
|
|
57
|
+
};
|
|
58
|
+
Object.freeze(contract);
|
|
59
|
+
if (definition.name) {
|
|
60
|
+
registerContract(contract);
|
|
61
|
+
}
|
|
62
|
+
return contract;
|
|
63
|
+
}
|
|
64
|
+
function isApiContract(value) {
|
|
65
|
+
return typeof value === "object" && value !== null && value.kind === API_CONTRACT_KIND;
|
|
66
|
+
}
|
|
67
|
+
function buildRequestPath(path, params) {
|
|
68
|
+
let url = path;
|
|
69
|
+
for (const match of path.matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
70
|
+
const name = match[1];
|
|
71
|
+
const value = params?.[name];
|
|
72
|
+
if (value === void 0 || value === null) {
|
|
73
|
+
throw new Error(`[nuxt-api-contract] Missing path parameter ":${name}" for ${path}`);
|
|
74
|
+
}
|
|
75
|
+
url = url.replace(`:${name}`, encodeURIComponent(String(value)));
|
|
76
|
+
}
|
|
77
|
+
return url;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class ApiError extends Error {
|
|
81
|
+
code;
|
|
82
|
+
statusCode;
|
|
83
|
+
details;
|
|
84
|
+
issues;
|
|
85
|
+
constructor(options) {
|
|
86
|
+
super(options.message);
|
|
87
|
+
this.name = "ApiError";
|
|
88
|
+
this.code = options.code;
|
|
89
|
+
this.statusCode = options.statusCode ?? 500;
|
|
90
|
+
this.details = options.details;
|
|
91
|
+
this.issues = options.issues;
|
|
92
|
+
}
|
|
93
|
+
toJSON() {
|
|
94
|
+
return serializeApiError(this);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function createApiError(codeOrOptions, message, statusCode, details) {
|
|
98
|
+
if (typeof codeOrOptions === "string") {
|
|
99
|
+
return new ApiError({ code: codeOrOptions, message: message ?? codeOrOptions, statusCode, details });
|
|
100
|
+
}
|
|
101
|
+
return new ApiError({
|
|
102
|
+
code: codeOrOptions.code,
|
|
103
|
+
message: codeOrOptions.message ?? codeOrOptions.code,
|
|
104
|
+
statusCode: codeOrOptions.statusCode,
|
|
105
|
+
details: codeOrOptions.details,
|
|
106
|
+
issues: codeOrOptions.issues
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function isApiError(value) {
|
|
110
|
+
return value instanceof ApiError || typeof value === "object" && value !== null && value.name === "ApiError" && typeof value.code === "string";
|
|
111
|
+
}
|
|
112
|
+
function serializeApiError(error) {
|
|
113
|
+
return {
|
|
114
|
+
error: {
|
|
115
|
+
code: error.code,
|
|
116
|
+
message: error.message,
|
|
117
|
+
statusCode: error.statusCode,
|
|
118
|
+
details: error.details,
|
|
119
|
+
issues: error.issues
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function parseApiErrorPayload(value) {
|
|
124
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
125
|
+
const error = value.error;
|
|
126
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
127
|
+
const { code, message, statusCode, details, issues } = error;
|
|
128
|
+
if (typeof code !== "string" || typeof message !== "string") return void 0;
|
|
129
|
+
return {
|
|
130
|
+
code,
|
|
131
|
+
message,
|
|
132
|
+
statusCode: typeof statusCode === "number" ? statusCode : void 0,
|
|
133
|
+
details,
|
|
134
|
+
issues: Array.isArray(issues) ? issues : void 0
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function toApiError(value, fallbackMessage = "Internal server error") {
|
|
138
|
+
if (isApiError(value)) return value;
|
|
139
|
+
if (value instanceof Error) {
|
|
140
|
+
return new ApiError({ code: "INTERNAL_ERROR", message: value.message || fallbackMessage, statusCode: 500 });
|
|
141
|
+
}
|
|
142
|
+
return new ApiError({ code: "INTERNAL_ERROR", message: fallbackMessage, statusCode: 500 });
|
|
143
|
+
}
|
|
144
|
+
const BUILT_IN_ERROR_CODES = {
|
|
145
|
+
validation: "VALIDATION_ERROR",
|
|
146
|
+
responseValidation: "API_CONTRACT_RESPONSE_VALIDATION_ERROR",
|
|
147
|
+
internal: "INTERNAL_ERROR",
|
|
148
|
+
notFound: "NOT_FOUND",
|
|
149
|
+
methodNotAllowed: "METHOD_NOT_ALLOWED"
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export { ApiError as A, BUILT_IN_ERROR_CODES as B, createApiError as a, buildRequestPath as b, clearContractRegistry as c, defineApiContract as d, getContractMock as e, isApiError as f, getContractByName as g, isApiContract as i, listRegisteredContracts as l, mockContract as m, parseApiErrorPayload as p, registerContract as r, serializeApiError as s, toApiError as t };
|
package/dist/shared.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
export { b as buildRequestPath, c as clearContractRegistry, d as defineApiContract, g as getContractByName,
|
|
2
|
-
export { A as ApiError, B as BUILT_IN_ERROR_CODES, c as createApiError, i as isApiError, p as parseApiErrorPayload, s as serializeApiError, t as toApiError } from './shared/nuxt-api-contract.obS6uV8A.mjs';
|
|
1
|
+
export { A as ApiError, B as BUILT_IN_ERROR_CODES, b as buildRequestPath, c as clearContractRegistry, a as createApiError, d as defineApiContract, g as getContractByName, e as getContractMock, i as isApiContract, f as isApiError, l as listRegisteredContracts, m as mockContract, p as parseApiErrorPayload, r as registerContract, s as serializeApiError, t as toApiError } from './shared/nuxt-api-contract.CHdRLlU7.mjs';
|
|
3
2
|
export { f as formatValidationMessage, s as sanitizeIssues, t as toValidationIssues } from './shared/nuxt-api-contract.DCAU2j7t.mjs';
|
|
4
3
|
export { s as serializeQuery, a as serializeQueryValue, b as stableStringify } from './shared/nuxt-api-contract.Dr4tPqGB.mjs';
|
|
5
|
-
export { a as autoMockContract, c as createRng, g as generateMockResponse, b as generateMockValue } from './shared/nuxt-api-contract.
|
|
4
|
+
export { a as autoMockContract, c as createRng, g as generateMockResponse, b as generateMockValue } from './shared/nuxt-api-contract.BWgzVTNN.mjs';
|
package/dist/testing.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { H3Event, EventHandler } from 'h3';
|
|
2
|
-
import { AnyApiContract, ContractClientResponse } from './client.mjs';
|
|
2
|
+
import { AnyApiContract, ContractClientResponse, HttpMethod } from './client.mjs';
|
|
3
3
|
import { ApiError } from './client.mjs';
|
|
4
4
|
import { ContractHandler } from './server.mjs';
|
|
5
5
|
import 'zod';
|
|
@@ -20,9 +20,9 @@ interface ContractHandlerMeta {
|
|
|
20
20
|
body: unknown;
|
|
21
21
|
headers: unknown;
|
|
22
22
|
event: H3Event;
|
|
23
|
-
}) => Awaitable<unknown>;
|
|
23
|
+
}) => Awaitable$1<unknown>;
|
|
24
24
|
}
|
|
25
|
-
type Awaitable<T> = T | Promise<T>;
|
|
25
|
+
type Awaitable$1<T> = T | Promise<T>;
|
|
26
26
|
/**
|
|
27
27
|
* Calls a contract handler pipeline directly (no HTTP server):
|
|
28
28
|
*
|
|
@@ -47,9 +47,114 @@ declare function callContract<C extends AnyApiContract>(contract: C, handler: Co
|
|
|
47
47
|
data: null;
|
|
48
48
|
error: ApiError;
|
|
49
49
|
}>;
|
|
50
|
-
type EventHandlerLike = (ctx: Record<string, unknown>) => Awaitable<unknown>;
|
|
50
|
+
type EventHandlerLike = (ctx: Record<string, unknown>) => Awaitable$1<unknown>;
|
|
51
51
|
/** Exposed so the module-level `defineContractHandler` can attach metadata. */
|
|
52
52
|
declare const contractHandlerMetaKey: symbol;
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* `testContract` — a testing suite bound to a contract + handler.
|
|
56
|
+
*
|
|
57
|
+
* Framework-agnostic: every `expect*` method throws a
|
|
58
|
+
* `ContractAssertionError` on failure, so it works with Vitest, Jest,
|
|
59
|
+
* `node:assert` and any other runner that treats thrown errors as failures.
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* import { testContract } from 'nuxt-api-contract/testing'
|
|
63
|
+
*
|
|
64
|
+
* const user = testContract(GetUser, handler)
|
|
65
|
+
*
|
|
66
|
+
* it('returns the user', async () => {
|
|
67
|
+
* await user.expectSuccess({ params: { id: '1' } })
|
|
68
|
+
* })
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
type Awaitable<T> = T | Promise<T>;
|
|
73
|
+
type TestHandler<C extends AnyApiContract> = ContractHandler<C> | EventHandler | ((ctx: Record<string, unknown>) => Awaitable<unknown>);
|
|
74
|
+
interface ContractTestInput {
|
|
75
|
+
params?: Record<string, unknown>;
|
|
76
|
+
query?: Record<string, unknown>;
|
|
77
|
+
body?: unknown;
|
|
78
|
+
headers?: Record<string, string>;
|
|
79
|
+
}
|
|
80
|
+
/** Thrown by every failed `testContract` assertion. */
|
|
81
|
+
declare class ContractAssertionError extends Error {
|
|
82
|
+
constructor(message: string);
|
|
83
|
+
}
|
|
84
|
+
interface ContractTestSuite<C extends AnyApiContract> {
|
|
85
|
+
/** Runs the full pipeline (validation -> handler -> response validation). */
|
|
86
|
+
call(input?: ContractTestInput): Promise<{
|
|
87
|
+
data: ContractClientResponse<C> | null;
|
|
88
|
+
error: ApiError | null;
|
|
89
|
+
}>;
|
|
90
|
+
/** Asserts the call succeeds; returns the typed response data. */
|
|
91
|
+
expectSuccess(input?: ContractTestInput): Promise<ContractClientResponse<C>>;
|
|
92
|
+
/** Asserts the call fails with (optionally) the given code and status. */
|
|
93
|
+
expectError(input?: ContractTestInput, code?: string, statusCode?: number): Promise<ApiError>;
|
|
94
|
+
/** Asserts a `VALIDATION_ERROR`; optionally checks that issues cover the given paths. */
|
|
95
|
+
expectValidationError(input?: ContractTestInput, issuePaths?: string[]): Promise<ApiError>;
|
|
96
|
+
/** Asserts the response validation catches a bad handler output. */
|
|
97
|
+
expectResponseValidationError(input: ContractTestInput, badResponse: unknown | ((ctx: Record<string, unknown>) => unknown)): Promise<ApiError>;
|
|
98
|
+
/** Validates an arbitrary value against the contract's response schema. */
|
|
99
|
+
validateResponse(value: unknown): void;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Creates a test suite for a contract + handler pair.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* const suite = testContract(GetUser, handler)
|
|
106
|
+
*
|
|
107
|
+
* await suite.expectSuccess({ params: { id: '1' } })
|
|
108
|
+
* await suite.expectError({ params: { id: 'missing' } }, 'USER_NOT_FOUND', 404)
|
|
109
|
+
* await suite.expectValidationError({ params: { id: '' } }, ['params.id'])
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
declare function testContract<C extends AnyApiContract>(contract: C, handler: TestHandler<C>): ContractTestSuite<C>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Contract coverage tracking: records which registered contracts have been
|
|
116
|
+
* exercised through `callContract` / `testContract` (and with which outcome).
|
|
117
|
+
* Enable it in a test setup hook and print the report in an after-hook:
|
|
118
|
+
*
|
|
119
|
+
* ```ts
|
|
120
|
+
* import { startContractCoverage, formatContractCoverage, getContractCoverage } from 'nuxt-api-contract/testing'
|
|
121
|
+
*
|
|
122
|
+
* beforeAll(() => startContractCoverage())
|
|
123
|
+
* afterAll(() => console.log(formatContractCoverage(getContractCoverage())))
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
|
|
127
|
+
interface ContractCoverageEntry {
|
|
128
|
+
/** Contract name, or `METHOD path` for anonymous contracts. */
|
|
129
|
+
key: string;
|
|
130
|
+
name?: string;
|
|
131
|
+
method: HttpMethod;
|
|
132
|
+
path: string;
|
|
133
|
+
calls: number;
|
|
134
|
+
failures: number;
|
|
135
|
+
}
|
|
136
|
+
interface ContractCoverageReport {
|
|
137
|
+
/** Named contracts registered in the registry at report time. */
|
|
138
|
+
total: number;
|
|
139
|
+
coveredCount: number;
|
|
140
|
+
percent: number;
|
|
141
|
+
covered: ContractCoverageEntry[];
|
|
142
|
+
uncovered: Array<{
|
|
143
|
+
name?: string;
|
|
144
|
+
method: HttpMethod;
|
|
145
|
+
path: string;
|
|
146
|
+
}>;
|
|
147
|
+
}
|
|
148
|
+
/** Starts (and resets) coverage recording. */
|
|
149
|
+
declare function startContractCoverage(): void;
|
|
150
|
+
/** Stops recording and returns the final report. */
|
|
151
|
+
declare function stopContractCoverage(): ContractCoverageReport;
|
|
152
|
+
/** Clears recorded stats without changing the active state. */
|
|
153
|
+
declare function resetContractCoverage(): void;
|
|
154
|
+
/** Builds the coverage report against the currently registered contracts. */
|
|
155
|
+
declare function getContractCoverage(): ContractCoverageReport;
|
|
156
|
+
/** Formats the report as a human-readable table for test output. */
|
|
157
|
+
declare function formatContractCoverage(report: ContractCoverageReport): string;
|
|
158
|
+
|
|
159
|
+
export { ContractAssertionError, callContract, contractHandlerMetaKey, formatContractCoverage, getContractCoverage, resetContractCoverage, startContractCoverage, stopContractCoverage, testContract };
|
|
160
|
+
export type { ContractCoverageEntry, ContractCoverageReport, ContractHandlerMeta, ContractTestInput, ContractTestSuite, TestHandler };
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { H3Event, EventHandler } from 'h3';
|
|
2
|
-
import { AnyApiContract, ContractClientResponse } from './client.js';
|
|
2
|
+
import { AnyApiContract, ContractClientResponse, HttpMethod } from './client.js';
|
|
3
3
|
import { ApiError } from './client.js';
|
|
4
4
|
import { ContractHandler } from './server.js';
|
|
5
5
|
import 'zod';
|
|
@@ -20,9 +20,9 @@ interface ContractHandlerMeta {
|
|
|
20
20
|
body: unknown;
|
|
21
21
|
headers: unknown;
|
|
22
22
|
event: H3Event;
|
|
23
|
-
}) => Awaitable<unknown>;
|
|
23
|
+
}) => Awaitable$1<unknown>;
|
|
24
24
|
}
|
|
25
|
-
type Awaitable<T> = T | Promise<T>;
|
|
25
|
+
type Awaitable$1<T> = T | Promise<T>;
|
|
26
26
|
/**
|
|
27
27
|
* Calls a contract handler pipeline directly (no HTTP server):
|
|
28
28
|
*
|
|
@@ -47,9 +47,114 @@ declare function callContract<C extends AnyApiContract>(contract: C, handler: Co
|
|
|
47
47
|
data: null;
|
|
48
48
|
error: ApiError;
|
|
49
49
|
}>;
|
|
50
|
-
type EventHandlerLike = (ctx: Record<string, unknown>) => Awaitable<unknown>;
|
|
50
|
+
type EventHandlerLike = (ctx: Record<string, unknown>) => Awaitable$1<unknown>;
|
|
51
51
|
/** Exposed so the module-level `defineContractHandler` can attach metadata. */
|
|
52
52
|
declare const contractHandlerMetaKey: symbol;
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* `testContract` — a testing suite bound to a contract + handler.
|
|
56
|
+
*
|
|
57
|
+
* Framework-agnostic: every `expect*` method throws a
|
|
58
|
+
* `ContractAssertionError` on failure, so it works with Vitest, Jest,
|
|
59
|
+
* `node:assert` and any other runner that treats thrown errors as failures.
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* import { testContract } from 'nuxt-api-contract/testing'
|
|
63
|
+
*
|
|
64
|
+
* const user = testContract(GetUser, handler)
|
|
65
|
+
*
|
|
66
|
+
* it('returns the user', async () => {
|
|
67
|
+
* await user.expectSuccess({ params: { id: '1' } })
|
|
68
|
+
* })
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
type Awaitable<T> = T | Promise<T>;
|
|
73
|
+
type TestHandler<C extends AnyApiContract> = ContractHandler<C> | EventHandler | ((ctx: Record<string, unknown>) => Awaitable<unknown>);
|
|
74
|
+
interface ContractTestInput {
|
|
75
|
+
params?: Record<string, unknown>;
|
|
76
|
+
query?: Record<string, unknown>;
|
|
77
|
+
body?: unknown;
|
|
78
|
+
headers?: Record<string, string>;
|
|
79
|
+
}
|
|
80
|
+
/** Thrown by every failed `testContract` assertion. */
|
|
81
|
+
declare class ContractAssertionError extends Error {
|
|
82
|
+
constructor(message: string);
|
|
83
|
+
}
|
|
84
|
+
interface ContractTestSuite<C extends AnyApiContract> {
|
|
85
|
+
/** Runs the full pipeline (validation -> handler -> response validation). */
|
|
86
|
+
call(input?: ContractTestInput): Promise<{
|
|
87
|
+
data: ContractClientResponse<C> | null;
|
|
88
|
+
error: ApiError | null;
|
|
89
|
+
}>;
|
|
90
|
+
/** Asserts the call succeeds; returns the typed response data. */
|
|
91
|
+
expectSuccess(input?: ContractTestInput): Promise<ContractClientResponse<C>>;
|
|
92
|
+
/** Asserts the call fails with (optionally) the given code and status. */
|
|
93
|
+
expectError(input?: ContractTestInput, code?: string, statusCode?: number): Promise<ApiError>;
|
|
94
|
+
/** Asserts a `VALIDATION_ERROR`; optionally checks that issues cover the given paths. */
|
|
95
|
+
expectValidationError(input?: ContractTestInput, issuePaths?: string[]): Promise<ApiError>;
|
|
96
|
+
/** Asserts the response validation catches a bad handler output. */
|
|
97
|
+
expectResponseValidationError(input: ContractTestInput, badResponse: unknown | ((ctx: Record<string, unknown>) => unknown)): Promise<ApiError>;
|
|
98
|
+
/** Validates an arbitrary value against the contract's response schema. */
|
|
99
|
+
validateResponse(value: unknown): void;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Creates a test suite for a contract + handler pair.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* const suite = testContract(GetUser, handler)
|
|
106
|
+
*
|
|
107
|
+
* await suite.expectSuccess({ params: { id: '1' } })
|
|
108
|
+
* await suite.expectError({ params: { id: 'missing' } }, 'USER_NOT_FOUND', 404)
|
|
109
|
+
* await suite.expectValidationError({ params: { id: '' } }, ['params.id'])
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
declare function testContract<C extends AnyApiContract>(contract: C, handler: TestHandler<C>): ContractTestSuite<C>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Contract coverage tracking: records which registered contracts have been
|
|
116
|
+
* exercised through `callContract` / `testContract` (and with which outcome).
|
|
117
|
+
* Enable it in a test setup hook and print the report in an after-hook:
|
|
118
|
+
*
|
|
119
|
+
* ```ts
|
|
120
|
+
* import { startContractCoverage, formatContractCoverage, getContractCoverage } from 'nuxt-api-contract/testing'
|
|
121
|
+
*
|
|
122
|
+
* beforeAll(() => startContractCoverage())
|
|
123
|
+
* afterAll(() => console.log(formatContractCoverage(getContractCoverage())))
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
|
|
127
|
+
interface ContractCoverageEntry {
|
|
128
|
+
/** Contract name, or `METHOD path` for anonymous contracts. */
|
|
129
|
+
key: string;
|
|
130
|
+
name?: string;
|
|
131
|
+
method: HttpMethod;
|
|
132
|
+
path: string;
|
|
133
|
+
calls: number;
|
|
134
|
+
failures: number;
|
|
135
|
+
}
|
|
136
|
+
interface ContractCoverageReport {
|
|
137
|
+
/** Named contracts registered in the registry at report time. */
|
|
138
|
+
total: number;
|
|
139
|
+
coveredCount: number;
|
|
140
|
+
percent: number;
|
|
141
|
+
covered: ContractCoverageEntry[];
|
|
142
|
+
uncovered: Array<{
|
|
143
|
+
name?: string;
|
|
144
|
+
method: HttpMethod;
|
|
145
|
+
path: string;
|
|
146
|
+
}>;
|
|
147
|
+
}
|
|
148
|
+
/** Starts (and resets) coverage recording. */
|
|
149
|
+
declare function startContractCoverage(): void;
|
|
150
|
+
/** Stops recording and returns the final report. */
|
|
151
|
+
declare function stopContractCoverage(): ContractCoverageReport;
|
|
152
|
+
/** Clears recorded stats without changing the active state. */
|
|
153
|
+
declare function resetContractCoverage(): void;
|
|
154
|
+
/** Builds the coverage report against the currently registered contracts. */
|
|
155
|
+
declare function getContractCoverage(): ContractCoverageReport;
|
|
156
|
+
/** Formats the report as a human-readable table for test output. */
|
|
157
|
+
declare function formatContractCoverage(report: ContractCoverageReport): string;
|
|
158
|
+
|
|
159
|
+
export { ContractAssertionError, callContract, contractHandlerMetaKey, formatContractCoverage, getContractCoverage, resetContractCoverage, startContractCoverage, stopContractCoverage, testContract };
|
|
160
|
+
export type { ContractCoverageEntry, ContractCoverageReport, ContractHandlerMeta, ContractTestInput, ContractTestSuite, TestHandler };
|
package/dist/testing.mjs
CHANGED
|
@@ -1,8 +1,72 @@
|
|
|
1
|
-
import { A as ApiError } from './shared/nuxt-api-contract.
|
|
2
|
-
import { v as validateContractInput, a as validateContractResponse } from './shared/nuxt-api-contract.
|
|
1
|
+
import { l as listRegisteredContracts, A as ApiError, B as BUILT_IN_ERROR_CODES } from './shared/nuxt-api-contract.CHdRLlU7.mjs';
|
|
2
|
+
import { v as validateContractInput, a as validateContractResponse } from './shared/nuxt-api-contract.CAIOgncy.mjs';
|
|
3
3
|
import 'zod';
|
|
4
4
|
import './shared/nuxt-api-contract.DCAU2j7t.mjs';
|
|
5
5
|
|
|
6
|
+
const state = { active: false, entries: /* @__PURE__ */ new Map() };
|
|
7
|
+
function entryKey(contract) {
|
|
8
|
+
return contract.name ?? `${contract.method} ${contract.path}`;
|
|
9
|
+
}
|
|
10
|
+
function startContractCoverage() {
|
|
11
|
+
state.active = true;
|
|
12
|
+
state.entries.clear();
|
|
13
|
+
}
|
|
14
|
+
function stopContractCoverage() {
|
|
15
|
+
state.active = false;
|
|
16
|
+
return getContractCoverage();
|
|
17
|
+
}
|
|
18
|
+
function resetContractCoverage() {
|
|
19
|
+
state.entries.clear();
|
|
20
|
+
}
|
|
21
|
+
function recordCoverageCall(contract, success) {
|
|
22
|
+
if (!state.active) return;
|
|
23
|
+
const key = entryKey(contract);
|
|
24
|
+
const entry = state.entries.get(key) ?? {
|
|
25
|
+
key,
|
|
26
|
+
name: contract.name,
|
|
27
|
+
method: contract.method,
|
|
28
|
+
path: contract.path,
|
|
29
|
+
calls: 0,
|
|
30
|
+
failures: 0
|
|
31
|
+
};
|
|
32
|
+
entry.calls++;
|
|
33
|
+
if (!success) entry.failures++;
|
|
34
|
+
state.entries.set(key, entry);
|
|
35
|
+
}
|
|
36
|
+
function getContractCoverage() {
|
|
37
|
+
const covered = [...state.entries.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
38
|
+
const coveredKeys = new Set(covered.map((entry) => entry.key));
|
|
39
|
+
const uncovered = listRegisteredContracts().filter((contract) => contract.name && !coveredKeys.has(contract.name)).map((contract) => ({ name: contract.name, method: contract.method, path: contract.path }));
|
|
40
|
+
const total = uncovered.length + covered.length;
|
|
41
|
+
return {
|
|
42
|
+
total,
|
|
43
|
+
coveredCount: covered.length,
|
|
44
|
+
percent: total === 0 ? 100 : Math.round(covered.length / total * 100),
|
|
45
|
+
covered,
|
|
46
|
+
uncovered
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function formatContractCoverage(report) {
|
|
50
|
+
const lines = [
|
|
51
|
+
"Contract coverage",
|
|
52
|
+
` ${report.coveredCount}/${report.total} contracts covered (${report.percent}%)`
|
|
53
|
+
];
|
|
54
|
+
if (report.covered.length > 0) {
|
|
55
|
+
lines.push("Covered:");
|
|
56
|
+
for (const entry of report.covered) {
|
|
57
|
+
const status = entry.failures > 0 ? `${entry.calls - entry.failures}/${entry.calls} ok` : `${entry.calls} ok`;
|
|
58
|
+
lines.push(` \u2713 ${entry.method.padEnd(6)} ${entry.path.padEnd(32)} ${status}${entry.failures > 0 ? ` (${entry.failures} failed)` : ""}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (report.uncovered.length > 0) {
|
|
62
|
+
lines.push("Uncovered:");
|
|
63
|
+
for (const entry of report.uncovered) {
|
|
64
|
+
lines.push(` \u2717 ${entry.method.padEnd(6)} ${entry.path}${entry.name ? ` (${entry.name})` : ""}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
6
70
|
const CONTRACT_HANDLER_META = Symbol.for("nuxt-api-contract.contractHandlerMeta");
|
|
7
71
|
async function callContract(contract, handler, input = {}) {
|
|
8
72
|
const runHandler = extractHandler(contract, handler);
|
|
@@ -14,8 +78,10 @@ async function callContract(contract, handler, input = {}) {
|
|
|
14
78
|
const event = createFakeEvent(headers);
|
|
15
79
|
const rawResponse = await runHandler({ params, query, body, headers, event });
|
|
16
80
|
const data = contract.response ? validateContractResponse(contract, contract.response, rawResponse) : rawResponse;
|
|
81
|
+
recordCoverageCall(contract, true);
|
|
17
82
|
return { data, error: null };
|
|
18
83
|
} catch (error) {
|
|
84
|
+
recordCoverageCall(contract, false);
|
|
19
85
|
return {
|
|
20
86
|
data: null,
|
|
21
87
|
error: error instanceof ApiError ? error : new ApiError({ code: "INTERNAL_ERROR", message: String(error) })
|
|
@@ -46,4 +112,95 @@ function createFakeEvent(headers) {
|
|
|
46
112
|
}
|
|
47
113
|
const contractHandlerMetaKey = CONTRACT_HANDLER_META;
|
|
48
114
|
|
|
49
|
-
|
|
115
|
+
class ContractAssertionError extends Error {
|
|
116
|
+
constructor(message) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.name = "ContractAssertionError";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function describeCall(contract) {
|
|
122
|
+
return `${contract.method} ${contract.path}`;
|
|
123
|
+
}
|
|
124
|
+
function testContract(contract, handler) {
|
|
125
|
+
const suite = {
|
|
126
|
+
async call(input) {
|
|
127
|
+
return callContract(contract, handler, input);
|
|
128
|
+
},
|
|
129
|
+
async expectSuccess(input) {
|
|
130
|
+
const { data, error } = await callContract(contract, handler, input);
|
|
131
|
+
if (error) {
|
|
132
|
+
throw new ContractAssertionError(
|
|
133
|
+
`[nuxt-api-contract] Expected success for ${describeCall(contract)}, got ${error.code} (${error.statusCode}): ${error.message}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return data;
|
|
137
|
+
},
|
|
138
|
+
async expectError(input, code, statusCode) {
|
|
139
|
+
const { data, error } = await callContract(contract, handler, input);
|
|
140
|
+
if (!error) {
|
|
141
|
+
throw new ContractAssertionError(
|
|
142
|
+
`[nuxt-api-contract] Expected an error for ${describeCall(contract)}, but the call succeeded with: ${JSON.stringify(data)}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (code && error.code !== code) {
|
|
146
|
+
throw new ContractAssertionError(
|
|
147
|
+
`[nuxt-api-contract] Expected error code "${code}" for ${describeCall(contract)}, got "${error.code}" (${error.message})`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (statusCode !== void 0 && error.statusCode !== statusCode) {
|
|
151
|
+
throw new ContractAssertionError(
|
|
152
|
+
`[nuxt-api-contract] Expected status ${statusCode} for ${describeCall(contract)}, got ${error.statusCode}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return error;
|
|
156
|
+
},
|
|
157
|
+
async expectValidationError(input, issuePaths) {
|
|
158
|
+
const error = await suite.expectError(input, BUILT_IN_ERROR_CODES.validation, 400);
|
|
159
|
+
if (issuePaths && issuePaths.length > 0) {
|
|
160
|
+
const actual = new Set((error.issues ?? []).map((issue) => issue.path));
|
|
161
|
+
const missing = issuePaths.filter((path) => {
|
|
162
|
+
for (const actualPath of actual) {
|
|
163
|
+
if (actualPath === path || actualPath.endsWith(`.${path}`) || path.endsWith(`.${actualPath}`) || actualPath.startsWith(`${path}.`)) return false;
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
});
|
|
167
|
+
if (missing.length > 0) {
|
|
168
|
+
throw new ContractAssertionError(
|
|
169
|
+
`[nuxt-api-contract] Expected validation issues for ${[...missing].join(", ")} on ${describeCall(contract)}, got: ${(error.issues ?? []).map((issue) => issue.path).join(", ") || "(none)"}`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return error;
|
|
174
|
+
},
|
|
175
|
+
async expectResponseValidationError(input, badResponse) {
|
|
176
|
+
const raw = badResponse;
|
|
177
|
+
const badHandler = typeof raw === "function" ? raw : ((_ctx) => raw);
|
|
178
|
+
const { error } = await callContract(contract, badHandler, input);
|
|
179
|
+
if (!error) {
|
|
180
|
+
throw new ContractAssertionError(
|
|
181
|
+
`[nuxt-api-contract] Expected a response validation failure for ${describeCall(contract)}, but the call succeeded`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (error.code !== BUILT_IN_ERROR_CODES.responseValidation) {
|
|
185
|
+
throw new ContractAssertionError(
|
|
186
|
+
`[nuxt-api-contract] Expected "${BUILT_IN_ERROR_CODES.responseValidation}" for ${describeCall(contract)}, got "${error.code}" (${error.message})`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return error;
|
|
190
|
+
},
|
|
191
|
+
validateResponse(value) {
|
|
192
|
+
if (!contract.response) return;
|
|
193
|
+
try {
|
|
194
|
+
validateContractResponse(contract, contract.response, value);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error instanceof ApiError) {
|
|
197
|
+
throw new ContractAssertionError(`[nuxt-api-contract] Response validation failed: ${error.message}`);
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
return suite;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export { ContractAssertionError, callContract, contractHandlerMetaKey, formatContractCoverage, getContractCoverage, resetContractCoverage, startContractCoverage, stopContractCoverage, testContract };
|
package/package.json
CHANGED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
const API_CONTRACT_KIND = "api-contract";
|
|
2
|
-
|
|
3
|
-
const REGISTRY_KEY = Symbol.for("nuxt-api-contract.registry");
|
|
4
|
-
function getStore() {
|
|
5
|
-
const globalThis_ = globalThis;
|
|
6
|
-
if (!globalThis_[REGISTRY_KEY]) {
|
|
7
|
-
globalThis_[REGISTRY_KEY] = { contracts: /* @__PURE__ */ new Map() };
|
|
8
|
-
}
|
|
9
|
-
return globalThis_[REGISTRY_KEY];
|
|
10
|
-
}
|
|
11
|
-
function registerContract(contract) {
|
|
12
|
-
if (!contract.name) return;
|
|
13
|
-
const store = getStore();
|
|
14
|
-
const existing = store.contracts.get(contract.name);
|
|
15
|
-
if (existing && existing !== contract) {
|
|
16
|
-
console.warn(
|
|
17
|
-
`[nuxt-api-contract] Duplicate contract name "${contract.name}" registered. The latest definition wins.`
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
store.contracts.set(contract.name, contract);
|
|
21
|
-
}
|
|
22
|
-
function getContractByName(name) {
|
|
23
|
-
return getStore().contracts.get(name);
|
|
24
|
-
}
|
|
25
|
-
function listRegisteredContracts() {
|
|
26
|
-
return [...getStore().contracts.values()];
|
|
27
|
-
}
|
|
28
|
-
function clearContractRegistry() {
|
|
29
|
-
getStore().contracts.clear();
|
|
30
|
-
}
|
|
31
|
-
const MOCK_STORE_KEY = Symbol.for("nuxt-api-contract.mocks");
|
|
32
|
-
const mockStore = globalThis[MOCK_STORE_KEY] ??= /* @__PURE__ */ new Map();
|
|
33
|
-
function mockContract(contract, mock) {
|
|
34
|
-
mockStore.set(contract, mock);
|
|
35
|
-
}
|
|
36
|
-
function getContractMock(contract) {
|
|
37
|
-
return mockStore.get(contract);
|
|
38
|
-
}
|
|
39
|
-
function defineApiContract(definition) {
|
|
40
|
-
const contract = {
|
|
41
|
-
kind: API_CONTRACT_KIND,
|
|
42
|
-
name: definition.name,
|
|
43
|
-
version: definition.version,
|
|
44
|
-
method: definition.method,
|
|
45
|
-
path: definition.path,
|
|
46
|
-
params: definition.params,
|
|
47
|
-
query: definition.query,
|
|
48
|
-
body: definition.body,
|
|
49
|
-
headers: definition.headers,
|
|
50
|
-
response: definition.response,
|
|
51
|
-
errors: definition.errors ? Object.freeze({ ...definition.errors }) : void 0,
|
|
52
|
-
summary: definition.summary,
|
|
53
|
-
description: definition.description,
|
|
54
|
-
tags: definition.tags ? Object.freeze([...definition.tags]) : void 0,
|
|
55
|
-
auth: definition.auth,
|
|
56
|
-
metadata: definition.metadata ? Object.freeze({ ...definition.metadata }) : void 0
|
|
57
|
-
};
|
|
58
|
-
Object.freeze(contract);
|
|
59
|
-
if (definition.name) {
|
|
60
|
-
registerContract(contract);
|
|
61
|
-
}
|
|
62
|
-
return contract;
|
|
63
|
-
}
|
|
64
|
-
function isApiContract(value) {
|
|
65
|
-
return typeof value === "object" && value !== null && value.kind === API_CONTRACT_KIND;
|
|
66
|
-
}
|
|
67
|
-
function buildRequestPath(path, params) {
|
|
68
|
-
let url = path;
|
|
69
|
-
for (const match of path.matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
70
|
-
const name = match[1];
|
|
71
|
-
const value = params?.[name];
|
|
72
|
-
if (value === void 0 || value === null) {
|
|
73
|
-
throw new Error(`[nuxt-api-contract] Missing path parameter ":${name}" for ${path}`);
|
|
74
|
-
}
|
|
75
|
-
url = url.replace(`:${name}`, encodeURIComponent(String(value)));
|
|
76
|
-
}
|
|
77
|
-
return url;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export { getContractMock as a, buildRequestPath as b, clearContractRegistry as c, defineApiContract as d, getContractByName as g, isApiContract as i, listRegisteredContracts as l, mockContract as m, registerContract as r };
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
class ApiError extends Error {
|
|
2
|
-
code;
|
|
3
|
-
statusCode;
|
|
4
|
-
details;
|
|
5
|
-
issues;
|
|
6
|
-
constructor(options) {
|
|
7
|
-
super(options.message);
|
|
8
|
-
this.name = "ApiError";
|
|
9
|
-
this.code = options.code;
|
|
10
|
-
this.statusCode = options.statusCode ?? 500;
|
|
11
|
-
this.details = options.details;
|
|
12
|
-
this.issues = options.issues;
|
|
13
|
-
}
|
|
14
|
-
toJSON() {
|
|
15
|
-
return serializeApiError(this);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
function createApiError(codeOrOptions, message, statusCode, details) {
|
|
19
|
-
if (typeof codeOrOptions === "string") {
|
|
20
|
-
return new ApiError({ code: codeOrOptions, message: message ?? codeOrOptions, statusCode, details });
|
|
21
|
-
}
|
|
22
|
-
return new ApiError({
|
|
23
|
-
code: codeOrOptions.code,
|
|
24
|
-
message: codeOrOptions.message ?? codeOrOptions.code,
|
|
25
|
-
statusCode: codeOrOptions.statusCode,
|
|
26
|
-
details: codeOrOptions.details,
|
|
27
|
-
issues: codeOrOptions.issues
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
function isApiError(value) {
|
|
31
|
-
return value instanceof ApiError || typeof value === "object" && value !== null && value.name === "ApiError" && typeof value.code === "string";
|
|
32
|
-
}
|
|
33
|
-
function serializeApiError(error) {
|
|
34
|
-
return {
|
|
35
|
-
error: {
|
|
36
|
-
code: error.code,
|
|
37
|
-
message: error.message,
|
|
38
|
-
statusCode: error.statusCode,
|
|
39
|
-
details: error.details,
|
|
40
|
-
issues: error.issues
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
function parseApiErrorPayload(value) {
|
|
45
|
-
if (typeof value !== "object" || value === null) return void 0;
|
|
46
|
-
const error = value.error;
|
|
47
|
-
if (typeof error !== "object" || error === null) return void 0;
|
|
48
|
-
const { code, message, statusCode, details, issues } = error;
|
|
49
|
-
if (typeof code !== "string" || typeof message !== "string") return void 0;
|
|
50
|
-
return {
|
|
51
|
-
code,
|
|
52
|
-
message,
|
|
53
|
-
statusCode: typeof statusCode === "number" ? statusCode : void 0,
|
|
54
|
-
details,
|
|
55
|
-
issues: Array.isArray(issues) ? issues : void 0
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
function toApiError(value, fallbackMessage = "Internal server error") {
|
|
59
|
-
if (isApiError(value)) return value;
|
|
60
|
-
if (value instanceof Error) {
|
|
61
|
-
return new ApiError({ code: "INTERNAL_ERROR", message: value.message || fallbackMessage, statusCode: 500 });
|
|
62
|
-
}
|
|
63
|
-
return new ApiError({ code: "INTERNAL_ERROR", message: fallbackMessage, statusCode: 500 });
|
|
64
|
-
}
|
|
65
|
-
const BUILT_IN_ERROR_CODES = {
|
|
66
|
-
validation: "VALIDATION_ERROR",
|
|
67
|
-
responseValidation: "API_CONTRACT_RESPONSE_VALIDATION_ERROR",
|
|
68
|
-
internal: "INTERNAL_ERROR",
|
|
69
|
-
notFound: "NOT_FOUND",
|
|
70
|
-
methodNotAllowed: "METHOD_NOT_ALLOWED"
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export { ApiError as A, BUILT_IN_ERROR_CODES as B, createApiError as c, isApiError as i, parseApiErrorPayload as p, serializeApiError as s, toApiError as t };
|