diff --git a/.chronus/changes/copilot-encode-string-boolean-changelog-2026-6-5-17-25-0.md b/.chronus/changes/copilot-encode-string-boolean-changelog-2026-6-5-17-25-0.md new file mode 100644 index 00000000000..316436bef04 --- /dev/null +++ b/.chronus/changes/copilot-encode-string-boolean-changelog-2026-6-5-17-25-0.md @@ -0,0 +1,9 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" + - "@typespec/spec-api" + - "@typespec/http-specs" +--- + +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. diff --git a/packages/compiler/generated-defs/TypeSpec.ts b/packages/compiler/generated-defs/TypeSpec.ts index 2f066713ac6..0fa7f3838e6 100644 --- a/packages/compiler/generated-defs/TypeSpec.ts +++ b/packages/compiler/generated-defs/TypeSpec.ts @@ -77,7 +77,7 @@ export type MediaTypeHintDecorator = ( /** * Specify how to encode the target type. * - * @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric types to encode as string). + * @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric and boolean types to encode as string). * @param encodedAs What target type is this being encoded as. Default to string. * @example offsetDateTime encoded with rfc7231 * @@ -98,6 +98,15 @@ export type MediaTypeHintDecorator = ( * @encode(string) id: int64; * } * ``` + * @example encode boolean type to string + * + * `@encode(string)` on boolean uses case-insensitive `true` / `false` values. + * + * ```tsp + * model FeatureFlags { + * @encode(string) enabled: boolean; + * } + * ``` */ export type EncodeDecorator = ( context: DecoratorContext, diff --git a/packages/compiler/lib/std/decorators.tsp b/packages/compiler/lib/std/decorators.tsp index 0c35a010e26..fed9f685237 100644 --- a/packages/compiler/lib/std/decorators.tsp +++ b/packages/compiler/lib/std/decorators.tsp @@ -566,7 +566,7 @@ enum ArrayEncoding { /** * Specify how to encode the target type. - * @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric types to encode as string). + * @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric and boolean types to encode as string). * @param encodedAs What target type is this being encoded as. Default to string. * * @example offsetDateTime encoded with rfc7231 @@ -590,6 +590,16 @@ enum ArrayEncoding { * @encode(string) id: int64; * } * ``` + * + * @example encode boolean type to string + * + * `@encode(string)` on boolean uses case-insensitive `true` / `false` values. + * + * ```tsp + * model FeatureFlags { + * @encode(string) enabled: boolean; + * } + * ``` */ extern dec encode( target: Scalar | ModelProperty, diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 9008e0f6be6..11d8ea2c29c 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -942,7 +942,7 @@ const diagnostics = { wrongType: paramMessage`Encoding '${"encoding"}' cannot be used on type '${"type"}'. Expected: ${"expected"}.`, wrongEncodingType: paramMessage`Encoding '${"encoding"}' on type '${"type"}' is expected to be serialized as '${"expected"}' but got '${"actual"}'.`, 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)'`, - firstArg: `First argument of "@encode" must be the encoding name or the string type when encoding numeric types.`, + firstArg: `First argument of "@encode" must be the encoding name or the string type when encoding numeric or boolean types.`, }, }, diff --git a/packages/compiler/src/lib/decorators.ts b/packages/compiler/src/lib/decorators.ts index 3290a9dec74..18bb45e365a 100644 --- a/packages/compiler/src/lib/decorators.ts +++ b/packages/compiler/src/lib/decorators.ts @@ -873,7 +873,9 @@ export type BytesKnownEncoding = "base64" | "base64url"; export interface EncodeData { /** * Known encoding key. - * 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. + * Can be undefined when `@encode(string)` is used on a numeric or boolean type. + * For numeric this means using the base10 decimal representation of the number. + * For boolean this means using `true` or `false`. */ encoding?: DateTimeKnownEncoding | DurationKnownEncoding | BytesKnownEncoding | string; type: Scalar; @@ -995,7 +997,7 @@ function validateEncodeData(context: DecoratorContext, target: Type, encodeData: case "base64url": return check(["bytes"], ["string"]); case undefined: - return check(["numeric"], ["string"]); + return check(["numeric", "boolean"], ["string"]); } } diff --git a/packages/compiler/test/decorators/decorators.test.ts b/packages/compiler/test/decorators/decorators.test.ts index 5a1ec399d76..1c46022acb0 100644 --- a/packages/compiler/test/decorators/decorators.test.ts +++ b/packages/compiler/test/decorators/decorators.test.ts @@ -665,6 +665,20 @@ describe("compiler: built-in decorators", () => { strictEqual(encodeData.encoding, undefined); strictEqual(encodeData.type.name, "string"); }); + + it(`@encode(string) on boolean model property`, async () => { + const { prop, program } = await Tester.compile(t.code` + model Foo { + @encode(string) + ${t.modelProperty("prop")}: boolean; + } + `); + + const encodeData = getEncode(program, prop); + ok(encodeData); + strictEqual(encodeData.encoding, undefined); + strictEqual(encodeData.type.name, "string"); + }); }); describe("invalid", () => { invalidCases.forEach(([target, encoding, encodeAs, expectedCode, expectedMessage]) => { @@ -693,7 +707,7 @@ describe("compiler: built-in decorators", () => { expectDiagnostics(diagnostics, { code: "invalid-encode", severity: "error", - message: "Encoding 'string' cannot be used on type 's'. Expected: numeric.", + message: "Encoding 'string' cannot be used on type 's'. Expected: numeric, boolean.", }); }); }); diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index c623a91cc95..17e173563c2 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -369,6 +369,90 @@ Expected response body: } ``` +### Encode_Boolean_Property_falseLower + +- Endpoint: `post /encode/boolean/property/false-lower` + +Test operation with request and response model containing a property of boolean type with string encode. +Expected request body: + +```json +{ + "value": "false" +} +``` + +Expected response body: + +```json +{ + "value": "false" +} +``` + +### Encode_Boolean_Property_falseMixed + +- Endpoint: `post /encode/boolean/property/false-mixed` + +Test operation with request and response model containing a property of boolean type with string encode. +Expected request body: + +```json +{ + "value": "FaLsE" +} +``` + +Expected response body: + +```json +{ + "value": "FaLsE" +} +``` + +### Encode_Boolean_Property_trueLower + +- Endpoint: `post /encode/boolean/property/true-lower` + +Test operation with request and response model containing a property of boolean type with string encode. +Expected request body: + +```json +{ + "value": "true" +} +``` + +Expected response body: + +```json +{ + "value": "true" +} +``` + +### Encode_Boolean_Property_trueUpper + +- Endpoint: `post /encode/boolean/property/true-upper` + +Test operation with request and response model containing a property of boolean type with string encode. +Expected request body: + +```json +{ + "value": "TRUE" +} +``` + +Expected response body: + +```json +{ + "value": "TRUE" +} +``` + ### Encode_Bytes_Header_base64 - Endpoint: `get /encode/bytes/header/base64` diff --git a/packages/http-specs/specs/encode/boolean/main.tsp b/packages/http-specs/specs/encode/boolean/main.tsp new file mode 100644 index 00000000000..9d4eba49d28 --- /dev/null +++ b/packages/http-specs/specs/encode/boolean/main.tsp @@ -0,0 +1,63 @@ +import "@typespec/http"; +import "@typespec/spector"; + +using Http; +using Spector; + +/** Test for encode decorator on boolean. */ +@scenarioService("/encode/boolean") +namespace Encode.Boolean; + +@route("/property") +namespace Property { + model BoolAsStringProperty { + @encode(string) + value: boolean; + } + + alias SendBoolTrueLower = SendBoolAsString<"true">; + + @route("/true-lower") + op trueLower is SendBoolTrueLower.sendBoolAsString; + + alias SendBoolFalseLower = SendBoolAsString<"false">; + + @route("/false-lower") + op falseLower is SendBoolFalseLower.sendBoolAsString; + + alias SendBoolTrueUpper = SendBoolAsString<"TRUE">; + + @route("/true-upper") + op trueUpper is SendBoolTrueUpper.sendBoolAsString; + + alias SendBoolFalseMixed = SendBoolAsString<"FaLsE">; + + @route("/false-mixed") + op falseMixed is SendBoolFalseMixed.sendBoolAsString; + + interface SendBoolAsString { + @scenario + @scenarioDoc( + """ + Test operation with request and response model containing a property of boolean type with string encode. + Expected request body: + ```json + { + "value": "{value}" + } + ``` + Expected response body: + ```json + { + "value": "{value}" + } + ``` + """, + { + value: StringValue, + } + ) + @post + sendBoolAsString(@body value: BoolAsStringProperty): BoolAsStringProperty; + } +} diff --git a/packages/http-specs/specs/encode/boolean/mockapi.ts b/packages/http-specs/specs/encode/boolean/mockapi.ts new file mode 100644 index 00000000000..590645176d3 --- /dev/null +++ b/packages/http-specs/specs/encode/boolean/mockapi.ts @@ -0,0 +1,43 @@ +import { json, match, passOnSuccess, ScenarioMockApi } from "@typespec/spec-api"; + +export const Scenarios: Record = {}; + +function createBodyServerTests(uri: string, responseValue: string, requestValue: boolean) { + return passOnSuccess({ + uri, + method: "post", + request: { + body: json({ + value: match.string.caseInsensitive(String(requestValue)), + }), + }, + response: { + status: 200, + body: json({ + value: responseValue, + }), + }, + kind: "MockApiDefinition", + }); +} + +Scenarios.Encode_Boolean_Property_trueLower = createBodyServerTests( + "/encode/boolean/property/true-lower", + "true", + true, +); +Scenarios.Encode_Boolean_Property_falseLower = createBodyServerTests( + "/encode/boolean/property/false-lower", + "false", + false, +); +Scenarios.Encode_Boolean_Property_trueUpper = createBodyServerTests( + "/encode/boolean/property/true-upper", + "TRUE", + true, +); +Scenarios.Encode_Boolean_Property_falseMixed = createBodyServerTests( + "/encode/boolean/property/false-mixed", + "FaLsE", + false, +); diff --git a/packages/spec-api/src/matchers/index.ts b/packages/spec-api/src/matchers/index.ts index 054a5bb5c3e..c28fbf7d986 100644 --- a/packages/spec-api/src/matchers/index.ts +++ b/packages/spec-api/src/matchers/index.ts @@ -1,5 +1,6 @@ import { dateTimeMatcher } from "./datetime.js"; import { baseUrlMatcher } from "./local-url.js"; +import { stringMatcher } from "./string.js"; export { createMatcher, @@ -18,6 +19,9 @@ export { dateTimeMatcher } from "./datetime.js"; * Namespace for built-in matchers. */ export const match = { + /** Matchers for comparing string values. */ + string: stringMatcher, + /** * Matchers for comparing datetime values semantically. * Validates that the actual value is in the correct format and represents diff --git a/packages/spec-api/src/matchers/string.ts b/packages/spec-api/src/matchers/string.ts new file mode 100644 index 00000000000..a2b2908e79c --- /dev/null +++ b/packages/spec-api/src/matchers/string.ts @@ -0,0 +1,24 @@ +import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.js"; + +export const stringMatcher = { + caseInsensitive(value: string): MockValueMatcher { + const normalized = value.toLowerCase(); + return createMatcher({ + check(actual: unknown) { + if (typeof actual !== "string") { + return err(`expected a string but got ${typeof actual}`); + } + if (actual.toLowerCase() !== normalized) { + return err(`expected case-insensitive "${value}" but got "${actual}"`); + } + return ok(); + }, + serialize() { + return value; + }, + toString() { + return `match.string.caseInsensitive("${value}")`; + }, + }); + }, +}; diff --git a/packages/spec-api/test/matchers/string.test.ts b/packages/spec-api/test/matchers/string.test.ts new file mode 100644 index 00000000000..2d48d48a5da --- /dev/null +++ b/packages/spec-api/test/matchers/string.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { isMatcher } from "../../src/match-engine.js"; +import { match } from "../../src/matchers/index.js"; +import { expectFail, expectPass } from "./matcher-test-utils.js"; + +describe("match.string.caseInsensitive()", () => { + it("should be identified by isMatcher", () => { + expect(isMatcher(match.string.caseInsensitive("true"))).toBe(true); + }); + + describe("check()", () => { + it("should match lower-case true", () => { + expectPass(match.string.caseInsensitive("true").check("true")); + }); + + it("should match upper-case true", () => { + expectPass(match.string.caseInsensitive("true").check("TRUE")); + }); + + it("should match mixed-case false", () => { + expectPass(match.string.caseInsensitive("false").check("FaLsE")); + }); + + it("should reject a different string value", () => { + expectFail( + match.string.caseInsensitive("true").check("false"), + 'expected case-insensitive "true"', + ); + }); + + it("should reject non-string values", () => { + expectFail( + match.string.caseInsensitive("true").check(true), + "expected a string but got boolean", + ); + expectFail( + match.string.caseInsensitive("false").check(null), + "expected a string but got object", + ); + }); + }); + + describe("serialize()", () => { + it("should serialize the original value", () => { + expect(match.string.caseInsensitive("TrUe").serialize()).toBe("TrUe"); + expect(match.string.caseInsensitive("FALSE").serialize()).toBe("FALSE"); + }); + }); + + describe("toString()", () => { + it("should return a descriptive string", () => { + expect(match.string.caseInsensitive("true").toString()).toBe( + 'match.string.caseInsensitive("true")', + ); + }); + }); +}); diff --git a/website/src/content/docs/docs/standard-library/built-in-decorators.md b/website/src/content/docs/docs/standard-library/built-in-decorators.md index e38804062e6..d5827c149b9 100644 --- a/website/src/content/docs/docs/standard-library/built-in-decorators.md +++ b/website/src/content/docs/docs/standard-library/built-in-decorators.md @@ -192,7 +192,7 @@ Specify how to encode the target type. #### Parameters | Name | Type | Description | |------|------|-------------| -| encodingOrEncodeAs | `Scalar` \| `valueof string \| EnumMember` | Known name of an encoding or a scalar type to encode as(Only for numeric types to encode as string). | +| encodingOrEncodeAs | `Scalar` \| `valueof string \| EnumMember` | Known name of an encoding or a scalar type to encode as(Only for numeric and boolean types to encode as string). | | encodedAs | `Scalar` | What target type is this being encoded as. Default to string. | #### Examples @@ -221,6 +221,16 @@ model Pet { } ``` +##### encode boolean type to string + +`@encode(string)` on boolean uses case-insensitive `true` / `false` values. + +```tsp +model FeatureFlags { + @encode(string) enabled: boolean; +} +``` + ### `@encodedName` {#@encodedName} @@ -1548,4 +1558,3 @@ model DogRead { ...Dog } ``` -