NeVeSpl / NTypewriter

File/code generator using Scriban text templates populated with C# code metadata from Roslyn API.
https://nevespl.github.io/NTypewriter/
MIT License
116 stars 25 forks source link

Documentation should include examples #34

Open NateRadebaugh opened 3 years ago

NateRadebaugh commented 3 years ago

I'd like to explore migrating from Typewriter to NTypewriter but while this documentation is very complete, I'd like some simple/complex examples for how to migrate my classes to corresponding typescript interfaces.

NeVeSpl commented 3 years ago

I know, I know. After a new version of scriban has been released, I will translate all available examples from Typewriter documentation to NTypewriter syntax. Until then, maybe @gregveres will share some of his final *.nt scripts here as an example.

NeVeSpl commented 3 years ago

I have added a few examples to documentation.

gregveres commented 3 years ago

I am sorry I didn't get to this sooner. It slipped too far down my todo list. Here is what I am using to generate interfaces from my DTO objects. I mark my DTO C# objects with an attribute called [ExportToTypescript]. Since I am coming from Knockout, I also have another attribute called [ExportToTypescritWithKnockout] that used to create an interface and a class in Typescript. But I have dropped this with my transition to Vue.

[_Interfaces.nt]

{{- # Helper classes }}
{{- func ImportType(type)
    useType = Type.Unwrap(type)
    if (useType.ArrayType != null) 
      useType = useType.ArrayType
    end
    if (type.IsEnum) 
      path = "../Enums/"
    else
      path = "./"
    end
    if ((useType.Attributes | Array.Filter @AttrIsExportToTypescript).size > 0)
        ret "import { " | String.Append useType.Name | String.Append " } from '" | String.Append path | String.Append useType.Name | String.Append "';\r"
    end
    ret null
    end
}}
{{- func AttrIsExportToTypescript(attr)
    ret attr.Name | String.Contains "ExportToTypescript"
    end
}}

{{- # output classes }}
{{- for class in data.Classes | Symbols.ThatHaveAttribute "ExportToTypescript" | Array.Concat (data.Classes | Symbols.ThatHaveAttribute "ExportToTypescriptWithKnockout")
        capture output }}

{{- for type in (class | Type.AllReferencedTypes)}}
    {{- ImportType type}}
{{-end}}

export interface {{class.Name}}{{if class.HasBaseClass}} extends {{class.BaseClass.Name; end}} {
  {{- for prop in class.Properties | Symbols.ThatArePublic }}
  {{ prop.Name }}: {{prop.Type | Type.ToTypeScriptType}}{{if !for.last}},{{end}}
  {{-end}}
}
{{- end}}
{{- Save output ("Interfaces\\" | String.Append class.BareName | String.Append ".ts")}}
{{- end}}

This creates a file per DTO. I have a similar file for enums and I put the .ts files in a folder called src/api/Interfaces or src/api/Enums.

The services file is different enough that I will include it here. This creates a file like this for a c# controller. I use a base class for all my api controllers and that is what I use as a trigger for export.

/* eslint-disable */
import { MakeRequest } from '../ApiServiceHelper';
import { AdServeContext } from '../Enums/AdServeContext';
import { AdImage } from '../Interfaces/AdImage';

export class AdImageService {

  public static getImageRoute = (adId: number, userId: number, context: AdServeContext) => `/api/AdServer/AdImage/${adId}/image?userId=${userId}&context=${context}`;
  public static getImage(adId: number, userId: number, context: AdServeContext) : Promise<void> {
    return MakeRequest<void>("get", this.getImageRoute(adId, userId, context), null);
  }

  public static replaceImageRoute = (adId: number) => `/api/AdServer/AdImage/${adId}/image`;
  public static replaceImage(adId: number) : Promise<AdImage> {
    return MakeRequest<AdImage>("put", this.replaceImageRoute(adId), null);
  }

}

I create a route function and an api call function per controller action. There are some times when I just need the route, like when I am adding a url to a button or link. But mostly the route is used extensively in unit testing to make test that the code is calling the end right end point with the right parameters.

Here is the .nt file that generated that typescript file [_Services.nt]

{{- # Helper classes }}
{{- importedTypeNames = []}}
{{- func ImportType(type)
    if (type == null)
      ret null
    end
    useType = Type.Unwrap(type)
    if (useType.ArrayType != null) 
      useType = useType.ArrayType
    end
    if (importedTypeNames | Array.Contains useType.Name) 
      ret null
    end
    importedTypeNames[importedTypeNames.size] = useType.Name
    if (type.IsEnum) 
      path = "../Enums/"
    else
      path = "../Interfaces/"
    end
    if ((useType.Attributes | Array.Filter @AttrIsExportToTypescript).size > 0)
        ret "import { " | String.Append useType.Name | String.Append " } from '" | String.Append path | String.Append useType.Name | String.Append "';\r"
    end
    ret null
    end
}}
{{- func AttrIsExportToTypescript(attr)
    ret attr.Name | String.Contains "ExportToTypescript"
    end
}}

{{- # output services }}
{{- for controller in data.Classes | Types.ThatInheritFrom "OurBaseApiController" 
        if controller.Namespace | String.StartsWith "SkyCourt.API.Controllers.Webhooks"; continue; end
        serviceName = controller.Name | String.Replace "Controller" "Service"
        importedTypeNames = []
        capture output -}}
/* eslint-disable */
import { MakeRequest } from '../ApiServiceHelper';
{{for method in controller.Methods }}
    {{- ImportType (method | Action.ReturnType)}}
    {{-for param in method | Action.Parameters}}
        {{- ImportType param.Type}}
    {{-end}}
{{-end}}

export class {{serviceName}} {
  {{for method in controller.Methods | Symbols.ThatArePublic
        methodName = method.BareName | String.ToLowerFirst 
        routeName =  methodName | String.Append "Route"
        returnType = (method | Action.ReturnType | Type.ToTypeScriptType) ?? "void"

        url = "/" | String.Append (method | Action.Url)
        bodyParameterName = (method | Action.BodyParameter)?.Name ?? "null"
        parameters = method | Action.Parameters | Parameters.ToTypeScript | Array.Join ", "
        urlParams = method | Action.Parameters false
        routeFnParams = urlParams | Parameters.ToTypeScript | Array.Join ", "
        routeCallParmas = urlParams | Array.Map "Name" | Array.Join ", "
  }}

  public static {{routeName}} = ({{routeFnParams}}) => `{{url}}`;
  public static {{methodName}}({{parameters}}) : Promise<{{returnType}}> {
    return MakeRequest<{{returnType}}>("{{method | Action.HttpMethod}}", this.{{routeName}}({{routeCallParmas}}), {{bodyParameterName}});
  }
  {{end}}
}
{{- end}}
{{- Save output ("Services\\" | String.Append serviceName | String.Append ".ts")}}
{{- end}}

And then one of the most important tricks that I found was to make sure you limit the projects to be searched to the subset of projects that contain items to export. I have 12 unit test projects and 6 WebJob related projects and when they were included in the search, a run of NTypewriter would take many seconds, a very noticable amount of time. But when I limited the projects to be searched to the 5 main projects in my solution, the run time was dropped to 1/2 a second.

Also, I stopped adding the .ts files to the VS project. This also saves many seconds. I know this isn't NTypewriter's fault, VS just takes a long time doing file operations. In my case, my UI is separate and I use VSCode to develop the UI and VS for the back end. So not adding the .ts files to the VS project was feasible.

Hope that helps and again, I am sorry for the delay.

gregveres commented 2 years ago

Here is an updated example. My app has grown to the point where it makes sense to break it up from a monolithic app into one with multiple front end apps. To facilitate this I am moving to Nx to manage the monorepo and therefore moving things into libraries. Nx requires all library exports are in a barrel file. I am now creating 3 libraries on the front end that represent:

I have three files (listed above) that create the TS files for each of these three. I wanted to share the latest change to them since this illustrates a new concept - writing multiple files from the same template file. I am going to use the Enums.nt file from above and add writing an index.ts file that exports all of the enums from the single file.

To do this, I capture the export line for each enum I encounter and then I save that file after the loop ends. Another change that I did at the same time, is that I now sort the list of Enums by name so that when I output this index.ts file, the enums are ordered in alphabetical order. This just aids in finding a specific enum.

{{ $barrelFile = "" }}
{{- for enum in data.Enums | Symbols.ThatHaveAttribute "ExportToTypescript" | Array.Sort "Name" -}}
{{- capture output -}}
export enum {{enum.Name}} {
  {{- for enumValue in enum.Values}}
  {{ enumValue.Name}} = {{ enumValue.Value}}{{-if !for.last}},{{end}}
  {{-end}}
}
{{- end}}
{{- Save output ("..\\..\\..\\SkyCourt.UI\\skycourt\\libs\\api\\enums\\src\\lib\\" | String.Append enum.BareName | String.Append ".ts")}}
{{- capture barrelOutput -}}
export { {{enum.Name}} } from './lib/{{enum.Name}}';
{{- end -}}
{{ $barrelFile = $barrelFile | String.Append barrelOutput | String.Append "\n" }}
{{- end}}
{{- Save $barrelFile ("..\\..\\..\\SkyCourt.UI\\skycourt\\libs\\api\\enums\\src\\index.ts")}}

Hope that helps others. I am really happy I switched from NTypewriter from Typewriter.

gregveres commented 2 years ago

Here is another example.

I have been struggling with optional values. I am stuck using Asp.Net (not Core) so I am stuck on C# 7.3. This means that I can't nullable reference types, which means that when I have a field in a DTO that needst to be optional, I often can not make it optional in the DTO, even though I can make it optional in the application's model. A common example I have in my domain is that I have optional dates that are part of my model. The model uses a DateTimeOffset, which can be made optional. But in my DTOs, I use strings to represent dates. When I define the C# version of the DTO, I can not use a string?, I have to use a string. When this gets converted to Typescript, it gets converted as a string and then I have typescript complaining when the UI code tries to assign a null to it. This has been a source of frustration that leads to lots of code like this, especially in unit tests: dto.date = null as unknown as string

Today I started using hashids for Ids that get transfered through DTOs. The interesting thing here is that in my c# code, I want to treat these Ids as ints because that is how they are stored and referenced in the database, but in the typescript code and api, they are hashed strings, so the DTO object needs to list these "ints" as strings in the DTO. I created a class called DbId to handle all of this seemlessly. I then turned my attention on how to get this class to translate to a string when NTypewriter generates the TS interface for me.

My answer was to use an attribute. This fits with my solution because I am using an "ExportToTypescript" attribute to flag the classes that NTypewriter will operate on. So I introduced a new attribute called TypescriptType with a parameter called Type that specifies the output type.

I then created a custom NTypewriter function that takes an IProperty and looks to see if the prop or the prop's type has this attribute and if it does, then I use the attribute's value as the TS type. If there is no attribute or I can't get the attribute's value, then I fall back to Type.ToTypescriptType() as the type.

I am putting the code here in case anyone else can find value from it.

First the attribute definition:

    /// <summary>
    ///     Indicates the type that should be used for this class property or class when exporting to typescript.
    ///     [TypescriptType(type = "string?")] for example this makes the property or every instance of the class
    ///     an optional string
    /// </summary>
    [AttributeUsage(AttributeTargets.Property | AttributeTargets.Class)]
    public class TypescriptType: Attribute
    {
        public string Type { get; set; }

        public override string ToString()
        {
            return Type;
        }
    }

then the custom function file (CustomTypewriter.nt.cs)

using System.Linq;
using NTypewriter.CodeModel;
using NTypewriter.CodeModel.Functions;

namespace MyNtypewriter
{
    class CustomTypewriter
    {
        public static string ToTypeScriptType(IProperty prop)
        {
            var attr = prop.Attributes.FirstOrDefault(a => a.Name == "TypescriptType") ?? prop.Type.Attributes.FirstOrDefault(a => a.Name == "TypescriptType");
            var attrType = attr?.Arguments.FirstOrDefault()?.Value ?? null;
            if (attrType != null) return attrType as string;

            return prop.Type.ToTypeScriptType();
        }
    }
}

Then finally, I use it in my .nt script like this:

...
export interface {{class.Name}}{{if class.HasBaseClass}} extends {{class.BaseClass.Name; end}} {
  {{- for prop in class.Properties | Symbols.ThatArePublic }}
  {{ prop.Name | String.ToCamelCase }}: {{prop | Custom.ToTypeScriptType }}{{if !for.last}},{{end}}
  {{-end}}
}
...

I know others have said they don't like the attribute solution, but I find it very convenient to use. 
RudeySH commented 2 years ago

FYI, you can use the latest version of C# in a .NET Framework application. I'm using C# 10 in an ASP.NET app built with .NET Framework 4.8, mostly without problems. Some of the newer features of C# won't work, but nullable reference types definitely do.

I don't use NTypewriter in this application, but I would assume NTypewriter will work just fine.