diff --git a/common/changes/@typespec/compiler/feature-encode_2023-05-03-22-31.json b/common/changes/@typespec/compiler/feature-encode_2023-05-03-22-31.json new file mode 100644 index 00000000000..f802eea9cb0 --- /dev/null +++ b/common/changes/@typespec/compiler/feature-encode_2023-05-03-22-31.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@typespec/compiler", + "comment": "**Added** `@encode` decorator used to specify encoding of types", + "type": "none" + } + ], + "packageName": "@typespec/compiler" +} \ No newline at end of file diff --git a/common/changes/@typespec/html-program-viewer/feature-encode_2023-05-04-15-44.json b/common/changes/@typespec/html-program-viewer/feature-encode_2023-05-04-15-44.json new file mode 100644 index 00000000000..e6e63870949 --- /dev/null +++ b/common/changes/@typespec/html-program-viewer/feature-encode_2023-05-04-15-44.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@typespec/html-program-viewer", + "comment": "Fix issue with showing enum members", + "type": "none" + } + ], + "packageName": "@typespec/html-program-viewer" +} \ No newline at end of file diff --git a/common/changes/@typespec/openapi3/feature-encode_2023-05-03-23-12.json b/common/changes/@typespec/openapi3/feature-encode_2023-05-03-23-12.json new file mode 100644 index 00000000000..cadeac9d9a1 --- /dev/null +++ b/common/changes/@typespec/openapi3/feature-encode_2023-05-03-23-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@typespec/openapi3", + "comment": "**Added** support for `@encode` decorator", + "type": "none" + } + ], + "packageName": "@typespec/openapi3" +} \ No newline at end of file diff --git a/docs/standard-library/built-in-decorators.md b/docs/standard-library/built-in-decorators.md index 26758262a6b..3633a37e57f 100644 --- a/docs/standard-library/built-in-decorators.md +++ b/docs/standard-library/built-in-decorators.md @@ -61,6 +61,25 @@ dec doc(target: unknown, doc: string, formatArgs?: object) | formatArgs | `model object` | Record with key value pair that can be interpolated in the doc. | +### `@encode` {#@encode} + +Specify how to encode the target type. + +```typespec +dec encode(target: Scalar | ModelProperty, encoding: string | EnumMember, encodedAs?: string | numeric) +``` + +#### Target + +`union Scalar | ModelProperty` + +#### Parameters +| Name | Type | Description | +|------|------|-------------| +| encoding | `union string \| EnumMember` | Known name of an encoding. | +| encodedAs | `union string \| numeric` | What target type is this being encoded as. Default to string. | + + ### `@error` {#@error} Specify that this model is an error type. Operations return error types when the operation has failed. diff --git a/packages/compiler/core/checker.ts b/packages/compiler/core/checker.ts index 2128c122736..22abe4f536d 100644 --- a/packages/compiler/core/checker.ts +++ b/packages/compiler/core/checker.ts @@ -94,6 +94,8 @@ import { ReturnRecord, Scalar, ScalarStatementNode, + StdTypeName, + StdTypes, StringLiteral, StringLiteralNode, Sym, @@ -243,12 +245,6 @@ const TypeInstantiationMap = class extends MultiKeyMap implements TypeInstantiationMap {}; -type StdTypeName = IntrinsicScalarName | "Array" | "Record" | "object"; -type StdTypes = { - // Models - Array: Model; - Record: Model; -} & Record; type ReflectionTypeName = keyof typeof ReflectionNameToKind; let currentSymbolId = 0; diff --git a/packages/compiler/core/decorator-utils.ts b/packages/compiler/core/decorator-utils.ts index 973a92819bf..62c1c666025 100644 --- a/packages/compiler/core/decorator-utils.ts +++ b/packages/compiler/core/decorator-utils.ts @@ -1,6 +1,6 @@ import { getPropertyType } from "../lib/decorators.js"; import { getTypeName } from "./helpers/type-name-utils.js"; -import { compilerAssert, Interface, Model, SyntaxKind } from "./index.js"; +import { compilerAssert, ignoreDiagnostics, Interface, Model, SyntaxKind } from "./index.js"; import { createDiagnostic, reportDiagnostic } from "./messages.js"; import { Program } from "./program.js"; import { @@ -61,6 +61,20 @@ export function validateDecoratorTarget( return true; } +export function isIntrinsicType( + program: Program, + type: Scalar, + kind: IntrinsicScalarName +): boolean { + return ignoreDiagnostics( + program.checker.isTypeAssignableTo( + type.projectionBase ?? type, + program.checker.getStdType(kind), + type + ) + ); +} + export function validateDecoratorTargetIntrinsic( context: DecoratorContext, target: Scalar | ModelProperty, diff --git a/packages/compiler/core/messages.ts b/packages/compiler/core/messages.ts index cb710f231cb..3f52dd0d90a 100644 --- a/packages/compiler/core/messages.ts +++ b/packages/compiler/core/messages.ts @@ -681,6 +681,15 @@ const diagnostics = { }, }, + "invalid-encode": { + severity: "error", + messages: { + default: "Invalid encoding", + wrongType: paramMessage`Encoding '${"encoding"}' cannot be used on type ${"type"}. Expected a ${"expected"}.`, + wrongEncodingType: paramMessage`Encoding '${"encoding"}' cannot be used on type ${"type"}. Expected a ${"expected"}.`, + }, + }, + /** * Service */ diff --git a/packages/compiler/core/types.ts b/packages/compiler/core/types.ts index 229ab9a5497..ff5add2e41a 100644 --- a/packages/compiler/core/types.ts +++ b/packages/compiler/core/types.ts @@ -91,6 +91,13 @@ export type Type = | ObjectType | Projection; +export type StdTypes = { + // Models + Array: Model; + Record: Model; +} & Record; +export type StdTypeName = keyof StdTypes; + export type TypeOrReturnRecord = Type | ReturnRecord; export interface ObjectType extends BaseType { diff --git a/packages/compiler/lib/decorators.ts b/packages/compiler/lib/decorators.ts index 93e0b0baf58..ac276784e72 100644 --- a/packages/compiler/lib/decorators.ts +++ b/packages/compiler/lib/decorators.ts @@ -1,11 +1,15 @@ import { + isIntrinsicType, validateDecoratorNotOnType, validateDecoratorTarget, validateDecoratorTargetIntrinsic, } from "../core/decorator-utils.js"; import { + StdTypeName, getDiscriminatedUnion, getTypeName, + ignoreDiagnostics, + reportDeprecated, validateDecoratorUniqueOnNode, } from "../core/index.js"; import { createDiagnostic, reportDiagnostic } from "../core/messages.js"; @@ -208,6 +212,14 @@ export function $format(context: DecoratorContext, target: Scalar | ModelPropert if (!validateDecoratorTargetIntrinsic(context, target, "@format", ["string", "bytes"])) { return; } + const targetType = getPropertyType(target); + if (targetType.kind === "Scalar" && isIntrinsicType(context.program, targetType, "bytes")) { + reportDeprecated( + context.program, + "Using `@format` on a bytes scalar is deprecated. Use `@encode` instead. https://github.com/microsoft/typespec/issues/1873", + target + ); + } context.program.stateMap(formatValuesKey).set(target, format); } @@ -227,7 +239,7 @@ export function $pattern( ) { validateDecoratorUniqueOnNode(context, target, $pattern); - if (!validateDecoratorTargetIntrinsic(context, target, "@pattern", "string")) { + if (!validateDecoratorTargetIntrinsic(context, target, "@pattern", ["string"])) { return; } @@ -250,7 +262,7 @@ export function $minLength( validateDecoratorUniqueOnNode(context, target, $minLength); if ( - !validateDecoratorTargetIntrinsic(context, target, "@minLength", "string") || + !validateDecoratorTargetIntrinsic(context, target, "@minLength", ["string"]) || !validateRange(context, minLength, getMaxLength(context.program, target)) ) { return; @@ -275,7 +287,7 @@ export function $maxLength( validateDecoratorUniqueOnNode(context, target, $maxLength); if ( - !validateDecoratorTargetIntrinsic(context, target, "@maxLength", "string") || + !validateDecoratorTargetIntrinsic(context, target, "@maxLength", ["string"]) || !validateRange(context, getMinLength(context.program, target), maxLength) ) { return; @@ -523,7 +535,7 @@ const secretTypesKey = createStateSymbol("secretTypes"); export function $secret(context: DecoratorContext, target: Scalar | ModelProperty) { validateDecoratorUniqueOnNode(context, target, $secret); - if (!validateDecoratorTargetIntrinsic(context, target, "@secret", "string")) { + if (!validateDecoratorTargetIntrinsic(context, target, "@secret", ["string"])) { return; } context.program.stateMap(secretTypesKey).set(target, true); @@ -533,6 +545,107 @@ export function isSecret(program: Program, target: Type): boolean | undefined { return program.stateMap(secretTypesKey).get(target); } +export type DateTimeKnownEncoding = "rfc3339" | "rfc7231" | "unixTimeStamp"; +export type DurationKnownEncoding = "ISO8601" | "seconds"; +export type BytesKnownEncoding = "base64" | "base64url"; +export interface EncodeData { + encoding: DateTimeKnownEncoding | DurationKnownEncoding | string; + type: Scalar; +} + +const encodeKey = createStateSymbol("encode"); +export function $encode( + context: DecoratorContext, + target: Scalar | ModelProperty, + encoding: string | EnumMember, + encodeAs?: Scalar +) { + validateDecoratorUniqueOnNode(context, target, $encode); + + const encodeData: EncodeData = { + encoding: typeof encoding === "string" ? encoding : encoding.value?.toString() ?? encoding.name, + type: encodeAs ?? context.program.checker.getStdType("string"), + }; + const targetType = getPropertyType(target); + if (targetType.kind !== "Scalar") { + return; + } + validateEncodeData(context, targetType, encodeData); + context.program.stateMap(encodeKey).set(target, encodeData); +} + +function validateEncodeData(context: DecoratorContext, target: Scalar, encodeData: EncodeData) { + function check(validTargets: StdTypeName[], validEncodeTypes: StdTypeName[]) { + const checker = context.program.checker; + const isTargetValid = validTargets.some((validTarget) => { + return ignoreDiagnostics( + checker.isTypeAssignableTo( + target.projectionBase ?? target, + checker.getStdType(validTarget), + target + ) + ); + }); + + if (!isTargetValid) { + reportDiagnostic(context.program, { + code: "invalid-encode", + messageId: "wrongType", + format: { + encoding: encodeData.encoding, + type: getTypeName(target), + expected: validTargets.join(", "), + }, + target: context.decoratorTarget, + }); + } + const isEncodingTypeValid = validEncodeTypes.some((validEncoding) => { + return ignoreDiagnostics( + checker.isTypeAssignableTo( + encodeData.type.projectionBase ?? encodeData.type, + checker.getStdType(validEncoding), + target + ) + ); + }); + + if (!isEncodingTypeValid) { + reportDiagnostic(context.program, { + code: "invalid-encode", + messageId: "wrongEncodingType", + format: { + encoding: encodeData.encoding, + type: getTypeName(target), + expected: validEncodeTypes.join(", "), + }, + target: context.decoratorTarget, + }); + } + } + + switch (encodeData.encoding) { + case "rfc3339": + return check(["utcDateTime", "offsetDateTime"], ["string"]); + case "rfc7231": + return check(["utcDateTime", "offsetDateTime"], ["string"]); + case "unixTimeStamp": + return check(["utcDateTime"], ["string"]); + case "seconds": + return check(["duration"], ["numeric"]); + case "base64": + return check(["bytes"], ["string"]); + case "base64url": + return check(["bytes"], ["string"]); + } +} + +export function getEncode( + program: Program, + target: Scalar | ModelProperty +): EncodeData | undefined { + return program.stateMap(encodeKey).get(target); +} + // -- @visibility decorator --------------------- const visibilitySettingsKey = createStateSymbol("visibilitySettings"); diff --git a/packages/compiler/lib/decorators.tsp b/packages/compiler/lib/decorators.tsp index 7c5df6adf13..5fcdd3fe71e 100644 --- a/packages/compiler/lib/decorators.tsp +++ b/packages/compiler/lib/decorators.tsp @@ -339,6 +339,79 @@ extern dec projectedName(target: unknown, targetName: string, projectedName: str */ extern dec discriminator(target: Model | Union, propertyName: string); +/** + * Known encoding to use on utcDateTime or offsetDateTime + */ +enum DateTimeKnownEncoding { + /** + * RFC 3339 standard. https://www.ietf.org/rfc/rfc3339.txt + * Encode to string. + */ + rfc3339: "rfc3339", + /** + * RFC 7231 standard. https://www.ietf.org/rfc/rfc7231.txt + * Encode to string. + */ + rfc7231: "rfc7231", + /** + * Encode to integer + */ + unixTimestamp: "unixTimestamp", +} + +/** + * Known encoding to use on duration + */ +enum DurationKnownEncoding { + /** + * ISO8601 duration + */ + ISO8601: "ISO8601", + /** + * Encode to integer or float + */ + seconds: "seconds", +} + +/** + * Known encoding to use on bytes + */ +enum BytesKnownEncoding { + /** + * ISO8601 duration + */ + ISO8601: "ISO8601", + /** + * Encode to integer or float + */ + seconds: "seconds", +} + +/** + * Specify how to encode the target type. + * @param encoding Known name of an encoding. + * @param encodedAs What target type is this being encoded as. Default to string. + * + * @example offsetDateTime encoded with rfc7231 + * + * ```tsp + * @encode("rfc7231") + * scalar myDateTime extends offsetDateTime; + * ``` + * + * @example utcDateTime encoded with unixTimestamp + * + * ```tsp + * @encode("unixTimestamp", int32) + * scalar myDateTime extends unixTimestamp; + * ``` + */ +extern dec encode( + target: Scalar | ModelProperty, + encoding: string | EnumMember, + encodedAs?: string | numeric +); + /** * Indicates that a property is only considered to be present or applicable ("visible") with * the in the given named contexts ("visibilities"). When a property has no visibilities applied diff --git a/packages/compiler/test/decorators/decorators.test.ts b/packages/compiler/test/decorators/decorators.test.ts index c8c4c9ccdc5..7ae80654073 100644 --- a/packages/compiler/test/decorators/decorators.test.ts +++ b/packages/compiler/test/decorators/decorators.test.ts @@ -2,6 +2,7 @@ import { deepStrictEqual, ok, strictEqual } from "assert"; import { Model, Operation, Scalar, getVisibility, isSecret } from "../../core/index.js"; import { getDoc, + getEncode, getFriendlyName, getKeyName, getKnownValues, @@ -357,6 +358,98 @@ describe("compiler: built-in decorators", () => { }); }); + describe("@encode", () => { + it(`set encoding on scalar`, async () => { + const { s } = (await runner.compile(` + @encode("rfc3339") + @test + scalar s extends utcDateTime; + `)) as { s: Scalar }; + + strictEqual(getEncode(runner.program, s)?.encoding, "rfc3339"); + }); + + it(`encode type default to string`, async () => { + const { s } = (await runner.compile(` + @encode("rfc3339") + @test + scalar s extends utcDateTime; + `)) as { s: Scalar }; + + strictEqual(getEncode(runner.program, s)?.type.name, "string"); + }); + + it(`change encode type`, async () => { + const { s } = (await runner.compile(` + @encode("unixTimestamp", int32) + @test + scalar s extends utcDateTime; + `)) as { s: Scalar }; + + strictEqual(getEncode(runner.program, s)?.type.name, "int32"); + }); + + describe("known encoding validation", () => { + const validCases = [ + ["utcDateTime", "rfc3339", undefined], + ["utcDateTime", "rfc7231", undefined], + ["offsetDateTime", "rfc3339", undefined], + ["offsetDateTime", "rfc7231", undefined], + ["utcDateTime", "unixTimeStamp", undefined], + ["duration", "ISO8601", undefined], + ["duration", "seconds", "int32"], + ["bytes", "base64", undefined], + ["bytes", "base64url", undefined], + // Do not block unknown encoding + ["utcDateTime", "custom-encoding", undefined], + ["duration", "custom-encoding", "int32"], + ]; + const invalidCases = [ + ["utcDateTime", "rfc3339", "int32"], + ["offsetDateTime", "rfc7231", "int64"], + ["offsetDateTime", "unixTimeStamp", undefined], + ["duration", "seconds", undefined], + ["duration", "rfc3339", undefined], + ["bytes", "rfc3339", undefined], + ]; + describe("valid", () => { + validCases.forEach(([target, encoding, encodeAs]) => { + it(`encoding '${encoding}' on ${target} encoded as ${encodeAs ?? "string"}`, async () => { + const encodeAsParam = encodeAs ? `, ${encodeAs}` : ""; + const { s } = (await runner.compile(` + @encode("${encoding}"${encodeAsParam}) + @test + scalar s extends ${target}; + `)) as { s: Scalar }; + + const encodeData = getEncode(runner.program, s); + ok(encodeData); + strictEqual(encodeData.encoding, encoding); + strictEqual(encodeData.type.name, encodeAs ?? "string"); + }); + }); + }); + describe("invalid", () => { + invalidCases.forEach(([target, encoding, encodeAs]) => { + it(`encoding '${encoding}' on ${target} encoded as ${ + encodeAs ?? "string" + }`, async () => { + const encodeAsParam = encodeAs ? `, ${encodeAs}` : ""; + const diagnostics = await runner.diagnose(` + @encode("${encoding}"${encodeAsParam}) + @test + scalar s extends ${target}; + `); + + expectDiagnostics(diagnostics, { + code: "invalid-encode", + }); + }); + }); + }); + }); + }); + describe("@withoutOmittedProperties", () => { it("removes a model property when given a string literal", async () => { const { TestModel } = await runner.compile( diff --git a/packages/html-program-viewer/src/ui.tsx b/packages/html-program-viewer/src/ui.tsx index 7ff490faa5e..b470f093427 100644 --- a/packages/html-program-viewer/src/ui.tsx +++ b/packages/html-program-viewer/src/ui.tsx @@ -136,7 +136,11 @@ const NamedTypeUI = ({ type, name, properties }: NamedTypeU valueUI = value; } else if (value.kind) { valueUI = render(value); - } else if (value[Symbol.iterator]) { + } else if ( + typeof value === "object" && + "entries" in value && + typeof value.entries === "function" + ) { valueUI = ; } else { valueUI = value; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 3cb298c34a6..cf8108fd9c2 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -11,6 +11,7 @@ import { getDiscriminatedUnion, getDiscriminator, getDoc, + getEncode, getFormat, getKnownValues, getMaxItems, @@ -1690,6 +1691,13 @@ function createOAPIEmitter(program: Program, options: ResolvedOpenAPI3EmitterOpt newTarget.format = "password"; } + const encodeData = getEncode(program, typespecType); + if (encodeData) { + const newType = getSchemaForScalar(encodeData.type); + newTarget.type = newType.type; + newTarget.format = mergeFormatAndEncoding(newTarget.format, encodeData.encoding); + } + if (isString) { const values = getKnownValues(program, typespecType); if (values) { @@ -1704,6 +1712,24 @@ function createOAPIEmitter(program: Program, options: ResolvedOpenAPI3EmitterOpt return newTarget; } + function mergeFormatAndEncoding(format: string | undefined, encoding: string): string { + switch (format) { + case undefined: + return encoding; + case "date-time": + switch (encoding) { + case "rfc3339": + return "date-time"; + case "unixTimestamp": + return "unix-timestamp"; + default: + return `date-time-${encoding}`; + } + default: + return encoding; + } + } + function applyExternalDocs(typespecType: Type, target: Record) { const externalDocs = getExternalDocs(program, typespecType); if (externalDocs) { diff --git a/packages/openapi3/test/primitive-types.test.ts b/packages/openapi3/test/primitive-types.test.ts index 5ebd66f3b18..61fad8baae9 100644 --- a/packages/openapi3/test/primitive-types.test.ts +++ b/packages/openapi3/test/primitive-types.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual, ok } from "assert"; +import { OpenAPI3Schema } from "../src/types.js"; import { oapiForModel } from "./test-host.js"; describe("openapi3: primitives", () => { @@ -190,4 +191,54 @@ describe("openapi3: primitives", () => { }); }); }); + + describe("using @encode decorator", () => { + async function testEncode( + scalar: string, + expectedOpenApi: OpenAPI3Schema, + encoding?: string, + encodeAs?: string + ) { + const encodeAsParam = encodeAs ? `, ${encodeAs}` : ""; + const encodeDecorator = encoding ? `@encode("${encoding}"${encodeAsParam})` : ""; + const res = await oapiForModel("s", `${encodeDecorator} scalar s extends ${scalar};`); + deepStrictEqual(res.schemas.s, expectedOpenApi); + } + + describe("utcDateTime", () => { + it("set format to 'date-time' by default", () => + testEncode("utcDateTime", { type: "string", format: "date-time" })); + it("set format to 'date-time-rfc7231' when encoding is rfc7231", () => + testEncode("utcDateTime", { type: "string", format: "date-time-rfc7231" }, "rfc7231")); + + it("set type to integer and format to 'unixTimeStamp' when encoding is unixTimestamp", () => + testEncode( + "utcDateTime", + { type: "integer", format: "unix-timestamp" }, + "unixTimestamp", + "int32" + )); + }); + + describe("offsetDateTime", () => { + it("set format to 'date-time' by default", () => + testEncode("offsetDateTime", { type: "string", format: "date-time" })); + it("set format to 'date-time-rfc7231' when encoding is rfc7231", () => + testEncode("offsetDateTime", { type: "string", format: "date-time-rfc7231" }, "rfc7231")); + }); + + describe("duration", () => { + it("set format to 'duration' by default", () => + testEncode("duration", { type: "string", format: "duration" })); + it("set interger with seconds format setting duration as seconds", () => + testEncode("duration", { type: "integer", format: "seconds" }, "seconds", "int32")); + }); + + describe("bytes", () => { + it("set format to 'base64' by default", () => + testEncode("bytes", { type: "string", format: "byte" })); + it("set interger with seconds format setting duration as seconds", () => + testEncode("bytes", { type: "string", format: "base64url" }, "base64url")); + }); + }); });