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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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