Skip to content

Commit

Permalink
feat(templates): add httpclient source-generator to Boilerplate proje…
Browse files Browse the repository at this point in the history
…ct template #6259 (#6266)
  • Loading branch information
ysmoradi authored Dec 11, 2023
1 parent 3596428 commit 8ced6fd
Show file tree
Hide file tree
Showing 52 changed files with 723 additions and 138 deletions.
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: AdminPanel CD
name: Admin Sample CD

# Project templates come equipped with CI/CD for both Azure DevOps and GitHub, providing you with a hassle-free way to get started with your new project. It is important to note that you should not depend on the contents of this file. More info at https://bitplatform.dev/templates/dev-ops

env:
API_SERVER_ADDRESS: 'https://adminpanel.bitplatform.dev/api/'
API_SERVER_ADDRESS: 'https://adminpanel.bitplatform.dev/'
APP_SERVICE_NAME: 'bit-adminpanel'

on:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: TodoSample CD
name: Todo Sample CD

# Project templates come equipped with CI/CD for both Azure DevOps and GitHub, providing you with a hassle-free way to get started with your new project. It is important to note that you should not depend on the contents of this file. More info at https://bitplatform.dev/templates/dev-ops

env:
API_SERVER_ADDRESS: 'https://todo.bitplatform.dev/api/'
API_SERVER_ADDRESS: 'https://todo.bitplatform.dev/'

on:
workflow_dispatch:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>11.0</LangVersion>
<IncludeBuildOutput>false</IncludeBuildOutput>
<IsRoslynComponent>true</IsRoslynComponent>
<PackageTags>$(PackageTags) Source-Generators</PackageTags>
<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="DoLess.UriTemplates" Version="1.4.0" PrivateAssets="all" GeneratePathProperty="true" />
<PackageReference Include="IndexRange" Version="1.0.3" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.3.1" PrivateAssets="all" />
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
Expand All @@ -27,4 +29,11 @@
</None>
</ItemGroup>

<Target Name="GetDependencyTargetPaths" AfterTargets="ResolvePackageDependenciesForBuild">
<ItemGroup>
<TargetPathWithTargetPlatformMoniker Include="$(PkgDoLess_UriTemplates)\lib\netstandard1.3\DoLess.UriTemplates.dll" IncludeRuntimeDependency="false" />
<None Include="$(PkgDoLess_UriTemplates)\lib\netstandard1.3\DoLess.UriTemplates.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Target>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.CodeAnalysis;

namespace Bit.SourceGenerators;

public class ActionParameter
{
public string Name { get; set; } = default!;

public ITypeSymbol Type { get; set; } = default!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;

namespace Bit.SourceGenerators;

public class ControllerAction
{
public IMethodSymbol Method { get; set; } = default!;

public ITypeSymbol ReturnType { get; set; } = default!;

public bool DoesReturnSomething => ReturnType.ToDisplayString() is not "System.Threading.Tasks.Task" or "System.Threading.Tasks.ValueTask";

public string HttpMethod { get; set; } = default!;

public string Url { get; set; } = default!;

public List<ActionParameter> Parameters { get; set; } = [];

public ActionParameter? BodyParameter { get; set; }

public bool HasCancellationToken { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;

namespace Bit.SourceGenerators;

[Generator]
public class HttpClientProxySourceGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() => new HttpClientProxySyntaxReceiver());
}

public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxContextReceiver is not HttpClientProxySyntaxReceiver receiver || receiver.IControllers.Any() is false)
{
return;
}

StringBuilder generatedClasses = new();

foreach (var iController in receiver.IControllers)
{
StringBuilder generatedMethods = new();

foreach (var action in iController.Actions)
{
string parameters = string.Join(", ", action.Parameters.Select(p => $"{p.Type.ToDisplayString()} {p.Name}"));

var hasQueryString = action.Url.Contains('?');

generatedMethods.AppendLine($@"
public async {action.ReturnType.ToDisplayString()} {action.Method.Name}({parameters})
{{
{$@"var url = $""{action.Url}"";"}
var dynamicQS = GetDynamicQueryString();
if (dynamicQS is not null)
{{
url += {(action.Url.Contains('?') ? "'&'" : "'?'")} + dynamicQS;
}}
{(action.DoesReturnSomething ? $@"return (await prerenderStateService.GetValue(url, async () =>
{{" : string.Empty)}
using var request = new HttpRequestMessage(HttpMethod.{action.HttpMethod}, url);
{(action.BodyParameter is not null ? $@"request.Content = JsonContent.Create({action.BodyParameter.Name}, options.GetTypeInfo<{action.BodyParameter.Type.ToDisplayString()}>());" : string.Empty)}
using var response = await httpClient.SendAsync(request{(action.HasCancellationToken ? ", cancellationToken" : string.Empty)});
{(action.DoesReturnSomething ? ($"return await response.Content.ReadFromJsonAsync(options.GetTypeInfo<{action.ReturnType.GetUnderlyingType().ToDisplayString()}>(){(action.HasCancellationToken ? ", cancellationToken" : string.Empty)});}}))!;") : string.Empty)}
}}
");
}

generatedClasses.AppendLine($@"
internal class {iController.ClassName}(HttpClient httpClient, JsonSerializerOptions options, IPrerenderStateService prerenderStateService) : AppControllerBase, {iController.Symbol.ToDisplayString()}
{{
{generatedMethods}
}}");
}

StringBuilder finalSource = new(@$"
using System.Text.Json;
using System.Web;
namespace Microsoft.Extensions.DependencyInjection;
[global::System.CodeDom.Compiler.GeneratedCode(""Bit.SourceGenerators"",""{BitSourceGeneratorUtil.GetPackageVersion()}"")]
[global::System.Diagnostics.DebuggerNonUserCode]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
internal static class IHttpClientServiceCollectionExtensions
{{
public static void AddTypedHttpClients(this IServiceCollection services)
{{
{string.Join(Environment.NewLine, receiver.IControllers.Select(i => $" services.TryAddTransient<{i.Symbol.ToDisplayString()}, {i.ClassName}>();"))}
}}
internal class AppControllerBase
{{
Dictionary<string, object?> queryString = [];
public void AddQueryString(string key, object? value)
{{
queryString.Add(key, value);
}}
public void AddQueryStrings(Dictionary<string, object?> queryString)
{{
foreach (var key in queryString.Keys)
{{
AddQueryString(key, queryString[key]);
}}
}}
protected string? GetDynamicQueryString()
{{
if (queryString is not {{ Count: > 0 }})
return null;
var collection = HttpUtility.ParseQueryString(string.Empty);
foreach (var key in queryString.Keys)
{{
collection.Add(key, queryString[key]?.ToString());
}}
queryString.Clear();
return collection.ToString();
}}
}}
{generatedClasses}
}}
");

context.AddSource($"HttpClientProxy.cs", finalSource.ToString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Collections.Generic;
using System.Linq;
using DoLess.UriTemplates;
using System.Web;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Bit.SourceGenerators;

public class HttpClientProxySyntaxReceiver : ISyntaxContextReceiver
{
public List<IController> IControllers { get; } = [];

public void OnVisitSyntaxNode(GeneratorSyntaxContext syntaxNode)
{
if (syntaxNode.Node is InterfaceDeclarationSyntax interfaceDeclarationSyntax
&& interfaceDeclarationSyntax.BaseList is not null
&& interfaceDeclarationSyntax.BaseList.Types.Any(t => t.Type.ToString() == "IAppController"))
{
var model = syntaxNode.SemanticModel.Compilation.GetSemanticModel(interfaceDeclarationSyntax.SyntaxTree);
var controllerSymbol = (ITypeSymbol)model.GetDeclaredSymbol(interfaceDeclarationSyntax)!;
bool isController = controllerSymbol.IsIController();

if (isController == true)
{
var controllerName = controllerSymbol.Name[1..].Replace("Controller", string.Empty);

var route = controllerSymbol
.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name.StartsWith("Route") is true)?
.ConstructorArguments
.FirstOrDefault()
.Value?
.ToString()
?.Replace("[controller]", controllerName) ?? string.Empty;

var actions = controllerSymbol.GetMembers()
.OfType<IMethodSymbol>()
.Where(m => m.MethodKind == MethodKind.Ordinary)
.Select(m => new ControllerAction
{
Method = m,
ReturnType = m.ReturnType,
HttpMethod = m.GetHttpMethod(),
Url = m.Name,
Parameters = m.Parameters.Select(y => new ActionParameter
{
Name = y.Name,
Type = y.Type
}).ToList()
}).ToList();

foreach (var action in actions)
{
var uriTemplate = UriTemplate.For($"{route}{action.Method.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name.StartsWith("Http") is true)?
.ConstructorArguments.FirstOrDefault().Value?.ToString()}".Replace("[action]", action.Method.Name));

foreach (var parameter in action.Parameters)
{
uriTemplate.WithParameter(parameter.Name, $"{{{parameter.Name}}}");
}

string url = HttpUtility.UrlDecode(uriTemplate.ExpandToString());

// if there is a parameter that is not a cancellation token and is not in the route template, then it is the body parameter
action.BodyParameter = action.Parameters.FirstOrDefault(p => p.Type.ToDisplayString() is not "System.Threading.CancellationToken" && url.Contains($"{{{p.Name}}}") is false);
action.HasCancellationToken = action.Parameters.Any(p => p.Type.ToDisplayString() is "System.Threading.CancellationToken");
action.Url = url;
}

IControllers.Add(new IController
{
Actions = actions,
Name = controllerName,
ClassName = controllerSymbol.Name[1..],
Symbol = controllerSymbol,
Syntax = interfaceDeclarationSyntax
});
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis;

namespace Bit.SourceGenerators;

public class IController
{
public string Name { get; set; } = default!;

public string ClassName { get; set; } = default!;

public ITypeSymbol Symbol { get; set; } = default!;

public InterfaceDeclarationSyntax Syntax { get; set; } = default!;

public List<ControllerAction> Actions { get; set; } = [];
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Linq;

namespace Microsoft.CodeAnalysis;

public static class ITypeSymbolExtensions
{
public static bool IsIController(this ITypeSymbol type)
{
return type.AllInterfaces.Any(x => x.Name == "IAppController");
}

public static string GetHttpMethod(this IMethodSymbol method)
{
return method.GetAttributes().FirstOrDefault(a => a.AttributeClass?.Name.StartsWith("Http") is true)?.AttributeClass?.Name switch
{
"HttpGetAttribute" => "Get",
"HttpPostAttribute" => "Post",
"HttpPutAttribute" => "Put",
"HttpDeleteAttribute" => "Delete",
"HttpPatchAttribute" => "Patch",
_ => "Get"
};
}

public static ITypeSymbol GetUnderlyingType(this ITypeSymbol typeSymbol)
{
return typeSymbol switch
{
INamedTypeSymbol namedTypeSymbol => namedTypeSymbol.TypeArguments.FirstOrDefault() ?? namedTypeSymbol,
_ => typeSymbol
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ variables:
AZURE_SUBSCRIPTION: 'bp-test-service-connection' # https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml#azure-resource-manager-service-connection
ConnectionStrings.SqlServerConnectionString: $(DB_CONNECTION_STRING)
AppSettings.IdentitySettings.IdentityCertificatePassword: $(API_IDENTITY_CERTIFICATE_PASSWORD)
ApiServerAddress: 'https://bp.bitplatform.dev/api/'
ApiServerAddress: 'https://bp.bitplatform.dev/'

jobs:

Expand Down
Loading

0 comments on commit 8ced6fd

Please sign in to comment.