From c408045e31e4cfc91df8ffd607786f36b4929515 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 22 Feb 2018 22:51:12 +0000 Subject: [PATCH] In RazorCompiler, support @inject directive --- .../BlazorRazorEngine.cs | 1 + .../InjectDirective.cs | 116 ++++++++++++++++++ .../RazorCompilerTest.cs | 69 +++++++++-- 3 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/InjectDirective.cs diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRazorEngine.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRazorEngine.cs index b8d708402d..fab6f1136d 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRazorEngine.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRazorEngine.cs @@ -25,6 +25,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor { FunctionsDirective.Register(configure); InheritsDirective.Register(configure); + InjectDirective.Register(configure); TemporaryLayoutPass.Register(configure); TemporaryImplementsPass.Register(configure); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/InjectDirective.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/InjectDirective.cs new file mode 100644 index 0000000000..3ab3a1f91e --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/InjectDirective.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + // Much of the following is equivalent to Microsoft.AspNetCore.Mvc.Razor.Extensions's InjectDirective, + // but this one outputs properties annotated for Blazor's property injector, plus it doesn't need to + // support multiple CodeTargets. + + internal class InjectDirective + { + const string InjectAttributeTypeName = "Microsoft.AspNetCore.Blazor.Components.InjectAttribute"; + + public static readonly DirectiveDescriptor Directive = DirectiveDescriptor.CreateDirective( + "inject", + DirectiveKind.SingleLine, + builder => + { + builder.Tokens.Add(DirectiveTokenDescriptor.CreateToken( + DirectiveTokenKind.Type, optional: false)); + builder.Tokens.Add(DirectiveTokenDescriptor.CreateToken( + DirectiveTokenKind.Member, optional: false)); + builder.Usage = DirectiveUsage.FileScopedMultipleOccurring; + builder.Description = "Inject a service from the application's service container into a property."; + }); + + public static void Register(IRazorEngineBuilder builder) + { + builder.AddDirective(Directive); + builder.Features.Add(new Pass()); + } + + private class Pass : IntermediateNodePassBase, IRazorDirectiveClassifierPass + { + protected override void ExecuteCore( + RazorCodeDocument codeDocument, + DocumentIntermediateNode documentNode) + { + var visitor = new Visitor(); + visitor.Visit(documentNode); + + var properties = new HashSet(StringComparer.Ordinal); + var classNode = documentNode.FindPrimaryClass(); + + for (var i = visitor.Directives.Count - 1; i >= 0; i--) + { + var directive = visitor.Directives[i]; + var tokens = directive.Tokens.ToArray(); + if (tokens.Length < 2) + { + continue; + } + + var typeName = tokens[0].Content; + var memberName = tokens[1].Content; + + if (!properties.Add(memberName)) + { + continue; + } + + classNode.Children.Add(new InjectIntermediateNode(typeName, memberName)); + } + } + + private class Visitor : IntermediateNodeWalker + { + public IList Directives { get; } + = new List(); + + public override void VisitDirective(DirectiveIntermediateNode node) + { + if (node.Directive == Directive) + { + Directives.Add(node); + } + } + } + + internal class InjectIntermediateNode : ExtensionIntermediateNode + { + private static readonly IList _injectedPropertyModifiers = new[] + { + $"[global::{InjectAttributeTypeName}]", + "private" // Encapsulation is the default + }; + + public string TypeName { get; } + public string MemberName { get; } + public override IntermediateNodeCollection Children => IntermediateNodeCollection.ReadOnly; + + public InjectIntermediateNode(string typeName, string memberName) + { + TypeName = typeName; + MemberName = memberName; + } + + public override void Accept(IntermediateNodeVisitor visitor) + => AcceptExtensionNode(this, visitor); + + public override void WriteNode(CodeTarget target, CodeRenderingContext context) + => context.CodeWriter.WriteAutoPropertyDeclaration( + _injectedPropertyModifiers, + TypeName, + MemberName); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs index 59bb95983c..013f3d74f0 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs @@ -366,7 +366,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public void SupportsChildComponentsViaTemporarySyntax() { // Arrange/Act - var testComponentTypeName = typeof(TestComponent).FullName.Replace('+', '.'); + var testComponentTypeName = FullTypeName(); var component = CompileToComponent($""); var frames = GetRenderTree(component); @@ -379,8 +379,8 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public void CanPassParametersToComponents() { // Arrange/Act - var testComponentTypeName = typeof(TestComponent).FullName.Replace('+', '.'); - var testObjectTypeName = typeof(SomeType).FullName.Replace('+', '.'); + var testComponentTypeName = FullTypeName(); + var testObjectTypeName = FullTypeName(); // TODO: Once we have the improved component tooling and can allow syntax // like StringProperty="My string" or BoolProperty=true, update this // test to use that syntax. @@ -444,7 +444,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public void CanIncludeChildrenInComponents() { // Arrange/Act - var testComponentTypeName = typeof(TestComponent).FullName.Replace('+', '.'); + var testComponentTypeName = FullTypeName(); var component = CompileToComponent($"" + $"Some text" + $"Nested text" + @@ -470,7 +470,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public void CanNestComponentChildContent() { // Arrange/Act - var testComponentTypeName = typeof(TestComponent).FullName.Replace('+', '.'); + var testComponentTypeName = FullTypeName(); var component = CompileToComponent( $"" + $"" + @@ -514,7 +514,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public void SupportsLayoutDeclarationsViaTemporarySyntax() { // Arrange/Act - var testComponentTypeName = typeof(TestLayout).FullName.Replace('+', '.'); + var testComponentTypeName = FullTypeName(); var component = CompileToComponent( $"@(Layout<{testComponentTypeName}>())\n" + $"Hello"); @@ -532,7 +532,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public void SupportsImplementsDeclarationsViaTemporarySyntax() { // Arrange/Act - var testInterfaceTypeName = typeof(ITestInterface).FullName.Replace('+', '.'); + var testInterfaceTypeName = FullTypeName(); var component = CompileToComponent( $"@(Implements<{testInterfaceTypeName}>())\n" + $"Hello"); @@ -548,7 +548,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public void SupportsInheritsDirective() { // Arrange/Act - var testBaseClassTypeName = typeof(TestBaseClass).FullName.Replace('+', '.'); + var testBaseClassTypeName = FullTypeName(); var component = CompileToComponent( $"@inherits {testBaseClassTypeName}" + Environment.NewLine + $"Hello"); @@ -616,6 +616,51 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test }); } + [Fact] + public void SupportsInjectDirective() + { + // Arrange/Act 1: Compilation + var componentType = CompileToComponent( + $"@inject {FullTypeName()} MyService1\n" + + $"@inject {FullTypeName()} MyService2\n" + + $"Hello from @MyService1 and @MyService2").GetType(); + + // Assert 1: Compiled type has correct properties + var propertyFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.NonPublic; + var injectableProperties = componentType.GetProperties(propertyFlags) + .Where(p => p.GetCustomAttribute() != null); + Assert.Collection(injectableProperties.OrderBy(p => p.Name), + property => + { + Assert.Equal("MyService1", property.Name); + Assert.Equal(typeof(IMyService1), property.PropertyType); + Assert.False(property.GetMethod.IsPublic); + Assert.False(property.SetMethod.IsPublic); + }, + property => + { + Assert.Equal("MyService2", property.Name); + Assert.Equal(typeof(IMyService2), property.PropertyType); + Assert.False(property.GetMethod.IsPublic); + Assert.False(property.SetMethod.IsPublic); + }); + + // Arrange/Act 2: DI-supplied component has correct behavior + var serviceProvider = new TestServiceProvider(); + serviceProvider.AddService(new MyService1Impl()); + serviceProvider.AddService(new MyService2Impl()); + var componentFactory = new ComponentFactory(serviceProvider); + var component = componentFactory.InstantiateComponent(componentType); + var frames = GetRenderTree(component); + + // Assert 2: Rendered component behaves correctly + Assert.Collection(frames, + frame => AssertFrame.Text(frame, "Hello from "), + frame => AssertFrame.Text(frame, typeof(MyService1Impl).FullName), + frame => AssertFrame.Text(frame, " and "), + frame => AssertFrame.Text(frame, typeof(MyService2Impl).FullName)); + } + private static RenderTreeFrame[] GetRenderTree(IComponent component) { var renderer = new TestRenderer(); @@ -789,5 +834,13 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public class TestBaseClass : BlazorComponent { } public class SomeType { } + + public interface IMyService1 { } + public interface IMyService2 { } + public class MyService1Impl : IMyService1 { } + public class MyService2Impl : IMyService2 { } + + private static string FullTypeName() + => typeof(T).FullName.Replace('+', '.'); } }