Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6425902
support encode
RodgeFu Sep 10, 2025
7326a6e
add changelog
RodgeFu Sep 10, 2025
a2b264a
Merge remote-tracking branch 'upstream/main' into csharp-property-encode
RodgeFu Sep 12, 2025
44f416f
use json converter for csharp encode
RodgeFu Sep 15, 2025
8a940bd
add comment
RodgeFu Sep 15, 2025
cb34add
fix test
RodgeFu Sep 15, 2025
a7a3a85
fix typo
RodgeFu Sep 15, 2025
a37c78b
update per feedback
RodgeFu Sep 16, 2025
18b9471
Merge remote-tracking branch 'upstream/main' into csharp-property-encode
RodgeFu Sep 16, 2025
c8a7fa0
update package reference
RodgeFu Sep 16, 2025
69368f2
some update
RodgeFu Sep 16, 2025
d0273a4
imporve seconds converter and add test
RodgeFu Sep 16, 2025
7ed82d7
some update
RodgeFu Sep 17, 2025
2d99af1
Merge remote-tracking branch 'upstream/main' into csharp-property-encode
RodgeFu Sep 17, 2025
a241039
remove 'attribute'
RodgeFu Sep 17, 2025
0499735
Revert "remove 'attribute'"
RodgeFu Sep 17, 2025
810a34b
remove attribute
RodgeFu Sep 17, 2025
306409f
update reference
RodgeFu Sep 18, 2025
3071ce2
bump alloy-js libs
RodgeFu Sep 18, 2025
bfdb04c
update reference
RodgeFu Sep 18, 2025
5ca44f7
Merge branch 'main' into csharp-property-encode
RodgeFu Sep 18, 2025
929d140
update per comments
RodgeFu Sep 19, 2025
35ed690
Merge branch 'main' into csharp-property-encode
RodgeFu Sep 19, 2025
2d47d07
Merge remote-tracking branch 'upstream/main' into csharp-property-encode
RodgeFu Sep 25, 2025
d5d4861
update per feedbacks
RodgeFu Sep 25, 2025
7cf5bb4
Delete pnpm-lock.yaml.orig
RodgeFu Sep 25, 2025
062f88a
Merge branch 'main' into csharp-property-encode
RodgeFu Sep 25, 2025
03f6eea
Merge branch 'main' into csharp-property-encode
RodgeFu Oct 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/csharp-property-encode-2025-8-10-15-50-46.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/emitter-framework"
---

[c#] Support encode when generating csharp property
2 changes: 2 additions & 0 deletions packages/emitter-framework/src/csharp/components/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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 (
<Output program={tester.program} namePolicy={policy}>
<SourceFile path="test.cs">{props.children}</SourceFile>
</Output>
);
}

const fakeJsonConverterKey = namekey("FakeJsonConverter");
function FakeJsonConverter() {
return (
<JsonConverter
name={fakeJsonConverterKey}
type={$(tester.program).builtin.string}
decodeAndReturn={(reader) => {
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(
<Wrapper>
<JsonConverterResolver.Provider value={createTestJsonConverterResolver()}>
{code`Resolved JsonConverter: ${useJsonConverterResolver()?.listResolvedJsonConverters().length}`}
</JsonConverterResolver.Provider>
</Wrapper>,
).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(
<Wrapper>
<JsonConverterResolver.Provider value={createTestJsonConverterResolver()}>
<List>
<Property type={r.prop1} jsonAttributes />
<br />
<For each={useJsonConverterResolver()?.listResolvedJsonConverters() ?? []}>
{(x) => <>{x.converter}</>}
</For>
</List>
</JsonConverterResolver.Provider>
</Wrapper>,
).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<string>
{
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);
}
}`);
});
Original file line number Diff line number Diff line change
@@ -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<useJsonConverterResolver>();

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<string, JsonConverterInfo>();
const customConverters = new Map<string, JsonConverterInfo>();

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<T> 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: <TimeSpanSecondsJsonConverter name={key} encodeType={encodeData.type} />,
};
} else if (
unwrappedType === $.builtin.duration &&
encodeData.encoding === ENCODING_DURATION_ISO8601
) {
const key = namekey(`TimeSpanIso8601JsonConverter`);
return {
namekey: key,
converter: <TimeSpanIso8601JsonConverter name={key} />,
};
} else {
// TODO: support other known encodings
return undefined;
}
}
}
Original file line number Diff line number Diff line change
@@ -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 (
<Output program={tester.program} namePolicy={policy}>
<SourceFile path="test.cs">{props.children}</SourceFile>
</Output>
);
}

const fakeJsonConverterKey = namekey("FakeJsonConverter");
function FakeJsonConverter() {
return (
<JsonConverter
name={fakeJsonConverterKey}
type={$(tester.program).builtin.string}
decodeAndReturn={(reader) => {
return code`return ${reader}.GetString();`;
}}
encodeAndWrite={(writer, value) => {
return code`${writer}.WriteStringValue(${value});`;
}}
/>
);
}

it("Custom JsonConverter", async () => {
await tester.compile(t.code``);
expect(
<Wrapper>
<FakeJsonConverter />
</Wrapper>,
).toRenderTo(`
using System;
using System.Text.Json;
using System.Text.Json.Serialization;

internal sealed class FakeJsonConverter : JsonConverter<string>
{
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(
<Wrapper>
<TimeSpanIso8601JsonConverter />
</Wrapper>,
).toRenderTo(`
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml;

internal sealed class TimeSpanIso8601JsonConverter : JsonConverter<TimeSpan>
{
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(
<Wrapper>
<TimeSpanSecondsJsonConverter encodeType={type} />
</Wrapper>,
).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<TimeSpan>
{
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);
}
}
`);
});
});
});
Loading
Loading