nuxt-api-contract 0.1.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.
Files changed (40) hide show
  1. package/README.md +99 -9
  2. package/dist/chunks/server.mjs +161 -0
  3. package/dist/cli.mjs +33 -1
  4. package/dist/client.d.mts +5 -69
  5. package/dist/client.d.ts +5 -69
  6. package/dist/client.mjs +3 -2
  7. package/dist/composables.mjs +2 -2
  8. package/dist/mock.d.mts +54 -0
  9. package/dist/mock.d.ts +54 -0
  10. package/dist/mock.mjs +7 -0
  11. package/dist/module.d.mts +8 -2
  12. package/dist/module.d.ts +8 -2
  13. package/dist/module.mjs +4 -2
  14. package/dist/runtime/shared/mock.mjs +188 -0
  15. package/dist/server.d.mts +7 -4
  16. package/dist/server.d.ts +7 -4
  17. package/dist/server.mjs +8 -7
  18. package/dist/shared/nuxt-api-contract.BOxyDvRy.d.mts +40 -0
  19. package/dist/shared/nuxt-api-contract.BWgzVTNN.mjs +191 -0
  20. package/dist/shared/{nuxt-api-contract.S1zqCiJX.d.ts → nuxt-api-contract.B_UvrNC8.d.ts} +1 -1
  21. package/dist/shared/nuxt-api-contract.BgPd-YXc.d.mts +53 -0
  22. package/dist/shared/nuxt-api-contract.BuWjAHJe.d.ts +40 -0
  23. package/dist/shared/nuxt-api-contract.C7KxMQHa.d.ts +53 -0
  24. package/dist/shared/{nuxt-api-contract.DDpgZj2g.mjs → nuxt-api-contract.CAIOgncy.mjs} +1 -1
  25. package/dist/shared/{nuxt-api-contract.CPm9WbWA.d.mts → nuxt-api-contract.CFG8gzJH.d.mts} +2 -2
  26. package/dist/shared/{nuxt-api-contract.CPm9WbWA.d.ts → nuxt-api-contract.CFG8gzJH.d.ts} +2 -2
  27. package/dist/shared/nuxt-api-contract.CHdRLlU7.mjs +152 -0
  28. package/dist/shared/nuxt-api-contract.DZfOzVaB.d.mts +16 -0
  29. package/dist/shared/nuxt-api-contract.DZfOzVaB.d.ts +16 -0
  30. package/dist/shared/nuxt-api-contract.Dr4tPqGB.mjs +38 -0
  31. package/dist/shared/{nuxt-api-contract.B9JBCRk8.d.mts → nuxt-api-contract.VCevNWQV.d.mts} +1 -1
  32. package/dist/shared.d.mts +4 -2
  33. package/dist/shared.d.ts +4 -2
  34. package/dist/shared.mjs +3 -2
  35. package/dist/testing.d.mts +111 -6
  36. package/dist/testing.d.ts +111 -6
  37. package/dist/testing.mjs +160 -3
  38. package/package.json +5 -1
  39. package/dist/shared/nuxt-api-contract.QDGSGaVY.mjs +0 -117
  40. package/dist/shared/nuxt-api-contract.obS6uV8A.mjs +0 -73
package/dist/testing.mjs CHANGED
@@ -1,8 +1,72 @@
1
- import { A as ApiError } from './shared/nuxt-api-contract.obS6uV8A.mjs';
2
- import { v as validateContractInput, a as validateContractResponse } from './shared/nuxt-api-contract.DDpgZj2g.mjs';
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
- export { callContract, contractHandlerMetaKey };
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,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-api-contract",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Type-safe API contracts between Nitro server routes and the Nuxt client.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,6 +56,10 @@
56
56
  "types": "./dist/openapi.d.mts",
57
57
  "import": "./dist/openapi.mjs"
58
58
  },
59
+ "./mock": {
60
+ "types": "./dist/mock.d.mts",
61
+ "import": "./dist/mock.mjs"
62
+ },
59
63
  "./package.json": "./package.json"
60
64
  },
61
65
  "bin": {
@@ -1,117 +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
- function serializeQueryValue(value) {
81
- if (value instanceof Date) return value.toISOString();
82
- if (typeof value === "bigint") return value.toString();
83
- if (Array.isArray(value)) return value.map((item) => serializePrimitive(item));
84
- if (value === void 0 || value === null) return "";
85
- if (typeof value === "object") return JSON.stringify(value);
86
- return value;
87
- }
88
- function serializePrimitive(value) {
89
- if (value instanceof Date) return value.toISOString();
90
- if (typeof value === "bigint") return value.toString();
91
- if (value === void 0 || value === null || typeof value === "object") return JSON.stringify(value) ?? "";
92
- return value;
93
- }
94
- function serializeQuery(query) {
95
- if (!query) return void 0;
96
- const result = {};
97
- for (const [key, value] of Object.entries(query)) {
98
- if (value === void 0) continue;
99
- result[key] = serializeQueryValue(value);
100
- }
101
- return result;
102
- }
103
- function stableStringify(value) {
104
- return JSON.stringify(sortValue(value));
105
- }
106
- function sortValue(value) {
107
- if (Array.isArray(value)) return value.map((item) => sortValue(item));
108
- if (value instanceof Date) return value.toISOString();
109
- if (typeof value === "bigint") return value.toString();
110
- if (value !== null && typeof value === "object") {
111
- const entries = Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
112
- return Object.fromEntries(entries.map(([key, item]) => [key, sortValue(item)]));
113
- }
114
- return value;
115
- }
116
-
117
- export { getContractMock as a, buildRequestPath as b, clearContractRegistry as c, defineApiContract as d, serializeQueryValue as e, stableStringify as f, getContractByName as g, isApiContract as i, listRegisteredContracts as l, mockContract as m, registerContract as r, serializeQuery as s };
@@ -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 };