Skip to content

Commit 85ff828

Browse files
Allow @encode(string) on boolean, define case-insensitive true|false string semantics, and add Spector coverage + generic string matcher support (#10875)
`@encode(string)` was restricted to numeric targets, blocking APIs that model booleans as strings (for example `"true"` / `"false"` in payloads or query values). This change extends support to boolean targets, clarifies the docs contract, and adds http-specs Spector coverage. - **Compiler validation** - Relaxed `@encode` validation so the implicit string encoding path (`@encode(string)`) is valid for `boolean` in addition to `numeric`. - Updated related diagnostic wording to reflect numeric-or-boolean applicability. - **Standard library/docs contract** - Updated `@encode` decorator docs to state boolean string encoding uses case-insensitive `true` / `false` values. - Added a boolean-focused `@encode(string)` example. - Removed parsing-specific wording from the boolean string semantics text. - **Coverage** - Added a focused compiler decorator test that validates `@encode(string)` on a boolean model property. - Updated the non-supported-type expectation to match the new allowed target set. - Added/updated Spector coverage in `packages/http-specs/specs/encode/boolean` with matching mock API and regenerated `packages/http-specs/spec-summary.md`. - Boolean property scenarios now cover multiple casings/values via: - `/encode/boolean/property/true-lower` - `/encode/boolean/property/false-lower` - `/encode/boolean/property/true-upper` - `/encode/boolean/property/false-mixed` - Updated boolean mock API request validation to use a standard matcher so request boolean-string values are accepted case-insensitively (`true`/`false`), while still returning varied response casings for coverage. - **Spector standard matcher support** - Added a built-in generic string matcher in `@typespec/spec-api`: `match.string.caseInsensitive(string)`. - Replaced the encode/boolean scenario’s local custom matcher with this shared matcher (`match.string.caseInsensitive(String(requestValue))`). - Added matcher unit tests under `packages/spec-api/test/matchers/string.test.ts`. ```tsp model FeatureFlags { @encode(string) enabled: boolean; } ``` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> Co-authored-by: Timothee Guerin <tiguerin@microsoft.com>
1 parent 1e1d1b3 commit 85ff828

13 files changed

Lines changed: 344 additions & 7 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@typespec/compiler"
5+
- "@typespec/spec-api"
6+
- "@typespec/http-specs"
7+
---
8+
9+
Allow `@encode(string)` on boolean targets, define case-insensitive `true`/`false` string semantics, and add shared case-insensitive string matcher support with encode/boolean Spector coverage.
10+
11+
```tsp
12+
model FeatureFlags {
13+
@encode(string)
14+
enabled: boolean;
15+
}
16+
```

packages/compiler/generated-defs/TypeSpec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export type MediaTypeHintDecorator = (
7777
/**
7878
* Specify how to encode the target type.
7979
*
80-
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric types to encode as string).
80+
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric and boolean types to encode as string).
8181
* @param encodedAs What target type is this being encoded as. Default to string.
8282
* @example offsetDateTime encoded with rfc7231
8383
*
@@ -98,6 +98,15 @@ export type MediaTypeHintDecorator = (
9898
* @encode(string) id: int64;
9999
* }
100100
* ```
101+
* @example encode boolean type to string
102+
*
103+
* `@encode(string)` on boolean uses case-insensitive `true` / `false` values.
104+
*
105+
* ```tsp
106+
* model FeatureFlags {
107+
* @encode(string) enabled: boolean;
108+
* }
109+
* ```
101110
*/
102111
export type EncodeDecorator = (
103112
context: DecoratorContext,

packages/compiler/lib/std/decorators.tsp

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ enum ArrayEncoding {
566566

567567
/**
568568
* Specify how to encode the target type.
569-
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric types to encode as string).
569+
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric and boolean types to encode as string).
570570
* @param encodedAs What target type is this being encoded as. Default to string.
571571
*
572572
* @example offsetDateTime encoded with rfc7231
@@ -590,6 +590,16 @@ enum ArrayEncoding {
590590
* @encode(string) id: int64;
591591
* }
592592
* ```
593+
*
594+
* @example encode boolean type to string
595+
*
596+
* `@encode(string)` on boolean uses case-insensitive `true` / `false` values.
597+
*
598+
* ```tsp
599+
* model FeatureFlags {
600+
* @encode(string) enabled: boolean;
601+
* }
602+
* ```
593603
*/
594604
extern dec encode(
595605
target: Scalar | ModelProperty,

packages/compiler/src/core/messages.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -951,7 +951,7 @@ const diagnostics = {
951951
wrongType: paramMessage`Encoding '${"encoding"}' cannot be used on type '${"type"}'. Expected: ${"expected"}.`,
952952
wrongEncodingType: paramMessage`Encoding '${"encoding"}' on type '${"type"}' is expected to be serialized as '${"expected"}' but got '${"actual"}'.`,
953953
wrongNumericEncodingType: paramMessage`Encoding '${"encoding"}' on type '${"type"}' is expected to be serialized as '${"expected"}' but got '${"actual"}'. Set '@encode' 2nd parameter to be of type ${"expected"}. e.g. '@encode("${"encoding"}", int32)'`,
954-
firstArg: `First argument of "@encode" must be the encoding name or the string type when encoding numeric types.`,
954+
firstArg: `First argument of "@encode" must be the encoding name or the string type when encoding numeric or boolean types.`,
955955
},
956956
},
957957

packages/compiler/src/lib/decorators.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,9 @@ export type BytesKnownEncoding = "base64" | "base64url";
873873
export interface EncodeData {
874874
/**
875875
* Known encoding key.
876-
* Can be undefined when `@encode(string)` is used on a numeric type. In that case it just means using the base10 decimal representation of the number.
876+
* Can be undefined when `@encode(string)` is used on a numeric or boolean type.
877+
* For numeric this means using the base10 decimal representation of the number.
878+
* For boolean this means using `true` or `false`.
877879
*/
878880
encoding?: DateTimeKnownEncoding | DurationKnownEncoding | BytesKnownEncoding | string;
879881
type: Scalar;
@@ -995,7 +997,7 @@ function validateEncodeData(context: DecoratorContext, target: Type, encodeData:
995997
case "base64url":
996998
return check(["bytes"], ["string"]);
997999
case undefined:
998-
return check(["numeric"], ["string"]);
1000+
return check(["numeric", "boolean"], ["string"]);
9991001
}
10001002
}
10011003

packages/compiler/test/decorators/decorators.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,20 @@ describe("compiler: built-in decorators", () => {
665665
strictEqual(encodeData.encoding, undefined);
666666
strictEqual(encodeData.type.name, "string");
667667
});
668+
669+
it(`@encode(string) on boolean model property`, async () => {
670+
const { prop, program } = await Tester.compile(t.code`
671+
model Foo {
672+
@encode(string)
673+
${t.modelProperty("prop")}: boolean;
674+
}
675+
`);
676+
677+
const encodeData = getEncode(program, prop);
678+
ok(encodeData);
679+
strictEqual(encodeData.encoding, undefined);
680+
strictEqual(encodeData.type.name, "string");
681+
});
668682
});
669683
describe("invalid", () => {
670684
invalidCases.forEach(([target, encoding, encodeAs, expectedCode, expectedMessage]) => {
@@ -693,7 +707,7 @@ describe("compiler: built-in decorators", () => {
693707
expectDiagnostics(diagnostics, {
694708
code: "invalid-encode",
695709
severity: "error",
696-
message: "Encoding 'string' cannot be used on type 's'. Expected: numeric.",
710+
message: "Encoding 'string' cannot be used on type 's'. Expected: numeric, boolean.",
697711
});
698712
});
699713
});

packages/http-specs/spec-summary.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,90 @@ Expected response body:
369369
}
370370
```
371371

372+
### Encode_Boolean_Property_falseLower
373+
374+
- Endpoint: `post /encode/boolean/property/false-lower`
375+
376+
Test operation with request and response model containing a property of boolean type with string encode.
377+
Expected request body:
378+
379+
```json
380+
{
381+
"value": "false"
382+
}
383+
```
384+
385+
Expected response body:
386+
387+
```json
388+
{
389+
"value": "false"
390+
}
391+
```
392+
393+
### Encode_Boolean_Property_falseMixed
394+
395+
- Endpoint: `post /encode/boolean/property/false-mixed`
396+
397+
Test operation with request and response model containing a property of boolean type with string encode.
398+
Expected request body:
399+
400+
```json
401+
{
402+
"value": "FaLsE"
403+
}
404+
```
405+
406+
Expected response body:
407+
408+
```json
409+
{
410+
"value": "FaLsE"
411+
}
412+
```
413+
414+
### Encode_Boolean_Property_trueLower
415+
416+
- Endpoint: `post /encode/boolean/property/true-lower`
417+
418+
Test operation with request and response model containing a property of boolean type with string encode.
419+
Expected request body:
420+
421+
```json
422+
{
423+
"value": "true"
424+
}
425+
```
426+
427+
Expected response body:
428+
429+
```json
430+
{
431+
"value": "true"
432+
}
433+
```
434+
435+
### Encode_Boolean_Property_trueUpper
436+
437+
- Endpoint: `post /encode/boolean/property/true-upper`
438+
439+
Test operation with request and response model containing a property of boolean type with string encode.
440+
Expected request body:
441+
442+
```json
443+
{
444+
"value": "TRUE"
445+
}
446+
```
447+
448+
Expected response body:
449+
450+
```json
451+
{
452+
"value": "TRUE"
453+
}
454+
```
455+
372456
### Encode_Bytes_Header_base64
373457

374458
- Endpoint: `get /encode/bytes/header/base64`
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import "@typespec/http";
2+
import "@typespec/spector";
3+
4+
using Http;
5+
using Spector;
6+
7+
/** Test for encode decorator on boolean. */
8+
@scenarioService("/encode/boolean")
9+
namespace Encode.Boolean;
10+
11+
@route("/property")
12+
namespace Property {
13+
model BoolAsStringProperty {
14+
@encode(string)
15+
value: boolean;
16+
}
17+
18+
alias SendBoolTrueLower = SendBoolAsString<"true">;
19+
20+
@route("/true-lower")
21+
op trueLower is SendBoolTrueLower.sendBoolAsString;
22+
23+
alias SendBoolFalseLower = SendBoolAsString<"false">;
24+
25+
@route("/false-lower")
26+
op falseLower is SendBoolFalseLower.sendBoolAsString;
27+
28+
alias SendBoolTrueUpper = SendBoolAsString<"TRUE">;
29+
30+
@route("/true-upper")
31+
op trueUpper is SendBoolTrueUpper.sendBoolAsString;
32+
33+
alias SendBoolFalseMixed = SendBoolAsString<"FaLsE">;
34+
35+
@route("/false-mixed")
36+
op falseMixed is SendBoolFalseMixed.sendBoolAsString;
37+
38+
interface SendBoolAsString<StringValue extends string> {
39+
@scenario
40+
@scenarioDoc(
41+
"""
42+
Test operation with request and response model containing a property of boolean type with string encode.
43+
Expected request body:
44+
```json
45+
{
46+
"value": "{value}"
47+
}
48+
```
49+
Expected response body:
50+
```json
51+
{
52+
"value": "{value}"
53+
}
54+
```
55+
""",
56+
{
57+
value: StringValue,
58+
}
59+
)
60+
@post
61+
sendBoolAsString(@body value: BoolAsStringProperty): BoolAsStringProperty;
62+
}
63+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { json, match, passOnSuccess, ScenarioMockApi } from "@typespec/spec-api";
2+
3+
export const Scenarios: Record<string, ScenarioMockApi> = {};
4+
5+
function createBodyServerTests(uri: string, responseValue: string, requestValue: boolean) {
6+
return passOnSuccess({
7+
uri,
8+
method: "post",
9+
request: {
10+
body: json({
11+
value: match.string.caseInsensitive(String(requestValue)),
12+
}),
13+
},
14+
response: {
15+
status: 200,
16+
body: json({
17+
value: responseValue,
18+
}),
19+
},
20+
kind: "MockApiDefinition",
21+
});
22+
}
23+
24+
Scenarios.Encode_Boolean_Property_trueLower = createBodyServerTests(
25+
"/encode/boolean/property/true-lower",
26+
"true",
27+
true,
28+
);
29+
Scenarios.Encode_Boolean_Property_falseLower = createBodyServerTests(
30+
"/encode/boolean/property/false-lower",
31+
"false",
32+
false,
33+
);
34+
Scenarios.Encode_Boolean_Property_trueUpper = createBodyServerTests(
35+
"/encode/boolean/property/true-upper",
36+
"TRUE",
37+
true,
38+
);
39+
Scenarios.Encode_Boolean_Property_falseMixed = createBodyServerTests(
40+
"/encode/boolean/property/false-mixed",
41+
"FaLsE",
42+
false,
43+
);

packages/spec-api/src/matchers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { dateTimeMatcher } from "./datetime.js";
22
import { baseUrlMatcher } from "./local-url.js";
3+
import { stringMatcher } from "./string.js";
34

45
export {
56
createMatcher,
@@ -18,6 +19,9 @@ export { dateTimeMatcher } from "./datetime.js";
1819
* Namespace for built-in matchers.
1920
*/
2021
export const match = {
22+
/** Matchers for comparing string values. */
23+
string: stringMatcher,
24+
2125
/**
2226
* Matchers for comparing datetime values semantically.
2327
* Validates that the actual value is in the correct format and represents

0 commit comments

Comments
 (0)