Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .github/workflows/build-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ jobs:
- name: Generate TypeScript code for test (JSON)
run: dotnet run --project ./src/TypedSignalR.Client.TypeScript.Generator/TypedSignalR.Client.TypeScript.Generator.csproj --framework ${{ env.DOTNET_TFM }} --no-build -- --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/json

- name: Generate TypeScript code for test (JSON nullable as null)
run: dotnet run --project ./src/TypedSignalR.Client.TypeScript.Generator/TypedSignalR.Client.TypeScript.Generator.csproj --framework ${{ env.DOTNET_TFM }} --no-build -- --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/json-null --nullable null

- name: Generate TypeScript code for test (MessagePack)
run: dotnet run --project ./src/TypedSignalR.Client.TypeScript.Generator/TypedSignalR.Client.TypeScript.Generator.csproj --framework ${{ env.DOTNET_TFM }} --no-build -- --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/msgpack --serializer MessagePack --naming-style none

Expand Down
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
<PackageVersion Include="Microsoft.Build" Version="17.11.48" />
<PackageVersion Include="Microsoft.Build.Locator" Version="1.10.12" />
<PackageVersion Include="System.Text.Json" Version="10.0.0" />
<PackageVersion Include="Tapper" Version="1.14.0" />
<PackageVersion Include="Tapper.Attributes" Version="1.14.0" />
<PackageVersion Include="Tapper" Version="1.15.0" />
<PackageVersion Include="Tapper.Attributes" Version="1.15.0" />
<PackageVersion Include="TypedSignalR.Client" Version="3.6.0" />
<PackageVersion Include="TypedSignalR.Client.Attributes" Version="1.1.0" />
<PackageVersion Include="TypedSignalR.Client.DevTools" Version="1.2.4" />
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ TypedSignalR.Client.TypeScript is a library/CLI tool that analyzes SignalR hub a
- [Packages](#packages)
- [Install Using .NET Tool](#install-using-net-tool)
- [Usage](#usage)
- [Nullable Style](#nullable-style)
- [Transpile the Types Contained in Referenced Assemblies](#transpile-the-types-contained-in-referenced-assemblies)
- [Supported Types](#supported-types)
- [Built-in Supported Types](#built-in-supported-types)
Expand Down Expand Up @@ -54,6 +55,7 @@ Please see the [Install Using .NET Tool](#install-using-net-tool) and [Usage](#u

```bash
$ dotnet tsrts --project path/to/Project.csproj --output generated
$ dotnet tsrts --project path/to/Project.csproj --output generated --nullable null
```

```ts
Expand Down Expand Up @@ -187,6 +189,12 @@ This command analyzes C# and generates TypeScript code.
$ dotnet tsrts --project path/to/Project.csproj --output generated
```

If you want nullable members and nullable hub/receiver method types to use `null` instead of `undefined`, pass `--nullable null`.

```bash
$ dotnet tsrts --project path/to/Project.csproj --output generated --nullable null
```

The generated code can be used as follows.
There are two important APIs that are generated.

Expand Down Expand Up @@ -228,6 +236,24 @@ const participants = await hubProxy.getParticipants()
// ...
```

## Nullable Style

TypedSignalR.Client.TypeScript forwards Tapper's nullable strategy and also applies it to generated hub and receiver method signatures.

Use the default `undefined` style to keep nullable reference type members as optional properties.

```bash
$ dotnet tsrts --project path/to/Project.csproj --output generated --nullable undefined
```

Use `null` when you want explicit `null` unions in generated TypeScript.

```bash
$ dotnet tsrts --project path/to/Project.csproj --output generated --nullable null
```

With `--nullable null`, a nullable C# property such as `string? Text` is emitted as `text: string | null`, and a nullable hub method such as `Task<string?> EchoNullableText(string? value)` is emitted with `string | null` in both the parameter and the return type.

## Transpile the Types Contained in Referenced Assemblies

By default, only types defined in the project specified by the --project option are targeted for transpiling. By passing the --asm true option, types contained in project/package reference assemblies will also be targeted for transpiling.
Expand All @@ -244,7 +270,7 @@ Here is a brief introduction of which types are supported.

### Built-in Supported Types

`bool` `byte` `sbyte` `char` `decimal` `double` `float` `int` `uint` `long` `ulong` `short` `ushort` `object` `string` `Uri` `Guid` `DateTime` `System.Nullable<T>` `byte[]` `T[]` `System.Array` `ArraySegment<T>` `List<T>` `LinkedList<T>` `Queue<T>` `Stack<T>` `HashSet<T>` `IEnumerable<T>` `IReadOnlyCollection<T>` `ICollection<T>` `IList<T>` `ISet<T>` `Dictionary<TKey, TValue>` `IDictionary<TKey, TValue>` `IReadOnlyDictionary<TKey, TValue>` `Tuple`
`bool` `byte` `sbyte` `char` `decimal` `double` `float` `int` `uint` `long` `ulong` `short` `ushort` `object` `string` `Uri` `Guid` `DateTime` `System.Nullable<T>` (`undefined` or `null`) `byte[]` `T[]` `System.Array` `ArraySegment<T>` `List<T>` `LinkedList<T>` `Queue<T>` `Stack<T>` `HashSet<T>` `IEnumerable<T>` `IReadOnlyCollection<T>` `ICollection<T>` `IList<T>` `ISet<T>` `Dictionary<TKey, TValue>` `IDictionary<TKey, TValue>` `IReadOnlyDictionary<TKey, TValue>` `Tuple`

### User Defined Types

Expand Down Expand Up @@ -465,7 +491,7 @@ jobs:

- run: dotnet tool install --global TypedSignalR.Client.TypeScript.Generator

- run: dotnet tsrts --project ./xxx/yyyy/zzz.csproj --output ${{ github.workspace }}/generated
- run: dotnet tsrts --project ./xxx/yyyy/zzz.csproj --output ${{ github.workspace }}/generated --nullable null

- uses: actions/upload-artifact@v4
with:
Expand All @@ -479,7 +505,7 @@ Add it to your build steps in your csproj.

```xml
<Target Name="SignalRClient" AfterTargets="PostBuildEvent" Condition=" '$(Configuration)'!='Release'">
<Exec WorkingDirectory="$(ProjectDir)" Command="dotnet tsrts --project path/to/Project.csproj --output generated --asm true" ContinueOnError="WarnAndContinue" />
<Exec WorkingDirectory="$(ProjectDir)" Command="dotnet tsrts --project path/to/Project.csproj --output generated --asm true --nullable null" ContinueOnError="WarnAndContinue" />
</Target>
```

Expand Down
8 changes: 6 additions & 2 deletions src/TypedSignalR.Client.TypeScript.Generator/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public async Task Transpile(
NamingStyle namingStyle = NamingStyle.CamelCase,
[Option(Description ="Enum representation style")]
EnumStyle @enum = EnumStyle.Value,
[Option("nullable", Description ="Nullable representation style. 'undefined' keeps optional properties, 'null' emits explicit null unions.")]
NullableStrategy nullable = NullableStrategy.Undefined,
[Option('m', Description = "Method naming style. If none is selected, the C# name is used as it is.")]
MethodStyle method = MethodStyle.CamelCase,
[Option("attr", Description ="The flag whether attributes such as JsonPropertyName should affect transpilation.")]
Expand All @@ -52,7 +54,7 @@ public async Task Transpile(
{
var compilation = await this.CreateCompilationAsync(project);

await TranspileCore(compilation, output, newLine, 4, assemblies, serializer, namingStyle, @enum, method, attribute);
await TranspileCore(compilation, output, newLine, 4, assemblies, serializer, namingStyle, @enum, nullable, method, attribute);

_logger.Log(LogLevel.Information, "======== Transpilation is completed. ========");
_logger.Log(LogLevel.Information, "Please check the output folder: {output}", output);
Expand Down Expand Up @@ -91,6 +93,7 @@ private async Task TranspileCore(
SerializerOption serializerOption,
NamingStyle namingStyle,
EnumStyle enumStyle,
NullableStrategy nullableStrategy,
MethodStyle methodStyle,
bool enableAttributeReference)
{
Expand Down Expand Up @@ -124,7 +127,8 @@ private async Task TranspileCore(
newLine,
indent,
referencedAssembliesTranspilation,
enableAttributeReference
enableAttributeReference,
nullableStrategy
);

// Tapper
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"commandName": "Project",
"commandLineArgs": "--project ..\\..\\..\\..\\..\\tests\\TypedSignalR.Client.TypeScript.Tests.Shared\\TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ..\\..\\..\\..\\..\\tests\\TypeScriptTests\\src\\generated\\json"
},
"Test-json-null": {
"commandName": "Project",
"commandLineArgs": "--project ..\\..\\..\\..\\..\\tests\\TypedSignalR.Client.TypeScript.Tests.Shared\\TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ..\\..\\..\\..\\..\\tests\\TypeScriptTests\\src\\generated\\json-null --nullable null"
},
"Test-msgpack": {
"commandName": "Project",
"commandLineArgs": "--project ..\\..\\..\\..\\..\\tests\\TypedSignalR.Client.TypeScript.Tests.Shared\\TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ..\\..\\..\\..\\..\\tests\\TypeScriptTests\\src\\generated\\msgpack --serializer MessagePack --naming-style none"
Expand Down
11 changes: 5 additions & 6 deletions src/TypedSignalR.Client.TypeScript/InterfaceTranspiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using Microsoft.CodeAnalysis;
using Microsoft.Extensions.Logging;
using Tapper;
using Tapper.TypeMappers;

namespace TypedSignalR.Client.TypeScript;

Expand Down Expand Up @@ -263,15 +262,15 @@ private static void WriteParameters(IMethodSymbol methodSymbol, ITranspilationOp
return;
}

codeWriter.Append($"{parameter.Name}: {TypeMapper.MapTo(parameter.Type, options)}");
codeWriter.Append($"{parameter.Name}: {TypeScriptTypeMapper.MapTo(parameter.Type, specialSymbols, (ITypedSignalRTranspilationOptions)options)}");
return;
}

var paramStrings = methodSymbol.Parameters
.Select(x =>
SymbolEqualityComparer.Default.Equals(x.Type, specialSymbols.CancellationTokenSymbol)
? null
: $"{x.Name}: {TypeMapper.MapTo(x.Type, options)}")
: $"{x.Name}: {TypeScriptTypeMapper.MapTo(x.Type, specialSymbols, (ITypedSignalRTranspilationOptions)options)}")
.Where(x => x is not null);

codeWriter.Append(string.Join(", ", paramStrings));
Expand Down Expand Up @@ -304,7 +303,7 @@ private static void WriteReturnType(
if (SymbolEqualityComparer.Default.Equals(returnType.OriginalDefinition, specialSymbols.AsyncEnumerableSymbol))
{
var typeArg = ((INamedTypeSymbol)returnType).TypeArguments[0];
codeWriter.Append($"IStreamResult<{TypeMapper.MapTo(typeArg, options)}>");
codeWriter.Append($"IStreamResult<{TypeScriptTypeMapper.MapTo(typeArg, specialSymbols, (ITypedSignalRTranspilationOptions)options)}>");
return;
}

Expand All @@ -322,14 +321,14 @@ private static void WriteReturnType(
{
var typeArg2 = namedTypeArg.TypeArguments[0];

codeWriter.Append($"IStreamResult<{TypeMapper.MapTo(typeArg2, options)}>");
codeWriter.Append($"IStreamResult<{TypeScriptTypeMapper.MapTo(typeArg2, specialSymbols, (ITypedSignalRTranspilationOptions)options)}>");
return;
}
}
}
}

codeWriter.Append(TypeMapper.MapTo(returnType, options));
codeWriter.Append(TypeScriptTypeMapper.MapTo(returnType, specialSymbols, (ITypedSignalRTranspilationOptions)options));
}

private static XmlDocument? GetDocumentationComment(ISymbol symbol)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ static string ParametersToTypeArray(IMethodSymbol methodSymbol, SpecialSymbols s
? methodSymbol.Parameters.SkipLast(1) // Ignore if the last parameter of a receiver's method is a CancellationToken.
: methodSymbol.Parameters;

var parameters = methodParameters.Select(x => TypeMapper.MapTo(x.Type, options));
var parameters = methodParameters.Select(x => TypeScriptTypeMapper.MapTo(x.Type, specialSymbols, options));

return $"[{string.Join(", ", parameters)}]";
}
Expand All @@ -52,7 +52,7 @@ private static string ParametersToTypeScriptString(this IMethodSymbol methodSymb
{
var parameters = methodSymbol.Parameters
.Where(x => !SymbolEqualityComparer.Default.Equals(x.Type, specialSymbols.CancellationTokenSymbol))
.Select(x => $"{x.Name}: {TypeMapper.MapTo(x.Type, options)}");
.Select(x => $"{x.Name}: {TypeScriptTypeMapper.MapTo(x.Type, specialSymbols, options)}");

return string.Join(", ", parameters);
}
Expand All @@ -77,7 +77,7 @@ private static string ReturnTypeToTypeScriptString(this IMethodSymbol methodSymb
{
var typeArg = (returnType as INamedTypeSymbol)!.TypeArguments[0];

return $"IStreamResult<{TypeMapper.MapTo(typeArg, options)}>";
return $"IStreamResult<{TypeScriptTypeMapper.MapTo(typeArg, specialSymbols, options)}>";
}

// Task<IAsyncEnumerable<T>>, Task<ChannelReader<T>>
Expand All @@ -90,11 +90,11 @@ private static string ReturnTypeToTypeScriptString(this IMethodSymbol methodSymb
{
var typeArg2 = (typeArg as INamedTypeSymbol)!.TypeArguments[0];

return $"IStreamResult<{TypeMapper.MapTo(typeArg2, options)}>";
return $"IStreamResult<{TypeScriptTypeMapper.MapTo(typeArg2, specialSymbols, options)}>";
}
}

return TypeMapper.MapTo(methodSymbol.ReturnType, options);
return TypeScriptTypeMapper.MapTo(methodSymbol.ReturnType, specialSymbols, options);
}

private static string CreateUnaryMethodString(IMethodSymbol methodSymbol, SpecialSymbols specialSymbols, ITypedSignalRTranspilationOptions options)
Expand Down
76 changes: 76 additions & 0 deletions src/TypedSignalR.Client.TypeScript/TypeScriptTypeMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Microsoft.CodeAnalysis;
using Tapper;
using Tapper.TypeMappers;

namespace TypedSignalR.Client.TypeScript;

internal static class TypeScriptTypeMapper
{
public static string MapTo(ITypeSymbol typeSymbol, SpecialSymbols specialSymbols, ITypedSignalRTranspilationOptions options)
{
if (TryMapNullableValueType(typeSymbol, specialSymbols, options, out var nullableValueType))
{
return nullableValueType;
}

if (TryMapGenericWrapper(typeSymbol, specialSymbols, options, out var genericWrapper))
{
return genericWrapper;
}

var mappedType = TypeMapper.MapTo(typeSymbol, options);

if (!typeSymbol.IsValueType && typeSymbol.NullableAnnotation is NullableAnnotation.Annotated)
{
return $"({mappedType} | {options.GetNullableUnionLiteral()})";
}

return mappedType;
}

private static bool TryMapNullableValueType(ITypeSymbol typeSymbol, SpecialSymbols specialSymbols, ITypedSignalRTranspilationOptions options, out string mappedType)
{
if (typeSymbol is INamedTypeSymbol namedTypeSymbol
&& namedTypeSymbol.IsGenericType
&& namedTypeSymbol.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
{
mappedType = $"({MapTo(namedTypeSymbol.TypeArguments[0], specialSymbols, options)} | {options.GetNullableUnionLiteral()})";
return true;
}

mappedType = string.Empty;
return false;
}

private static bool TryMapGenericWrapper(ITypeSymbol typeSymbol, SpecialSymbols specialSymbols, ITypedSignalRTranspilationOptions options, out string mappedType)
{
if (typeSymbol is not INamedTypeSymbol namedTypeSymbol || !namedTypeSymbol.IsGenericType)
{
mappedType = string.Empty;
return false;
}

if (SymbolEqualityComparer.Default.Equals(namedTypeSymbol.OriginalDefinition, specialSymbols.GenericTaskSymbol))
{
mappedType = $"Promise<{MapTo(namedTypeSymbol.TypeArguments[0], specialSymbols, options)}>";
return true;
}

if (specialSymbols.AsyncEnumerableSymbol is not null
&& SymbolEqualityComparer.Default.Equals(namedTypeSymbol.OriginalDefinition, specialSymbols.AsyncEnumerableSymbol))
{
mappedType = $"Subject<{MapTo(namedTypeSymbol.TypeArguments[0], specialSymbols, options)}>";
return true;
}

if (specialSymbols.ChannelReaderSymbol is not null
&& SymbolEqualityComparer.Default.Equals(namedTypeSymbol.OriginalDefinition, specialSymbols.ChannelReaderSymbol))
{
mappedType = $"Subject<{MapTo(namedTypeSymbol.TypeArguments[0], specialSymbols, options)}>";
return true;
}

mappedType = string.Empty;
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public TypedSignalRTranspilationOptions(Compilation compilation,
SerializerOption serializerOption,
NamingStyle namingStyle,
EnumStyle enumStyle,
NullableStrategy nullableStrategy,
MethodStyle methodStyle,
NewLineOption newLineOption,
int indent,
Expand All @@ -25,7 +26,8 @@ public TypedSignalRTranspilationOptions(Compilation compilation,
newLineOption,
indent,
referencedAssembliesTranspilation,
enableAttributeReference)
enableAttributeReference,
nullableStrategy)
{
MethodStyle = methodStyle;
}
Expand Down
4 changes: 3 additions & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ $ dotnet run --project ./tests/TypedSignalR.Client.TypeScript.Tests.Server/Typed

Generate TypeScript code.
```
$ dotnet tsrts --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --out ./tests/TypeScriptTests/src/generated
$ dotnet tsrts --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/json
$ dotnet tsrts --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/json-null --nullable null
$ dotnet tsrts --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/msgpack --serializer MessagePack --naming-style none
```

Launch tests.
Expand Down
23 changes: 23 additions & 0 deletions tests/TypeScriptTests/src/json-null/nullableStrategy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getHubProxyFactory } from '../generated/json-null/TypedSignalR.Client'
import { IUnaryHub } from '../generated/json-null/TypedSignalR.Client/TypedSignalR.Client.TypeScript.Tests.Shared';
import { MyRequestItem } from '../generated/json-null/TypedSignalR.Client.TypeScript.Tests.Shared';

type Expect<T extends true> = T;
type IsEqual<A, B> =
(<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2) ? true : false;

type IsOptional<T, K extends keyof T> = {} extends Pick<T, K> ? true : false;

type TextPropertyIsRequired = Expect<IsEqual<IsOptional<MyRequestItem, 'text'>, false>>;
type TextPropertyUsesNull = Expect<IsEqual<MyRequestItem['text'], string | null>>;
type NullableMethodParameter = Expect<IsEqual<Parameters<IUnaryHub['echoNullableText']>[0], string | null>>;
type NullableMethodReturn = Expect<IsEqual<Awaited<ReturnType<IUnaryHub['echoNullableText']>>, string | null>>;

test('nullableStrategy.test', () => {
const item: MyRequestItem = { text: null };
const factory = getHubProxyFactory("IUnaryHub");

expect(item.text).toBeNull();
expect(factory).toBeDefined();
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ public Task<MyEnum> EchoMyEnum(MyEnum myEnum)
return Task.FromResult(myEnum);
}

public Task<string?> EchoNullableText(string? value)
{
_logger.Log(LogLevel.Information, "UnaryHub.EchoNullableText");

return Task.FromResult(value);
}

public Task<string> Get()
{
_logger.Log(LogLevel.Information, "UnaryHub.Get");
Expand Down
Loading