diff --git a/.chronus/changes/csharp-property-encode-2025-8-10-15-50-46.md b/.chronus/changes/csharp-property-encode-2025-8-10-15-50-46.md new file mode 100644 index 00000000000..0fa8a9de714 --- /dev/null +++ b/.chronus/changes/csharp-property-encode-2025-8-10-15-50-46.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/emitter-framework" +--- + +[c#] Support encode when generating csharp property \ No newline at end of file diff --git a/.chronus/changes/csharp-property-encode-2025-9-22-17-24-2.md b/.chronus/changes/csharp-property-encode-2025-9-22-17-24-2.md new file mode 100644 index 00000000000..ae0bea2d162 --- /dev/null +++ b/.chronus/changes/csharp-property-encode-2025-9-22-17-24-2.md @@ -0,0 +1,9 @@ +--- +changeKind: internal +packages: + - "@typespec/http-client-js" + - "@typespec/http-client" + - "@typespec/tspd" +--- + +version bump the alloy libraries diff --git a/packages/emitter-framework/package.json b/packages/emitter-framework/package.json index eb31afae6b7..22147326cb9 100644 --- a/packages/emitter-framework/package.json +++ b/packages/emitter-framework/package.json @@ -55,16 +55,16 @@ "license": "MIT", "description": "", "peerDependencies": { - "@alloy-js/core": "^0.20.0", - "@alloy-js/csharp": "^0.20.0", - "@alloy-js/typescript": "^0.20.0", + "@alloy-js/core": "^0.21.0", + "@alloy-js/csharp": "^0.21.0", + "@alloy-js/typescript": "^0.21.0", "@typespec/compiler": "workspace:^" }, "devDependencies": { - "@alloy-js/cli": "^0.20.0", - "@alloy-js/core": "^0.20.0", + "@alloy-js/cli": "^0.21.0", + "@alloy-js/core": "^0.21.0", "@alloy-js/rollup-plugin": "^0.1.0", - "@alloy-js/typescript": "^0.20.0", + "@alloy-js/typescript": "^0.21.0", "@typespec/compiler": "workspace:^", "concurrently": "^9.1.2", "pathe": "^2.0.3", diff --git a/packages/emitter-framework/src/csharp/components/index.ts b/packages/emitter-framework/src/csharp/components/index.ts index 2c4d1d88eed..9d22f199970 100644 --- a/packages/emitter-framework/src/csharp/components/index.ts +++ b/packages/emitter-framework/src/csharp/components/index.ts @@ -1,4 +1,6 @@ export * from "./class/declaration.js"; export * from "./enum/declaration.jsx"; +export * from "./json-converter/json-converter-resolver.jsx"; +export * from "./json-converter/json-converter.jsx"; export * from "./property/property.jsx"; export * from "./type-expression.jsx"; diff --git a/packages/emitter-framework/src/csharp/components/json-converter/json-converter-resolver.test.tsx b/packages/emitter-framework/src/csharp/components/json-converter/json-converter-resolver.test.tsx new file mode 100644 index 00000000000..2c64392835b --- /dev/null +++ b/packages/emitter-framework/src/csharp/components/json-converter/json-converter-resolver.test.tsx @@ -0,0 +1,120 @@ +import { Tester } from "#test/test-host.js"; +import { code, For, List, namekey, type Children } from "@alloy-js/core"; +import { createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp"; +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { beforeEach, expect, it } from "vitest"; +import { Output } from "../../../core/components/output.jsx"; +import { Property } from "../property/property.jsx"; +import { + createJsonConverterResolver, + JsonConverterResolver, + useJsonConverterResolver, + type JsonConverterResolverOptions, +} from "./json-converter-resolver.jsx"; +import { JsonConverter } from "./json-converter.jsx"; + +let tester: TesterInstance; + +beforeEach(async () => { + tester = await Tester.createInstance(); +}); + +function Wrapper(props: { children: Children }) { + const policy = createCSharpNamePolicy(); + return ( + + {props.children} + + ); +} + +const fakeJsonConverterKey = namekey("FakeJsonConverter"); +function FakeJsonConverter() { + return ( + { + return code`return ${reader}.GetString();`; + }} + encodeAndWrite={(writer, value) => { + return code`${writer}.WriteStringValue(${value});`; + }} + /> + ); +} + +function createTestJsonConverterResolver() { + const option: JsonConverterResolverOptions = { + customConverters: [ + { + type: $(tester.program).builtin.string, + encodeData: { + type: $(tester.program).builtin.string, + encoding: "fake-change", + }, + info: { + converter: FakeJsonConverter, + namekey: fakeJsonConverterKey, + }, + }, + ], + }; + return createJsonConverterResolver(option); +} + +it("No resolved converters", async () => { + await tester.compile(t.code``); + expect( + + + {code`Resolved JsonConverter: ${useJsonConverterResolver()?.listResolvedJsonConverters().length}`} + + , + ).toRenderTo(`Resolved JsonConverter: 0`); +}); + +it("Resolve custom converter", async () => { + const r = await tester.compile(t.code` + model BaseModel { + @encode("fake-change", string) + ${t.modelProperty("prop1")}: string; + } + `); + + expect( + + + + +
+ + {(x) => <>{x.converter}} + +
+
+
, + ).toRenderTo(` + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + [JsonPropertyName("prop1")] + [JsonConverter(typeof(FakeJsonConverter))] + public required string Prop1 { get; set; } + + + internal sealed class FakeJsonConverter : JsonConverter + { + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.GetString(); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } + }`); +}); diff --git a/packages/emitter-framework/src/csharp/components/json-converter/json-converter-resolver.tsx b/packages/emitter-framework/src/csharp/components/json-converter/json-converter-resolver.tsx new file mode 100644 index 00000000000..5a6d899b9d8 --- /dev/null +++ b/packages/emitter-framework/src/csharp/components/json-converter/json-converter-resolver.tsx @@ -0,0 +1,117 @@ +import { useTsp } from "#core/index.js"; +import { createContext, namekey, useContext, type Children, type Namekey } from "@alloy-js/core"; +import { + getTypeName, + type DurationKnownEncoding, + type EncodeData, + type Type, +} from "@typespec/compiler"; +import { capitalize } from "@typespec/compiler/casing"; +import { getNullableUnionInnerType } from "../utils/nullable-util.js"; +import { TimeSpanIso8601JsonConverter, TimeSpanSecondsJsonConverter } from "./json-converter.jsx"; + +interface JsonConverterInfo { + namekey: Namekey; + converter: Children; +} + +/** + * Help to resolve JsonConverter for a given type with encoding: + * 1. Avoid unnecessary duplicate JsonConverter declaration for the same type with same encoding. + * 2. Provide resolved JsonConverters to be generated centralized properly as needed. + * */ +export interface useJsonConverterResolver { + resolveJsonConverter: (type: Type, encodeData: EncodeData) => JsonConverterInfo | undefined; + listResolvedJsonConverters: () => JsonConverterInfo[]; +} + +export const JsonConverterResolver = createContext(); + +export function useJsonConverterResolver(): useJsonConverterResolver | undefined { + return useContext(JsonConverterResolver); +} + +export interface JsonConverterResolverOptions { + /** Custom JSON converters besides the built-in ones for known encode*/ + customConverters?: { type: Type; encodeData: EncodeData; info: JsonConverterInfo }[]; +} + +export function createJsonConverterResolver( + options?: JsonConverterResolverOptions, +): useJsonConverterResolver { + const resolvedConverters = new Map(); + const customConverters = new Map(); + + if (options?.customConverters) { + for (const item of options.customConverters) { + const key = getJsonConverterKey(item.type, item.encodeData); + customConverters.set(key, item.info); + } + } + + return { + resolveJsonConverter: (type: Type, encodeData: EncodeData) => { + const key = getJsonConverterKey(type, encodeData); + const found = resolvedConverters.get(key); + if (found) { + return found; + } else { + const resolved = customConverters.get(key) ?? resolveKnownJsonConverter(type, encodeData); + if (resolved) { + resolvedConverters.set(key, resolved); + return resolved; + } + } + return undefined; + }, + listResolvedJsonConverters: () => Array.from(resolvedConverters.values()), + }; + + function getJsonConverterKey(type: Type, encodeData: EncodeData) { + return `type:${getTypeName(type)}-encoding:${encodeData.encoding}-encodeType:${getTypeName(encodeData.type)}`; + } + + function resolveKnownJsonConverter( + type: Type, + encodeData: EncodeData, + ): JsonConverterInfo | undefined { + const ENCODING_DURATION_SECONDS: DurationKnownEncoding = "seconds"; + const ENCODING_DURATION_ISO8601: DurationKnownEncoding = "ISO8601"; + const { $ } = useTsp(); + // Unwrap nullable because JsonConverter would handle null by default for us. + const unwrappedType = type.kind === "Union" ? (getNullableUnionInnerType(type) ?? type) : type; + if ( + unwrappedType === $.builtin.duration && + encodeData.encoding === ENCODING_DURATION_SECONDS && + [ + $.builtin.int16, + $.builtin.uint16, + $.builtin.int32, + $.builtin.uint32, + $.builtin.int64, + $.builtin.uint64, + $.builtin.float32, + $.builtin.float64, + ].includes(encodeData.type) + ) { + const capitalizedTypeName = capitalize(encodeData.type.name); + const key: Namekey = namekey(`TimeSpanSeconds${capitalizedTypeName}JsonConverter`); + return { + namekey: key, + converter: , + }; + } else if ( + unwrappedType === $.builtin.duration && + encodeData.encoding === ENCODING_DURATION_ISO8601 + ) { + const key = namekey(`TimeSpanIso8601JsonConverter`); + return { + namekey: key, + converter: , + }; + } else { + // TODO: support other known encodings + return undefined; + } + } +} diff --git a/packages/emitter-framework/src/csharp/components/json-converter/json-converter.test.tsx b/packages/emitter-framework/src/csharp/components/json-converter/json-converter.test.tsx new file mode 100644 index 00000000000..92e4972e90f --- /dev/null +++ b/packages/emitter-framework/src/csharp/components/json-converter/json-converter.test.tsx @@ -0,0 +1,141 @@ +import { Tester } from "#test/test-host.js"; +import { code, namekey, type Children } from "@alloy-js/core"; +import { createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp"; +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Output } from "../../../core/components/output.jsx"; +import { + JsonConverter, + TimeSpanIso8601JsonConverter, + TimeSpanSecondsJsonConverter, +} from "./json-converter.jsx"; + +let tester: TesterInstance; + +beforeEach(async () => { + tester = await Tester.createInstance(); +}); + +function Wrapper(props: { children: Children }) { + const policy = createCSharpNamePolicy(); + return ( + + {props.children} + + ); +} + +const fakeJsonConverterKey = namekey("FakeJsonConverter"); +function FakeJsonConverter() { + return ( + { + return code`return ${reader}.GetString();`; + }} + encodeAndWrite={(writer, value) => { + return code`${writer}.WriteStringValue(${value});`; + }} + /> + ); +} + +it("Custom JsonConverter", async () => { + await tester.compile(t.code``); + expect( + + + , + ).toRenderTo(` + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + internal sealed class FakeJsonConverter : JsonConverter + { + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.GetString(); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } + }`); +}); + +describe("Known JsonConverters", () => { + it("TimeSpanIso8601JsonConverter", async () => { + await tester.compile(t.code``); + expect( + + + , + ).toRenderTo(` + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + using System.Xml; + + internal sealed class TimeSpanIso8601JsonConverter : JsonConverter + { + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var isoString = reader.GetString(); + if( isoString == null) + { + throw new FormatException("Invalid ISO8601 duration string: null"); + } + return XmlConvert.ToTimeSpan(isoString); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteStringValue(XmlConvert.ToString(value)); + } + } + `); + }); + + describe.each([ + ["int16", "(short)", "GetInt16"], + ["uint16", "(ushort)", "GetUInt16"], + ["int32", "(int)", "GetInt32"], + ["uint32", "(uint)", "GetUInt32"], + ["int64", "(long)", "GetInt64"], + ["uint64", "(ulong)", "GetUInt64"], + ["float32", "(float)", "GetSingle"], + ["float64", "", "GetDouble"], + ] as const)("%s", (typeName, jsonWriteType, jsonReaderMethod) => { + it("TimeSpanSecondsJsonConverter", async () => { + await tester.compile(t.code``); + const type = $(tester.program).builtin[typeName]; + expect( + + + , + ).toRenderTo(` + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + internal sealed class TimeSpanSeconds${typeName.charAt(0).toUpperCase() + typeName.slice(1)}JsonConverter : JsonConverter + { + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var seconds = reader.${jsonReaderMethod}(); + return TimeSpan.FromSeconds(seconds); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteNumberValue(${jsonWriteType}value.TotalSeconds); + } + } + `); + }); + }); +}); diff --git a/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx b/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx new file mode 100644 index 00000000000..ae26ef0efde --- /dev/null +++ b/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx @@ -0,0 +1,146 @@ +import { useTsp } from "#core/index.js"; +import { code, List, namekey, type Namekey, type Refkey } from "@alloy-js/core"; +import type { Children } from "@alloy-js/core/jsx-runtime"; +import { ClassDeclaration, Method } from "@alloy-js/csharp"; +import System, { Xml } from "@alloy-js/csharp/global/System"; +import Json, { Serialization } from "@alloy-js/csharp/global/System/Text/Json"; +import { type Type } from "@typespec/compiler"; +import { capitalize } from "@typespec/compiler/casing"; +import { TypeExpression } from "../type-expression.jsx"; + +interface JsonConverterProps { + name: string | Namekey; + type: Type; + refkey?: Refkey; + /** Decode and return value from reader*/ + decodeAndReturn: (reader: Namekey, typeToConvert: Namekey, options: Namekey) => Children; + /** Encode the given value and send to writer*/ + encodeAndWrite: (writer: Namekey, value: Namekey, options: Namekey) => Children; +} + +/** + * Generate a Json converter class inheriting System.Text.Json.Serialization.JsonConverter which can be used by System.Text.Json.Serialization.JsonConverterAttribute to provide custom serialization. + * @see https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/converters-how-to#steps-to-follow-the-basic-pattern + */ +export function JsonConverter(props: JsonConverterProps) { + const readParamReader: Namekey = namekey("reader"); + const readParamTypeToConvert: Namekey = namekey("typeToConvert"); + const readParamOptions: Namekey = namekey("options"); + const writeParamWriter: Namekey = namekey("writer"); + const writeParamValue: Namekey = namekey("value"); + const writeParamOptions: Namekey = namekey("options"); + const propTypeExpression = code`${()}`; + return ( + `} + > + + + {code`${props.decodeAndReturn(readParamReader, readParamTypeToConvert, readParamOptions)}`} + + + {code`${props.encodeAndWrite(writeParamWriter, writeParamValue, writeParamOptions)}`} + + + + ); +} + +export function TimeSpanSecondsJsonConverter(props: { + name?: string | Namekey; + refkey?: Refkey; + encodeType: Type; +}) { + const { $ } = useTsp(); + const map: Map = new Map([ + [$.builtin.int16, { jsonWriteType: "short", jsonReaderMethod: "GetInt16" }], + [$.builtin.uint16, { jsonWriteType: "ushort", jsonReaderMethod: "GetUInt16" }], + [$.builtin.int32, { jsonWriteType: "int", jsonReaderMethod: "GetInt32" }], + [$.builtin.uint32, { jsonWriteType: "uint", jsonReaderMethod: "GetUInt32" }], + [$.builtin.int64, { jsonWriteType: "long", jsonReaderMethod: "GetInt64" }], + [$.builtin.uint64, { jsonWriteType: "ulong", jsonReaderMethod: "GetUInt64" }], + [$.builtin.float32, { jsonWriteType: "float", jsonReaderMethod: "GetSingle" }], + [$.builtin.float64, { jsonWriteType: "double", jsonReaderMethod: "GetDouble" }], + ]); + if (props.encodeType.kind !== "Scalar" || !map.has(props.encodeType)) { + throw new Error( + `TimeSpanSecondsJsonConverter only supports encodeType of int16, uint16, int32, uint32, int64, uint64, float32, or float64. Received: kind = ${props.encodeType.kind} ${props.encodeType.kind === "Scalar" ? `name = ${props.encodeType.name}` : ""}`, + ); + } + const found = map.get(props.encodeType)!; + const capitalizedTypeName = capitalize(props.encodeType.name); + const defaultName = `TimeSpanSeconds${capitalizedTypeName}JsonConverter`; + + return ( + { + return code`var seconds = ${reader}.${found.jsonReaderMethod}(); + return ${System.TimeSpan}.FromSeconds(seconds);`; + }} + encodeAndWrite={(writer, value) => { + return code`${writer}.WriteNumberValue(${found.jsonWriteType === "double" || `(${found.jsonWriteType})`}${value}.TotalSeconds);`; + }} + /> + ); +} + +export function TimeSpanIso8601JsonConverter(props: { name?: string | Namekey; refkey?: Refkey }) { + const { $ } = useTsp(); + return ( + { + return code`var isoString = ${reader}.GetString(); + if( isoString == null) + { + throw new ${System.FormatException}("Invalid ISO8601 duration string: null"); + } + return ${Xml.XmlConvert}.ToTimeSpan(isoString);`; + }} + encodeAndWrite={(writer, value) => { + return code`${writer}.WriteStringValue(${Xml.XmlConvert}.ToString(${value}));`; + }} + /> + ); +} diff --git a/packages/emitter-framework/src/csharp/components/property/property.test.tsx b/packages/emitter-framework/src/csharp/components/property/property.test.tsx index 58034ce1bc1..7444028b58e 100644 --- a/packages/emitter-framework/src/csharp/components/property/property.test.tsx +++ b/packages/emitter-framework/src/csharp/components/property/property.test.tsx @@ -1,9 +1,14 @@ import { Tester } from "#test/test-host.js"; -import { List, type Children } from "@alloy-js/core"; +import { For, List, type Children } from "@alloy-js/core"; import { ClassDeclaration, createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp"; import { t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; import { Output } from "../../../core/components/output.jsx"; +import { + createJsonConverterResolver, + JsonConverterResolver, + useJsonConverterResolver, +} from "../json-converter/json-converter-resolver.jsx"; import { Property } from "./property.jsx"; let tester: TesterInstance; @@ -93,9 +98,11 @@ describe("jsonAttributes", () => { , ).toRenderTo(` + using System.Text.Json.Serialization; + class Test { - [System.Text.Json.JsonPropertyName("prop1")] + [JsonPropertyName("prop1")] public required string Prop1 { get; set; } } `); @@ -114,11 +121,13 @@ describe("jsonAttributes", () => { , ).toRenderTo(` - class Test - { - [System.Text.Json.JsonPropertyName("prop_1")] - public required string Prop1 { get; set; } - } + using System.Text.Json.Serialization; + + class Test + { + [JsonPropertyName("prop_1")] + public required string Prop1 { get; set; } + } `); }); @@ -177,4 +186,99 @@ describe("jsonAttributes", () => { } `); }); + + it("json converter: duration -> seconds(int32)", async () => { + const r = await tester.compile(t.code` + model BaseModel { + @encode(DurationKnownEncoding.seconds, int32) + ${t.modelProperty("prop1")}?: duration; + @encode(DurationKnownEncoding.seconds, float64) + ${t.modelProperty("prop2")}: duration; + @encode(DurationKnownEncoding.ISO8601, string) + ${t.modelProperty("prop3")}: duration; + } + `); + + expect( + + + + + + + + // JsonConverter wont work as nested class, but good enough for test to verify the + generated code. + + {(x) => <>{x.converter}} + + + + , + ).toRenderTo(` + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + using System.Xml; + + class Test + { + [JsonPropertyName("prop1")] + [JsonConverter(typeof(TimeSpanSecondsInt32JsonConverter))] + public TimeSpan? Prop1 { get; set; } + [JsonPropertyName("prop2")] + [JsonConverter(typeof(TimeSpanSecondsFloat64JsonConverter))] + public required TimeSpan Prop2 { get; set; } + [JsonPropertyName("prop3")] + [JsonConverter(typeof(TimeSpanIso8601JsonConverter))] + public required TimeSpan Prop3 { get; set; } + + + // JsonConverter wont work as nested class, but good enough for test to verify the generated code. + internal sealed class TimeSpanSecondsInt32JsonConverter : JsonConverter + { + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var seconds = reader.GetInt32(); + return TimeSpan.FromSeconds(seconds); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteNumberValue((int)value.TotalSeconds); + } + } + internal sealed class TimeSpanSecondsFloat64JsonConverter : JsonConverter + { + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var seconds = reader.GetDouble(); + return TimeSpan.FromSeconds(seconds); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.TotalSeconds); + } + } + internal sealed class TimeSpanIso8601JsonConverter : JsonConverter + { + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var isoString = reader.GetString(); + if( isoString == null) + { + throw new FormatException("Invalid ISO8601 duration string: null"); + } + return XmlConvert.ToTimeSpan(isoString); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteStringValue(XmlConvert.ToString(value)); + } + } + } + `); + }); }); diff --git a/packages/emitter-framework/src/csharp/components/property/property.tsx b/packages/emitter-framework/src/csharp/components/property/property.tsx index 519db53ec9b..8863892e06f 100644 --- a/packages/emitter-framework/src/csharp/components/property/property.tsx +++ b/packages/emitter-framework/src/csharp/components/property/property.tsx @@ -1,15 +1,26 @@ -import { type Children } from "@alloy-js/core"; +import { code, REFKEYABLE, type Children } from "@alloy-js/core"; import * as cs from "@alloy-js/csharp"; import { Attribute } from "@alloy-js/csharp"; -import { getProperty, type ModelProperty, resolveEncodedName, type Type } from "@typespec/compiler"; +import { Serialization } from "@alloy-js/csharp/global/System/Text/Json"; +import { + getEncode, + getProperty, + resolveEncodedName, + type ModelProperty, + type Type, +} from "@typespec/compiler"; import { useTsp } from "../../../core/index.js"; +import { useJsonConverterResolver } from "../json-converter/json-converter-resolver.jsx"; import { TypeExpression } from "../type-expression.jsx"; import { getDocComments } from "../utils/doc-comments.jsx"; import { getNullableUnionInnerType } from "../utils/nullable-util.js"; export interface PropertyProps { type: ModelProperty; - /** If set the property will add the json serialization attributes(using System.Text.Json). */ + /** If set the property will add the json serialization attributes(using System.Text.Json.Serialization). + * - the JsonPropertyName attribute + * - the JsonConverter attribute if the property has encoding and a JsonConverterResolver context is available + * */ jsonAttributes?: boolean; } @@ -17,8 +28,8 @@ export interface PropertyProps { * Create a C# property declaration from a TypeSpec property type. */ export function Property(props: PropertyProps): Children { - const result = preprocessPropertyType(props.type); const { $ } = useTsp(); + const result = preprocessPropertyType(props.type); let overrideType: "" | "override" | "new" = ""; let isVirtual = false; @@ -49,6 +60,22 @@ export function Property(props: PropertyProps): Children { }); } } + const attributes = []; + if (props.jsonAttributes) { + attributes.push(); + const encodeData = getEncode($.program, props.type); + if (encodeData) { + const JsonConverterResolver = useJsonConverterResolver(); + if (JsonConverterResolver) { + const converter = JsonConverterResolver.resolveJsonConverter(result.type, encodeData); + if (converter) { + attributes.push( + } />, + ); + } + } + } + } return ( ] : undefined} + attributes={attributes} get set /> @@ -75,7 +102,12 @@ export interface JsonNameAttributeProps { function JsonNameAttribute(props: JsonNameAttributeProps): Children { const { program } = useTsp(); const jsonName = resolveEncodedName(program, props.type, "application/json"); - return ; + return ( + + ); } function preprocessPropertyType(prop: ModelProperty): { type: Type; nullable: boolean } { @@ -92,3 +124,12 @@ function preprocessPropertyType(prop: ModelProperty): { type: Type; nullable: bo return { type, nullable: prop.optional }; } } + +function JsonConverterAttribute(props: { type: Children }): Children { + return ( + + ); +} diff --git a/packages/http-client-js/package.json b/packages/http-client-js/package.json index a428edb5494..259f8dbe704 100644 --- a/packages/http-client-js/package.json +++ b/packages/http-client-js/package.json @@ -53,14 +53,14 @@ "@typespec/rest": "workspace:^" }, "dependencies": { - "@alloy-js/core": "^0.20.0", - "@alloy-js/typescript": "^0.20.0", + "@alloy-js/core": "^0.21.0", + "@alloy-js/typescript": "^0.21.0", "@typespec/emitter-framework": "workspace:^", "@typespec/http-client": "workspace:^", "prettier": "~3.6.2" }, "devDependencies": { - "@alloy-js/cli": "^0.20.0", + "@alloy-js/cli": "^0.21.0", "@alloy-js/rollup-plugin": "^0.1.0", "@types/yargs": "~17.0.33", "@typespec/http": "workspace:^", diff --git a/packages/http-client/package.json b/packages/http-client/package.json index 1127926a99b..283418d6cff 100644 --- a/packages/http-client/package.json +++ b/packages/http-client/package.json @@ -23,17 +23,17 @@ } }, "peerDependencies": { - "@alloy-js/core": "^0.20.0", - "@alloy-js/typescript": "^0.20.0", + "@alloy-js/core": "^0.21.0", + "@alloy-js/typescript": "^0.21.0", "@typespec/compiler": "workspace:^", "@typespec/emitter-framework": "workspace:^", "@typespec/http": "workspace:^" }, "devDependencies": { - "@alloy-js/cli": "^0.20.0", - "@alloy-js/core": "^0.20.0", + "@alloy-js/cli": "^0.21.0", + "@alloy-js/core": "^0.21.0", "@alloy-js/rollup-plugin": "^0.1.0", - "@alloy-js/typescript": "^0.20.0", + "@alloy-js/typescript": "^0.21.0", "@types/node": "~24.3.0", "@typespec/compiler": "workspace:^", "@typespec/emitter-framework": "workspace:^", diff --git a/packages/tspd/package.json b/packages/tspd/package.json index 778f0993429..72b246ce775 100644 --- a/packages/tspd/package.json +++ b/packages/tspd/package.json @@ -55,9 +55,9 @@ "!dist/test/**" ], "dependencies": { - "@alloy-js/core": "^0.20.0", - "@alloy-js/markdown": "^0.20.0", - "@alloy-js/typescript": "^0.20.0", + "@alloy-js/core": "^0.21.0", + "@alloy-js/markdown": "^0.21.0", + "@alloy-js/typescript": "^0.21.0", "@microsoft/api-extractor": "^7.52.1", "@microsoft/api-extractor-model": "^7.30.6", "@microsoft/tsdoc": "^0.15.1", @@ -71,7 +71,7 @@ "yargs": "~18.0.0" }, "devDependencies": { - "@alloy-js/cli": "^0.20.0", + "@alloy-js/cli": "^0.21.0", "@alloy-js/rollup-plugin": "^0.1.0", "@types/node": "~24.3.0", "@types/yargs": "~17.0.33", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19ed15825d1..9d475e4c009 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,8 @@ settings: overrides: cross-spawn@>=7.0.0 <7.0.5: ^7.0.5 rollup: 4.49.0 + '@alloy-js/core': 0.21.0-dev.8 + '@alloy-js/csharp': 0.21.0-dev.19 importers: @@ -412,21 +414,21 @@ importers: packages/emitter-framework: dependencies: '@alloy-js/csharp': - specifier: ^0.20.0 - version: 0.20.0 + specifier: 0.21.0-dev.19 + version: 0.21.0-dev.19 devDependencies: '@alloy-js/cli': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.21.0 + version: 0.21.0 '@alloy-js/core': - specifier: ^0.20.0 - version: 0.20.0 + specifier: 0.21.0-dev.8 + version: 0.21.0-dev.8 '@alloy-js/rollup-plugin': specifier: ^0.1.0 version: 0.1.0(@babel/core@7.28.4)(@types/babel__core@7.20.5)(rollup@4.49.0) '@alloy-js/typescript': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.21.0 + version: 0.21.0 '@typespec/compiler': specifier: workspace:^ version: link:../compiler @@ -658,17 +660,17 @@ importers: packages/http-client: devDependencies: '@alloy-js/cli': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.21.0 + version: 0.21.0 '@alloy-js/core': - specifier: ^0.20.0 - version: 0.20.0 + specifier: 0.21.0-dev.8 + version: 0.21.0-dev.8 '@alloy-js/rollup-plugin': specifier: ^0.1.0 version: 0.1.0(@babel/core@7.28.4)(@types/babel__core@7.20.5)(rollup@4.49.0) '@alloy-js/typescript': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.21.0 + version: 0.21.0 '@types/node': specifier: ~24.3.0 version: 24.3.1 @@ -703,11 +705,11 @@ importers: packages/http-client-js: dependencies: '@alloy-js/core': - specifier: ^0.20.0 - version: 0.20.0 + specifier: 0.21.0-dev.8 + version: 0.21.0-dev.8 '@alloy-js/typescript': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.21.0 + version: 0.21.0 '@typespec/compiler': specifier: workspace:^ version: link:../compiler @@ -725,8 +727,8 @@ importers: version: 3.6.2 devDependencies: '@alloy-js/cli': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.21.0 + version: 0.21.0 '@alloy-js/rollup-plugin': specifier: ^0.1.0 version: 0.1.0(@babel/core@7.28.4)(@types/babel__core@7.20.5)(rollup@4.49.0) @@ -2214,14 +2216,14 @@ importers: packages/tspd: dependencies: '@alloy-js/core': - specifier: ^0.20.0 - version: 0.20.0 + specifier: 0.21.0-dev.8 + version: 0.21.0-dev.8 '@alloy-js/markdown': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.21.0 + version: 0.21.0 '@alloy-js/typescript': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.21.0 + version: 0.21.0 '@microsoft/api-extractor': specifier: ^7.52.1 version: 7.52.12(@types/node@24.3.1) @@ -2257,8 +2259,8 @@ importers: version: 18.0.0 devDependencies: '@alloy-js/cli': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.21.0 + version: 0.21.0 '@alloy-js/rollup-plugin': specifier: ^0.1.0 version: 0.1.0(@babel/core@7.28.4)(@types/babel__core@7.20.5)(rollup@4.49.0) @@ -2666,26 +2668,26 @@ packages: '@alloy-js/babel-preset@0.2.1': resolution: {integrity: sha512-vz9kvQwx5qBzHIw4ryqUaQqpgNOMBmkdDcV3e2zZfMq8Pp16ePFtvviHh6RwyLcvXQQClex3ZZy8ON9TifMnxw==} - '@alloy-js/cli@0.20.0': - resolution: {integrity: sha512-iDiJAs2yjP5G8lMmry+4usM33tL9G1u8IKp+beh0jx5RqfLsZsXOu78S4ZIZLqskAMkCLuRJogsh8fPREg2CzQ==} + '@alloy-js/cli@0.21.0': + resolution: {integrity: sha512-k1Rf6kbYPdMKYJ1pFmhbk0NpW7p/aL/HbmxpJxmF/tbXAhZmNO62f9JM4qF64jNnq9byq31PMBSOIAIZFLqa1A==} engines: {node: '>=18.0.0'} hasBin: true - '@alloy-js/core@0.20.0': - resolution: {integrity: sha512-ylPf+ayI9MsqUPrNVzND3Oh9rVrfOOcMkyVwtXXaxaobWPkcRq2I4rX09FkG0i/9DoaLE6ZCvUfdgJsM29MYBA==} + '@alloy-js/core@0.21.0-dev.8': + resolution: {integrity: sha512-CFKy8/sCHTxj/YWG4uJFFRyr68713LOms+gJcQ2NWulqc8JT4bl2XQM8hF/KbkngRY5oKcGsl8VPkn9o7i5hGg==} - '@alloy-js/csharp@0.20.0': - resolution: {integrity: sha512-Yn8oua43tVWYGN9Gt5DDtGUdLIB9io6/nL8dK4qDvL019w9uK7f3wosr+/JtSm14PuToN4jM1s7HNVzqh41KUA==} + '@alloy-js/csharp@0.21.0-dev.19': + resolution: {integrity: sha512-+J5+LNCJj2ozUTL2aClLpG5GTzGDjjPNitucxYAaIhbR3J0a/R1SNBQIB2eUSdC2/9LM1vWynVgqE+0zr4V6ew==} - '@alloy-js/markdown@0.20.0': - resolution: {integrity: sha512-c1Q4dzUvWC4Bdoi6dRT9yAYVoCiqz3ZMClV8CHzEsgZYjjdS0S2ZWWmgxzS87rSDHSjmQIXJ4BcUZfKyfnMrFA==} + '@alloy-js/markdown@0.21.0': + resolution: {integrity: sha512-Er2aqWdolajWUrHxeqZoiK/Grdet2zaEr8ZtIbvv/M0sMz975p0ltijZNF3OnMde0wFlk1Jg14hkiitI9wFVgQ==} '@alloy-js/rollup-plugin@0.1.0': resolution: {integrity: sha512-MXR8mBdSh/pxMP8kIXAcMYKsm5yOWZ+igxcaRX1vBXFiHU4eK7gE/5q6Fk8Vdydh+MItWtgekwIhUWvcszdNFQ==} engines: {node: '>=18.0.0'} - '@alloy-js/typescript@0.20.0': - resolution: {integrity: sha512-F1y5QjneE8GVxIq6oYsebu+Fccrn72qFHelNX5GSLfs4Ps2fxpk2+70rsGznZyHe9LIt70StaAciTjH6cxH4bQ==} + '@alloy-js/typescript@0.21.0': + resolution: {integrity: sha512-SsxdYkXhrP8jjO2gENav9bHPHaonNrreW469RaOot3cRqhsHPA1RmBrzNPJov37YknzTg4Wlk0JsEFT4Qibgfg==} '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} @@ -13450,7 +13452,7 @@ snapshots: - '@babel/core' - supports-color - '@alloy-js/cli@0.20.0': + '@alloy-js/cli@0.21.0': dependencies: '@alloy-js/babel-preset': 0.2.1(@babel/core@7.28.4) '@babel/core': 7.28.4 @@ -13460,7 +13462,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@alloy-js/core@0.20.0': + '@alloy-js/core@0.21.0-dev.8': dependencies: '@vue/reactivity': 3.5.21 cli-table3: 0.6.5 @@ -13468,16 +13470,16 @@ snapshots: picocolors: 1.1.1 prettier: 3.6.2 - '@alloy-js/csharp@0.20.0': + '@alloy-js/csharp@0.21.0-dev.19': dependencies: - '@alloy-js/core': 0.20.0 + '@alloy-js/core': 0.21.0-dev.8 change-case: 5.4.4 marked: 16.2.1 pathe: 2.0.3 - '@alloy-js/markdown@0.20.0': + '@alloy-js/markdown@0.21.0': dependencies: - '@alloy-js/core': 0.20.0 + '@alloy-js/core': 0.21.0-dev.8 yaml: 2.8.1 '@alloy-js/rollup-plugin@0.1.0(@babel/core@7.28.4)(@types/babel__core@7.20.5)(rollup@4.49.0)': @@ -13491,9 +13493,9 @@ snapshots: - rollup - supports-color - '@alloy-js/typescript@0.20.0': + '@alloy-js/typescript@0.21.0': dependencies: - '@alloy-js/core': 0.20.0 + '@alloy-js/core': 0.21.0-dev.8 change-case: 5.4.4 pathe: 2.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c1fec505b34..a64243c65fa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,3 +9,5 @@ packages: overrides: "cross-spawn@>=7.0.0 <7.0.5": "^7.0.5" rollup: 4.49.0 # Regression in 4.50.0 https://github.com/rollup/rollup/issues/6099 + "@alloy-js/core": 0.21.0-dev.8 + "@alloy-js/csharp": 0.21.0-dev.19