Add support for encoding numeric types as string (#4020)

fix #3856
This commit is contained in:
Timothee Guerin 2024-07-30 13:58:57 -07:00 коммит произвёл GitHub
Родитель 44ffaf7e18
Коммит 44fc030f8d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
14 изменённых файлов: 313 добавлений и 171 удалений

Просмотреть файл

@ -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

Просмотреть файл

@ -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>

Просмотреть файл

@ -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
@ -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
@ -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}

Просмотреть файл

@ -1,6 +1,7 @@
import type {
DecoratorContext,
Enum,
EnumValue,
Interface,
Model,
ModelProperty,
@ -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
*
@ -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;

Просмотреть файл

@ -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
@ -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
);

Просмотреть файл

@ -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.`,
},
},

Просмотреть файл

@ -55,6 +55,7 @@ import {
getTypeName,
ignoreDiagnostics,
isArrayModelType,
isValue,
reportDeprecated,
validateDecoratorUniqueOnNode,
} from "../core/index.js";
@ -90,6 +91,7 @@ import {
DiagnosticTarget,
Enum,
EnumMember,
EnumValue,
Interface,
Model,
ModelProperty,
@ -682,8 +684,13 @@ 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;
}
@ -691,38 +698,48 @@ 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 };
}
}
@ -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(", "),
},
@ -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,
@ -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"]);
}
}

Просмотреть файл

@ -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]) => {
@ -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.",
});
});
});
});
});

Просмотреть файл

@ -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;
}
}

Просмотреть файл

@ -10,7 +10,6 @@ import {
getAllTags,
getAnyExtensionFromPath,
getDoc,
getEncode,
getFormat,
getKnownValues,
getMaxItems,
@ -44,7 +43,6 @@ import {
ProjectionApplication,
projectProgram,
resolvePath,
Scalar,
serializeValueAsJson,
Service,
Type,
@ -97,6 +95,7 @@ import {
import { buildVersionProjections, VersionProjections } from "@typespec/versioning";
import { stringify } from "yaml";
import { getRef } from "./decorators.js";
import { applyEncoding } from "./encoding.js";
import { createDiagnostic, FileType, OpenAPI3EmitterOptions } from "./lib.js";
import { getDefaultValue, isBytesKeptRaw, OpenAPI3SchemaEmitter } from "./schema-emitter.js";
import {
@ -1476,7 +1475,12 @@ function createOAPIEmitter(
if (!typeSchema) {
return undefined;
}
const schema = applyEncoding(param, applyIntrinsicDecorators(param, typeSchema));
const schema = applyEncoding(
program,
param,
applyIntrinsicDecorators(param, typeSchema),
options
);
if (param.defaultValue) {
schema.default = getDefaultValue(program, param.defaultValue);
}
@ -1746,61 +1750,6 @@ function createOAPIEmitter(
return newTarget;
}
function applyEncoding(
typespecType: Scalar | ModelProperty,
target: OpenAPI3Schema
): OpenAPI3Schema {
const encodeData = getEncode(program, typespecType);
if (encodeData) {
const newTarget = { ...target };
const newType = callSchemaEmitter(
encodeData.type,
Visibility.Read,
false,
"application/json"
) as OpenAPI3Schema;
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,
encodeAsFormat: string | undefined
): string {
switch (format) {
case undefined:
return encodeAsFormat ?? encoding;
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;
}
}
function applyExternalDocs(typespecType: Type, target: Record<string, unknown>) {
const externalDocs = getExternalDocs(program, typespecType);
if (externalDocs) {

Просмотреть файл

@ -73,8 +73,10 @@ import {
shouldInline,
} from "@typespec/openapi";
import { getOneOf, getRef } from "./decorators.js";
import { applyEncoding } from "./encoding.js";
import { OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js";
import { ResolvedOpenAPI3EmitterOptions } from "./openapi.js";
import { getSchemaForStdScalars } from "./std-scalar-schemas.js";
import { OpenAPI3Discriminator, OpenAPI3Schema, OpenAPI3SchemaProperty } from "./types.js";
import { VisibilityUsageTracker } from "./visibility-usage.js";
@ -718,66 +720,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter<
}
#getSchemaForStdScalars(scalar: Scalar & { name: IntrinsicScalarName }): OpenAPI3Schema {
switch (scalar.name) {
case "bytes":
return { type: "string", format: "byte" };
case "numeric":
return { type: "number" };
case "integer":
return { type: "integer" };
case "int8":
return { type: "integer", format: "int8" };
case "int16":
return { type: "integer", format: "int16" };
case "int32":
return { type: "integer", format: "int32" };
case "int64":
return { type: "integer", format: "int64" };
case "safeint":
switch (this.#options.safeintStrategy) {
case "double-int":
return { type: "integer", format: "double-int" };
case "int64":
default:
return { type: "integer", format: "int64" };
}
case "uint8":
return { type: "integer", format: "uint8" };
case "uint16":
return { type: "integer", format: "uint16" };
case "uint32":
return { type: "integer", format: "uint32" };
case "uint64":
return { type: "integer", format: "uint64" };
case "float":
return { type: "number" };
case "float64":
return { type: "number", format: "double" };
case "float32":
return { type: "number", format: "float" };
case "decimal":
return { type: "number", format: "decimal" };
case "decimal128":
return { type: "number", format: "decimal128" };
case "string":
return { type: "string" };
case "boolean":
return { type: "boolean" };
case "plainDate":
return { type: "string", format: "date" };
case "utcDateTime":
case "offsetDateTime":
return { type: "string", format: "date-time" };
case "plainTime":
return { type: "string", format: "time" };
case "duration":
return { type: "string", format: "duration" };
case "url":
return { type: "string", format: "uri" };
default:
const _assertNever: never = scalar.name;
return {};
}
return getSchemaForStdScalars(scalar, this.#options);
}
#applySchemaExamples(
@ -904,31 +847,16 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter<
typespecType: Scalar | ModelProperty,
target: OpenAPI3Schema | Placeholder<OpenAPI3Schema>
): OpenAPI3Schema {
const encodeData = getEncode(this.emitter.getProgram(), typespecType);
if (encodeData) {
const newTarget = new ObjectBuilder(target);
const newType = this.#getSchemaForStdScalars(encodeData.type as any);
newTarget.type = newType.type;
// If the target already has a format it takes priority. (e.g. int32)
newTarget.format = this.#mergeFormatAndEncoding(
newTarget.format,
encodeData.encoding,
newType.format
);
return newTarget;
}
const result = new ObjectBuilder(target);
return result;
return applyEncoding(this.emitter.getProgram(), typespecType, target as any, this.#options);
}
#mergeFormatAndEncoding(
format: string | undefined,
encoding: string,
encoding: string | undefined,
encodeAsFormat: string | undefined
): string {
): string | undefined {
switch (format) {
case undefined:
return encodeAsFormat ?? encoding;
return encodeAsFormat ?? encoding ?? format;
case "date-time":
switch (encoding) {
case "rfc3339":

Просмотреть файл

@ -0,0 +1,69 @@
import type { IntrinsicScalarName, Scalar } from "@typespec/compiler";
import type { ResolvedOpenAPI3EmitterOptions } from "./openapi.js";
import type { OpenAPI3Schema } from "./types.js";
export function getSchemaForStdScalars(
scalar: Scalar & { name: IntrinsicScalarName },
options: ResolvedOpenAPI3EmitterOptions
): OpenAPI3Schema {
switch (scalar.name) {
case "bytes":
return { type: "string", format: "byte" };
case "numeric":
return { type: "number" };
case "integer":
return { type: "integer" };
case "int8":
return { type: "integer", format: "int8" };
case "int16":
return { type: "integer", format: "int16" };
case "int32":
return { type: "integer", format: "int32" };
case "int64":
return { type: "integer", format: "int64" };
case "safeint":
switch (options.safeintStrategy) {
case "double-int":
return { type: "integer", format: "double-int" };
case "int64":
default:
return { type: "integer", format: "int64" };
}
case "uint8":
return { type: "integer", format: "uint8" };
case "uint16":
return { type: "integer", format: "uint16" };
case "uint32":
return { type: "integer", format: "uint32" };
case "uint64":
return { type: "integer", format: "uint64" };
case "float":
return { type: "number" };
case "float64":
return { type: "number", format: "double" };
case "float32":
return { type: "number", format: "float" };
case "decimal":
return { type: "number", format: "decimal" };
case "decimal128":
return { type: "number", format: "decimal128" };
case "string":
return { type: "string" };
case "boolean":
return { type: "boolean" };
case "plainDate":
return { type: "string", format: "date" };
case "utcDateTime":
case "offsetDateTime":
return { type: "string", format: "date-time" };
case "plainTime":
return { type: "string", format: "time" };
case "duration":
return { type: "string", format: "duration" };
case "url":
return { type: "string", format: "uri" };
default:
const _assertNever: never = scalar.name;
return {};
}
}

Просмотреть файл

@ -259,11 +259,16 @@ describe("openapi3: primitives", () => {
async function testEncode(
scalar: string,
expectedOpenApi: OpenAPI3Schema,
encoding?: string,
encoding?: string | null,
encodeAs?: string
) {
const encodeAsParam = encodeAs ? `, ${encodeAs}` : "";
const encodeDecorator = encoding ? `@encode("${encoding}"${encodeAsParam})` : "";
const encodeDecorator =
encoding === null
? `@encode(${encodeAs})`
: encoding !== undefined
? `@encode("${encoding}"${encodeAsParam})`
: "";
const res1 = await oapiForModel("s", `${encodeDecorator} scalar s extends ${scalar};`);
deepStrictEqual(res1.schemas.s, expectedOpenApi);
const res2 = await oapiForModel("Test", `model Test {${encodeDecorator} prop: ${scalar}};`);
@ -309,5 +314,19 @@ describe("openapi3: primitives", () => {
it("set format to base64url when encoding bytes as base64url", () =>
testEncode("bytes", { type: "string", format: "base64url" }, "base64url"));
});
describe("int64", () => {
it("set type: integer and format to 'int64' by default", () =>
testEncode("int64", { type: "integer", format: "int64" }));
it("set type: string and format to int64 when @encode(string)", () =>
testEncode("int64", { type: "string", format: "int64" }, null, "string"));
});
describe("decimal128", () => {
it("set type: integer and format to 'int64' by default", () =>
testEncode("decimal128", { type: "number", format: "decimal128" }));
it("set type: string and format to int64 when @encode(string)", () =>
testEncode("decimal128", { type: "string", format: "decimal128" }, null, "string"));
});
});
});

Просмотреть файл

@ -21,6 +21,6 @@ export type XmlEncoding =
| "TypeSpec.Xml.Encoding.xmlBase64Binary";
export interface XmlEncodeData extends EncodeData {
encoding: XmlEncoding | EncodeData["encoding"];
encoding?: XmlEncoding | EncodeData["encoding"];
type: Scalar;
}