-
Notifications
You must be signed in to change notification settings - Fork 316
Support encode when generating csharp property in emitter framework #8415
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
RodgeFu
wants to merge
28
commits into
microsoft:main
Choose a base branch
from
RodgeFu:csharp-property-encode
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
6425902
support encode
RodgeFu 7326a6e
add changelog
RodgeFu a2b264a
Merge remote-tracking branch 'upstream/main' into csharp-property-encode
RodgeFu 44f416f
use json converter for csharp encode
RodgeFu 8a940bd
add comment
RodgeFu cb34add
fix test
RodgeFu a7a3a85
fix typo
RodgeFu a37c78b
update per feedback
RodgeFu 18b9471
Merge remote-tracking branch 'upstream/main' into csharp-property-encode
RodgeFu c8a7fa0
update package reference
RodgeFu 69368f2
some update
RodgeFu d0273a4
imporve seconds converter and add test
RodgeFu 7ed82d7
some update
RodgeFu 2d99af1
Merge remote-tracking branch 'upstream/main' into csharp-property-encode
RodgeFu a241039
remove 'attribute'
RodgeFu 0499735
Revert "remove 'attribute'"
RodgeFu 810a34b
remove attribute
RodgeFu 306409f
update reference
RodgeFu 3071ce2
bump alloy-js libs
RodgeFu bfdb04c
update reference
RodgeFu 5ca44f7
Merge branch 'main' into csharp-property-encode
RodgeFu 929d140
update per comments
RodgeFu 35ed690
Merge branch 'main' into csharp-property-encode
RodgeFu 2d47d07
Merge remote-tracking branch 'upstream/main' into csharp-property-encode
RodgeFu d5d4861
update per feedbacks
RodgeFu 7cf5bb4
Delete pnpm-lock.yaml.orig
RodgeFu 062f88a
Merge branch 'main' into csharp-property-encode
RodgeFu 03f6eea
Merge branch 'main' into csharp-property-encode
RodgeFu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
.chronus/changes/csharp-property-encode-2025-8-10-15-50-46.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
120 changes: 120 additions & 0 deletions
120
...s/emitter-framework/src/csharp/components/json-converter/json-converter-resolver.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}`); | ||
}); |
117 changes: 117 additions & 0 deletions
117
packages/emitter-framework/src/csharp/components/json-converter/json-converter-resolver.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
RodgeFu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* */ | ||
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; | ||
} | ||
} | ||
} |
141 changes: 141 additions & 0 deletions
141
packages/emitter-framework/src/csharp/components/json-converter/json-converter.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
`); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.