diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index 0bbb5b3eab..3a10eeffde 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -65,6 +65,8 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.EntityFirst); // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); + // Ignore the entity IsAutoentity as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(entity => entity.IsAutoentity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); // Ignore the UserProvidedEnabledOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index d67a1f9f28..9c9ba17f2c 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -45,6 +45,9 @@ public record Entity [JsonIgnore] public bool IsLinkingEntity { get; init; } + [JsonIgnore] + public bool IsAutoentity { get; init; } + [JsonConstructor] public Entity( EntitySource Source, @@ -58,7 +61,8 @@ public Entity( bool IsLinkingEntity = false, EntityHealthCheckConfig? Health = null, string? Description = null, - EntityMcpOptions? Mcp = null) + EntityMcpOptions? Mcp = null, + bool IsAutoentity = false) { this.Health = Health; this.Source = Source; @@ -72,6 +76,7 @@ public Entity( this.IsLinkingEntity = IsLinkingEntity; this.Description = Description; this.Mcp = Mcp; + this.IsAutoentity = IsAutoentity; } /// diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 89cc7413d1..659914a7ac 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -269,6 +269,11 @@ public bool TryAddGeneratedAutoentityNameToDataSourceName(string entityName, str return false; } + public bool RemoveGeneratedAutoentityNameFromDataSourceName(string entityName) + { + return _entityNameToDataSourceName.Remove(entityName); + } + /// /// Constructor for runtimeConfig. /// To be used when setting up from cli json scenario. diff --git a/src/Core/Configurations/RuntimeConfigProvider.cs b/src/Core/Configurations/RuntimeConfigProvider.cs index 644782e2cc..9dcd488779 100644 --- a/src/Core/Configurations/RuntimeConfigProvider.cs +++ b/src/Core/Configurations/RuntimeConfigProvider.cs @@ -426,4 +426,23 @@ public void AddMergedEntitiesToConfig(Dictionary newEntities) }; _configLoader.EditRuntimeConfig(newRuntimeConfig); } + + public void RemoveGeneratedAutoentitiesFromConfig() + { + Dictionary entities = new(_configLoader.RuntimeConfig!.Entities); + foreach ((string name, Entity entity) in entities) + { + if (entity.IsAutoentity) + { + entities.Remove(name); + _configLoader.RuntimeConfig!.RemoveGeneratedAutoentityNameFromDataSourceName(name); + } + } + + RuntimeConfig newRuntimeConfig = _configLoader.RuntimeConfig! with + { + Entities = new(entities) + }; + _configLoader.EditRuntimeConfig(newRuntimeConfig); + } } diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index c8d86e8e11..ab3a672af8 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -971,6 +971,16 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) foreach ((string entityName, Entity entity) in runtimeConfig.Entities) { HashSet totalSupportedOperationsFromAllRoles = new(); + + if (entity.Permissions.Length == 0) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Entity: {entityName} has no permissions defined.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + + } + foreach (EntityPermission permissionSetting in entity.Permissions) { string roleName = permissionSetting.Role; diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 96fa47dcfd..297c8e2743 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -349,7 +349,8 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona Health: autoentity.Template.Health, Fields: null, Relationships: null, - Mappings: new()); + Mappings: new(), + IsAutoentity: true); // Add the generated entity to the linking entities dictionary. // This allows the entity to be processed later during metadata population. diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 6aa2712468..0ea9d25eb6 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -353,6 +353,12 @@ public async Task InitializeAsync() GenerateRestPathToEntityMap(); InitODataParser(); + + if (_isValidateOnly) + { + RemoveGeneratedAutoentities(); + } + timer.Stop(); _logger.LogTrace($"Done inferring Sql database schema in {timer.ElapsedMilliseconds}ms."); } @@ -714,6 +720,15 @@ protected virtual Task GenerateAutoentitiesIntoEntities(IReadOnlyDictionary + /// Removes the entities that were generated from the autoentities property. + /// This should only be done when we only want to validate the entities. + /// + private void RemoveGeneratedAutoentities() + { + _runtimeConfigProvider.RemoveGeneratedAutoentitiesFromConfig(); + } + protected void PopulateDatabaseObjectForEntity( Entity entity, string entityName, diff --git a/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs b/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs index 0f9ff6c1b8..815a42595c 100644 --- a/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs +++ b/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs @@ -64,6 +64,7 @@ private static void GenerateConfigFile( string entityBackingColumn = "title", string entityExposedName = "title", string mcpEnabled = "true", + string autoentityName = "autoentity_{object}", string configFileName = CONFIG_FILE_NAME) { File.WriteAllText(configFileName, @" @@ -180,6 +181,30 @@ private static void GenerateConfigFile( } ] } + }, + ""autoentities"": { + ""BooksAutoentities"": { + ""patterns"": { + ""include"": [ ""%book%"" ], + ""name"": """ + autoentityName + @""" + }, + ""template"": { + ""rest"": { + ""enabled"": true + } + } + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [ + { + ""action"": ""*"" + } + ] + } + ] + } } }"); } @@ -768,6 +793,41 @@ await WaitForConditionAsync( Assert.AreEqual(HttpStatusCode.OK, restResult.StatusCode); } + /// + /// Hot reload the configuration file so that it changes the name of the autoentity properties. + /// Then we assert that the hot reload is successful by sending a request to the newly created autoentity. + /// + [TestCategory(MSSQL_ENVIRONMENT)] + [TestMethod] + public async Task HotReloadAutoentities() + { + // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + + // Act + HttpResponseMessage restResult = await _testClient.GetAsync($"rest/autoentity_books"); + + GenerateConfigFile( + connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}", + autoentityName: "HotReload_{object}"); + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); + + HttpResponseMessage failRestResult = await _testClient.GetAsync($"rest/autoentity_books"); + HttpResponseMessage hotReloadRestResult = await _testClient.GetAsync($"rest/HotReload_books"); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, restResult.StatusCode, + $"REST request before hot-reload failed when it was expected to succeed. Response: {await restResult.Content.ReadAsStringAsync()}"); + Assert.AreEqual(HttpStatusCode.NotFound, failRestResult.StatusCode, + $"REST request after hot-reload succeeded when it was expected to fail. Response: {await failRestResult.Content.ReadAsStringAsync()}"); + Assert.AreEqual(HttpStatusCode.OK, hotReloadRestResult.StatusCode, + $"REST request after hot-reload failed when it was expected to succeed. Response: {await hotReloadRestResult.Content.ReadAsStringAsync()}"); + } + /// /// /// (Warning: This test only currently works in the pipeline due to constrains of not /// being able to change from one database type to another, under normal circumstances diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index 637280b45d..9d8c213b7c 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -69,6 +69,8 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.EntityFirst); // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); + // Ignore the entity IsAutoentity as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(entity => entity.IsAutoentity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); // Ignore the UserProvidedEnabledOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter.