Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .chronus/changes/feat-spector-matchers-2026-2-12-14-17-25.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
changeKind: feature
packages:
- "@typespec/spec-api"
- "@typespec/spector"
- "@typespec/http-specs"
---

Add matcher framework for flexible value comparison in scenarios. `match.dateTime()` enables semantic datetime comparison that handles precision and timezone differences across languages.
165 changes: 29 additions & 136 deletions packages/http-specs/specs/encode/datetime/mockapi.ts
Original file line number Diff line number Diff line change
@@ -1,274 +1,167 @@
import {
CollectionFormat,
json,
MockRequest,
passOnSuccess,
ScenarioMockApi,
validateValueFormat,
ValidationError,
} from "@typespec/spec-api";
import { json, match, MockRequest, passOnSuccess, ScenarioMockApi } from "@typespec/spec-api";

export const Scenarios: Record<string, ScenarioMockApi> = {};

function createQueryServerTests(
uri: string,
paramData: any,
format: "rfc7231" | "rfc3339" | undefined,
value: any,
collectionFormat?: CollectionFormat,
format: "rfc7231" | "rfc3339" | undefined,
) {
return passOnSuccess({
uri,
method: "get",
request: {
query: paramData,
query: { value: format ? match.dateTime[format](value) : value },
},
response: {
status: 204,
},
handler(req: MockRequest) {
if (format) {
validateValueFormat(req.query["value"] as string, format);
if (Date.parse(req.query["value"] as string) !== Date.parse(value)) {
throw new ValidationError(`Wrong value`, value, req.query["value"]);
}
} else {
req.expect.containsQueryParam("value", value, collectionFormat);
}
return {
status: 204,
};
},
kind: "MockApiDefinition",
});
}
Scenarios.Encode_Datetime_Query_default = createQueryServerTests(
"/encode/datetime/query/default",
{
value: "2022-08-26T18:38:00.000Z",
},
"rfc3339",
"2022-08-26T18:38:00.000Z",
"rfc3339",
);
Scenarios.Encode_Datetime_Query_rfc3339 = createQueryServerTests(
"/encode/datetime/query/rfc3339",
{
value: "2022-08-26T18:38:00.000Z",
},
"rfc3339",
"2022-08-26T18:38:00.000Z",
"rfc3339",
);
Scenarios.Encode_Datetime_Query_rfc7231 = createQueryServerTests(
"/encode/datetime/query/rfc7231",
{
value: "Fri, 26 Aug 2022 14:38:00 GMT",
},
"rfc7231",
"Fri, 26 Aug 2022 14:38:00 GMT",
"rfc7231",
);
Scenarios.Encode_Datetime_Query_unixTimestamp = createQueryServerTests(
"/encode/datetime/query/unix-timestamp",
{
value: 1686566864,
},
undefined,
"1686566864",
undefined,
);
Scenarios.Encode_Datetime_Query_unixTimestampArray = createQueryServerTests(
"/encode/datetime/query/unix-timestamp-array",
{
value: [1686566864, 1686734256].join(","),
},
[1686566864, 1686734256].join(","),
undefined,
["1686566864", "1686734256"],
"csv",
);
function createPropertyServerTests(
uri: string,
data: any,
format: "rfc7231" | "rfc3339" | undefined,
value: any,
format: "rfc7231" | "rfc3339" | undefined,
) {
const matcherBody = { value: format ? match.dateTime[format](value) : value };
return passOnSuccess({
uri,
method: "post",
request: {
body: json(data),
body: json(matcherBody),
},
response: {
status: 200,
},
handler: (req: MockRequest) => {
if (format) {
validateValueFormat(req.body["value"], format);
if (Date.parse(req.body["value"]) !== Date.parse(value)) {
throw new ValidationError(`Wrong value`, value, req.body["value"]);
}
} else {
req.expect.coercedBodyEquals({ value: value });
}
return {
status: 200,
body: json({ value: value }),
};
body: json(matcherBody),
},
kind: "MockApiDefinition",
});
}
Scenarios.Encode_Datetime_Property_default = createPropertyServerTests(
"/encode/datetime/property/default",
{
value: "2022-08-26T18:38:00.000Z",
},
"rfc3339",
"2022-08-26T18:38:00.000Z",
"rfc3339",
);
Scenarios.Encode_Datetime_Property_rfc3339 = createPropertyServerTests(
"/encode/datetime/property/rfc3339",
{
value: "2022-08-26T18:38:00.000Z",
},
"rfc3339",
"2022-08-26T18:38:00.000Z",
"rfc3339",
);
Scenarios.Encode_Datetime_Property_rfc7231 = createPropertyServerTests(
"/encode/datetime/property/rfc7231",
{
value: "Fri, 26 Aug 2022 14:38:00 GMT",
},
"rfc7231",
"Fri, 26 Aug 2022 14:38:00 GMT",
"rfc7231",
);
Scenarios.Encode_Datetime_Property_unixTimestamp = createPropertyServerTests(
"/encode/datetime/property/unix-timestamp",
{
value: 1686566864,
},
undefined,
1686566864,
undefined,
);
Scenarios.Encode_Datetime_Property_unixTimestampArray = createPropertyServerTests(
"/encode/datetime/property/unix-timestamp-array",
{
value: [1686566864, 1686734256],
},
undefined,
[1686566864, 1686734256],
undefined,
);
function createHeaderServerTests(
uri: string,
data: any,
format: "rfc7231" | "rfc3339" | undefined,
value: any,
format: "rfc7231" | "rfc3339" | undefined,
) {
const matcherHeaders = { value: format ? match.dateTime[format](value) : value };
return passOnSuccess({
uri,
method: "get",
request: {
headers: data,
headers: matcherHeaders,
},
response: {
status: 204,
},
handler(req: MockRequest) {
if (format) {
validateValueFormat(req.headers["value"], format);
if (Date.parse(req.headers["value"]) !== Date.parse(value)) {
throw new ValidationError(`Wrong value`, value, req.headers["value"]);
}
} else {
req.expect.containsHeader("value", value);
}
return {
status: 204,
};
},
kind: "MockApiDefinition",
});
}
Scenarios.Encode_Datetime_Header_default = createHeaderServerTests(
"/encode/datetime/header/default",
{
value: "Fri, 26 Aug 2022 14:38:00 GMT",
},
"rfc7231",
"Fri, 26 Aug 2022 14:38:00 GMT",
"rfc7231",
);
Scenarios.Encode_Datetime_Header_rfc3339 = createHeaderServerTests(
"/encode/datetime/header/rfc3339",
{
value: "2022-08-26T18:38:00.000Z",
},
"rfc3339",
"2022-08-26T18:38:00.000Z",
"rfc3339",
);
Scenarios.Encode_Datetime_Header_rfc7231 = createHeaderServerTests(
"/encode/datetime/header/rfc7231",
{
value: "Fri, 26 Aug 2022 14:38:00 GMT",
},
"rfc7231",
"Fri, 26 Aug 2022 14:38:00 GMT",
"rfc7231",
);
Scenarios.Encode_Datetime_Header_unixTimestamp = createHeaderServerTests(
"/encode/datetime/header/unix-timestamp",
{
value: 1686566864,
},
1686566864,
undefined,
"1686566864",
);
Scenarios.Encode_Datetime_Header_unixTimestampArray = createHeaderServerTests(
"/encode/datetime/header/unix-timestamp-array",
{
value: [1686566864, 1686734256].join(","),
},
[1686566864, 1686734256].join(","),
undefined,
"1686566864,1686734256",
);
function createResponseHeaderServerTests(uri: string, data: any, value: any) {
function createResponseHeaderServerTests(uri: string, value: any) {
return passOnSuccess({
uri,
method: "get",
request: {},
response: {
status: 204,
headers: data,
headers: { value },
},
handler: (req: MockRequest) => {
return {
status: 204,
headers: { value: value },
headers: { value },
};
},
kind: "MockApiDefinition",
});
}
Scenarios.Encode_Datetime_ResponseHeader_default = createResponseHeaderServerTests(
"/encode/datetime/responseheader/default",
{
value: "Fri, 26 Aug 2022 14:38:00 GMT",
},
"Fri, 26 Aug 2022 14:38:00 GMT",
);
Scenarios.Encode_Datetime_ResponseHeader_rfc3339 = createResponseHeaderServerTests(
"/encode/datetime/responseheader/rfc3339",
{
value: "2022-08-26T18:38:00.000Z",
},
"2022-08-26T18:38:00.000Z",
);
Scenarios.Encode_Datetime_ResponseHeader_rfc7231 = createResponseHeaderServerTests(
"/encode/datetime/responseheader/rfc7231",
{
value: "Fri, 26 Aug 2022 14:38:00 GMT",
},
"Fri, 26 Aug 2022 14:38:00 GMT",
);
Scenarios.Encode_Datetime_ResponseHeader_unixTimestamp = createResponseHeaderServerTests(
"/encode/datetime/responseheader/unix-timestamp",
{
value: "1686566864",
},
1686566864,
"1686566864",
);
7 changes: 4 additions & 3 deletions packages/spec-api/src/expectation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import deepEqual from "deep-equal";
import { matchValues } from "./matchers.js";
import {
validateBodyEmpty,
validateBodyEquals,
Expand Down Expand Up @@ -89,8 +89,9 @@ export class RequestExpectation {
* @param expected Expected value
*/
public deepEqual(actual: unknown, expected: unknown, message = "Values not deep equal"): void {
if (!deepEqual(actual, expected, { strict: true })) {
throw new ValidationError(message, expected, actual);
const result = matchValues(actual, expected);
if (!result.pass) {
throw new ValidationError(`${message}: ${result.message}`, expected, actual);
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/spec-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { match } from "./match.js";
export { isMatcher, matchValues, type MatchResult, type MockValueMatcher } from "./matchers.js";
export { MockRequest } from "./mock-request.js";
export {
BODY_EMPTY_ERROR_MESSAGE,
Expand Down
Loading
Loading