diff --git a/Rest.Net.Example/Program.cs b/Rest.Net.Example/Program.cs index 5356a89..ae53d73 100644 --- a/Rest.Net.Example/Program.cs +++ b/Rest.Net.Example/Program.cs @@ -7,6 +7,7 @@ using System.Net; using System.IO; using MontoyaTech.Rest.Net; using System.Net.Mime; +using System.Collections; namespace MontoyaTech.Rest.Net.Example { @@ -16,6 +17,8 @@ namespace MontoyaTech.Rest.Net.Example { public string Id; + public char FirstInitial; + public UserRole Role { get; set; } public List Permissions; @@ -39,6 +42,8 @@ namespace MontoyaTech.Rest.Net.Example public List List = null; + public string[] Array = null; + public ulong Property { get; set; } public User() { } diff --git a/Rest.Net/Rest.Net.csproj b/Rest.Net/Rest.Net.csproj index c29ba1f..09d50cc 100644 --- a/Rest.Net/Rest.Net.csproj +++ b/Rest.Net/Rest.Net.csproj @@ -17,7 +17,7 @@ MontoyaTech.Rest.Net MontoyaTech.Rest.Net True - 1.4.8 + 1.4.9 Logo_Symbol_Black_Outline.png diff --git a/Rest.Net/RestCSharpClientGenerator.cs b/Rest.Net/RestCSharpClientGenerator.cs index d52e9bd..73d264e 100644 --- a/Rest.Net/RestCSharpClientGenerator.cs +++ b/Rest.Net/RestCSharpClientGenerator.cs @@ -33,6 +33,8 @@ namespace MontoyaTech.Rest.Net var writer = new CodeWriter(); + writer.WriteLine("//Generated using MontoyaTech.Rest.Net"); + writer.WriteLine("using System;"); writer.WriteLine("using System.Net;"); writer.WriteLine("using System.Net.Http;"); @@ -233,7 +235,7 @@ namespace MontoyaTech.Rest.Net { writer.WriteBreak(); - if (field.DeclaringType.IsEnum) + if (field.DeclaringType != null && field.DeclaringType.IsEnum) writer.WriteLine($"{field.Name} = {field.GetRawConstantValue()},"); else writer.WriteLine($"public {this.GetTypeFullyResolvedName(field.FieldType)} {field.Name};"); @@ -280,13 +282,13 @@ namespace MontoyaTech.Rest.Net //Output the client instance if (!this.StaticCode) - writer.WriteBreak().WriteLine("public Client Client;"); + writer.WriteBreak().WriteLine($"public {this.ClientName} Client;"); //Output the constuctor if not static. if (!this.StaticCode) { - writer.WriteBreak().WriteLine($"public {name}Api(Client client)").WriteLine("{").Indent(); + writer.WriteBreak().WriteLine($"public {name}Api({this.ClientName} client)").WriteLine("{").Indent(); writer.WriteLine("this.Client = client;"); diff --git a/Rest.Net/RestClientGenerator.cs b/Rest.Net/RestClientGenerator.cs index d45fe80..706ad9a 100644 --- a/Rest.Net/RestClientGenerator.cs +++ b/Rest.Net/RestClientGenerator.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.ComponentModel.Design; using System.Data; using System.Linq; using System.Net.Http; @@ -22,6 +23,25 @@ namespace MontoyaTech.Rest.Net /// public string ClientName = "Client"; + /// + /// Generates a Rest Client from a RouteListener and returns the code. + /// + /// + public string Generate(RouteListener listener) + { + return this.Generate(listener.Routes); + } + + /// + /// Generates a Rest Client from a set of routes and returns the code. + /// + /// + /// + public virtual string Generate(List routes) + { + throw new NotImplementedException(); + } + /// /// Returns whether or not a given type belongs to DotNet. /// @@ -322,6 +342,25 @@ namespace MontoyaTech.Rest.Net } } + /// + /// Returns the default value for a given type. + /// + /// + /// + protected internal virtual string GetTypeDefaultValue(Type type) + { + var typeCode = Type.GetTypeCode(type); + + if (type.IsEnum) + return "0"; + else if (typeCode == TypeCode.String || typeCode == TypeCode.Object) + return "null"; + else if (typeCode == TypeCode.Char) + return "'\0'"; + else + return Activator.CreateInstance(type).ToString(); + } + /// /// Finds all the route groupings from a set of routes and returns those groups. /// @@ -358,22 +397,43 @@ namespace MontoyaTech.Rest.Net } /// - /// Generates a Rest Client from a RouteListener and returns the code. + /// Sorts a list of types by the order of which should be declared first. Useful + /// for languages that require classes to be dependency ordered. /// + /// /// - public string Generate(RouteListener listener) + protected internal virtual List SortTypesByDependencies(List types) { - return this.Generate(listener.Routes); - } + var dependencies = new Dictionary>(); - /// - /// Generates a Rest Client from a set of routes and returns the code. - /// - /// - /// - public virtual string Generate(List routes) - { - throw new NotImplementedException(); + //Find all the inner dependencies of the types + foreach (var type in types) + if (!dependencies.ContainsKey(type)) + dependencies.Add(type, FindTypeDependencies(type).ToHashSet()); + + //Now begin sorting these dependencies + var sorted = new LinkedList>>(); + foreach (var dependency in dependencies) + { + var best = sorted.Last; + var curr = sorted.Last; + while (curr != null) + { + if (dependency.Value.Contains(curr.Value.Key)) + break; + else if (curr.Value.Value.Contains(dependency.Key)) + best = curr.Previous; + + curr = curr.Previous; + } + + if (best == null) + sorted.AddFirst(dependency); + else + best.List.AddAfter(best, dependency); + } + + return sorted.Select(value => value.Key).ToList(); } } } diff --git a/Rest.Net/RestJavascriptClientGenerator.cs b/Rest.Net/RestJavascriptClientGenerator.cs new file mode 100644 index 0000000..7681a87 --- /dev/null +++ b/Rest.Net/RestJavascriptClientGenerator.cs @@ -0,0 +1,567 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace MontoyaTech.Rest.Net +{ + /// + /// The outline of a rest client generator that can generate a javascript client. + /// + public class RestJavascriptClientGenerator : RestClientGenerator + { + /// + /// Whether or not to generate static code, if true the client will be static. + /// + public bool StaticCode = false; + + /// + /// Generates a Javascript Client from a given set of routes and returns it. + /// + /// + /// + public override string Generate(List routes) + { + var includedTypes = this.FindRoutesDependencies(routes); + + var routeGroups = this.FindRouteGroups(routes); + + var writer = new CodeWriter(); + + writer.WriteLine("//Generated using MontoyaTech.Rest.Net"); + + writer.WriteBreak().WriteLine($"class {this.ClientName} {{").Indent(); + + //Create the base url field + if (this.StaticCode) + { + writer.WriteBreak().WriteLine("/** @type {string} */"); + writer.WriteLine("static BaseUrl = null;"); + } + else + { + writer.WriteBreak().WriteLine("/** @type {string} */"); + writer.WriteLine("BaseUrl = null;"); + } + + //Create fields foreach route group so they can be accessed. + if (!this.StaticCode) + { + foreach (var group in routeGroups) + { + writer.WriteBreak().WriteLine($"/** @type {{{group.Key}Api}} */"); + writer.WriteLine($"{group.Key} = null;"); + } + } + + //Create the client constructor or init method + if (this.StaticCode) + { + writer.WriteBreak().WriteLine("/** @param {string} baseUrl */"); + writer.Write("static init(baseUrl) ").WriteLine("{").Indent(); + + //Make sure the baseUrl isn't null or whitespace + writer.WriteBreak().WriteLine("if (baseUrl == null || baseUrl == undefined || baseUrl.trim() == '') {").Indent(); + writer.WriteLine("throw 'baseUrl cannot be null or empty.';"); + writer.Outdent().WriteLine("}"); + + //If the baseUrl ends with a /, remove it. + writer.WriteBreak().WriteLine("if (baseUrl.endsWith('/')) {").Indent(); + writer.WriteLine("baseUrl = baseUrl.substring(0, baseUrl.length - 1);"); + writer.Outdent().WriteLine("}"); + + //Store the baseUrl + writer.WriteBreak().WriteLine($"{this.ClientName}.BaseUrl = baseUrl;"); + + writer.Outdent().WriteLine("}"); + } + else + { + writer.WriteBreak().WriteLine("/** @param {string} baseUrl */"); + writer.Write("constructor(baseUrl) ").WriteLine("{").Indent(); + + //Make sure the baseUrl isn't null or whitespace + writer.WriteBreak().WriteLine("if (baseUrl == null || baseUrl == undefined || baseUrl.trim() == '') {").Indent(); + writer.WriteLine("throw 'baseUrl cannot be null or empty.';"); + writer.Outdent().WriteLine("}"); + + //If the baseUrl ends with a /, remove it. + writer.WriteBreak().WriteLine("if (baseUrl.endsWith('/')) {").Indent(); + writer.WriteLine("baseUrl = baseUrl.substring(0, baseUrl.length - 1);"); + writer.Outdent().WriteLine("}"); + + //Store the baseUrl + writer.WriteBreak().WriteLine("this.BaseUrl = baseUrl;"); + + //Init all the route group fields + writer.WriteBreak(); + foreach (var group in routeGroups) + writer.WriteLine($"this.{group.Key} = new {group.Key}Api(this);"); + + writer.Outdent().WriteLine("}"); + } + + writer.Outdent().WriteLine("}"); + + this.GenerateJavascriptRouteGroups(routeGroups, writer); + + includedTypes = this.SortTypesByDependencies(includedTypes); + + this.GenerateJavascriptIncludedTypes(includedTypes, writer); + + writer.WriteBreak().WriteLine($"window.{this.ClientName} = {this.ClientName};"); + + writer.WriteBreak().WriteLine($"export {{ {this.ClientName} }};"); + + return writer.ToString(); + } + + protected internal override string GetTypeFullyResolvedName(Type type) + { + var typeCode = Type.GetTypeCode(type); + + if (typeof(Array).IsAssignableFrom(type)) + { + return $"Array<{this.GetTypeFullyResolvedName(type.GetElementType())}>"; + } + else if (typeof(IList).IsAssignableFrom(type)) + { + var builder = new StringBuilder(); + + builder.Append("Array"); + + var genericArguments = type.GetGenericArguments(); + + if (genericArguments != null && genericArguments.Length > 0) + { + builder.Append("<"); + + for (int i = 0; i < genericArguments.Length; i++) + { + if (i > 0) + builder.Append(", "); + + builder.Append(this.GetTypeFullyResolvedName(genericArguments[i])); + } + + builder.Append(">"); + } + + return builder.ToString(); + } + else if (typeCode == TypeCode.String || typeCode == TypeCode.Char) + { + return "string"; + } + else if (typeCode == TypeCode.Boolean) + { + return "boolean"; + } + else if (typeCode == TypeCode.DBNull) + { + return "object"; + } + else if (typeCode == TypeCode.DateTime) + { + return "string"; + } + else if (typeCode == TypeCode.Object) + { + if (type.DeclaringType != null && !IsTypeDotNet(type.DeclaringType)) + return $"{this.GetTypeFullyResolvedName(type.DeclaringType)}{base.GetTypeFullyResolvedName(type)}"; + else + return base.GetTypeFullyResolvedName(type); + } + else + { + return "number"; + } + } + + protected internal override string GetTypeDefaultValue(Type type) + { + var typeCode = Type.GetTypeCode(type); + + if (typeCode == TypeCode.Char || typeCode == TypeCode.DateTime) + return "null"; + + return base.GetTypeDefaultValue(type); + } + + protected internal virtual void GenerateJavascriptIncludedTypes(List types, CodeWriter writer) + { + foreach (var type in types) + { + bool subType = false; + + //See if this type belongs to another type in the list. + if (type.DeclaringType != null) + for (int i = 0; i < types.Count && !subType; i++) + if (type.DeclaringType == types[i]) + subType = true; + + //If not, generate the code for this type. + if (!subType) + this.GenerateJavascriptIncludedType(type, types, writer); + } + } + + protected internal virtual void GenerateJavascriptIncludedType(Type type, List types, CodeWriter writer) + { + writer.WriteBreak(); + + var newName = type.GetCustomAttribute(); + + writer.Write($"class {(type.DeclaringType != null ? type.DeclaringType.Name : "")}{(newName != null ? newName.Name : type.Name)}"); + + if (!type.IsEnum && !(IsTypeDotNet(type.BaseType) && type.BaseType.Name == "Object")) + writer.Write(" extends ").Write(this.GetTypeFullyResolvedName(type.BaseType)); + + writer.WriteLine(" {").Indent(); + + FieldInfo[] fields; + + if (type.IsEnum) + fields = type.GetFields(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Static); + else + fields = type.GetFields(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public); + + foreach (var field in fields) + this.GenerateJavascriptIncludedField(field, writer); + + var properties = type.GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public).Where(property => property.GetSetMethod() != null && property.GetGetMethod() != null).ToArray(); + + foreach (var property in properties) + this.GenerateJavascriptIncludedProperty(property, writer); + + //Generate a helper constructor + writer.WriteBreak().WriteLine("/**").Indent(); + writer.WriteLine("@method"); + + foreach (var field in fields) + writer.WriteLine($"@param {{{this.GetTypeFullyResolvedName(field.FieldType)}}} {field.Name}"); + + foreach (var property in properties) + writer.WriteLine($"@param {{{this.GetTypeFullyResolvedName(property.PropertyType)}}} {property.Name}"); + + writer.Outdent().WriteLine("*/"); + writer.Write("constructor("); + + foreach (var field in fields) + writer.WriteSeparator().Write(field.Name).Write(" = ").Write(this.GetTypeDefaultValue(field.FieldType)); + + foreach (var property in properties) + writer.WriteSeparator().Write(property.Name).Write(" = ").Write(this.GetTypeDefaultValue(property.PropertyType)); + + writer.WriteLine(") {").Indent(); + + foreach (var field in fields) + writer.Write("this.").Write(field.Name).Write(" = ").Write(field.Name).WriteLine(";"); + + foreach (var property in properties) + writer.Write("this.").Write(property.Name).Write(" = ").Write(property.Name).WriteLine(";"); + + writer.Outdent().WriteLine("}"); + + writer.Outdent().WriteLine("}"); + + writer.WriteBreak().WriteLine($"{this.ClientName}.{(type.DeclaringType != null ? type.DeclaringType.Name : "")}{(newName != null ? newName.Name : type.Name)} = {(type.DeclaringType != null ? type.DeclaringType.Name : "")}{(newName != null ? newName.Name : type.Name)};"); + + //Generate any types that belong to this. + for (int i = 0; i < types.Count; i++) + if (types[i].DeclaringType == type) + GenerateJavascriptIncludedType(types[i], types, writer); + } + + protected internal virtual void GenerateJavascriptIncludedField(FieldInfo field, CodeWriter writer) + { + writer.WriteBreak(); + + if (field.DeclaringType != null && field.DeclaringType.IsEnum) + { + writer.WriteLine("/** @type {number} */"); + writer.WriteLine($"static {field.Name} = {field.GetRawConstantValue()};"); + } + else + { + writer.WriteLine($"/** @type {{{GetTypeFullyResolvedName(field.FieldType)}}} */"); + writer.WriteLine($"{field.Name} = {GetTypeDefaultValue(field.FieldType)};"); + } + } + + protected internal virtual void GenerateJavascriptIncludedProperty(PropertyInfo property, CodeWriter writer) + { + writer.WriteBreak(); + + writer.WriteLine($"/** @type {{{GetTypeFullyResolvedName(property.PropertyType)}}} */"); + writer.WriteLine($"{property.Name} = {GetTypeDefaultValue(property.PropertyType)};"); + } + + protected internal virtual void GenerateJavascriptRouteGroups(Dictionary> groups, CodeWriter writer) + { + foreach (var group in groups) + this.GenerateJavascriptRouteGroup(group.Key, group.Value, writer); + } + + protected internal virtual void GenerateJavascriptRouteGroup(string name, List routes, CodeWriter writer) + { + writer.WriteBreak(); + + //Output the class header + if (this.StaticCode) + writer.WriteLine($"class {name} {{").Indent(); + else + writer.WriteLine($"class {name}Api {{").Indent(); + + //Output the client instance + if (!this.StaticCode) + { + writer.WriteBreak().WriteLine($"/** @type {{{this.ClientName}}} */"); + writer.WriteLine("Client = null;"); + } + + //Output the constuctor if not static. + if (!this.StaticCode) + { + writer.WriteBreak(); + + writer.WriteLine("/**").Indent(); + writer.WriteLine("@method"); + writer.WriteLine($"@param {{{this.ClientName}}} client"); + writer.Outdent().WriteLine("*/"); + + writer.Write($"constructor(client) ").WriteLine("{").Indent(); + + writer.WriteLine("this.Client = client;"); + + writer.Outdent().WriteLine("}"); + } + + //Output all the route functions. + foreach (var route in routes) + this.GenerateJavascriptRouteFunction(route, writer); + + writer.Outdent().WriteLine("}"); + + //Expose this route group on the client. + if (this.StaticCode) + writer.WriteBreak().WriteLine($"{this.ClientName}.{name} = {name};"); + else + writer.WriteBreak().WriteLine($"{this.ClientName}.{name}Api = {name}Api;"); + } + + protected internal virtual void GenerateJavascriptRouteFunction(Route route, CodeWriter writer) + { + writer.WriteBreak(); + + var methodInfo = route.GetTarget().GetMethodInfo(); + + var routeName = methodInfo.GetCustomAttribute(); + + var routeRequest = methodInfo.GetCustomAttribute(); + + var routeResponse = methodInfo.GetCustomAttribute(); + + var parameters = methodInfo.GetParameters(); + + //Generate the function jsdoc tags + writer.WriteLine("/**").Indent(); + + writer.WriteLine("@method"); + writer.WriteLine("@async"); + writer.WriteLine($"@name {(routeName == null ? methodInfo.Name : routeName.Name)}"); + + //Generate parameter docs + if (parameters != null) + for (int i = 1; i < parameters.Length; i++) + writer.WriteLine($"@param {{{this.GetTypeFullyResolvedName(parameters[i].ParameterType)}}} {parameters[i].Name}"); + + //Generate request doc if any + if (routeRequest != null) + writer.WriteLine($"@param {{{this.GetTypeFullyResolvedName(routeRequest.RequestType)}}} request"); + + //Generate response doc if any + if (routeResponse != null) + writer.WriteLine($"@returns {{{this.GetTypeFullyResolvedName(routeResponse.ResponseType)}}}"); + + writer.Outdent().WriteLine("*/"); + + //Generate the route function header + if (this.StaticCode) + writer.Write($"static async {(routeName == null ? methodInfo.Name : routeName.Name)}("); + else + writer.Write($"async {(routeName == null ? methodInfo.Name : routeName.Name)}("); + + //Generate the functions parameters + if (parameters != null) + { + for (int i = 1; i < parameters.Length; i++) + { + writer.WriteSeparator(); + writer.Write(parameters[i].Name); + } + } + + if (routeRequest != null) + { + writer.WriteSeparator(); + writer.Write("request"); + } + + if (routeResponse != null && routeResponse.Parameter) + { + writer.WriteSeparator(); + writer.Write("input"); + } + + writer.WriteLine(") {").Indent(); + + //Generate function body + + writer.WriteLine("var response = await fetch(").Indent(); + + //Generate the request url + if (this.StaticCode) + writer.WriteSeparator().Write('`').Write($"${{{this.ClientName}.BaseUrl}}"); + else + writer.WriteSeparator().Write('`').Write("${this.Client.BaseUrl}"); + + //Reconstruct the route syntax into a request url. + var components = route.Syntax.Split('/'); + int argumentIndex = 0; + foreach (var component in components) + { + if (!string.IsNullOrWhiteSpace(component)) + { + writer.Write('/'); + + if (component.StartsWith("{")) + { + writer.Write("${").Write(parameters[argumentIndex++ + 1].Name).Write("}"); + } + else if (component == "*") + { + writer.Write("*"); + } + else if (component == "**") + { + break; + } + else + { + writer.Write(component); + } + } + } + + writer.WriteLine("`, {").Indent(); + + //Include credentials + writer.WriteLine("credentials: 'include',"); + + //Generate the method + writer.WriteLine($"method: '{route.Method.ToLower()}',"); + + //If we have a body, generate that + if (routeRequest != null) + { + if (routeRequest.RequestType.IsAssignableTo(typeof(Stream))) + { + //TODO + } + else if (routeRequest.Json) + { + writer.WriteLine("body: JSON.stringify(request),"); + } + else + { + writer.WriteLine("body: request.toString(),"); + } + } + + writer.Outdent().WriteLine("}"); + + writer.Outdent().WriteLine(");"); + + if (routeResponse != null) + { + writer.WriteBreak().WriteLine("if (response.ok) {").Indent(); + + if (routeResponse.ResponseType.IsAssignableTo(typeof(Stream))) + { + if (routeResponse.Parameter) + { + //TODO + } + else + { + //TODO + } + } + else + { + if (routeResponse.Json) + { + writer.WriteBreak().WriteLine("return await response.json();"); + } + else + { + switch (Type.GetTypeCode(routeResponse.ResponseType)) + { + case TypeCode.Boolean: + writer.WriteBreak().WriteLine("return (await(response.text())).toLowerCase() === 'true';"); + break; + + case TypeCode.Char: + writer.WriteBreak().WriteLine("return (await(response.text()))[0];"); + break; + + case TypeCode.DateTime: + writer.WriteBreak().WriteLine("return Date.parse((await(response.text())));"); + break; + + case TypeCode.String: + writer.WriteBreak().WriteLine("return await response.text();"); + break; + + case TypeCode.Byte: + case TypeCode.Decimal: + case TypeCode.Double: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.SByte: + case TypeCode.Single: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + writer.WriteBreak().WriteLine("return Number((await(response.text())));"); + break; + + case TypeCode.Object: + throw new NotSupportedException("ResponseType isn't JSON but is an object."); + } + } + } + + writer.Outdent().WriteLine("} else {").Indent(); + writer.WriteLine("throw `Unexpected http response status: ${response.status}`;"); + writer.Outdent().WriteLine("}"); + } + else + { + writer.WriteBreak().WriteLine("if (!response.ok) {").Indent(); + writer.WriteLine("throw `Unexpected http response status: ${response.status}`;"); + writer.Outdent().WriteLine("}"); + } + + //Close off the route function. + writer.Outdent().WriteLine("}"); + } + } +} diff --git a/Rest.Net/RouteListener.cs b/Rest.Net/RouteListener.cs index 21b66a2..485a8ea 100644 --- a/Rest.Net/RouteListener.cs +++ b/Rest.Net/RouteListener.cs @@ -240,7 +240,7 @@ namespace MontoyaTech.Rest.Net /// The name of the Client. Default is Client. /// Whether or not to generate a static client. /// - public string GenerateCSharpClient(string clientName = "Client", bool staticCode =false) + public string GenerateCSharpClient(string clientName = "Client", bool staticCode = false) { var generator = new RestCSharpClientGenerator(); @@ -249,5 +249,21 @@ namespace MontoyaTech.Rest.Net return generator.Generate(this); } + + /// + /// Generates a Javascript client from this Route Listener and returns the code. + /// + /// + /// + /// + public string GenerateJavascriptClient(string clientName = "Client", bool staticCode = false) + { + var generator = new RestJavascriptClientGenerator(); + + generator.ClientName = clientName; + generator.StaticCode = staticCode; + + return generator.Generate(this); + } } }