nuxt-api-contract 0.1.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/LICENSE +21 -0
- package/README.md +385 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +99 -0
- package/dist/client.d.mts +70 -0
- package/dist/client.d.ts +70 -0
- package/dist/client.mjs +3 -0
- package/dist/composables.d.mts +45 -0
- package/dist/composables.d.ts +45 -0
- package/dist/composables.mjs +97 -0
- package/dist/module.d.mts +35 -0
- package/dist/module.d.ts +35 -0
- package/dist/module.mjs +145 -0
- package/dist/openapi.d.mts +46 -0
- package/dist/openapi.d.ts +46 -0
- package/dist/openapi.mjs +312 -0
- package/dist/runtime/server/devtoolsRoute.mjs +6 -0
- package/dist/runtime/server/openapiRoute.mjs +6 -0
- package/dist/runtime/shared/contract.mjs +77 -0
- package/dist/runtime/shared/errors.mjs +71 -0
- package/dist/runtime/shared/format.mjs +32 -0
- package/dist/runtime/shared/serialization.mjs +36 -0
- package/dist/runtime/shared/types.mjs +1 -0
- package/dist/server.d.mts +41 -0
- package/dist/server.d.ts +41 -0
- package/dist/server.mjs +94 -0
- package/dist/shared/nuxt-api-contract.B9JBCRk8.d.mts +37 -0
- package/dist/shared/nuxt-api-contract.CPm9WbWA.d.mts +165 -0
- package/dist/shared/nuxt-api-contract.CPm9WbWA.d.ts +165 -0
- package/dist/shared/nuxt-api-contract.D31EDcwH.d.mts +109 -0
- package/dist/shared/nuxt-api-contract.D31EDcwH.d.ts +109 -0
- package/dist/shared/nuxt-api-contract.DCAU2j7t.mjs +34 -0
- package/dist/shared/nuxt-api-contract.DDpgZj2g.mjs +66 -0
- package/dist/shared/nuxt-api-contract.QDGSGaVY.mjs +117 -0
- package/dist/shared/nuxt-api-contract.S1zqCiJX.d.ts +37 -0
- package/dist/shared/nuxt-api-contract.obS6uV8A.mjs +73 -0
- package/dist/shared.d.mts +4 -0
- package/dist/shared.d.ts +4 -0
- package/dist/shared.mjs +3 -0
- package/dist/testing.d.mts +55 -0
- package/dist/testing.d.ts +55 -0
- package/dist/testing.mjs +49 -0
- package/package.json +104 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ZodError } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A sanitized validation issue that is safe to send to the client.
|
|
5
|
+
*/
|
|
6
|
+
interface ValidationIssue {
|
|
7
|
+
/** Dotted path inside the validated value, e.g. `query.limit`. */
|
|
8
|
+
path: string;
|
|
9
|
+
/** Human readable message. */
|
|
10
|
+
message: string;
|
|
11
|
+
/** Expected type/shape description, when available. */
|
|
12
|
+
expected?: string;
|
|
13
|
+
/** Short serialized received value. Only populated outside production. */
|
|
14
|
+
received?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Maps a Zod error into sanitized validation issues. */
|
|
17
|
+
declare function toValidationIssues(error: ZodError): ValidationIssue[];
|
|
18
|
+
/**
|
|
19
|
+
* Builds a developer-friendly multi-line message:
|
|
20
|
+
*
|
|
21
|
+
* ```
|
|
22
|
+
* [nuxt-api-contract]
|
|
23
|
+
*
|
|
24
|
+
* Invalid query for GET /api/users
|
|
25
|
+
*
|
|
26
|
+
* query.limit:
|
|
27
|
+
* Expected number
|
|
28
|
+
* Received string
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
declare function formatValidationMessage(options: {
|
|
32
|
+
subject: string;
|
|
33
|
+
method: string;
|
|
34
|
+
path: string;
|
|
35
|
+
issues: ValidationIssue[];
|
|
36
|
+
includeReceived?: boolean;
|
|
37
|
+
}): string;
|
|
38
|
+
/**
|
|
39
|
+
* Strips received values from issues (used before exposing issues in
|
|
40
|
+
* production, where they may contain sensitive data such as passwords).
|
|
41
|
+
*/
|
|
42
|
+
declare function sanitizeIssues(issues: ValidationIssue[]): ValidationIssue[];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Stable, machine-readable error payload sent over the wire.
|
|
46
|
+
*/
|
|
47
|
+
interface ApiErrorPayload {
|
|
48
|
+
error: {
|
|
49
|
+
code: string;
|
|
50
|
+
message: string;
|
|
51
|
+
statusCode?: number;
|
|
52
|
+
details?: unknown;
|
|
53
|
+
issues?: ValidationIssue[];
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Unified API error. Thrown on the server (and converted to an h3 error),
|
|
58
|
+
* reconstructed on the client from the error payload.
|
|
59
|
+
*/
|
|
60
|
+
declare class ApiError extends Error {
|
|
61
|
+
readonly code: string;
|
|
62
|
+
readonly statusCode: number;
|
|
63
|
+
readonly details?: unknown;
|
|
64
|
+
readonly issues?: ValidationIssue[];
|
|
65
|
+
constructor(options: {
|
|
66
|
+
code: string;
|
|
67
|
+
message: string;
|
|
68
|
+
statusCode?: number;
|
|
69
|
+
details?: unknown;
|
|
70
|
+
issues?: ValidationIssue[];
|
|
71
|
+
});
|
|
72
|
+
toJSON(): ApiErrorPayload;
|
|
73
|
+
}
|
|
74
|
+
type CreateApiErrorInput = {
|
|
75
|
+
code: string;
|
|
76
|
+
message?: string;
|
|
77
|
+
statusCode?: number;
|
|
78
|
+
details?: unknown;
|
|
79
|
+
issues?: ValidationIssue[];
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Creates a unified API error.
|
|
83
|
+
*
|
|
84
|
+
* ```ts
|
|
85
|
+
* throw createApiError('USER_NOT_FOUND', 'User not found', 404)
|
|
86
|
+
* throw createApiError({ code: 'VALIDATION_ERROR', statusCode: 400, details })
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
declare function createApiError(input: CreateApiErrorInput): ApiError;
|
|
90
|
+
declare function createApiError(code: string, message?: string, statusCode?: number, details?: unknown): ApiError;
|
|
91
|
+
/** Type guard for `ApiError` (works across module instances / payload objects). */
|
|
92
|
+
declare function isApiError(value: unknown): value is ApiError;
|
|
93
|
+
/** Serializes an `ApiError` into the wire payload. */
|
|
94
|
+
declare function serializeApiError(error: ApiError): ApiErrorPayload;
|
|
95
|
+
/** Narrows an unknown parsed payload into an `ApiError` payload, if it matches. */
|
|
96
|
+
declare function parseApiErrorPayload(value: unknown): ApiErrorPayload['error'] | undefined;
|
|
97
|
+
/** Wraps an unknown thrown value into an `ApiError`. */
|
|
98
|
+
declare function toApiError(value: unknown, fallbackMessage?: string): ApiError;
|
|
99
|
+
/** Built-in error codes. */
|
|
100
|
+
declare const BUILT_IN_ERROR_CODES: {
|
|
101
|
+
readonly validation: "VALIDATION_ERROR";
|
|
102
|
+
readonly responseValidation: "API_CONTRACT_RESPONSE_VALIDATION_ERROR";
|
|
103
|
+
readonly internal: "INTERNAL_ERROR";
|
|
104
|
+
readonly notFound: "NOT_FOUND";
|
|
105
|
+
readonly methodNotAllowed: "METHOD_NOT_ALLOWED";
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export { ApiError as A, BUILT_IN_ERROR_CODES as B, serializeApiError as b, createApiError as c, toValidationIssues as d, formatValidationMessage as f, isApiError as i, parseApiErrorPayload as p, sanitizeIssues as s, toApiError as t };
|
|
109
|
+
export type { CreateApiErrorInput as C, ValidationIssue as V, ApiErrorPayload as a };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function toValidationIssues(error) {
|
|
2
|
+
return error.issues.map((issue) => ({
|
|
3
|
+
path: issue.path.length > 0 ? issue.path.join(".") : "(root)",
|
|
4
|
+
message: issue.message,
|
|
5
|
+
expected: "expected" in issue ? String(issue.expected) : void 0,
|
|
6
|
+
received: "received" in issue ? truncateReceived(String(issue.received)) : void 0
|
|
7
|
+
}));
|
|
8
|
+
}
|
|
9
|
+
function formatValidationMessage(options) {
|
|
10
|
+
const lines = [
|
|
11
|
+
"[nuxt-api-contract]",
|
|
12
|
+
"",
|
|
13
|
+
`Invalid ${options.subject} for ${options.method} ${options.path}`,
|
|
14
|
+
""
|
|
15
|
+
];
|
|
16
|
+
for (const issue of options.issues) {
|
|
17
|
+
const path = issue.path === "(root)" ? options.subject : `${options.subject}.${issue.path}`;
|
|
18
|
+
lines.push(`${path}:`);
|
|
19
|
+
lines.push(` Expected ${issue.expected ?? issue.message}`);
|
|
20
|
+
if (options.includeReceived !== false && issue.received !== void 0) {
|
|
21
|
+
lines.push(` Received ${issue.received}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return lines.join("\n");
|
|
25
|
+
}
|
|
26
|
+
function truncateReceived(value, max = 80) {
|
|
27
|
+
const serialized = value.length > max ? `${value.slice(0, max)}\u2026` : value;
|
|
28
|
+
return serialized;
|
|
29
|
+
}
|
|
30
|
+
function sanitizeIssues(issues) {
|
|
31
|
+
return issues.map((issue) => ({ ...issue, received: void 0 }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { formatValidationMessage as f, sanitizeIssues as s, toValidationIssues as t };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { c as createApiError, B as BUILT_IN_ERROR_CODES } from './nuxt-api-contract.obS6uV8A.mjs';
|
|
3
|
+
import { t as toValidationIssues, s as sanitizeIssues, f as formatValidationMessage } from './nuxt-api-contract.DCAU2j7t.mjs';
|
|
4
|
+
|
|
5
|
+
function readRuntimeConfig(getConfig) {
|
|
6
|
+
const config = getConfig();
|
|
7
|
+
return config?.apiContract ?? {};
|
|
8
|
+
}
|
|
9
|
+
function shouldValidateResponse(mode) {
|
|
10
|
+
if (mode === "always") return true;
|
|
11
|
+
if (mode === "development") return process.env.NODE_ENV !== "production";
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
function validateContractInput(contract, subject, schema, value) {
|
|
15
|
+
const result = schema.safeParse(value);
|
|
16
|
+
if (result.success) {
|
|
17
|
+
return result.data;
|
|
18
|
+
}
|
|
19
|
+
const issues = toValidationIssues(result.error);
|
|
20
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
21
|
+
throw createApiError({
|
|
22
|
+
code: BUILT_IN_ERROR_CODES.validation,
|
|
23
|
+
statusCode: 400,
|
|
24
|
+
message: formatValidationMessage({
|
|
25
|
+
subject,
|
|
26
|
+
method: contract.method,
|
|
27
|
+
path: contract.path,
|
|
28
|
+
issues: isProduction ? sanitizeIssues(issues) : issues,
|
|
29
|
+
includeReceived: !isProduction
|
|
30
|
+
}),
|
|
31
|
+
details: { subject, issues: isProduction ? sanitizeIssues(issues) : issues },
|
|
32
|
+
issues: isProduction ? sanitizeIssues(issues) : issues
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function validateContractResponse(contract, schema, value) {
|
|
36
|
+
const result = schema.safeParse(value);
|
|
37
|
+
if (result.success) {
|
|
38
|
+
return result.data;
|
|
39
|
+
}
|
|
40
|
+
const issues = toValidationIssues(result.error);
|
|
41
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
42
|
+
const safeIssues = isProduction ? sanitizeIssues(issues) : issues;
|
|
43
|
+
throw createApiError({
|
|
44
|
+
code: BUILT_IN_ERROR_CODES.responseValidation,
|
|
45
|
+
statusCode: 500,
|
|
46
|
+
message: formatValidationMessage({
|
|
47
|
+
subject: "response",
|
|
48
|
+
method: contract.method,
|
|
49
|
+
path: contract.path,
|
|
50
|
+
issues: safeIssues,
|
|
51
|
+
includeReceived: !isProduction
|
|
52
|
+
}),
|
|
53
|
+
details: {
|
|
54
|
+
contract: contract.name ?? "(anonymous)",
|
|
55
|
+
path: contract.path,
|
|
56
|
+
method: contract.method,
|
|
57
|
+
issues: safeIssues
|
|
58
|
+
},
|
|
59
|
+
issues: safeIssues
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function rawQuerySchema() {
|
|
63
|
+
return z.record(z.string(), z.unknown());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { validateContractResponse as a, rawQuerySchema as b, readRuntimeConfig as r, shouldValidateResponse as s, validateContractInput as v };
|
|
@@ -0,0 +1,117 @@
|
|
|
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 };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { H3Event, EventHandler } from 'h3';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { A as AnyApiContract, M as MaybePromise, i as ContractHandlerResponse } from './nuxt-api-contract.CPm9WbWA.js';
|
|
4
|
+
|
|
5
|
+
type Infer<T> = T extends z.ZodType ? z.infer<T> : never;
|
|
6
|
+
/**
|
|
7
|
+
* Context handed to contract handlers. All data is already validated.
|
|
8
|
+
*/
|
|
9
|
+
interface ContractHandlerContext<TParams = Record<string, unknown>, TQuery = Record<string, unknown>, TBody = undefined, THeaders = Record<string, string>> {
|
|
10
|
+
params: TParams;
|
|
11
|
+
query: TQuery;
|
|
12
|
+
body: TBody;
|
|
13
|
+
headers: THeaders;
|
|
14
|
+
/** Raw h3 event, for status codes, cookies, auth, etc. */
|
|
15
|
+
event: H3Event;
|
|
16
|
+
/** Reserved for auth integrations (populated by middleware/plugins). */
|
|
17
|
+
user?: unknown;
|
|
18
|
+
}
|
|
19
|
+
type ContractHandler<C extends AnyApiContract> = (ctx: ContractHandlerContext<C['params'] extends z.ZodType ? Infer<C['params']> : Record<string, unknown>, C['query'] extends z.ZodType ? Infer<C['query']> : Record<string, unknown>, C['body'] extends z.ZodType ? Infer<C['body']> : undefined, C['headers'] extends z.ZodType ? Infer<C['headers']> : Record<string, string>>) => MaybePromise<ContractHandlerResponse<C>>;
|
|
20
|
+
/**
|
|
21
|
+
* Defines a Nitro event handler bound to a contract:
|
|
22
|
+
*
|
|
23
|
+
* 1. validates `params`, `query`, `body` and `headers` against the schemas;
|
|
24
|
+
* 2. invokes the handler with the validated (typed) data;
|
|
25
|
+
* 3. optionally validates the response (see `apiContract.validateResponse`);
|
|
26
|
+
* 4. converts thrown `ApiError`s into the unified error payload.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* export default defineContractHandler(GetUser, async ({ params }) => {
|
|
30
|
+
* return { id: params.id, name: 'John' }
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
declare function defineContractHandler<C extends AnyApiContract>(contract: C, handler: ContractHandler<C>): EventHandler;
|
|
35
|
+
|
|
36
|
+
export { defineContractHandler as d };
|
|
37
|
+
export type { ContractHandler as C, ContractHandlerContext as a };
|
|
@@ -0,0 +1,73 @@
|
|
|
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 };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ContractMock, MockResponseInput, buildRequestPath, clearContractRegistry, defineApiContract, getContractByName, getContractMock, isApiContract, listRegisteredContracts, mockContract, registerContract, serializeQuery, serializeQueryValue, stableStringify } from './client.mjs';
|
|
2
|
+
export { A as ApiError, a as ApiErrorPayload, B as BUILT_IN_ERROR_CODES, C as CreateApiErrorInput, V as ValidationIssue, c as createApiError, f as formatValidationMessage, i as isApiError, p as parseApiErrorPayload, s as sanitizeIssues, b as serializeApiError, t as toApiError, d as toValidationIssues } from './shared/nuxt-api-contract.D31EDcwH.mjs';
|
|
3
|
+
export { a as API_CONTRACT_KIND, A as AnyApiContract, b as ApiContract, c as ApiContractDefinition, d as ApiRequestOptions, e as AuthConfig, C as ContractBodyInput, f as ContractClientResponse, g as ContractErrorCode, h as ContractFromDefinition, i as ContractHandlerResponse, j as ContractHeadersInput, k as ContractParamsInput, l as ContractPathParams, m as ContractQueryInput, E as EmptyObject, n as ExtractSchemaInput, o as ExtractSchemaOutput, H as HttpMethod, I as IsEmptyObject, M as MaybePromise, P as PathParams, R as ResolvedApiRequestOptions, S as SplitPath } from './shared/nuxt-api-contract.CPm9WbWA.mjs';
|
|
4
|
+
import 'zod';
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ContractMock, MockResponseInput, buildRequestPath, clearContractRegistry, defineApiContract, getContractByName, getContractMock, isApiContract, listRegisteredContracts, mockContract, registerContract, serializeQuery, serializeQueryValue, stableStringify } from './client.js';
|
|
2
|
+
export { A as ApiError, a as ApiErrorPayload, B as BUILT_IN_ERROR_CODES, C as CreateApiErrorInput, V as ValidationIssue, c as createApiError, f as formatValidationMessage, i as isApiError, p as parseApiErrorPayload, s as sanitizeIssues, b as serializeApiError, t as toApiError, d as toValidationIssues } from './shared/nuxt-api-contract.D31EDcwH.js';
|
|
3
|
+
export { a as API_CONTRACT_KIND, A as AnyApiContract, b as ApiContract, c as ApiContractDefinition, d as ApiRequestOptions, e as AuthConfig, C as ContractBodyInput, f as ContractClientResponse, g as ContractErrorCode, h as ContractFromDefinition, i as ContractHandlerResponse, j as ContractHeadersInput, k as ContractParamsInput, l as ContractPathParams, m as ContractQueryInput, E as EmptyObject, n as ExtractSchemaInput, o as ExtractSchemaOutput, H as HttpMethod, I as IsEmptyObject, M as MaybePromise, P as PathParams, R as ResolvedApiRequestOptions, S as SplitPath } from './shared/nuxt-api-contract.CPm9WbWA.js';
|
|
4
|
+
import 'zod';
|
package/dist/shared.mjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { b as buildRequestPath, c as clearContractRegistry, d as defineApiContract, g as getContractByName, a as getContractMock, i as isApiContract, l as listRegisteredContracts, m as mockContract, r as registerContract, s as serializeQuery, e as serializeQueryValue, f as stableStringify } from './shared/nuxt-api-contract.QDGSGaVY.mjs';
|
|
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';
|
|
3
|
+
export { f as formatValidationMessage, s as sanitizeIssues, t as toValidationIssues } from './shared/nuxt-api-contract.DCAU2j7t.mjs';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { H3Event, EventHandler } from 'h3';
|
|
2
|
+
import { AnyApiContract, ContractClientResponse } from './client.mjs';
|
|
3
|
+
import { ApiError } from './client.mjs';
|
|
4
|
+
import { ContractHandler } from './server.mjs';
|
|
5
|
+
import 'zod';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Contract testing utilities.
|
|
9
|
+
*
|
|
10
|
+
* `callContract` executes the full pipeline — request validation, handler,
|
|
11
|
+
* response validation — without an HTTP server, which makes it usable in
|
|
12
|
+
* plain Vitest unit tests.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface ContractHandlerMeta {
|
|
16
|
+
contract: AnyApiContract;
|
|
17
|
+
handler: (ctx: {
|
|
18
|
+
params: unknown;
|
|
19
|
+
query: unknown;
|
|
20
|
+
body: unknown;
|
|
21
|
+
headers: unknown;
|
|
22
|
+
event: H3Event;
|
|
23
|
+
}) => Awaitable<unknown>;
|
|
24
|
+
}
|
|
25
|
+
type Awaitable<T> = T | Promise<T>;
|
|
26
|
+
/**
|
|
27
|
+
* Calls a contract handler pipeline directly (no HTTP server):
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* const { data } = await callContract(GetUserContract, handler, { params: { id } })
|
|
31
|
+
* expect(data.id).toBe(id)
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Input is validated exactly like the real handler, and the response is
|
|
35
|
+
* always validated against the contract's response schema. Throws `ApiError`
|
|
36
|
+
* with the same codes the real endpoint would produce.
|
|
37
|
+
*/
|
|
38
|
+
declare function callContract<C extends AnyApiContract>(contract: C, handler: ContractHandler<C> | EventHandler | EventHandlerLike, input?: {
|
|
39
|
+
params?: Record<string, unknown>;
|
|
40
|
+
query?: Record<string, unknown>;
|
|
41
|
+
body?: unknown;
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
data: ContractClientResponse<C>;
|
|
45
|
+
error: null;
|
|
46
|
+
} | {
|
|
47
|
+
data: null;
|
|
48
|
+
error: ApiError;
|
|
49
|
+
}>;
|
|
50
|
+
type EventHandlerLike = (ctx: Record<string, unknown>) => Awaitable<unknown>;
|
|
51
|
+
/** Exposed so the module-level `defineContractHandler` can attach metadata. */
|
|
52
|
+
declare const contractHandlerMetaKey: symbol;
|
|
53
|
+
|
|
54
|
+
export { callContract, contractHandlerMetaKey };
|
|
55
|
+
export type { ContractHandlerMeta };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { H3Event, EventHandler } from 'h3';
|
|
2
|
+
import { AnyApiContract, ContractClientResponse } from './client.js';
|
|
3
|
+
import { ApiError } from './client.js';
|
|
4
|
+
import { ContractHandler } from './server.js';
|
|
5
|
+
import 'zod';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Contract testing utilities.
|
|
9
|
+
*
|
|
10
|
+
* `callContract` executes the full pipeline — request validation, handler,
|
|
11
|
+
* response validation — without an HTTP server, which makes it usable in
|
|
12
|
+
* plain Vitest unit tests.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface ContractHandlerMeta {
|
|
16
|
+
contract: AnyApiContract;
|
|
17
|
+
handler: (ctx: {
|
|
18
|
+
params: unknown;
|
|
19
|
+
query: unknown;
|
|
20
|
+
body: unknown;
|
|
21
|
+
headers: unknown;
|
|
22
|
+
event: H3Event;
|
|
23
|
+
}) => Awaitable<unknown>;
|
|
24
|
+
}
|
|
25
|
+
type Awaitable<T> = T | Promise<T>;
|
|
26
|
+
/**
|
|
27
|
+
* Calls a contract handler pipeline directly (no HTTP server):
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* const { data } = await callContract(GetUserContract, handler, { params: { id } })
|
|
31
|
+
* expect(data.id).toBe(id)
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Input is validated exactly like the real handler, and the response is
|
|
35
|
+
* always validated against the contract's response schema. Throws `ApiError`
|
|
36
|
+
* with the same codes the real endpoint would produce.
|
|
37
|
+
*/
|
|
38
|
+
declare function callContract<C extends AnyApiContract>(contract: C, handler: ContractHandler<C> | EventHandler | EventHandlerLike, input?: {
|
|
39
|
+
params?: Record<string, unknown>;
|
|
40
|
+
query?: Record<string, unknown>;
|
|
41
|
+
body?: unknown;
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
data: ContractClientResponse<C>;
|
|
45
|
+
error: null;
|
|
46
|
+
} | {
|
|
47
|
+
data: null;
|
|
48
|
+
error: ApiError;
|
|
49
|
+
}>;
|
|
50
|
+
type EventHandlerLike = (ctx: Record<string, unknown>) => Awaitable<unknown>;
|
|
51
|
+
/** Exposed so the module-level `defineContractHandler` can attach metadata. */
|
|
52
|
+
declare const contractHandlerMetaKey: symbol;
|
|
53
|
+
|
|
54
|
+
export { callContract, contractHandlerMetaKey };
|
|
55
|
+
export type { ContractHandlerMeta };
|
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
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';
|
|
3
|
+
import 'zod';
|
|
4
|
+
import './shared/nuxt-api-contract.DCAU2j7t.mjs';
|
|
5
|
+
|
|
6
|
+
const CONTRACT_HANDLER_META = Symbol.for("nuxt-api-contract.contractHandlerMeta");
|
|
7
|
+
async function callContract(contract, handler, input = {}) {
|
|
8
|
+
const runHandler = extractHandler(contract, handler);
|
|
9
|
+
try {
|
|
10
|
+
const params = contract.params ? validateContractInput(contract, "params", contract.params, input.params ?? {}) : input.params ?? {};
|
|
11
|
+
const query = contract.query ? validateContractInput(contract, "query", contract.query, input.query ?? {}) : input.query;
|
|
12
|
+
const body = contract.body ? validateContractInput(contract, "body", contract.body, input.body) : void 0;
|
|
13
|
+
const headers = contract.headers ? validateContractInput(contract, "headers", contract.headers, input.headers ?? {}) : input.headers ?? {};
|
|
14
|
+
const event = createFakeEvent(headers);
|
|
15
|
+
const rawResponse = await runHandler({ params, query, body, headers, event });
|
|
16
|
+
const data = contract.response ? validateContractResponse(contract, contract.response, rawResponse) : rawResponse;
|
|
17
|
+
return { data, error: null };
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return {
|
|
20
|
+
data: null,
|
|
21
|
+
error: error instanceof ApiError ? error : new ApiError({ code: "INTERNAL_ERROR", message: String(error) })
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function extractHandler(contract, handler) {
|
|
26
|
+
const meta = handler[CONTRACT_HANDLER_META];
|
|
27
|
+
if (meta && meta.contract.path === contract.path && meta.contract.method === contract.method) {
|
|
28
|
+
return meta.handler;
|
|
29
|
+
}
|
|
30
|
+
if (typeof handler === "function") {
|
|
31
|
+
return handler;
|
|
32
|
+
}
|
|
33
|
+
throw new Error("[nuxt-api-contract] callContract requires a contract handler function");
|
|
34
|
+
}
|
|
35
|
+
function createFakeEvent(headers) {
|
|
36
|
+
return {
|
|
37
|
+
context: {
|
|
38
|
+
params: {}
|
|
39
|
+
},
|
|
40
|
+
node: {
|
|
41
|
+
req: {
|
|
42
|
+
headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const contractHandlerMetaKey = CONTRACT_HANDLER_META;
|
|
48
|
+
|
|
49
|
+
export { callContract, contractHandlerMetaKey };
|