Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "@typespec/http-specs"
---

Add encode/duration lossy Spector scenarios verifying a duration whose value carries more precision than the target integer encoding (fractional seconds and sub-millisecond milliseconds) is serialized as an integer
54 changes: 54 additions & 0 deletions packages/http-specs/spec-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,60 @@ Expected header `duration: P40D`
Test iso8601 encode for a duration array header.
Expected header `duration: [P40D,P50D]`

### Encode_Duration_Lossy_headerInt32Milliseconds

- Endpoint: `get /encode/duration/lossy/header/int32-milliseconds`

Test int32 milliseconds encode for a duration header whose value has a sub-millisecond fractional component.
The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected header is `duration: 36250` or `duration: 36251`.

### Encode_Duration_Lossy_headerInt32Seconds

- Endpoint: `get /encode/duration/lossy/header/int32-seconds`

Test int32 seconds encode for a duration header whose value has a fractional (sub-second) component.
The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected header is `duration: 36` or `duration: 37`.

### Encode_Duration_Lossy_propertyInt32Milliseconds

- Endpoint: `post /encode/duration/lossy/property/int32-milliseconds`

Test int32 milliseconds encode for a duration property whose value has a sub-millisecond fractional component.
The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected request body `value` is `36250` or `36251`.

### Encode_Duration_Lossy_propertyInt32Seconds

- Endpoint: `post /encode/duration/lossy/property/int32-seconds`

Test int32 seconds encode for a duration property whose value has a fractional (sub-second) component.
The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected request body `value` is `36` or `37`.

### Encode_Duration_Lossy_queryInt32Milliseconds

- Endpoint: `get /encode/duration/lossy/query/int32-milliseconds`

Test int32 milliseconds encode for a duration query parameter whose value has a sub-millisecond fractional component.
The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected query parameter is `input=36250` or `input=36251`.

### Encode_Duration_Lossy_queryInt32Seconds

- Endpoint: `get /encode/duration/lossy/query/int32-seconds`

Test int32 seconds encode for a duration query parameter whose value has a fractional (sub-second) component.
The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected query parameter is `input=36` or `input=37`.

### Encode_Duration_Property_default

- Endpoint: `post /encode/duration/property/default`
Expand Down
102 changes: 102 additions & 0 deletions packages/http-specs/specs/encode/duration/main.tsp
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from discussion on the issue I don't think we can add a test for this as floor vs round is not something TypeSpec should have an opinion here and it more of an emitter implementation detail

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test can still validate that the proper number type is serialized (e.g. integer rather than double/decimal).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the scenarios to validate only the number type. The mock handlers reject a floating point value and assert an integer is serialized, without checking an exact value. I also reworded the scenarioDocs to state that floor vs. round is an emitter implementation detail and that the test only validates that an integer is sent (e.g. 35 or 36). Done in 4b13fac.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot actually we can just use input data such that the rounding vs truncating decision wouldn't matter - e.g. 36.25.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by that logic should we have a test to make sure you don't somehow send a string instead of a int as well? It just feels like this is one case you caught here but there is an infinite number of potential failures here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an emitter could also very well here decide that is this an error (crash) so can never get this test to work

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by that logic should we have a test to make sure you don't somehow send a string instead of a int as well?

These type violations are already covered by every round trip test. This case is special because the source type carries more precision than the target encoding. This is a lossy encode scenario - not arbitrary type mismatch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an emitter could also very well here decide that is this an error (crash) so can never get this test to work

The contract is "encode this duration as int32 seconds," so crashing is non-conformant behavior.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hhm alright fair enough, though do we need the same for milliseconds encoding?

Original file line number Diff line number Diff line change
Expand Up @@ -729,3 +729,105 @@ namespace Header {
duration: Int32MillisecondsDuration[],
): NoContentResponse;
}

/**
* Lossy encode scenarios.
*
* These scenarios cover the case where the source `duration` carries more precision than the
* target encoding can represent (e.g. a sub-second value encoded as integer seconds). The client
* must still serialize the value using the target number type (an integer), discarding the extra
* precision, rather than emitting a floating point number. This is distinct from arbitrary type
* mismatches, which are already covered by the round-trip scenarios above.
*/
@route("/lossy")
namespace Lossy {
model Int32SecondsDurationProperty {
@encode(DurationKnownEncoding.seconds, int32)
value: duration;
}

model Int32MillisecondsDurationProperty {
@encode(DurationKnownEncoding.milliseconds, int32)
value: duration;
}

@route("/query/int32-seconds")
@scenario
@scenarioDoc("""
Test int32 seconds encode for a duration query parameter whose value has a fractional (sub-second) component.
The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected query parameter is `input=36` or `input=37`.
""")
op queryInt32Seconds(
@query
@encode(DurationKnownEncoding.seconds, int32)
input: duration,
): NoContentResponse;

@route("/query/int32-milliseconds")
@scenario
@scenarioDoc("""
Test int32 milliseconds encode for a duration query parameter whose value has a sub-millisecond fractional component.
The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected query parameter is `input=36250` or `input=36251`.
""")
op queryInt32Milliseconds(
@query
@encode(DurationKnownEncoding.milliseconds, int32)
input: duration,
): NoContentResponse;

@route("/property/int32-seconds")
@scenario
@scenarioDoc("""
Test int32 seconds encode for a duration property whose value has a fractional (sub-second) component.
The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected request body `value` is `36` or `37`.
""")
@post
op propertyInt32Seconds(@body body: Int32SecondsDurationProperty): Int32SecondsDurationProperty;

@route("/property/int32-milliseconds")
@scenario
@scenarioDoc("""
Test int32 milliseconds encode for a duration property whose value has a sub-millisecond fractional component.
The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected request body `value` is `36250` or `36251`.
""")
@post
op propertyInt32Milliseconds(
@body body: Int32MillisecondsDurationProperty,
): Int32MillisecondsDurationProperty;

@route("/header/int32-seconds")
@scenario
@scenarioDoc("""
Test int32 seconds encode for a duration header whose value has a fractional (sub-second) component.
The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected header is `duration: 36` or `duration: 37`.
""")
op headerInt32Seconds(
@header
@encode(DurationKnownEncoding.seconds, int32)
duration: duration,
): NoContentResponse;

@route("/header/int32-milliseconds")
@scenario
@scenarioDoc("""
Test int32 milliseconds encode for a duration header whose value has a sub-millisecond fractional component.
The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#.
The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision.
Because emitters may floor, round, or ceil when discarding that precision, the expected header is `duration: 36250` or `duration: 36251`.
""")
op headerInt32Milliseconds(
@header
@encode(DurationKnownEncoding.milliseconds, int32)
duration: duration,
): NoContentResponse;
}
144 changes: 144 additions & 0 deletions packages/http-specs/specs/encode/duration/mockapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,45 @@ function createBodyServerTests(uri: string, data: any, value: any) {
kind: "MockApiDefinition",
});
}

// Validates that a duration whose value carries more precision than the target encoding (a lossy
// encode) is serialized as an integer. The allowed values cover floor, round and ceil so the test
// does not take a position on an emitter's rounding mode while still rejecting floating point output.
function createLossyBodyServerTests(uri: string, allowed: number[]) {
return passOnSuccess({
uri,
method: "post",
request: {
body: json({ value: allowed[0] }),
},
response: {
status: 200,
body: json({ value: allowed[0] }),
},
handler: (req: MockRequest) => {
const value = req.body?.value;
if (typeof value !== "number" || !Number.isInteger(value)) {
throw new ValidationError(
`Expected body property "value" to be serialized as an integer but got ${value}`,
"an integer",
value,
);
}
if (!allowed.includes(value)) {
throw new ValidationError(
`Expected body property "value" to be one of ${allowed.join(", ")} but got ${value}`,
allowed.join(" | "),
value,
);
}
return {
status: 200,
body: json({ value }),
};
},
kind: "MockApiDefinition",
});
}
Scenarios.Encode_Duration_Property_default = createBodyServerTests(
"/encode/duration/property/default",
{
Expand Down Expand Up @@ -175,6 +214,45 @@ function createQueryFloatServerTests(uri: string, paramData: any, value: number)
kind: "MockApiDefinition",
});
}

// Validates that a duration whose value carries more precision than the target encoding (a lossy
// encode) is serialized as an integer. The allowed values cover floor, round and ceil so the test
// does not take a position on an emitter's rounding mode while still rejecting floating point output.
function createLossyQueryServerTests(uri: string, allowed: number[]) {
return passOnSuccess({
uri,
method: "get",
request: {
query: {
input: allowed[0],
},
},
response: {
status: 204,
},
handler: (req: MockRequest) => {
const actual = req.query["input"] as string;
if (!/^[-+]?\d+$/.test(actual)) {
throw new ValidationError(
`Expected query param input to be serialized as an integer but got ${actual}`,
"an integer",
actual,
);
}
if (!allowed.map(String).includes(actual)) {
throw new ValidationError(
`Expected query param input to be one of ${allowed.join(", ")} but got ${actual}`,
allowed.join(" | "),
actual,
);
}
return {
status: 204,
};
},
kind: "MockApiDefinition",
});
}
Scenarios.Encode_Duration_Query_default = createQueryServerTests(
"/encode/duration/query/default",
{
Expand Down Expand Up @@ -321,6 +399,45 @@ function createHeaderFloatServerTests(uri: string, value: number) {
});
}

// Validates that a duration whose value carries more precision than the target encoding (a lossy
// encode) is serialized as an integer. The allowed values cover floor, round and ceil so the test
// does not take a position on an emitter's rounding mode while still rejecting floating point output.
function createLossyHeaderServerTests(uri: string, allowed: number[]) {
return passOnSuccess({
uri,
method: "get",
request: {
headers: {
duration: String(allowed[0]),
},
},
response: {
status: 204,
},
handler: (req: MockRequest) => {
const actual = req.headers["duration"];
if (!/^[-+]?\d+$/.test(actual)) {
throw new ValidationError(
`Expected header duration to be serialized as an integer but got ${actual}`,
"an integer",
actual,
);
}
if (!allowed.map(String).includes(actual)) {
throw new ValidationError(
`Expected header duration to be one of ${allowed.join(", ")} but got ${actual}`,
allowed.join(" | "),
actual,
);
}
return {
status: 204,
};
},
kind: "MockApiDefinition",
});
}

Scenarios.Encode_Duration_Header_default = createHeaderServerTests(
"/encode/duration/header/default",
{
Expand Down Expand Up @@ -408,3 +525,30 @@ Scenarios.Encode_Duration_Header_floatMillisecondsLargerUnit = createHeaderFloat
"/encode/duration/header/float-milliseconds-larger-unit",
210000,
);

// Lossy encode scenarios: the source duration carries more precision than the target integer
// encoding, so floor/round/ceil are all acceptable results (e.g. 36.25s -> 36 or 37).
Scenarios.Encode_Duration_Lossy_queryInt32Seconds = createLossyQueryServerTests(
"/encode/duration/lossy/query/int32-seconds",
[36, 37],
);
Scenarios.Encode_Duration_Lossy_queryInt32Milliseconds = createLossyQueryServerTests(
"/encode/duration/lossy/query/int32-milliseconds",
[36250, 36251],
);
Scenarios.Encode_Duration_Lossy_propertyInt32Seconds = createLossyBodyServerTests(
"/encode/duration/lossy/property/int32-seconds",
[36, 37],
);
Scenarios.Encode_Duration_Lossy_propertyInt32Milliseconds = createLossyBodyServerTests(
"/encode/duration/lossy/property/int32-milliseconds",
[36250, 36251],
);
Scenarios.Encode_Duration_Lossy_headerInt32Seconds = createLossyHeaderServerTests(
"/encode/duration/lossy/header/int32-seconds",
[36, 37],
);
Scenarios.Encode_Duration_Lossy_headerInt32Milliseconds = createLossyHeaderServerTests(
"/encode/duration/lossy/header/int32-milliseconds",
[36250, 36251],
);
Loading