diff --git a/src/core/EdFi.DataManagementService.Core.External/Interface/IApiService.cs b/src/core/EdFi.DataManagementService.Core.External/Interface/IApiService.cs index 1870fa844..5f2577b48 100644 --- a/src/core/EdFi.DataManagementService.Core.External/Interface/IApiService.cs +++ b/src/core/EdFi.DataManagementService.Core.External/Interface/IApiService.cs @@ -4,6 +4,7 @@ // See the LICENSE and NOTICES files in the project root for more information. using EdFi.DataManagementService.Core.External.Model; using EdFi.DataManagementService.Core.External.Frontend; +using System.Text.Json.Nodes; namespace EdFi.DataManagementService.Core.External.Interface; @@ -40,4 +41,10 @@ public interface IApiService /// DMS entry point for data model information from ApiSchema.json /// public IList GetDataModelInfo(); + + /// + /// Get the ordered list of dependencies + /// + /// + public JsonArray GetDependencies(); } diff --git a/src/core/EdFi.DataManagementService.Core.Tests.Unit/ApiSchema/DependencyCalculatorTests.cs b/src/core/EdFi.DataManagementService.Core.Tests.Unit/ApiSchema/DependencyCalculatorTests.cs new file mode 100644 index 000000000..fa80d51ee --- /dev/null +++ b/src/core/EdFi.DataManagementService.Core.Tests.Unit/ApiSchema/DependencyCalculatorTests.cs @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using System.Text.Json.Nodes; +using EdFi.DataManagementService.Core.ApiSchema; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; + +namespace EdFi.DataManagementService.Core.Tests.Unit.ApiSchema; + +public class DependencyCalculatorTests +{ + private DependencyCalculator? _dependencyCalculator; + + [TestFixture] + public class Given_A_Sample_ApiSchema() : DependencyCalculatorTests + { + private readonly string _sampleSchema = + """ + { + "projectNameMapping": { + "Ed-Fi": "ed-fi" + }, + "projectSchemas": { + "ed-fi": { + "resourceNameMapping": { + "AbsenceEventCategory": "absenceEventCategoryDescriptors" + }, + "resourceSchemas": { + "absenceEventCategoryDescriptors": { + "documentPathsMapping": { + }, + "isSchoolYearEnumeration": false, + "resourceName": "AbsenceEventCategoryDescriptor" + } + } + } + } + } + """; + + private readonly string _expectedDependencies = + """ + [ + { + "resource": "/ed-fi/absenceEventCategoryDescriptors", + "order": 1, + "operations": [ + "Create", + "Update" + ] + } + ] + """; + + [SetUp] + public void Setup() + { + var logger = NullLogger.Instance; + _dependencyCalculator = new DependencyCalculator(JsonNode.Parse(_sampleSchema)!, logger); + } + + [Test] + public void It_should_calculate_dependencies() + { + var dependencies = _dependencyCalculator!.GetDependenciesFromResourceSchema(); + dependencies.Should().NotBeEmpty(); + dependencies.Count.Should().Be(1); + + var expectedDependencies = JsonNode.Parse(_expectedDependencies)!.AsArray(); + dependencies!.Should().BeEquivalentTo(expectedDependencies!, options => options + .WithoutStrictOrdering() + .IgnoringCyclicReferences()); + } + } + + [TestFixture] + public class Given_A_Sample_ApiSchema_With_Subclass_Resources() : DependencyCalculatorTests + { + private readonly string _sampleSchema = + """ + { + "projectNameMapping": { + "Ed-Fi": "ed-fi" + }, + "projectSchemas": { + "ed-fi": { + "resourceNameMapping": { + "EducationOrganizationCategory": "educationOrganizationCategoryDescriptors", + "LocalEducationAgency": "localEducationAgencies", + "School": "schools" + }, + "resourceSchemas": { + "educationOrganizationCategoryDescriptors": { + "documentPathsMapping": { + }, + "isDescriptor": true, + "isSchoolYearEnumeration": false, + "isSubclass": false, + "resourceName": "EducationOrganizationCategoryDescriptor" + }, + "localEducationAgencies": { + "documentPathsMapping": { + "EducationOrganizationCategoryDescriptor": { + "isDescriptor": true, + "isReference": true, + "projectName": "Ed-Fi", + "resourceName": "EducationOrganizationCategoryDescriptor" + }, + "ParentLocalEducationAgency": { + "isReference": true, + "projectName": "Ed-Fi", + "resourceName": "LocalEducationAgency" + } + }, + "isSubclass": true, + "isSchoolYearEnumeration": false, + "resourceName": "LocalEducationAgency", + "subclassType": "domainEntity", + "superclassProjectName": "Ed-Fi", + "superclassResourceName": "EducationOrganization" + }, + "schools": { + "documentPathsMapping": { + "EducationOrganizationCategoryDescriptor": { + "isDescriptor": true, + "isReference": true, + "projectName": "Ed-Fi", + "resourceName": "EducationOrganizationCategoryDescriptor" + }, + "LocalEducationAgency": { + "isReference": true, + "projectName": "Ed-Fi", + "resourceName": "LocalEducationAgency" + }, + "WebSite": { + "isReference": false, + "path": "$.webSite" + } + }, + "isSubclass": true, + "isSchoolYearEnumeration": false, + "resourceName": "School", + "subclassType": "domainEntity", + "superclassProjectName": "Ed-Fi", + "superclassResourceName": "EducationOrganization" + } + } + } + } + } + """; + + private readonly string _expectedDependencies = + """ + [ + { + "resource": "/ed-fi/educationOrganizationCategoryDescriptors", + "order": 1, + "operations": [ + "Create", + "Update" + ] + }, + { + "resource": "/ed-fi/localEducationAgencies", + "order": 2, + "operations": [ + "Create", + "Update" + ] + }, + { + "resource": "/ed-fi/schools", + "order": 3, + "operations": [ + "Create", + "Update" + ] + } + ] + """; + + [SetUp] + public void Setup() + { + var logger = NullLogger.Instance; + _dependencyCalculator = new DependencyCalculator(JsonNode.Parse(_sampleSchema)!, logger); + } + + [Test] + public void It_should_calculate_dependencies() + { + var dependencies = _dependencyCalculator!.GetDependenciesFromResourceSchema(); + dependencies.Should().NotBeEmpty(); + + var expectedDependencies = JsonNode.Parse(_expectedDependencies)!.AsArray(); + dependencies!.Should().BeEquivalentTo(expectedDependencies!, options => options + .WithoutStrictOrdering() + .IgnoringCyclicReferences()); + } + } + + + [TestFixture] + public class Given_A_Sample_ApiSchema_With_Superclass_Reference() : DependencyCalculatorTests + { + private readonly string _sampleSchema = + """ + { + "projectNameMapping": { + "Ed-Fi": "ed-fi" + }, + "projectSchemas": { + "ed-fi": { + "resourceNameMapping": { + "EducationOrganizationCategory": "educationOrganizationCategoryDescriptors", + "LocalEducationAgency": "localEducationAgencies", + "OpenStaffPosition": "openStaffPositions", + "School": "schools" + }, + "resourceSchemas": { + "educationOrganizationCategoryDescriptors": { + "documentPathsMapping": { + }, + "isDescriptor": true, + "isSchoolYearEnumeration": false, + "isSubclass": false, + "resourceName": "EducationOrganizationCategoryDescriptor" + }, + "openStaffPositions": { + "allowIdentityUpdates": false, + "documentPathsMapping": { + "EducationOrganization": { + "isDescriptor": false, + "isReference": true, + "projectName": "Ed-Fi", + "resourceName": "EducationOrganization" + } + }, + "isSubclass": false, + "isSchoolYearEnumeration": false, + "resourceName": "OpenStaffPosition" + }, + "localEducationAgencies": { + "documentPathsMapping": { + "EducationOrganizationCategoryDescriptor": { + "isDescriptor": true, + "isReference": true, + "projectName": "Ed-Fi", + "resourceName": "EducationOrganizationCategoryDescriptor" + }, + "ParentLocalEducationAgency": { + "isReference": true, + "projectName": "Ed-Fi", + "resourceName": "LocalEducationAgency" + } + }, + "isSubclass": true, + "isSchoolYearEnumeration": false, + "resourceName": "LocalEducationAgency", + "subclassType": "domainEntity", + "superclassProjectName": "Ed-Fi", + "superclassResourceName": "EducationOrganization" + }, + "schools": { + "documentPathsMapping": { + "EducationOrganizationCategoryDescriptor": { + "isDescriptor": true, + "isReference": true, + "projectName": "Ed-Fi", + "resourceName": "EducationOrganizationCategoryDescriptor" + }, + "LocalEducationAgency": { + "isReference": true, + "projectName": "Ed-Fi", + "resourceName": "LocalEducationAgency" + } + }, + "isSubclass": true, + "isSchoolYearEnumeration": false, + "resourceName": "School" + } + } + } + } + } + """; + + private readonly string _expectedDescriptors = + """ + [ + { + "resource": "/ed-fi/educationOrganizationCategoryDescriptors", + "order": 1, + "operations": [ + "Create", + "Update" + ] + }, + { + "resource": "/ed-fi/localEducationAgencies", + "order": 2, + "operations": [ + "Create", + "Update" + ] + }, + { + "resource": "/ed-fi/schools", + "order": 3, + "operations": [ + "Create", + "Update" + ] + }, + { + "resource": "/ed-fi/openStaffPositions", + "order": 4, + "operations": [ + "Create", + "Update" + ] + } + ] + """; + + [SetUp] + public void Setup() + { + var logger = NullLogger.Instance; + _dependencyCalculator = new DependencyCalculator(JsonNode.Parse(_sampleSchema)!, logger); + } + + [Test] + public void It_should_calculate_dependencies() + { + var dependencies = _dependencyCalculator!.GetDependenciesFromResourceSchema(); + dependencies.Should().NotBeEmpty(); + + var expectedDependencies = JsonNode.Parse(_expectedDescriptors)!.AsArray(); + dependencies!.Should().BeEquivalentTo(expectedDependencies!, options => options + .WithoutStrictOrdering() + .IgnoringCyclicReferences()); + } + } + + [TestFixture] + public class Given_A_Sample_ApiSchema_Missing_ProjectSchemas() : DependencyCalculatorTests + { + private readonly string _sampleSchema = + """ + { + "projectNameMapping": { + "Ed-Fi": "ed-fi" + } + } + """; + + [SetUp] + public void Setup() + { + var logger = NullLogger.Instance; + _dependencyCalculator = new DependencyCalculator(JsonNode.Parse(_sampleSchema)!, logger); + } + + [Test] + public void It_should_throw_invalid_operation() + { + Action act = () => _dependencyCalculator!.GetDependenciesFromResourceSchema(); + act.Should().Throw(); + } + } + + [TestFixture] + public class Given_A_Dependency_Calculator() : DependencyCalculatorTests + { + [Test] + public void It_should_return_proper_ordered_dependencies1() + { + Dictionary> resources = new Dictionary> + { + { "A", ["B"] }, + { "B", [] }, + { "C", ["B"] }, + }; + + var dependencies = DependencyCalculator.GetDependencies(resources); + + dependencies["A"].Should().Be(2); + dependencies["B"].Should().Be(1); + dependencies["C"].Should().Be(2); + } + + [Test] + public void It_should_return_proper_ordered_dependencies2() + { + Dictionary> resources = new Dictionary> + { + { "A", ["B"] }, + { "B", ["C", "D"] }, + { "C", [] }, + { "D", [] } + }; + + var dependencies = DependencyCalculator.GetDependencies(resources); + + dependencies["A"].Should().Be(3); + dependencies["B"].Should().Be(2); + dependencies["C"].Should().Be(1); + dependencies["D"].Should().Be(1); + } + + [Test] + public void It_should_handle_circular_dependencies() + { + Dictionary> resources = new Dictionary> + { + { "EOCD", [] }, + { "OSP", ["S"] }, + { "LEA", ["EOCD", "LEA"] }, + { "S", ["EOCD", "LEA"] } + }; + + var dependencies = DependencyCalculator.GetDependencies(resources); + + dependencies["EOCD"].Should().Be(1); + dependencies["LEA"].Should().Be(2); + dependencies["S"].Should().Be(3); + dependencies["OSP"].Should().Be(4); + } + } +} + diff --git a/src/core/EdFi.DataManagementService.Core/ApiSchema/ApiSchemaDocument.cs b/src/core/EdFi.DataManagementService.Core/ApiSchema/ApiSchemaDocument.cs index e2a6555d5..1e2a47520 100644 --- a/src/core/EdFi.DataManagementService.Core/ApiSchema/ApiSchemaDocument.cs +++ b/src/core/EdFi.DataManagementService.Core/ApiSchema/ApiSchemaDocument.cs @@ -25,4 +25,21 @@ internal class ApiSchemaDocument(JsonNode _apiSchemaRootNode, ILogger _logger) _logger ); } + + /// + /// Gets all ProjectSchema nodes in the document. + /// + public List GetAllProjectSchemaNodes() + { + JsonNode schema = _apiSchemaRootNode; + + var projectSchemasNode = schema["projectSchemas"]; + + if (projectSchemasNode == null) + { + throw new InvalidOperationException("Expected ProjectSchmas node to exist."); + } + + return projectSchemasNode.SelectNodesFromPropertyValues(); + } } diff --git a/src/core/EdFi.DataManagementService.Core/ApiSchema/DependencyCalculator.cs b/src/core/EdFi.DataManagementService.Core/ApiSchema/DependencyCalculator.cs new file mode 100644 index 000000000..2b6fe26e4 --- /dev/null +++ b/src/core/EdFi.DataManagementService.Core/ApiSchema/DependencyCalculator.cs @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace EdFi.DataManagementService.Core.ApiSchema; + +internal class DependencyCalculator(JsonNode _apiSchemaRootNode, ILogger _logger) +{ + public JsonArray GetDependenciesFromResourceSchema() + { + var apiSchemaDocument = new ApiSchemaDocument(_apiSchemaRootNode, _logger); + var dependenciesJsonArray = new JsonArray(); + foreach (JsonNode projectSchemaNode in apiSchemaDocument.GetAllProjectSchemaNodes()) + { + var resourceSchemas = projectSchemaNode["resourceSchemas"]?.AsObject().Select(x => new ResourceSchema(x.Value!)).ToList()!; + + Dictionary> resources = + resourceSchemas + .ToDictionary( + rs => rs.ResourceName.Value, + rs => rs.DocumentPaths.Where(d => d.IsReference).Select(d => ReplaceAbstractResourceNames(d.ResourceName.Value)).ToList()); + + var orderedResources = GetDependencies(resources); + + string ResourceNameMapping(string resourceName) + { + var resourceNameNode = projectSchemaNode["resourceNameMapping"]; + if (resourceNameNode == null) + { + throw new InvalidOperationException("ResourceNameMapping missing"); + } + + if (resourceName.EndsWith("Descriptor")) + { + resourceName = resourceName.Replace("Descriptor", string.Empty); + } + + var resourceNode = resourceNameNode[resourceName]; + if (resourceNode == null) + { + throw new InvalidOperationException($"No resource name mapping for {resourceName}"); + } + + return resourceNode.GetValue(); + } + + foreach (var orderedResource in orderedResources.OrderBy(o => o.Value).ThenBy(o => o.Key)) + { + string resourceName = ResourceNameMapping(orderedResource.Key); + + dependenciesJsonArray.Add(new { resource = $"/{projectSchemaNode!.GetPropertyName()}/{resourceName}", order = orderedResource.Value, operations = new[] { "Create", "Update" } }); + } + } + + return dependenciesJsonArray; + } + + private static string ReplaceAbstractResourceNames(string resourceName) + { + // These resources are similar to abstract base classes, so they are not represented in the resourceSchemas + // portion of the schema document. This is a rudimentary replacement with the most specific version of the resource + if (resourceName == "EducationOrganization") + { + resourceName = "School"; + } + + if (resourceName == "GeneralStudentProgramAssociation") + { + resourceName = "StudentProgramAssociation"; + } + + return resourceName; + } + + public static Dictionary GetDependencies(Dictionary> resources) + { + Dictionary orderedNodes = resources.ToDictionary(d => d.Key, _ => 0); + Dictionary visitedNodes = []; + + foreach (var resource in resources.Select(d => d.Key)) + { + RecursivelyDetermineDependencies(resource); + } + + int RecursivelyDetermineDependencies(string resourceName) + { + if (orderedNodes[resourceName] > 0) + { + return orderedNodes[resourceName]; + } + + visitedNodes.TryAdd(resourceName, 0); + var maxDepth = 0; + foreach (var dependency in resources[resourceName]) + { + if (visitedNodes.ContainsKey(dependency)) + { + if (visitedNodes[dependency] > maxDepth) + { + maxDepth = visitedNodes[dependency]; + } + } + else + { + var level = RecursivelyDetermineDependencies(dependency); + if (level > maxDepth) + maxDepth = level; + } + } + orderedNodes[resourceName] = maxDepth + 1; + visitedNodes[resourceName] = maxDepth + 1; + return maxDepth + 1; + } + + return orderedNodes; + } +} diff --git a/src/core/EdFi.DataManagementService.Core/ApiSchema/Extensions/JsonHelperExtensions.cs b/src/core/EdFi.DataManagementService.Core/ApiSchema/Extensions/JsonHelperExtensions.cs index b8ebc0d09..5a4cf6c5f 100644 --- a/src/core/EdFi.DataManagementService.Core/ApiSchema/Extensions/JsonHelperExtensions.cs +++ b/src/core/EdFi.DataManagementService.Core/ApiSchema/Extensions/JsonHelperExtensions.cs @@ -244,4 +244,23 @@ public static void TryCoerceStringToNumber(this JsonNode jsonNode) } } } + + /// + /// Helper to extract a list of JsonNodes as the values of all the properties of a JsonNode + /// + /// + public static List SelectNodesFromPropertyValues(this JsonNode jsonNode) + { + KeyValuePair[]? nodeKeys = jsonNode?.AsObject().ToArray(); + + if (nodeKeys == null) + { + throw new InvalidOperationException("Unexpected null"); + } + + return nodeKeys + .Where(x => x.Value != null) + .Select(x => x.Value ?? new JsonObject()) + .ToList(); + } } diff --git a/src/core/EdFi.DataManagementService.Core/ApiService.cs b/src/core/EdFi.DataManagementService.Core/ApiService.cs index 69c43e62c..527ddfe7a 100644 --- a/src/core/EdFi.DataManagementService.Core/ApiService.cs +++ b/src/core/EdFi.DataManagementService.Core/ApiService.cs @@ -238,23 +238,10 @@ public async Task DeleteById(FrontendRequest frontendRequest) /// public IList GetDataModelInfo() { - JsonNode schema = _apiSchemaProvider.ApiSchemaRootNode; - - KeyValuePair[]? projectSchemas = schema["projectSchemas"]?.AsObject().ToArray(); - if (projectSchemas == null || projectSchemas.Length == 0) - { - string errorMessage = "No projectSchemas found, ApiSchema.json is invalid"; - _logger.LogCritical(errorMessage); - throw new InvalidOperationException(errorMessage); - } + var apiSchemaDocument = new ApiSchemaDocument(_apiSchemaProvider.ApiSchemaRootNode, _logger); IList result = []; - List projectSchemaNodes = projectSchemas - .Where(x => x.Value != null) - .Select(x => x.Value ?? new JsonObject()) - .ToList(); - - foreach (JsonNode projectSchemaNode in projectSchemaNodes) + foreach (JsonNode projectSchemaNode in apiSchemaDocument.GetAllProjectSchemaNodes()) { var projectName = projectSchemaNode?["projectName"]?.GetValue() ?? string.Empty; var projectVersion = projectSchemaNode?["projectVersion"]?.GetValue() ?? string.Empty; @@ -264,4 +251,14 @@ public IList GetDataModelInfo() } return result; } + + /// + /// Get resource dependencies + /// + /// JSON array ordered by dependency sequence + public JsonArray GetDependencies() + { + var dependencyCalculator = new DependencyCalculator(_apiSchemaProvider.ApiSchemaRootNode, _logger); + return dependencyCalculator.GetDependenciesFromResourceSchema(); + } } diff --git a/src/frontend/EdFi.DataManagementService.Frontend.AspNetCore.Tests.Unit/Modules/MetaDataModuleTests.cs b/src/frontend/EdFi.DataManagementService.Frontend.AspNetCore.Tests.Unit/Modules/MetaDataModuleTests.cs index a1ca5b345..95109bc95 100644 --- a/src/frontend/EdFi.DataManagementService.Frontend.AspNetCore.Tests.Unit/Modules/MetaDataModuleTests.cs +++ b/src/frontend/EdFi.DataManagementService.Frontend.AspNetCore.Tests.Unit/Modules/MetaDataModuleTests.cs @@ -202,13 +202,6 @@ public async Task Metadata_Returns_Invalid_Resource_Error() public async Task Metadata_Returns_Dependencies() { // Arrange - var contentProvider = A.Fake(); - - var json = """[{"name": "dependency1"},{"name": "dependency2"}]"""; - JsonNode _dependencyJson = JsonNode.Parse(json)!; - - A.CallTo(() => contentProvider.LoadJsonContent(A.Ignored)).Returns(_dependencyJson); - var httpContext = A.Fake(); await using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => @@ -217,7 +210,6 @@ public async Task Metadata_Returns_Dependencies() builder.ConfigureServices( (collection) => { - collection.AddTransient((x) => contentProvider); collection.AddTransient(x => httpContext); } ); @@ -229,11 +221,11 @@ public async Task Metadata_Returns_Dependencies() var content = await response.Content.ReadAsStringAsync(); var jsonContent = JsonNode.Parse(content); - var name = jsonContent?[0]?["name"]?.GetValue(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); jsonContent.Should().NotBeNull(); - name.Should().Be("dependency1"); + jsonContent?[0]!["resource"]?.GetValue().Should().Be("/ed-fi/absenceEventCategoryDescriptors"); + jsonContent?[0]!["order"]?.GetValue().Should().Be(1); } } diff --git a/src/frontend/EdFi.DataManagementService.Frontend.AspNetCore/Modules/MetadataEndpointModule.cs b/src/frontend/EdFi.DataManagementService.Frontend.AspNetCore/Modules/MetadataEndpointModule.cs index ab2666615..e6b3d8f9d 100644 --- a/src/frontend/EdFi.DataManagementService.Frontend.AspNetCore/Modules/MetadataEndpointModule.cs +++ b/src/frontend/EdFi.DataManagementService.Frontend.AspNetCore/Modules/MetadataEndpointModule.cs @@ -6,6 +6,7 @@ using System.Net; using System.Text.RegularExpressions; +using EdFi.DataManagementService.Core.External.Interface; using EdFi.DataManagementService.Frontend.AspNetCore.Configuration; using EdFi.DataManagementService.Frontend.AspNetCore.Content; using EdFi.DataManagementService.Frontend.AspNetCore.Infrastructure.Extensions; @@ -50,9 +51,9 @@ internal async Task GetMetadata(HttpContext httpContext) await httpContext.Response.WriteAsJsonAsync(content); } - internal async Task GetDependencies(HttpContext httpContext, IContentProvider contentProvider) + internal async Task GetDependencies(HttpContext httpContext, IApiService apiService) { - var content = contentProvider.LoadJsonContent("dependencies"); + var content = apiService.GetDependencies(); await httpContext.Response.WriteAsSerializedJsonAsync(content); }