@typespec/spec-api 0.1.0-dev.0 → 0.1.0-dev.1
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/CHANGELOG.md +18 -0
- package/dist/expectation.d.ts +8 -5
- package/dist/expectation.d.ts.map +1 -1
- package/dist/expectation.js +12 -8
- package/dist/expectation.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/match-engine.d.ts +58 -0
- package/dist/match-engine.d.ts.map +1 -0
- package/dist/match-engine.js +156 -0
- package/dist/match-engine.js.map +1 -0
- package/dist/matchers/datetime.d.ts +8 -0
- package/dist/matchers/datetime.d.ts.map +1 -0
- package/dist/matchers/datetime.js +47 -0
- package/dist/matchers/datetime.js.map +1 -0
- package/dist/matchers/index.d.ts +39 -0
- package/dist/matchers/index.d.ts.map +1 -0
- package/dist/matchers/index.js +36 -0
- package/dist/matchers/index.js.map +1 -0
- package/dist/matchers/local-url.d.ts +3 -0
- package/dist/matchers/local-url.d.ts.map +1 -0
- package/dist/matchers/local-url.js +22 -0
- package/dist/matchers/local-url.js.map +1 -0
- package/dist/request-validations.d.ts +2 -2
- package/dist/request-validations.d.ts.map +1 -1
- package/dist/request-validations.js +54 -19
- package/dist/request-validations.js.map +1 -1
- package/dist/response-utils.d.ts +48 -11
- package/dist/response-utils.d.ts.map +1 -1
- package/dist/response-utils.js +70 -30
- package/dist/response-utils.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -12
- package/src/expectation.ts +13 -9
- package/src/index.ts +10 -0
- package/src/match-engine.ts +221 -0
- package/src/matchers/datetime.ts +66 -0
- package/src/matchers/index.ts +48 -0
- package/src/matchers/local-url.ts +24 -0
- package/src/request-validations.ts +78 -27
- package/src/response-utils.ts +106 -34
- package/src/types.ts +2 -0
- package/temp/.tsbuildinfo +1 -1
- package/test/match-engine.test.ts +303 -0
- package/test/matchers/datetime.test.ts +232 -0
- package/test/matchers/local-url.test.ts +82 -0
- package/test/matchers/matcher-test-utils.ts +17 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matcher framework for Spector mock API validation.
|
|
3
|
+
*
|
|
4
|
+
* Matchers are special objects that can be placed anywhere in an expected value tree.
|
|
5
|
+
* The comparison engine recognizes them and delegates to `matcher.check(actual)`
|
|
6
|
+
* instead of doing strict equality — enabling flexible comparisons for types like
|
|
7
|
+
* datetime that serialize differently across languages.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Symbol used to identify matcher objects */
|
|
11
|
+
export const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher");
|
|
12
|
+
|
|
13
|
+
/** Result of a match operation */
|
|
14
|
+
export type MatchResult = { pass: true } | { pass: false; message: string };
|
|
15
|
+
|
|
16
|
+
const OK: MatchResult = Object.freeze({ pass: true });
|
|
17
|
+
|
|
18
|
+
/** Create a passing match result */
|
|
19
|
+
export function ok(): MatchResult {
|
|
20
|
+
return OK;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Create a failing match result with a message */
|
|
24
|
+
export function err(message: string): MatchResult {
|
|
25
|
+
return { pass: false, message };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Interface for custom value matchers.
|
|
30
|
+
* Implement this to create new matcher types.
|
|
31
|
+
*/
|
|
32
|
+
export interface MockValueMatcher<T = unknown> {
|
|
33
|
+
readonly [MatcherSymbol]: true;
|
|
34
|
+
/** Check whether the actual value matches the expectation */
|
|
35
|
+
check(actual: unknown, config?: MatcherConfig): MatchResult;
|
|
36
|
+
/** The raw value to use when serializing */
|
|
37
|
+
serialize(config?: MatcherConfig): T;
|
|
38
|
+
/** @internal Delegates to serialize() for JSON.stringify compatibility */
|
|
39
|
+
toJSON(): T;
|
|
40
|
+
/** Human-readable description for debugging */
|
|
41
|
+
toString(): string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Configuration available to matchers at runtime */
|
|
45
|
+
export interface MatcherConfig {
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const emptyConfig: MatcherConfig = { baseUrl: "" };
|
|
50
|
+
|
|
51
|
+
interface MatcherImpl<T> {
|
|
52
|
+
check(actual: unknown): MatchResult;
|
|
53
|
+
serialize(): T;
|
|
54
|
+
toString?: () => string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Create a MockValueMatcher with the MatcherSymbol already set.
|
|
58
|
+
* Accepts either a plain implementation object (for matchers that don't need config)
|
|
59
|
+
* or a factory function `(config) => impl` (for matchers that do).
|
|
60
|
+
*/
|
|
61
|
+
export function createMatcher<T = unknown>(
|
|
62
|
+
implOrFactory: MatcherImpl<T> | ((config: MatcherConfig) => MatcherImpl<T>),
|
|
63
|
+
): MockValueMatcher<T> {
|
|
64
|
+
const resolve =
|
|
65
|
+
typeof implOrFactory === "function"
|
|
66
|
+
? (config: MatcherConfig) => implOrFactory(config)
|
|
67
|
+
: () => implOrFactory;
|
|
68
|
+
return {
|
|
69
|
+
[MatcherSymbol]: true,
|
|
70
|
+
check(actual: unknown, config?: MatcherConfig): MatchResult {
|
|
71
|
+
return resolve(config ?? emptyConfig).check(actual);
|
|
72
|
+
},
|
|
73
|
+
serialize(config?: MatcherConfig): T {
|
|
74
|
+
return resolve(config ?? emptyConfig).serialize();
|
|
75
|
+
},
|
|
76
|
+
toJSON() {
|
|
77
|
+
return resolve(emptyConfig).serialize();
|
|
78
|
+
},
|
|
79
|
+
toString() {
|
|
80
|
+
const impl = resolve(emptyConfig);
|
|
81
|
+
return impl.toString?.() ?? String(impl.serialize());
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Type guard to check if a value is a MockValueMatcher */
|
|
87
|
+
export function isMatcher(value: unknown): value is MockValueMatcher {
|
|
88
|
+
return (
|
|
89
|
+
typeof value === "object" &&
|
|
90
|
+
value !== null &&
|
|
91
|
+
MatcherSymbol in value &&
|
|
92
|
+
(value as any)[MatcherSymbol] === true
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatValue(value: unknown): string {
|
|
97
|
+
if (value === null) return "null";
|
|
98
|
+
if (value === undefined) return "undefined";
|
|
99
|
+
if (typeof value === "string") return `"${value}"`;
|
|
100
|
+
if (Buffer.isBuffer(value)) return `Buffer(${value.length})`;
|
|
101
|
+
if (Array.isArray(value)) return `Array(${value.length})`;
|
|
102
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
103
|
+
return String(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pathErr(message: string, path: string): MatchResult {
|
|
107
|
+
const prefix = path ? `at ${path}: ` : "";
|
|
108
|
+
return err(`${prefix}${message}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Recursively compares actual vs expected values.
|
|
113
|
+
* When a MockValueMatcher is encountered in the expected tree, delegates to matcher.check().
|
|
114
|
+
* Otherwise uses strict equality semantics (same as deep-equal with strict: true).
|
|
115
|
+
*/
|
|
116
|
+
export function matchValues(
|
|
117
|
+
actual: unknown,
|
|
118
|
+
expected: unknown,
|
|
119
|
+
path: string = "$",
|
|
120
|
+
config: MatcherConfig = emptyConfig,
|
|
121
|
+
): MatchResult {
|
|
122
|
+
if (expected === actual) {
|
|
123
|
+
return ok();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (isMatcher(expected)) {
|
|
127
|
+
const result = expected.check(actual, config);
|
|
128
|
+
if (!result.pass) {
|
|
129
|
+
return pathErr(result.message, path);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof expected !== typeof actual) {
|
|
135
|
+
return pathErr(
|
|
136
|
+
`Type mismatch: expected ${typeof expected} but got ${typeof actual} (${formatValue(actual)})`,
|
|
137
|
+
path,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (expected === null || actual === null) {
|
|
142
|
+
return pathErr(`Expected ${formatValue(expected)} but got ${formatValue(actual)}`, path);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (Array.isArray(expected)) {
|
|
146
|
+
if (!Array.isArray(actual)) {
|
|
147
|
+
return pathErr(`Expected an array but got ${formatValue(actual)}`, path);
|
|
148
|
+
}
|
|
149
|
+
if (expected.length !== actual.length) {
|
|
150
|
+
return pathErr(
|
|
151
|
+
`Array length mismatch: expected ${expected.length} but got ${actual.length}`,
|
|
152
|
+
path,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
for (let i = 0; i < expected.length; i++) {
|
|
156
|
+
const result = matchValues(actual[i], expected[i], `${path}[${i}]`, config);
|
|
157
|
+
if (!result.pass) {
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return ok();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (Buffer.isBuffer(expected)) {
|
|
165
|
+
if (!Buffer.isBuffer(actual)) {
|
|
166
|
+
return pathErr(`Expected a Buffer but got ${typeof actual}`, path);
|
|
167
|
+
}
|
|
168
|
+
if (!expected.equals(actual)) {
|
|
169
|
+
return pathErr(`Buffer contents differ`, path);
|
|
170
|
+
}
|
|
171
|
+
return ok();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (typeof expected === "object") {
|
|
175
|
+
const expectedObj = expected as Record<string, unknown>;
|
|
176
|
+
const actualObj = actual as Record<string, unknown>;
|
|
177
|
+
|
|
178
|
+
// Keys with undefined values in expected mean "must not be present in actual"
|
|
179
|
+
const expectedPresentKeys = Object.keys(expectedObj).filter(
|
|
180
|
+
(k) => expectedObj[k] !== undefined,
|
|
181
|
+
);
|
|
182
|
+
const expectedAbsentKeys = Object.keys(expectedObj).filter((k) => expectedObj[k] === undefined);
|
|
183
|
+
const actualKeys = Object.keys(actualObj);
|
|
184
|
+
|
|
185
|
+
// Verify keys that should be absent are not in actual
|
|
186
|
+
for (const key of expectedAbsentKeys) {
|
|
187
|
+
if (key in actualObj && actualObj[key] !== undefined) {
|
|
188
|
+
return pathErr(
|
|
189
|
+
`Key "${key}" should not be present but got ${formatValue(actualObj[key])}`,
|
|
190
|
+
path,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (expectedPresentKeys.length !== actualKeys.length) {
|
|
196
|
+
const missing = expectedPresentKeys.filter((k) => !(k in actualObj));
|
|
197
|
+
const extra = actualKeys.filter(
|
|
198
|
+
(k) => !expectedPresentKeys.includes(k) && !expectedAbsentKeys.includes(k),
|
|
199
|
+
);
|
|
200
|
+
const parts: string[] = [
|
|
201
|
+
`Key count mismatch: expected ${expectedPresentKeys.length} but got ${actualKeys.length}`,
|
|
202
|
+
];
|
|
203
|
+
if (missing.length > 0) parts.push(`missing: [${missing.join(", ")}]`);
|
|
204
|
+
if (extra.length > 0) parts.push(`extra: [${extra.join(", ")}]`);
|
|
205
|
+
return pathErr(parts.join(". "), path);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const key of expectedPresentKeys) {
|
|
209
|
+
if (!(key in actualObj)) {
|
|
210
|
+
return pathErr(`Missing key "${key}"`, path);
|
|
211
|
+
}
|
|
212
|
+
const result = matchValues(actualObj[key], expectedObj[key], `${path}.${key}`, config);
|
|
213
|
+
if (!result.pass) {
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return ok();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return pathErr(`Expected ${formatValue(expected)} but got ${formatValue(actual)}`, path);
|
|
221
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.js";
|
|
2
|
+
|
|
3
|
+
const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/i;
|
|
4
|
+
const utcRfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/i;
|
|
5
|
+
const rfc7231Pattern =
|
|
6
|
+
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s\d{2}\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s\d{4}\s\d{2}:\d{2}:\d{2}\sGMT$/i;
|
|
7
|
+
|
|
8
|
+
function createDateTimeMatcher(
|
|
9
|
+
value: string,
|
|
10
|
+
label: string,
|
|
11
|
+
formatName: string,
|
|
12
|
+
formatPattern: RegExp,
|
|
13
|
+
): MockValueMatcher<string> {
|
|
14
|
+
const expectedMs = Date.parse(value);
|
|
15
|
+
if (isNaN(expectedMs)) {
|
|
16
|
+
throw new Error(`${label}: invalid datetime value: ${value}`);
|
|
17
|
+
}
|
|
18
|
+
return createMatcher({
|
|
19
|
+
check(actual: unknown) {
|
|
20
|
+
if (typeof actual !== "string") {
|
|
21
|
+
return err(
|
|
22
|
+
`${label}: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (!formatPattern.test(actual)) {
|
|
26
|
+
return err(`${label}: expected ${formatName} format but got "${actual}"`);
|
|
27
|
+
}
|
|
28
|
+
const actualMs = Date.parse(actual);
|
|
29
|
+
if (isNaN(actualMs)) {
|
|
30
|
+
return err(
|
|
31
|
+
`${label}: value "${actual}" matches ${formatName} format but is not a valid date`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (actualMs !== expectedMs) {
|
|
35
|
+
return err(
|
|
36
|
+
`${label}: timestamps differ \u2014 expected ${new Date(expectedMs).toISOString()} but got ${new Date(actualMs).toISOString()}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return ok();
|
|
40
|
+
},
|
|
41
|
+
serialize() {
|
|
42
|
+
return value;
|
|
43
|
+
},
|
|
44
|
+
toString() {
|
|
45
|
+
return `${label}(${value})`;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const dateTimeMatcher = {
|
|
51
|
+
rfc3339(value: string): MockValueMatcher<string> {
|
|
52
|
+
return createDateTimeMatcher(value, "match.dateTime.rfc3339", "rfc3339", rfc3339Pattern);
|
|
53
|
+
},
|
|
54
|
+
/** Like rfc3339 but rejects timezone offsets — only Z (UTC) suffix is allowed. */
|
|
55
|
+
utcRfc3339(value: string): MockValueMatcher<string> {
|
|
56
|
+
return createDateTimeMatcher(
|
|
57
|
+
value,
|
|
58
|
+
"match.dateTime.utcRfc3339",
|
|
59
|
+
"utcRfc3339",
|
|
60
|
+
utcRfc3339Pattern,
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
rfc7231(value: string): MockValueMatcher<string> {
|
|
64
|
+
return createDateTimeMatcher(value, "match.dateTime.rfc7231", "rfc7231", rfc7231Pattern);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { dateTimeMatcher } from "./datetime.js";
|
|
2
|
+
import { baseUrlMatcher } from "./local-url.js";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
createMatcher,
|
|
6
|
+
err,
|
|
7
|
+
isMatcher,
|
|
8
|
+
MatcherSymbol,
|
|
9
|
+
matchValues,
|
|
10
|
+
ok,
|
|
11
|
+
type MatcherConfig,
|
|
12
|
+
type MatchResult,
|
|
13
|
+
type MockValueMatcher,
|
|
14
|
+
} from "../match-engine.js";
|
|
15
|
+
export { dateTimeMatcher } from "./datetime.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Namespace for built-in matchers.
|
|
19
|
+
*/
|
|
20
|
+
export const match = {
|
|
21
|
+
/**
|
|
22
|
+
* Matchers for comparing datetime values semantically.
|
|
23
|
+
* Validates that the actual value is in the correct format and represents
|
|
24
|
+
* the same point in time as the expected value.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")
|
|
29
|
+
* match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z") // rejects offsets, only Z
|
|
30
|
+
* match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT")
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
dateTime: dateTimeMatcher,
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Matcher for URL values that include the server's base URL.
|
|
37
|
+
*
|
|
38
|
+
* The matcher is created with just the path portion. At runtime, `expandDyns()`
|
|
39
|
+
* resolves it by injecting the server's actual base URL (e.g. `http://localhost:3000`).
|
|
40
|
+
* The resolved matcher validates that the actual value equals `baseUrl + path`.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* match.localUrl("/payload/pageable/next-page")
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
localUrl: baseUrlMatcher,
|
|
48
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.js";
|
|
2
|
+
|
|
3
|
+
export function baseUrlMatcher(path: string): MockValueMatcher<string> {
|
|
4
|
+
return createMatcher((config) => ({
|
|
5
|
+
check(actual: unknown) {
|
|
6
|
+
if (typeof actual !== "string") {
|
|
7
|
+
return err(
|
|
8
|
+
`match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`,
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
const expected = config.baseUrl + path;
|
|
12
|
+
if (actual !== expected) {
|
|
13
|
+
return err(`match.localUrl: expected "${expected}" but got "${actual}"`);
|
|
14
|
+
}
|
|
15
|
+
return ok();
|
|
16
|
+
},
|
|
17
|
+
serialize() {
|
|
18
|
+
return config.baseUrl + path;
|
|
19
|
+
},
|
|
20
|
+
toString() {
|
|
21
|
+
return `match.localUrl("${path}")`;
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import deepEqual from "deep-equal";
|
|
2
|
-
import * as prettier from "prettier";
|
|
3
2
|
import { parseString } from "xml2js";
|
|
4
|
-
import {
|
|
3
|
+
import { matchValues, type MockValueMatcher } from "./match-engine.js";
|
|
4
|
+
import { CollectionFormat, RequestExt, Resolver, ResolverConfig } from "./types.js";
|
|
5
5
|
import { ValidationError } from "./validation-error.js";
|
|
6
6
|
|
|
7
7
|
export const BODY_NOT_EQUAL_ERROR_MESSAGE = "Body provided doesn't match expected body";
|
|
@@ -37,43 +37,89 @@ export const validateBodyEquals = (
|
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
const result = matchValues(request.body, expectedBody);
|
|
41
|
+
if (!result.pass) {
|
|
42
|
+
throw new ValidationError(
|
|
43
|
+
`${BODY_NOT_EQUAL_ERROR_MESSAGE}: ${result.message}`,
|
|
44
|
+
expectedBody,
|
|
45
|
+
request.body,
|
|
46
|
+
);
|
|
42
47
|
}
|
|
43
48
|
};
|
|
44
49
|
|
|
45
|
-
export const validateXmlBodyEquals = (
|
|
50
|
+
export const validateXmlBodyEquals = (
|
|
51
|
+
request: RequestExt,
|
|
52
|
+
expectedBody: string | Resolver,
|
|
53
|
+
config?: ResolverConfig,
|
|
54
|
+
): void => {
|
|
55
|
+
const resolvedConfig = config ?? { baseUrl: "" };
|
|
56
|
+
// When expectedBody is a Resolver (e.g. from xml`...`), serialize() already includes the XML declaration.
|
|
57
|
+
// When it's a plain string, we need to prepend it.
|
|
58
|
+
const expectedXml =
|
|
59
|
+
typeof expectedBody === "string"
|
|
60
|
+
? `<?xml version='1.0' encoding='UTF-8'?>` + expectedBody
|
|
61
|
+
: expectedBody.serialize(resolvedConfig);
|
|
62
|
+
|
|
46
63
|
if (request.rawBody === undefined || isBodyEmpty(request.rawBody)) {
|
|
47
|
-
throw new ValidationError(BODY_EMPTY_ERROR_MESSAGE,
|
|
64
|
+
throw new ValidationError(BODY_EMPTY_ERROR_MESSAGE, expectedXml, request.rawBody);
|
|
48
65
|
}
|
|
49
66
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
let actualParsedBody = "";
|
|
67
|
+
let actualParsed: unknown;
|
|
53
68
|
parseString(request.rawBody, (err: Error | null, result: any): void => {
|
|
54
|
-
if (err !== null)
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
actualParsedBody = result;
|
|
69
|
+
if (err !== null) throw err;
|
|
70
|
+
actualParsed = result;
|
|
58
71
|
});
|
|
59
72
|
|
|
60
|
-
let
|
|
61
|
-
parseString(
|
|
62
|
-
if (err !== null)
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
expectedParsedBody = result;
|
|
73
|
+
let expectedParsed: unknown;
|
|
74
|
+
parseString(expectedXml, (err: Error | null, result: any): void => {
|
|
75
|
+
if (err !== null) throw err;
|
|
76
|
+
expectedParsed = result;
|
|
66
77
|
});
|
|
67
78
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
// If the expected body is a DynValue with matchers, use matcher-aware comparison
|
|
80
|
+
const matchers =
|
|
81
|
+
typeof expectedBody !== "string" && "getMatchers" in expectedBody
|
|
82
|
+
? (expectedBody as any).getMatchers(resolvedConfig)
|
|
83
|
+
: [];
|
|
84
|
+
|
|
85
|
+
if (matchers.length > 0) {
|
|
86
|
+
const matcherMap = new Map<string, MockValueMatcher>();
|
|
87
|
+
for (const { serialized, matcher } of matchers) {
|
|
88
|
+
matcherMap.set(serialized, matcher);
|
|
89
|
+
}
|
|
90
|
+
expectedParsed = substituteMatchers(expectedParsed, matcherMap);
|
|
91
|
+
|
|
92
|
+
const result = matchValues(actualParsed, expectedParsed);
|
|
93
|
+
if (!result.pass) {
|
|
94
|
+
throw new ValidationError(
|
|
95
|
+
`${BODY_NOT_EQUAL_ERROR_MESSAGE}: ${result.message}`,
|
|
96
|
+
expectedXml,
|
|
97
|
+
request.rawBody,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
if (!deepEqual(actualParsed, expectedParsed, { strict: true })) {
|
|
102
|
+
throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedXml, request.rawBody);
|
|
103
|
+
}
|
|
74
104
|
}
|
|
75
105
|
};
|
|
76
106
|
|
|
107
|
+
function substituteMatchers(value: unknown, matcherMap: Map<string, MockValueMatcher>): unknown {
|
|
108
|
+
if (typeof value === "string") {
|
|
109
|
+
return matcherMap.get(value) ?? value;
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
return value.map((v) => substituteMatchers(v, matcherMap));
|
|
113
|
+
}
|
|
114
|
+
if (typeof value === "object" && value !== null) {
|
|
115
|
+
const obj = value as Record<string, unknown>;
|
|
116
|
+
return Object.fromEntries(
|
|
117
|
+
Object.entries(obj).map(([k, v]) => [k, substituteMatchers(v, matcherMap)]),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
77
123
|
export const validateCoercedDateBodyEquals = (
|
|
78
124
|
request: RequestExt,
|
|
79
125
|
expectedBody: unknown | undefined,
|
|
@@ -85,8 +131,13 @@ export const validateCoercedDateBodyEquals = (
|
|
|
85
131
|
return;
|
|
86
132
|
}
|
|
87
133
|
|
|
88
|
-
|
|
89
|
-
|
|
134
|
+
const result = matchValues(coerceDate(request.body), expectedBody);
|
|
135
|
+
if (!result.pass) {
|
|
136
|
+
throw new ValidationError(
|
|
137
|
+
`${BODY_NOT_EQUAL_ERROR_MESSAGE}: ${result.message}`,
|
|
138
|
+
expectedBody,
|
|
139
|
+
request.body,
|
|
140
|
+
);
|
|
90
141
|
}
|
|
91
142
|
};
|
|
92
143
|
|
package/src/response-utils.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isMatcher, type MockValueMatcher } from "./match-engine.js";
|
|
1
2
|
import { MockBody, MockMultipartBody, Resolver, ResolverConfig } from "./types.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -18,19 +19,47 @@ function createResolver(content: unknown): Resolver {
|
|
|
18
19
|
const expanded = expandDyns(content, config);
|
|
19
20
|
return JSON.stringify(expanded);
|
|
20
21
|
},
|
|
22
|
+
resolve: (config: ResolverConfig) => {
|
|
23
|
+
// Preserve matchers so matchValues can use them for flexible validation
|
|
24
|
+
return expandDyns(content, config, { resolveMatchers: false });
|
|
25
|
+
},
|
|
21
26
|
};
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
const XML_DECLARATION = `<?xml version='1.0' encoding='UTF-8'?>`;
|
|
30
|
+
|
|
24
31
|
/**
|
|
25
|
-
* Sends the provided XML
|
|
26
|
-
* The XML declaration prefix
|
|
27
|
-
*
|
|
32
|
+
* Sends the provided XML content in a MockResponse body.
|
|
33
|
+
* The XML declaration prefix is automatically prepended.
|
|
34
|
+
*
|
|
35
|
+
* Can be used as a plain function or as a tagged template literal.
|
|
36
|
+
* When used as a tagged template, interpolated matchers (e.g. `match.localUrl`)
|
|
37
|
+
* are resolved at serialization time via `expandDyns`.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* // Plain string
|
|
42
|
+
* xml("<Root>hello</Root>")
|
|
43
|
+
*
|
|
44
|
+
* // Tagged template with matcher
|
|
45
|
+
* xml`<Root><Link>${match.localUrl("/next")}</Link></Root>`
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
28
48
|
* @returns {MockBody} response body with application/xml content type.
|
|
29
49
|
*/
|
|
30
|
-
export function xml(
|
|
50
|
+
export function xml(content: string): MockBody;
|
|
51
|
+
export function xml(strings: TemplateStringsArray, ...values: unknown[]): MockBody;
|
|
52
|
+
export function xml(content: string | TemplateStringsArray, ...values: unknown[]): MockBody {
|
|
53
|
+
if (typeof content !== "string") {
|
|
54
|
+
return {
|
|
55
|
+
contentType: "application/xml",
|
|
56
|
+
rawContent: dyn`${XML_DECLARATION}${dyn(content, ...values)}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
31
60
|
return {
|
|
32
61
|
contentType: "application/xml",
|
|
33
|
-
rawContent:
|
|
62
|
+
rawContent: XML_DECLARATION + content,
|
|
34
63
|
};
|
|
35
64
|
}
|
|
36
65
|
|
|
@@ -44,10 +73,11 @@ export function multipart(
|
|
|
44
73
|
};
|
|
45
74
|
}
|
|
46
75
|
|
|
47
|
-
export interface DynValue
|
|
76
|
+
export interface DynValue extends Resolver {
|
|
48
77
|
readonly isDyn: true;
|
|
49
|
-
|
|
50
|
-
|
|
78
|
+
(config: ResolverConfig): string;
|
|
79
|
+
/** Returns all matchers embedded in this template with their serialized values. */
|
|
80
|
+
getMatchers(config: ResolverConfig): Array<{ serialized: string; matcher: MockValueMatcher }>;
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
export interface DynItem<T extends keyof ResolverConfig> {
|
|
@@ -62,47 +92,89 @@ export function dynItem<const T extends keyof ResolverConfig>(name: T): DynItem<
|
|
|
62
92
|
};
|
|
63
93
|
}
|
|
64
94
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (value !== undefined) {
|
|
80
|
-
result.push(value);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
result.push(strings[i + 1]);
|
|
95
|
+
/**
|
|
96
|
+
* Tagged template for building strings with deferred resolution.
|
|
97
|
+
* Interpolated values can be:
|
|
98
|
+
* - `dynItem("baseUrl")` — resolved from `ResolverConfig`
|
|
99
|
+
* - Matchers (e.g. `match.localUrl(...)`) — resolved via `expandDyns`
|
|
100
|
+
* - Other `dyn` templates — recursively resolved
|
|
101
|
+
* - Plain strings/numbers — used as-is
|
|
102
|
+
*/
|
|
103
|
+
export function dyn(strings: readonly string[], ...values: unknown[]): DynValue {
|
|
104
|
+
const template = (config: ResolverConfig) => {
|
|
105
|
+
let result = strings[0];
|
|
106
|
+
values.forEach((v, i) => {
|
|
107
|
+
result += String(expandDyns(v, config));
|
|
108
|
+
result += strings[i + 1];
|
|
84
109
|
});
|
|
85
|
-
return result
|
|
110
|
+
return result;
|
|
86
111
|
};
|
|
87
|
-
template.keys = dynKeys;
|
|
88
112
|
template.isDyn = true as const;
|
|
113
|
+
template.serialize = template;
|
|
114
|
+
template.resolve = template;
|
|
115
|
+
template.getMatchers = (config: ResolverConfig) => {
|
|
116
|
+
const result: Array<{ serialized: string; matcher: MockValueMatcher }> = [];
|
|
117
|
+
for (const v of values) {
|
|
118
|
+
collectMatchers(v, config, result);
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
};
|
|
89
122
|
return template;
|
|
90
123
|
}
|
|
91
124
|
|
|
92
|
-
|
|
125
|
+
function collectMatchers(
|
|
126
|
+
value: unknown,
|
|
127
|
+
config: ResolverConfig,
|
|
128
|
+
out: Array<{ serialized: string; matcher: MockValueMatcher }>,
|
|
129
|
+
): void {
|
|
130
|
+
if (isMatcher(value)) {
|
|
131
|
+
out.push({ serialized: String(value.serialize(config)), matcher: value });
|
|
132
|
+
} else if (typeof value === "function" && "isDyn" in value && value.isDyn) {
|
|
133
|
+
const dynVal = value as DynValue;
|
|
134
|
+
if (dynVal.getMatchers) {
|
|
135
|
+
out.push(...dynVal.getMatchers(config));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ExpandDynsOptions {
|
|
141
|
+
/** When true, matchers are resolved to their `toJSON()` value. Default: true. */
|
|
142
|
+
resolveMatchers?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Recursively expands all dynamic values.
|
|
147
|
+
* - Dyn functions are called with the config.
|
|
148
|
+
* - Resolvable matchers (e.g. `match.localUrl`) are resolved via `resolve(config)`.
|
|
149
|
+
* - By default, matchers are resolved to their `toJSON()` plain value.
|
|
150
|
+
* Pass `{ resolveMatchers: false }` to preserve matchers for use with `matchValues`.
|
|
151
|
+
*/
|
|
152
|
+
export function expandDyns<T>(value: T, config: ResolverConfig, options?: ExpandDynsOptions): T {
|
|
153
|
+
const resolve = options?.resolveMatchers ?? true;
|
|
154
|
+
return _expandDyns(value, config, resolve);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _expandDyns<T>(value: T, config: ResolverConfig, resolveMatchers: boolean): T {
|
|
93
158
|
if (typeof value === "string") {
|
|
94
159
|
return value;
|
|
95
160
|
} else if (Array.isArray(value)) {
|
|
96
|
-
return value.map((v) =>
|
|
161
|
+
return value.map((v) => _expandDyns(v, config, resolveMatchers)) as any;
|
|
97
162
|
} else if (typeof value === "object" && value !== null) {
|
|
163
|
+
// DynItem — resolve from config
|
|
164
|
+
if ("isDyn" in value && (value as any).isDyn && "name" in value) {
|
|
165
|
+
return (config as any)[(value as any).name] as any;
|
|
166
|
+
}
|
|
167
|
+
if (isMatcher(value)) {
|
|
168
|
+
return resolveMatchers ? (value.serialize(config) as any) : (value as any);
|
|
169
|
+
}
|
|
98
170
|
const obj = value as Record<string, unknown>;
|
|
99
171
|
return Object.fromEntries(
|
|
100
|
-
Object.entries(obj).map(([key, v]) => [key,
|
|
172
|
+
Object.entries(obj).map(([key, v]) => [key, _expandDyns(v, config, resolveMatchers)]),
|
|
101
173
|
) as any;
|
|
102
174
|
} else if (typeof value === "function") {
|
|
103
175
|
if ("isDyn" in value && value.isDyn) {
|
|
104
|
-
const dynValue = value as any as DynValue
|
|
105
|
-
return dynValue(config
|
|
176
|
+
const dynValue = value as any as DynValue;
|
|
177
|
+
return dynValue(config) as any;
|
|
106
178
|
} else {
|
|
107
179
|
throw new Error("Invalid function value");
|
|
108
180
|
}
|