Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion packages/compiler/generated-defs/TypeSpec.ts
Comment thread
timotheeguerin marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion packages/compiler/lib/std/decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
},
},

Expand Down
6 changes: 4 additions & 2 deletions packages/compiler/src/lib/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"]);
}
}

Expand Down
16 changes: 15 additions & 1 deletion packages/compiler/test/decorators/decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) => {
Expand Down Expand Up @@ -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.",
});
});
});
Expand Down
84 changes: 84 additions & 0 deletions packages/http-specs/spec-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
63 changes: 63 additions & 0 deletions packages/http-specs/specs/encode/boolean/main.tsp
Original file line number Diff line number Diff line change
@@ -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<StringValue extends string> {
@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;
}
}
43 changes: 43 additions & 0 deletions packages/http-specs/specs/encode/boolean/mockapi.ts
Comment thread
timotheeguerin marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { json, match, passOnSuccess, ScenarioMockApi } from "@typespec/spec-api";

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

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,
);
4 changes: 4 additions & 0 deletions packages/spec-api/src/matchers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { dateTimeMatcher } from "./datetime.js";
import { baseUrlMatcher } from "./local-url.js";
import { stringMatcher } from "./string.js";

export {
createMatcher,
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions packages/spec-api/src/matchers/string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.js";

export const stringMatcher = {
caseInsensitive(value: string): MockValueMatcher<string> {
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}")`;
},
});
},
};
Loading
Loading