From ced2453138a5af628fbaf1cba4989a66c9a4232a Mon Sep 17 00:00:00 2001 From: James Gunn Date: Mon, 19 Aug 2024 07:52:50 +0100 Subject: [PATCH] Add a source generator to generate versioned DTOs (#1466) --- TeachingRecordSystem/Directory.Build.props | 3 +- TeachingRecordSystem/TeachingRecordSystem.sln | 17 + .../TeachingRecordSystem.Api.Generator.csproj | 14 + .../VersionedDtoGenerator.cs | 427 ++++++++++++++++++ .../TeachingRecordSystem.Api.csproj | 2 + .../V3/GenerateVersionedDtoAttribute.cs | 9 + .../Responses/FindTeachersResponse.cs | 1 - .../Controllers/TeacherController.cs | 52 +++ .../Controllers/TeachersController.cs | 9 +- .../Requests/GetTeacherRequestIncludes.cs | 25 + .../V20240416/Responses/GetTeacherResponse.cs | 7 + .../V20240606/Controllers/PersonController.cs | 9 +- .../CreateDateOfBirthChangeResponse.cs | 4 + .../Responses/CreateNameChangeResponse.cs | 4 + .../V20240606/Responses/FindPersonResponse.cs | 18 +- .../V20240606/Responses/GetPersonResponse.cs | 123 ++--- 16 files changed, 607 insertions(+), 117 deletions(-) create mode 100644 TeachingRecordSystem/gen/TeachingRecordSystem.Api.Generator/TeachingRecordSystem.Api.Generator.csproj create mode 100644 TeachingRecordSystem/gen/TeachingRecordSystem.Api.Generator/VersionedDtoGenerator.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/GenerateVersionedDtoAttribute.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeacherController.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Requests/GetTeacherRequestIncludes.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Responses/GetTeacherResponse.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/CreateDateOfBirthChangeResponse.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/CreateNameChangeResponse.cs diff --git a/TeachingRecordSystem/Directory.Build.props b/TeachingRecordSystem/Directory.Build.props index 86baf3788..760d0cf0a 100644 --- a/TeachingRecordSystem/Directory.Build.props +++ b/TeachingRecordSystem/Directory.Build.props @@ -6,12 +6,13 @@ enable true + true TeachingRecordSystem TeachingRecordSystemTests - + diff --git a/TeachingRecordSystem/TeachingRecordSystem.sln b/TeachingRecordSystem/TeachingRecordSystem.sln index ee1bb9576..2e2b58064 100644 --- a/TeachingRecordSystem/TeachingRecordSystem.sln +++ b/TeachingRecordSystem/TeachingRecordSystem.sln @@ -51,6 +51,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeachingRecordSystem.UiTest EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeachingRecordSystem.AuthorizeAccess.EndToEndTests", "tests\TeachingRecordSystem.AuthorizeAccess.EndToEndTests\TeachingRecordSystem.AuthorizeAccess.EndToEndTests.csproj", "{2D7946CB-44D2-431D-A9E1-A4472FACC1DC}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "gen", "gen", "{5E273A79-2EA3-46CD-9049-769F880868FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeachingRecordSystem.Api.Generator", "gen\TeachingRecordSystem.Api.Generator\TeachingRecordSystem.Api.Generator.csproj", "{7EA8C0A7-C149-4928-8E37-55D44976D765}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -277,6 +281,18 @@ Global {2D7946CB-44D2-431D-A9E1-A4472FACC1DC}.Release|x64.Build.0 = Release|Any CPU {2D7946CB-44D2-431D-A9E1-A4472FACC1DC}.Release|x86.ActiveCfg = Release|Any CPU {2D7946CB-44D2-431D-A9E1-A4472FACC1DC}.Release|x86.Build.0 = Release|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Debug|x64.ActiveCfg = Debug|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Debug|x64.Build.0 = Debug|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Debug|x86.ActiveCfg = Debug|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Debug|x86.Build.0 = Debug|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Release|Any CPU.Build.0 = Release|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Release|x64.ActiveCfg = Release|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Release|x64.Build.0 = Release|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Release|x86.ActiveCfg = Release|Any CPU + {7EA8C0A7-C149-4928-8E37-55D44976D765}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -300,6 +316,7 @@ Global {08E99A19-AD1F-4F95-BF13-31248D486799} = {91DCFC76-6636-4AC1-B81C-7F8AE1F22116} {B22223A8-9CB0-4B60-B516-18FD684CF01D} = {91DCFC76-6636-4AC1-B81C-7F8AE1F22116} {2D7946CB-44D2-431D-A9E1-A4472FACC1DC} = {91DCFC76-6636-4AC1-B81C-7F8AE1F22116} + {7EA8C0A7-C149-4928-8E37-55D44976D765} = {5E273A79-2EA3-46CD-9049-769F880868FE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {61D239F9-888B-4D01-84BC-9276C92383EA} diff --git a/TeachingRecordSystem/gen/TeachingRecordSystem.Api.Generator/TeachingRecordSystem.Api.Generator.csproj b/TeachingRecordSystem/gen/TeachingRecordSystem.Api.Generator/TeachingRecordSystem.Api.Generator.csproj new file mode 100644 index 000000000..81d4062f4 --- /dev/null +++ b/TeachingRecordSystem/gen/TeachingRecordSystem.Api.Generator/TeachingRecordSystem.Api.Generator.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0 + false + true + + + + + + + + diff --git a/TeachingRecordSystem/gen/TeachingRecordSystem.Api.Generator/VersionedDtoGenerator.cs b/TeachingRecordSystem/gen/TeachingRecordSystem.Api.Generator/VersionedDtoGenerator.cs new file mode 100644 index 000000000..c01ab788e --- /dev/null +++ b/TeachingRecordSystem/gen/TeachingRecordSystem.Api.Generator/VersionedDtoGenerator.cs @@ -0,0 +1,427 @@ +using System.Diagnostics; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using GeneratedTypeInfo = (string DestinationType, string ReferenceType); +using VersionedReference = (string DestinationFullyQualifiedTypeName, string ReferenceVersion, int ReferenceType); + +[Generator(LanguageNames.CSharp)] +public class VersionedDtoGenerator : ISourceGenerator +{ + private const string GenerateVersionedDtoAttributeName = "TeachingRecordSystem.Api.V3.GenerateVersionedDtoAttribute"; + private const string BaseNamespace = "TeachingRecordSystem.Api.V3"; + + private const int RecordReferenceType = 0; + private const int EnumReferenceType = 1; + + public void Execute(GeneratorExecutionContext context) + { + var syntaxReceiver = (SyntaxReceiver)context.SyntaxReceiver!; + + var generatedTypes = new HashSet(); + var generatedRecords = new Dictionary(); + var versionedReferences = new List(); + + foreach (var generateDtoInfo in syntaxReceiver.Records) + { + var semanticModel = context.Compilation.GetSemanticModel(generateDtoInfo.Record.SyntaxTree); + var typeSymbol = (INamedTypeSymbol)semanticModel.GetDeclaredSymbol(generateDtoInfo.Record)!; + var destinationNamespace = typeSymbol.ContainingNamespace.ToString(); + + if (!IsVersionedNamespace(destinationNamespace)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.InvalidNamespace, + generateDtoInfo.Location, + messageArgs: destinationNamespace)); + continue; + } + + // Extract the arguments from the GenerateVersionedDtoAttribute + var attr = typeSymbol.GetAttributes().Single(a => a.AttributeClass?.ContainingNamespace + "." + a.AttributeClass?.Name == GenerateVersionedDtoAttributeName); + var sourceType = (INamedTypeSymbol)attr.ConstructorArguments[0].Value!; + var excludeMembers = attr.ConstructorArguments[1].Values!.Select(t => t.Value!.ToString()).ToArray(); + var sourceNamespace = sourceType.ContainingNamespace.ToString(); + + if (!IsVersionedNamespace(sourceNamespace)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.InvalidReferenceNamespace, + generateDtoInfo.Location, + messageArgs: sourceNamespace)); + continue; + } + + var sourceFullyQualifiedTypeName = sourceType.ContainingNamespace.ToString() + "." + sourceType.Name; + var destinationVersion = GetTypeVersion(destinationNamespace); + var destinationFullyQualifiedTypeName = destinationNamespace + "." + typeSymbol.Name; + + if (generatedTypes.Any(t => t.DestinationType == destinationFullyQualifiedTypeName)) + { + return; + } + + GeneratePartialRecordDeclaration( + destinationFullyQualifiedTypeName, + sourceFullyQualifiedTypeName, + copyAttributes: false, + excludeMembers); + } + + void EnsureReference(VersionedReference reference) + { + if (!versionedReferences.Contains(reference)) + { + versionedReferences.Add(reference); + + // If the type has already been defined we don't need to generate it + if (context.Compilation.GetTypeByMetadataName(reference.DestinationFullyQualifiedTypeName) is not null) + { + return; + } + + // If we've already generated the type there's nothing to do + if (generatedTypes.Any(t => t.DestinationType == reference.DestinationFullyQualifiedTypeName)) + { + return; + } + + if (reference.ReferenceType == RecordReferenceType) + { + GeneratePartialRecordDeclaration( + reference.DestinationFullyQualifiedTypeName, + ReplaceVersion(reference.DestinationFullyQualifiedTypeName, reference.ReferenceVersion), + copyAttributes: true, + excludeMembers: []); + } + else + { + Debug.Assert(reference.ReferenceType == EnumReferenceType); + + GenerateEnum( + reference.DestinationFullyQualifiedTypeName, + ReplaceVersion(reference.DestinationFullyQualifiedTypeName, reference.ReferenceVersion)); + } + } + } + + void GeneratePartialRecordDeclaration( + string destinationFullyQualifiedTypeName, + string referenceFullyQualifiedTypeName, + bool copyAttributes, + string[] excludeMembers) + { + var destinationVersion = GetTypeVersion(destinationFullyQualifiedTypeName); + var destinationNamespace = GetNamespaceFromFullyQualifiedTypeName(destinationFullyQualifiedTypeName); + var referenceVersion = GetTypeVersion(referenceFullyQualifiedTypeName); + var referenceNamespace = GetNamespaceFromFullyQualifiedTypeName(referenceFullyQualifiedTypeName); + var destinationTypeName = destinationFullyQualifiedTypeName.Split('.').Last(); + + var usings = new HashSet(); + var attributeLists = new List(); + var propertyDeclarations = new List(); + + var referenceTypeSymbol = context.Compilation.GetTypeByMetadataName(referenceFullyQualifiedTypeName); + + // If the reference type was itself generated we need to include the generated members too + generatedRecords.TryGetValue(referenceFullyQualifiedTypeName, out var referenceGeneratedTypeInfo); + + var allProperties = new List<(PropertyDeclarationSyntax PropertySyntax, INamedTypeSymbol? PropertyType)>(); + var allUsings = new List(); + + void AddProperty(PropertyDeclarationSyntax property, INamedTypeSymbol? propertyType) + { + // If this type references another versioned type in the same namespace, add it to the list to ensure it's generated + if (propertyType is not null) + { + RecordReferenceIfTypeIsVersionedAndInSameNamespace(propertyType, referenceNamespace); + } + + propertyDeclarations.Add(property.GetText().ToString()); + allProperties.Add((property, propertyType)); + } + + void AddUsing(UsingDirectiveSyntax usingSyntax) + { + var usingStatement = usingSyntax.GetText().ToString(); + + if (usingSyntax.Name is QualifiedNameSyntax qualifiedNameSyntax) + { + var fullName = qualifiedNameSyntax.Left + "." + qualifiedNameSyntax.Right; + } + + usings.Add(usingStatement); + allUsings.Add(usingSyntax); + } + + foreach (var declaringSyntax in referenceTypeSymbol?.DeclaringSyntaxReferences ?? []) + { + var referenceSyntax = declaringSyntax.GetSyntax(); + var semanticModel = context.Compilation.GetSemanticModel(referenceSyntax.SyntaxTree); + + var rootSyntax = referenceSyntax; + while (rootSyntax.Parent is not null) + { + rootSyntax = rootSyntax.Parent; + } + + foreach (var usingSyntax in rootSyntax.DescendantNodes().OfType()) + { + AddUsing(usingSyntax); + } + + if (copyAttributes) + { + foreach (var attributeList in referenceSyntax.ChildNodes().OfType()) + { + attributeLists.Add(attributeList.GetText().ToString()); + } + } + + foreach (var property in referenceSyntax.DescendantNodes().OfType()) + { + if (excludeMembers.Contains(property.Identifier.ValueText)) + { + continue; + } + + var propertyType = semanticModel.GetTypeInfo(property.Type).Type as INamedTypeSymbol; + + AddProperty(property, propertyType); + } + } + + if (referenceGeneratedTypeInfo is not null) + { + foreach (var @using in referenceGeneratedTypeInfo.Usings) + { + AddUsing(@using); + } + + foreach (var (property, propertyType) in referenceGeneratedTypeInfo.Properties) + { + AddProperty(property, propertyType); + } + } + + var codeBuilder = new StringBuilder(); + + codeBuilder.AppendLine("// "); + codeBuilder.AppendLine("#nullable enable"); + codeBuilder.AppendLine(); + + foreach (var usingStatement in usings) + { + codeBuilder.Append(usingStatement); + } + if (usings.Count > 0) + { + codeBuilder.AppendLine(); + } + + codeBuilder.AppendLine($"namespace {destinationNamespace};"); + codeBuilder.AppendLine(); + + if (copyAttributes) + { + foreach (var attributeList in attributeLists) + { + codeBuilder.Append(attributeList.ToString()); + } + } + + codeBuilder.AppendLine($"public partial record {destinationTypeName}"); + codeBuilder.AppendLine("{"); + foreach (var propertyDeclaration in propertyDeclarations) + { + codeBuilder.Append(propertyDeclaration); + } + codeBuilder.AppendLine("}"); + + var relativeName = destinationNamespace.Substring(BaseNamespace.Length + 1); + context.AddSource($"{relativeName}.{destinationTypeName}.g.cs", codeBuilder.ToString()); + + var generatedTypeInfo = new GeneratedTypeInfo(destinationFullyQualifiedTypeName, referenceFullyQualifiedTypeName); + generatedTypes.Add(generatedTypeInfo); + + var generatedRecordInfo = new GeneratedRecordInfo(allUsings.ToArray(), allProperties.ToArray()); + generatedRecords.Add(destinationFullyQualifiedTypeName, generatedRecordInfo); + + void RecordReferenceIfTypeIsVersionedAndInSameNamespace(INamedTypeSymbol symbol, string @namespace) + { + var fullPropertyType = symbol.ContainingNamespace + "." + symbol.Name; + + if (symbol.ContainingNamespace.ToString() == @namespace && IsVersionedType(fullPropertyType)) + { + if (symbol.TypeKind is not TypeKind.Enum && !(symbol.TypeKind is TypeKind.Class && symbol.IsRecord)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.UnsupportedTypeKind, + symbol.Locations.FirstOrDefault(), + messageArgs: symbol.TypeKind.ToString())); + return; + } + + var referenceType = symbol.TypeKind is TypeKind.Enum ? EnumReferenceType : RecordReferenceType; + + EnsureReference((ReplaceVersion(fullPropertyType, destinationVersion), referenceVersion, referenceType)); + } + + if (symbol.IsGenericType) + { + foreach (var genericArg in symbol.TypeArguments.OfType()) + { + RecordReferenceIfTypeIsVersionedAndInSameNamespace(genericArg, @namespace); + } + } + } + } + + void GenerateEnum( + string destinationFullyQualifiedTypeName, + string referenceFullyQualifiedTypeName) + { + var destinationVersion = GetTypeVersion(destinationFullyQualifiedTypeName); + var destinationNamespace = GetNamespaceFromFullyQualifiedTypeName(destinationFullyQualifiedTypeName); + var referenceVersion = GetTypeVersion(referenceFullyQualifiedTypeName); + + // If the reference enum was itself generated we won't find it in context.Compilation. + // Instead, recursively look for its source type until we find a version that is in the Compilation. + var sourceGeneratedFrom = generatedTypes.FirstOrDefault(g => g.DestinationType == referenceFullyQualifiedTypeName); + if (sourceGeneratedFrom.ReferenceType is not null) + { + GenerateEnum( + destinationFullyQualifiedTypeName, + sourceGeneratedFrom.ReferenceType); + return; + } + + var referenceTypeSymbol = context.Compilation.GetTypeByMetadataName(referenceFullyQualifiedTypeName) ?? + throw new Exception($"Could not find '{referenceFullyQualifiedTypeName}."); + var destinationTypeName = referenceTypeSymbol.Name; + + var declaringSyntax = referenceTypeSymbol.DeclaringSyntaxReferences.Single(); + + var referenceSyntax = declaringSyntax.GetSyntax(); + var semanticModel = context.Compilation.GetSemanticModel(referenceSyntax.SyntaxTree); + + var enumMemberDeclarations = new List(); + + foreach (var member in referenceSyntax.DescendantNodes().OfType()) + { + enumMemberDeclarations.Add(member.GetText().ToString()); + } + + var codeBuilder = new StringBuilder(); + + codeBuilder.AppendLine("// "); + codeBuilder.AppendLine("#nullable enable"); + codeBuilder.AppendLine(); + + codeBuilder.AppendLine($"namespace {destinationNamespace};"); + codeBuilder.AppendLine(); + + codeBuilder.AppendLine($"public enum {destinationTypeName}"); + codeBuilder.AppendLine("{"); + codeBuilder.AppendLine(string.Join(",\n", enumMemberDeclarations)); + codeBuilder.AppendLine("}"); + + var relativeName = destinationNamespace.Substring(BaseNamespace.Length + 1); + context.AddSource($"{relativeName}.{destinationTypeName}.g.cs", codeBuilder.ToString()); + + var generatedTypeInfo = new GeneratedTypeInfo(destinationFullyQualifiedTypeName, referenceFullyQualifiedTypeName); + generatedTypes.Add(generatedTypeInfo); + } + } + + public void Initialize(GeneratorInitializationContext context) + { + context.RegisterForSyntaxNotifications(() => new SyntaxReceiver()); + } + + private static bool IsVersionedType(string typeName) => typeName.StartsWith(BaseNamespace + ".V"); + + private static bool IsVersionedNamespace(string @namespace) => @namespace.StartsWith(BaseNamespace + ".V"); + + private static string ReplaceVersion(string typeName, string newVersion) => + typeName.Replace(GetTypeVersion(typeName), newVersion); + + private static string GetTypeVersion(string typeName) + { + var relativeNamespace = typeName.Substring(BaseNamespace.Length + 1); + return relativeNamespace.Split('.')[0]; + } + + private static string GetNamespaceFromFullyQualifiedTypeName(string typeName) => + typeName.Substring(0, typeName.LastIndexOf('.')); + + private class SyntaxReceiver : ISyntaxReceiver + { + public List Records { get; } = []; + + public void OnVisitSyntaxNode(SyntaxNode syntaxNode) + { + if (syntaxNode is RecordDeclarationSyntax recordDeclarationSyntax) + { + var generateDtoAttrs = recordDeclarationSyntax.AttributeLists.SelectMany(al => al.Attributes) + .Where(a => a.Name is SimpleNameSyntax sns && sns.Identifier.Text is "GenerateVersionedDtoAttribute" or "GenerateVersionedDto"); + + foreach (var attr in generateDtoAttrs) + { + Records.Add(new GenerateDtoInfo(recordDeclarationSyntax, attr, syntaxNode.GetLocation())); + } + } + } + } + + private sealed class GenerateDtoInfo(RecordDeclarationSyntax record, AttributeSyntax attribute, Location location) + { + public RecordDeclarationSyntax Record { get; } = record; + + public AttributeSyntax Attribute { get; } = attribute; + + public Location Location { get; } = location; + } + + private sealed class GeneratedRecordInfo(UsingDirectiveSyntax[] usings, (PropertyDeclarationSyntax Property, INamedTypeSymbol? PropertyType)[] properties) + { + public UsingDirectiveSyntax[] Usings { get; } = usings; + + public (PropertyDeclarationSyntax Property, INamedTypeSymbol? PropertyType)[] Properties { get; } = properties; + } + + internal static class DiagnosticDescriptors + { + private const string Category = "Design"; + + public static DiagnosticDescriptor UnsupportedTypeKind { get; } = new DiagnosticDescriptor( + id: "TRSDTOGEN001", + title: "Unsupported type kind", + messageFormat: "{0} is not a supported type kind.", + category: Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + customTags: WellKnownDiagnosticTags.NotConfigurable); + + public static DiagnosticDescriptor InvalidReferenceNamespace { get; } = new DiagnosticDescriptor( + id: "TRSDTOGEN002", + title: "Unsupported reference namespace", + messageFormat: "{0} is not a versioned namespace.", + category: Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + customTags: WellKnownDiagnosticTags.NotConfigurable); + + public static DiagnosticDescriptor InvalidNamespace { get; } = new DiagnosticDescriptor( + id: "TRSDTOGEN003", + title: "Unsupported namespace", + messageFormat: "{0} is not a versioned namespace.", + category: Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + customTags: WellKnownDiagnosticTags.NotConfigurable); + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/TeachingRecordSystem.Api.csproj b/TeachingRecordSystem/src/TeachingRecordSystem.Api/TeachingRecordSystem.Api.csproj index cc1b22691..09de55569 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/TeachingRecordSystem.Api.csproj +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/TeachingRecordSystem.Api.csproj @@ -2,6 +2,7 @@ net8.0 + @@ -34,6 +35,7 @@ + diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/GenerateVersionedDtoAttribute.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/GenerateVersionedDtoAttribute.cs new file mode 100644 index 000000000..3a9024121 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/GenerateVersionedDtoAttribute.cs @@ -0,0 +1,9 @@ +namespace TeachingRecordSystem.Api.V3; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class GenerateVersionedDtoAttribute(Type sourceType, params string[] excludeMembers) : Attribute +{ + public Type SourceType { get; } = sourceType; + + public string[] ExcludeMembers { get; } = excludeMembers; +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/FindTeachersResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/FindTeachersResponse.cs index 50794297f..f7b3cf593 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/FindTeachersResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/FindTeachersResponse.cs @@ -4,7 +4,6 @@ namespace TeachingRecordSystem.Api.V3.V20240101.Responses; -[AutoMap(typeof(FindPersonByLastNameAndDateOfBirthResult))] public record FindTeachersResponse { public required int Total { get; init; } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeacherController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeacherController.cs new file mode 100644 index 000000000..62dd6e9a3 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeacherController.cs @@ -0,0 +1,52 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using TeachingRecordSystem.Api.Infrastructure.ModelBinding; +using TeachingRecordSystem.Api.Infrastructure.Security; +using TeachingRecordSystem.Api.V3.Core.Operations; +using TeachingRecordSystem.Api.V3.V20240416.Requests; +using TeachingRecordSystem.Api.V3.V20240416.Responses; + +namespace TeachingRecordSystem.Api.V3.V20240416.Controllers; + +[Route("teacher")] +public class TeacherController(IMapper mapper) : ControllerBase +{ + [Authorize(AuthorizationPolicies.IdentityUserWithTrn)] + [HttpGet] + [SwaggerOperation( + OperationId = "GetCurrentTeacher", + Summary = "Get the current teacher's details", + Description = "Gets the details for the authenticated teacher.")] + [ProducesResponseType(typeof(GetTeacherResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + public async Task Get( + [FromQuery, ModelBinder(typeof(FlagsEnumStringListModelBinder)), SwaggerParameter("The additional properties to include in the response.")] GetTeacherRequestIncludes? include, + [FromServices] GetPersonHandler handler) + { + var trn = User.FindFirstValue("trn"); + + if (trn is null) + { + return MissingOrInvalidTrn(); + } + + var command = new GetPersonCommand( + trn, + include is not null ? (GetPersonCommandIncludes)include : GetPersonCommandIncludes.None, + DateOfBirth: null); + + var result = await handler.Handle(command); + + if (result is null) + { + return MissingOrInvalidTrn(); + } + + var response = mapper.Map(result); + return Ok(response); + + IActionResult MissingOrInvalidTrn() => Forbid(); + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeachersController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeachersController.cs index 142eaaa65..648e2b166 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeachersController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeachersController.cs @@ -4,7 +4,8 @@ using TeachingRecordSystem.Api.Infrastructure.ModelBinding; using TeachingRecordSystem.Api.Infrastructure.Security; using TeachingRecordSystem.Api.V3.Core.Operations; -using GetTeacherDtoVersion = TeachingRecordSystem.Api.V3.V20240101; +using TeachingRecordSystem.Api.V3.V20240416.Requests; +using TeachingRecordSystem.Api.V3.V20240416.Responses; namespace TeachingRecordSystem.Api.V3.V20240416.Controllers; @@ -16,13 +17,13 @@ public class TeachersController(IMapper mapper) : ControllerBase OperationId = "GetTeacherByTrn", Summary = "Get teacher details by TRN", Description = "Gets the details of the teacher corresponding to the given TRN.")] - [ProducesResponseType(typeof(GetTeacherDtoVersion.Responses.GetTeacherResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(GetTeacherResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] [Authorize(Policy = AuthorizationPolicies.GetPerson)] public async Task Get( [FromRoute] string trn, - [FromQuery, ModelBinder(typeof(FlagsEnumStringListModelBinder)), SwaggerParameter("The additional properties to include in the response.")] GetTeacherDtoVersion.Requests.GetTeacherRequestIncludes? include, + [FromQuery, ModelBinder(typeof(FlagsEnumStringListModelBinder)), SwaggerParameter("The additional properties to include in the response.")] GetTeacherRequestIncludes? include, [FromQuery, SwaggerParameter("Adds an additional check that the record has the specified dateOfBirth, if provided.")] DateOnly? dateOfBirth, [FromServices] GetPersonHandler handler) { @@ -38,7 +39,7 @@ public async Task Get( return NotFound(); } - var response = mapper.Map(result); + var response = mapper.Map(result); return Ok(response); } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Requests/GetTeacherRequestIncludes.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Requests/GetTeacherRequestIncludes.cs new file mode 100644 index 000000000..1cab8128f --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Requests/GetTeacherRequestIncludes.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; + +namespace TeachingRecordSystem.Api.V3.V20240416.Requests; + +[Flags] +[Description("Comma-separated list of data to include in response.")] +public enum GetTeacherRequestIncludes +{ + None = 0, + + Induction = 1 << 0, + InitialTeacherTraining = 1 << 1, + NpqQualifications = 1 << 2, + MandatoryQualifications = 1 << 3, + PendingDetailChanges = 1 << 4, + HigherEducationQualifications = 1 << 5, + Sanctions = 1 << 6, + Alerts = 1 << 7, + PreviousNames = 1 << 8, + + [ExcludeFromSchema] + _AllowIdSignInWithProhibitions = 1 << 9, + + All = Induction | InitialTeacherTraining | NpqQualifications | MandatoryQualifications | PendingDetailChanges | HigherEducationQualifications | Sanctions | Alerts | PreviousNames +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Responses/GetTeacherResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Responses/GetTeacherResponse.cs new file mode 100644 index 000000000..f8a17d84f --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Responses/GetTeacherResponse.cs @@ -0,0 +1,7 @@ +using TeachingRecordSystem.Api.V3.Core.Operations; + +namespace TeachingRecordSystem.Api.V3.V20240416.Responses; + +[AutoMap(typeof(GetPersonResult))] +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponse))] +public partial record GetTeacherResponse; diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonController.cs index d3d74a59a..1fd22d822 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonController.cs @@ -7,7 +7,6 @@ using TeachingRecordSystem.Api.V3.Core.Operations; using TeachingRecordSystem.Api.V3.V20240606.Requests; using TeachingRecordSystem.Api.V3.V20240606.Responses; -using CreateDetailChangeResponseVersion = TeachingRecordSystem.Api.V3.V20240412; namespace TeachingRecordSystem.Api.V3.V20240606.Controllers; @@ -41,7 +40,7 @@ public async Task Get( OperationId = "CreateNameChange", Summary = "Create name change request", Description = "Creates a name change request for the authenticated teacher.")] - [ProducesResponseType(typeof(CreateDetailChangeResponseVersion.Responses.CreateNameChangeResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(CreateNameChangeResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [Authorize(AuthorizationPolicies.IdentityUserWithTrn)] public async Task CreateNameChange( @@ -60,7 +59,7 @@ public async Task CreateNameChange( }; var caseNumber = await handler.Handle(command); - var response = new CreateDetailChangeResponseVersion.Responses.CreateNameChangeResponse() { CaseNumber = caseNumber }; + var response = new CreateNameChangeResponse() { CaseNumber = caseNumber }; return Ok(response); } @@ -69,7 +68,7 @@ public async Task CreateNameChange( OperationId = "CreateDobChange", Summary = "Create DOB change request", Description = "Creates a date of birth change request for the authenticated teacher.")] - [ProducesResponseType(typeof(CreateDetailChangeResponseVersion.Responses.CreateDateOfBirthChangeResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(CreateDateOfBirthChangeResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [Authorize(AuthorizationPolicies.IdentityUserWithTrn)] public async Task CreateDateOfBirthChange( @@ -86,7 +85,7 @@ public async Task CreateDateOfBirthChange( }; var caseNumber = await handler.Handle(command); - var response = new CreateDetailChangeResponseVersion.Responses.CreateNameChangeResponse() { CaseNumber = caseNumber }; + var response = new CreateNameChangeResponse() { CaseNumber = caseNumber }; return Ok(response); } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/CreateDateOfBirthChangeResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/CreateDateOfBirthChangeResponse.cs new file mode 100644 index 000000000..e7bd4e2d5 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/CreateDateOfBirthChangeResponse.cs @@ -0,0 +1,4 @@ +namespace TeachingRecordSystem.Api.V3.V20240606.Responses; + +[GenerateVersionedDto(typeof(V20240412.Responses.CreateDateOfBirthChangeResponse))] +public partial record CreateDateOfBirthChangeResponse; diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/CreateNameChangeResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/CreateNameChangeResponse.cs new file mode 100644 index 000000000..aacaa5c0a --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/CreateNameChangeResponse.cs @@ -0,0 +1,4 @@ +namespace TeachingRecordSystem.Api.V3.V20240606.Responses; + +[GenerateVersionedDto(typeof(V20240412.Responses.CreateNameChangeResponse))] +public partial record CreateNameChangeResponse; diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/FindPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/FindPersonResponse.cs index 314c567ee..d852c2a2c 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/FindPersonResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/FindPersonResponse.cs @@ -1,25 +1,15 @@ using TeachingRecordSystem.Api.V3.Core.Operations; -using TeachingRecordSystem.Api.V3.V20240101.ApiModels; using TeachingRecordSystem.Api.V3.V20240606.Requests; namespace TeachingRecordSystem.Api.V3.V20240606.Responses; -[AutoMap(typeof(FindPersonByLastNameAndDateOfBirthResult))] -public record FindPersonResponse +[GenerateVersionedDto(typeof(V20240101.Responses.FindTeachersResponse), excludeMembers: ["Query", "Results"])] +public partial record FindPersonResponse { - public required int Total { get; init; } public required FindPersonRequest Query { get; init; } public required IReadOnlyCollection Results { get; init; } } [AutoMap(typeof(FindPersonByLastNameAndDateOfBirthResultItem))] -public record FindPersonResponseResult -{ - public required string Trn { get; init; } - public required DateOnly DateOfBirth { get; init; } - public required string FirstName { get; init; } - public required string MiddleName { get; init; } - public required string LastName { get; init; } - public required IReadOnlyCollection Sanctions { get; init; } - public required IReadOnlyCollection PreviousNames { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.FindTeachersResponseResult))] +public partial record FindPersonResponseResult; diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/GetPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/GetPersonResponse.cs index ad0ea98fe..a5ad56c23 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/GetPersonResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/GetPersonResponse.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Serialization; using Optional; using TeachingRecordSystem.Api.V3.Core.Operations; using TeachingRecordSystem.Api.V3.V20240101.ApiModels; @@ -6,7 +5,7 @@ namespace TeachingRecordSystem.Api.V3.V20240606.Responses; [AutoMap(typeof(GetPersonResult))] -public record GetPersonResponse +public partial record GetPersonResponse { public required string Trn { get; init; } public required string FirstName { get; init; } @@ -31,121 +30,61 @@ public record GetPersonResponse } [AutoMap(typeof(GetPersonResultQts))] -public record GetPersonResponseQts -{ - public required DateOnly? Awarded { get; init; } - public required string CertificateUrl { get; init; } - public required string? StatusDescription { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseQts))] +public partial record GetPersonResponseQts; [AutoMap(typeof(GetPersonResultEyts))] -public record GetPersonResponseEyts -{ - public required DateOnly? Awarded { get; init; } - public required string CertificateUrl { get; init; } - public required string? StatusDescription { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseEyts))] +public partial record GetPersonResponseEyts; [AutoMap(typeof(GetPersonResultInduction))] -public record GetPersonResponseInduction -{ - public required DateOnly? StartDate { get; init; } - public required DateOnly? EndDate { get; init; } - public required InductionStatus? Status { get; init; } - public required string? StatusDescription { get; init; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public required string? CertificateUrl { get; init; } - public required IReadOnlyCollection Periods { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInduction))] +public partial record GetPersonResponseInduction; [AutoMap(typeof(GetPersonResultInductionPeriod))] -public record GetPersonResponseInductionPeriod -{ - public required DateOnly? StartDate { get; init; } - public required DateOnly? EndDate { get; init; } - public required int? Terms { get; init; } - public required GetPersonResponseInductionPeriodAppropriateBody? AppropriateBody { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInductionPeriod))] +public partial record GetPersonResponseInductionPeriod; [AutoMap(typeof(GetPersonResultInductionPeriodAppropriateBody))] -public record GetPersonResponseInductionPeriodAppropriateBody -{ - public required string Name { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInductionPeriodAppropriateBody))] +public partial record GetPersonResponseInductionPeriodAppropriateBody; [AutoMap(typeof(GetPersonResultInitialTeacherTraining))] -public record GetPersonResponseInitialTeacherTraining -{ - public required GetPersonResponseInitialTeacherTrainingQualification? Qualification { get; init; } - public required DateOnly? StartDate { get; init; } - public required DateOnly? EndDate { get; init; } - public required IttProgrammeType? ProgrammeType { get; init; } - public required string? ProgrammeTypeDescription { get; init; } - public required IttOutcome? Result { get; init; } - public required GetPersonResponseInitialTeacherTrainingAgeRange? AgeRange { get; init; } - public required GetPersonResponseInitialTeacherTrainingProvider? Provider { get; init; } - public required IReadOnlyCollection Subjects { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInitialTeacherTraining))] +public partial record GetPersonResponseInitialTeacherTraining; [AutoMap(typeof(GetPersonResultInitialTeacherTrainingQualification))] -public record GetPersonResponseInitialTeacherTrainingQualification -{ - public required string Name { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInitialTeacherTrainingQualification))] +public partial record GetPersonResponseInitialTeacherTrainingQualification; [AutoMap(typeof(GetPersonResultInitialTeacherTrainingAgeRange))] -public record GetPersonResponseInitialTeacherTrainingAgeRange -{ - public required string Description { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInitialTeacherTrainingAgeRange))] +public partial record GetPersonResponseInitialTeacherTrainingAgeRange; [AutoMap(typeof(GetPersonResultInitialTeacherTrainingProvider))] -public record GetPersonResponseInitialTeacherTrainingProvider -{ - public required string Name { get; init; } - public required string Ukprn { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInitialTeacherTrainingProvider))] +public partial record GetPersonResponseInitialTeacherTrainingProvider; [AutoMap(typeof(GetPersonResultInitialTeacherTrainingSubject))] -public record GetPersonResponseInitialTeacherTrainingSubject -{ - public required string Code { get; init; } - public required string Name { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInitialTeacherTrainingSubject))] +public partial record GetPersonResponseInitialTeacherTrainingSubject; [AutoMap(typeof(GetPersonResultNpqQualification))] -public record GetPersonResponseNpqQualification -{ - public required DateOnly Awarded { get; init; } - public required GetPersonResponseNpqQualificationType Type { get; init; } - public required string CertificateUrl { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseNpqQualification))] +public partial record GetPersonResponseNpqQualification; [AutoMap(typeof(GetPersonResultNpqQualificationType))] -public record GetPersonResponseNpqQualificationType -{ - public required NpqQualificationType Code { get; init; } - public required string Name { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseNpqQualificationType))] +public partial record GetPersonResponseNpqQualificationType; [AutoMap(typeof(GetPersonResultMandatoryQualification))] -public record GetPersonResponseMandatoryQualification -{ - public required DateOnly Awarded { get; init; } - public required string Specialism { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseMandatoryQualification))] +public partial record GetPersonResponseMandatoryQualification; [AutoMap(typeof(GetPersonResultHigherEducationQualification))] -public record GetPersonResponseHigherEducationQualification -{ - public required string? Name { get; init; } - public required DateOnly? Awarded { get; init; } - public required IReadOnlyCollection Subjects { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseHigherEducationQualification))] +public partial record GetPersonResponseHigherEducationQualification; [AutoMap(typeof(GetPersonResultHigherEducationQualificationSubject))] -public record GetPersonResponseHigherEducationQualificationSubject -{ - public required string Code { get; init; } - public required string Name { get; init; } -} +[GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseHigherEducationQualificationSubject))] +public partial record GetPersonResponseHigherEducationQualificationSubject;