Skip to content

Commit

Permalink
Add support for encoding numeric types as string (#4020)
Browse files Browse the repository at this point in the history
fix #3856
  • Loading branch information
timotheeguerin committed Jul 30, 2024
1 parent 44ffaf7 commit 44fc030
Show file tree
Hide file tree
Showing 14 changed files with 313 additions and 171 deletions.
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.)

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 @@ -100,7 +100,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 @@ -110,7 +110,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 @@ -130,6 +130,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

0 comments on commit 44fc030

Please sign in to comment.