Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for encoding numeric types as string #4020

Merged
Merged
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
10 changes: 10 additions & 0 deletions .chronus/changes/encode-numeric-as-string-2024-6-25-20-9-18.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/compiler"
- "@typespec/openapi3"
- "@typespec/xml"
---

Add support for encoding numeric types as string
38 changes: 38 additions & 0 deletions docs/libraries/http/encoding.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,41 @@ model User {
</td>
</tr>
</table>

## Numeric types ( `int64`, `decimal128`, `float64`, etc.)
Copy link
Member

@MaryGao MaryGao Jul 31, 2024

Choose a reason for hiding this comment

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

Do we have a plan to add linter rule to warn that some safe numeric is marked as encode string e.g int16, or safeint?

Copy link
Member Author

Choose a reason for hiding this comment

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

good point filed na issue for an azure linter Azure/typespec-azure#1277


By default numeric types are serialized as a JSON number. However for large types like `int64` or `decimal128` that cannot be represented in certain languages like JavaScript it is recommended to serialize them as string over the wire.

<table>
<tr><td>TypeSpec</td><td>Example payload</td></tr>
<tr>
<td>

```tsp
model User {
id: int64; // JSON number

@encode(string)
idAsString: int64; // JSON string

viaSalar: decimalString;
}

@encode(string)
scalar decimalString extends decimal128;
```

</td>
<td>

```json
{
"id": 1234567890123456789012345678901234567890,
"idAsString": "1234567890123456789012345678901234567890",
"viaSalar": "1.3"
}
```

</td>
</tr>
</table>
13 changes: 11 additions & 2 deletions docs/standard-library/built-in-decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ model Pet {}

Specify how to encode the target type.
```typespec
@encode(encoding: string | EnumMember, encodedAs?: Scalar)
@encode(encodingOrEncodeAs: Scalar | valueof string | EnumMember, encodedAs?: Scalar)
```

#### Target
Expand All @@ -107,7 +107,7 @@ Specify how to encode the target type.
#### Parameters
| Name | Type | Description |
|------|------|-------------|
| encoding | `string \| EnumMember` | Known name of an encoding. |
| 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). |
| encodedAs | `Scalar` | What target type is this being encoded as. Default to string. |

#### Examples
Expand All @@ -127,6 +127,15 @@ scalar myDateTime extends offsetDateTime;
scalar myDateTime extends unixTimestamp;
```

##### encode numeric type to string


```tsp
model Pet {
@encode(string) id: int64;
}
```


### `@encodedName` {#@encodedName}

Expand Down
12 changes: 10 additions & 2 deletions packages/compiler/generated-defs/TypeSpec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
DecoratorContext,
Enum,
EnumValue,
Interface,
Model,
ModelProperty,
Expand All @@ -26,7 +27,7 @@ export interface OperationExample {
/**
* Specify how to encode the target type.
*
* @param encoding Known name of an encoding.
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric 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 @@ -40,11 +41,18 @@ export interface OperationExample {
* @encode("unixTimestamp", int32)
* scalar myDateTime extends unixTimestamp;
* ```
* @example encode numeric type to string
*
* ```tsp
* model Pet {
* @encode(string) id: int64;
* }
* ```
*/
export type EncodeDecorator = (
context: DecoratorContext,
target: Scalar | ModelProperty,
encoding: Type,
encodingOrEncodeAs: Scalar | string | EnumValue,
encodedAs?: Scalar
) => void;

Expand Down
12 changes: 10 additions & 2 deletions packages/compiler/lib/std/decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ enum BytesKnownEncoding {

/**
* Specify how to encode the target type.
* @param encoding Known name of an encoding.
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric 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 @@ -493,10 +493,18 @@ enum BytesKnownEncoding {
* @encode("unixTimestamp", int32)
* scalar myDateTime extends unixTimestamp;
* ```
*
* @example encode numeric type to string
*
* ```tsp
* model Pet {
* @encode(string) id: int64;
* }
* ```
*/
extern dec encode(
target: Scalar | ModelProperty,
encoding: string | EnumMember,
encodingOrEncodeAs: (valueof string | EnumMember) | Scalar,
encodedAs?: Scalar
);

Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,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.`,
},
},

Expand Down
69 changes: 44 additions & 25 deletions packages/compiler/src/lib/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
getTypeName,
ignoreDiagnostics,
isArrayModelType,
isValue,
reportDeprecated,
validateDecoratorUniqueOnNode,
} from "../core/index.js";
Expand Down Expand Up @@ -90,6 +91,7 @@ import {
DiagnosticTarget,
Enum,
EnumMember,
EnumValue,
Interface,
Model,
ModelProperty,
Expand Down Expand Up @@ -682,47 +684,62 @@ export function isSecret(program: Program, target: Type): boolean | undefined {
export type DateTimeKnownEncoding = "rfc3339" | "rfc7231" | "unixTimestamp";
export type DurationKnownEncoding = "ISO8601" | "seconds";
export type BytesKnownEncoding = "base64" | "base64url";

export interface EncodeData {
encoding: DateTimeKnownEncoding | DurationKnownEncoding | BytesKnownEncoding | string;
/**
* 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.
*/
encoding?: DateTimeKnownEncoding | DurationKnownEncoding | BytesKnownEncoding | string;
type: Scalar;
}

const encodeKey = createStateSymbol("encode");
export const $encode: EncodeDecorator = (
context: DecoratorContext,
target: Scalar | ModelProperty,
encoding: string | Type,
encoding: string | EnumValue | Scalar,
encodeAs?: Scalar
) => {
validateDecoratorUniqueOnNode(context, target, $encode);

const encodingStr = computeEncoding(encoding);
if (encodingStr === undefined) {
const encodeData = computeEncoding(context.program, encoding, encodeAs);
if (encodeData === undefined) {
return;
}
const encodeData: EncodeData = {
encoding: encodingStr,
type: encodeAs ?? context.program.checker.getStdType("string"),
};
const targetType = getPropertyType(target);
validateEncodeData(context, targetType, encodeData);
context.program.stateMap(encodeKey).set(target, encodeData);
};
function computeEncoding(encoding: string | Type) {
if (typeof encoding === "string") {
return encoding;
}
switch (encoding.kind) {
case "String":
return encoding.value;
case "EnumMember":
if (encoding.value && typeof encoding.value === "string") {
return encoding.value;
} else {
return getTypeName(encoding);
}
default:

function computeEncoding(
program: Program,
encodingOrEncodeAs: string | EnumValue | Scalar,
encodeAs: Scalar | undefined
): EncodeData | undefined {
const strType = program.checker.getStdType("string");
const resolvedEncodeAs = encodeAs ?? strType;
if (typeof encodingOrEncodeAs === "string") {
return { encoding: encodingOrEncodeAs, type: resolvedEncodeAs };
} else if (isValue(encodingOrEncodeAs)) {
const member = encodingOrEncodeAs.value;
if (member.value && typeof member.value === "string") {
return { encoding: member.value, type: resolvedEncodeAs };
} else {
return { encoding: getTypeName(member), type: resolvedEncodeAs };
}
} else {
const originalType = encodingOrEncodeAs.projectionBase ?? encodingOrEncodeAs;
if (originalType !== strType) {
reportDiagnostic(program, {
code: "invalid-encode",
messageId: "firstArg",
target: encodingOrEncodeAs,
});
return undefined;
}

return { type: encodingOrEncodeAs };
}
}

Expand All @@ -742,7 +759,7 @@ function validateEncodeData(context: DecoratorContext, target: Type, encodeData:
code: "invalid-encode",
messageId: "wrongType",
format: {
encoding: encodeData.encoding,
encoding: encodeData.encoding ?? "string",
type: getTypeName(target),
expected: validTargets.join(", "),
},
Expand All @@ -763,11 +780,11 @@ function validateEncodeData(context: DecoratorContext, target: Type, encodeData:
const typeName = getTypeName(encodeData.type.projectionBase ?? encodeData.type);
reportDiagnostic(context.program, {
code: "invalid-encode",
messageId: ["unixTimestamp", "seconds"].includes(encodeData.encoding)
messageId: ["unixTimestamp", "seconds"].includes(encodeData.encoding ?? "string")
? "wrongNumericEncodingType"
: "wrongEncodingType",
format: {
encoding: encodeData.encoding,
encoding: encodeData.encoding!,
type: getTypeName(target),
expected: validEncodeTypes.join(", "),
actual: typeName,
Expand All @@ -790,6 +807,8 @@ function validateEncodeData(context: DecoratorContext, target: Type, encodeData:
return check(["bytes"], ["string"]);
case "base64url":
return check(["bytes"], ["string"]);
case undefined:
return check(["numeric"], ["string"]);
}
}

Expand Down
27 changes: 27 additions & 0 deletions packages/compiler/test/decorators/decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,19 @@ describe("compiler: built-in decorators", () => {
strictEqual(encodeData.type.name, encodeAs ?? "string");
});
});

it(`@encode(string) on numeric scalar`, async () => {
const { s } = (await runner.compile(`
@encode(string)
@test
scalar s extends int64;
`)) as { s: Scalar };

const encodeData = getEncode(runner.program, s);
ok(encodeData);
strictEqual(encodeData.encoding, undefined);
strictEqual(encodeData.type.name, "string");
});
});
describe("invalid", () => {
invalidCases.forEach(([target, encoding, encodeAs, expectedCode, expectedMessage]) => {
Expand All @@ -750,6 +763,20 @@ describe("compiler: built-in decorators", () => {
});
});
});

it(`@encode(string) on non-numeric scalar`, async () => {
const diagnostics = await runner.diagnose(`
@encode(string)
@test
scalar s extends utcDateTime;
`);

expectDiagnostics(diagnostics, {
code: "invalid-encode",
severity: "error",
message: "Encoding 'string' cannot be used on type 's'. Expected: numeric.",
});
});
});
});
});
Expand Down
57 changes: 57 additions & 0 deletions packages/openapi3/src/encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { type ModelProperty, Program, type Scalar, getEncode } from "@typespec/compiler";
import type { ResolvedOpenAPI3EmitterOptions } from "./openapi.js";
import { getSchemaForStdScalars } from "./std-scalar-schemas.js";
import type { OpenAPI3Schema } from "./types.js";

export function applyEncoding(
program: Program,
typespecType: Scalar | ModelProperty,
target: OpenAPI3Schema,
options: ResolvedOpenAPI3EmitterOptions
): OpenAPI3Schema {
const encodeData = getEncode(program, typespecType);
if (encodeData) {
const newTarget = { ...target };
const newType = getSchemaForStdScalars(encodeData.type as any, options);
newTarget.type = newType.type;
// If the target already has a format it takes priority. (e.g. int32)
newTarget.format = mergeFormatAndEncoding(
newTarget.format,
encodeData.encoding,
newType.format
);
return newTarget;
}
return target;
}

function mergeFormatAndEncoding(
format: string | undefined,
encoding: string | undefined,
encodeAsFormat: string | undefined
): string | undefined {
switch (format) {
case undefined:
return encodeAsFormat ?? encoding ?? format;
case "date-time":
switch (encoding) {
case "rfc3339":
return "date-time";
case "unixTimestamp":
return "unixtime";
case "rfc7231":
return "http-date";
default:
return encoding;
}
case "duration":
switch (encoding) {
case "ISO8601":
return "duration";
default:
return encodeAsFormat ?? encoding;
}
default:
return encodeAsFormat ?? encoding ?? format;
}
}
Loading
Loading