@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/expectation.d.ts +8 -5
  3. package/dist/expectation.d.ts.map +1 -1
  4. package/dist/expectation.js +12 -8
  5. package/dist/expectation.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/match-engine.d.ts +58 -0
  11. package/dist/match-engine.d.ts.map +1 -0
  12. package/dist/match-engine.js +156 -0
  13. package/dist/match-engine.js.map +1 -0
  14. package/dist/matchers/datetime.d.ts +8 -0
  15. package/dist/matchers/datetime.d.ts.map +1 -0
  16. package/dist/matchers/datetime.js +47 -0
  17. package/dist/matchers/datetime.js.map +1 -0
  18. package/dist/matchers/index.d.ts +39 -0
  19. package/dist/matchers/index.d.ts.map +1 -0
  20. package/dist/matchers/index.js +36 -0
  21. package/dist/matchers/index.js.map +1 -0
  22. package/dist/matchers/local-url.d.ts +3 -0
  23. package/dist/matchers/local-url.d.ts.map +1 -0
  24. package/dist/matchers/local-url.js +22 -0
  25. package/dist/matchers/local-url.js.map +1 -0
  26. package/dist/request-validations.d.ts +2 -2
  27. package/dist/request-validations.d.ts.map +1 -1
  28. package/dist/request-validations.js +54 -19
  29. package/dist/request-validations.js.map +1 -1
  30. package/dist/response-utils.d.ts +48 -11
  31. package/dist/response-utils.d.ts.map +1 -1
  32. package/dist/response-utils.js +70 -30
  33. package/dist/response-utils.js.map +1 -1
  34. package/dist/types.d.ts +2 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +12 -12
  37. package/src/expectation.ts +13 -9
  38. package/src/index.ts +10 -0
  39. package/src/match-engine.ts +221 -0
  40. package/src/matchers/datetime.ts +66 -0
  41. package/src/matchers/index.ts +48 -0
  42. package/src/matchers/local-url.ts +24 -0
  43. package/src/request-validations.ts +78 -27
  44. package/src/response-utils.ts +106 -34
  45. package/src/types.ts +2 -0
  46. package/temp/.tsbuildinfo +1 -1
  47. package/test/match-engine.test.ts +303 -0
  48. package/test/matchers/datetime.test.ts +232 -0
  49. package/test/matchers/local-url.test.ts +82 -0
  50. 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 { CollectionFormat, RequestExt } from "./types.js";
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
- if (!deepEqual(request.body, expectedBody, { strict: true })) {
41
- throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.body);
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 = (request: RequestExt, expectedBody: string): void => {
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, expectedBody, request.rawBody);
64
+ throw new ValidationError(BODY_EMPTY_ERROR_MESSAGE, expectedXml, request.rawBody);
48
65
  }
49
66
 
50
- expectedBody = `<?xml version='1.0' encoding='UTF-8'?>` + expectedBody;
51
-
52
- let actualParsedBody = "";
67
+ let actualParsed: unknown;
53
68
  parseString(request.rawBody, (err: Error | null, result: any): void => {
54
- if (err !== null) {
55
- throw err;
56
- }
57
- actualParsedBody = result;
69
+ if (err !== null) throw err;
70
+ actualParsed = result;
58
71
  });
59
72
 
60
- let expectedParsedBody = "";
61
- parseString(expectedBody, (err: Error | null, result: any): void => {
62
- if (err !== null) {
63
- throw err;
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
- if (!deepEqual(actualParsedBody, expectedParsedBody, { strict: true })) {
69
- throw new ValidationError(
70
- BODY_NOT_EQUAL_ERROR_MESSAGE,
71
- prettier.format(expectedBody),
72
- prettier.format(request.body),
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
- if (!deepEqual(coerceDate(request.body), expectedBody, { strict: true })) {
89
- throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.body);
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
 
@@ -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 string in a MockResponse body.
26
- * The XML declaration prefix will automatically be added to xmlString.
27
- * @content Object to return as XML.
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(xmlString: string): MockBody {
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: `<?xml version='1.0' encoding='UTF-8'?>` + xmlString,
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<T extends string[]> {
76
+ export interface DynValue extends Resolver {
48
77
  readonly isDyn: true;
49
- readonly keys: T;
50
- (dict: Record<T[number], string>): string;
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
- /** Specify that this value is dynamic and needs to be interpolated with the given keys */
66
- export function dyn<const T extends (keyof ResolverConfig)[]>(
67
- strings: readonly string[],
68
- ...keys: (DynItem<T[number]> | string)[]
69
- ): DynValue<T> {
70
- const dynKeys: T = [] as any;
71
- const template = (dict: Record<T[number], string>) => {
72
- const result = [strings[0]];
73
- keys.forEach((key, i) => {
74
- if (typeof key === "string") {
75
- result.push(key);
76
- } else {
77
- dynKeys.push(key.name);
78
- const value = (dict as any)[key.name];
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.join("");
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
- export function expandDyns<T>(value: T, config: ResolverConfig): T {
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) => expandDyns(v, config)) as any;
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, expandDyns(v, config)]),
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<string[]>;
105
- return dynValue(config as any) as any;
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
  }