diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 1c2cf2ec74..663cc519bd 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -492,7 +492,40 @@ "ttl-seconds": { "type": "integer", "description": "Time to live in seconds", - "default": 5 + "default": 5, + "minimum": 1 + }, + "level-2": { + "type": "object", + "description": "Configuration for the level 2 (distributed) cache and backplane.", + "additionalProperties": false, + "properties": { + "enabled": { + "$ref": "#/$defs/boolean-or-string", + "description": "Enable or disable the level 2 distributed cache.", + "default": false + }, + "provider": { + "type": "string", + "description": "The provider for the L2 cache. Currently only 'redis' is supported." + }, + "connection-string": { + "type": "string", + "description": "The connection string for the level 2 cache provider." + }, + "partition": { + "type": "string", + "description": "The prefix to use for cache keys in level 2 and backplane, useful in a shared environment to avoid collisions." + } + }, + "if": { + "properties": { + "enabled": { "const": true } + } + }, + "then": { + "required": ["connection-string"] + } } } }, diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 8c5ece5c3b..34d768f7fe 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -811,7 +811,7 @@ public void TestDatabaseTypeUpdate(string dbType) string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); Assert.IsNotNull(config.Runtime); - Assert.AreEqual(config.DataSource.DatabaseType, Enum.Parse(dbType, ignoreCase: true)); + Assert.AreEqual(config.DataSource!.DatabaseType, Enum.Parse(dbType, ignoreCase: true)); } /// @@ -841,7 +841,7 @@ public void TestDatabaseTypeUpdateCosmosDB_NoSQLToMSSQL() string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); Assert.IsNotNull(config.Runtime); - Assert.AreEqual(config.DataSource.DatabaseType, DatabaseType.MSSQL); + Assert.AreEqual(config.DataSource!.DatabaseType, DatabaseType.MSSQL); Assert.AreEqual(config.DataSource.Options!.GetValueOrDefault("set-session-context", false), true); Assert.IsFalse(config.DataSource.Options!.ContainsKey("database")); Assert.IsFalse(config.DataSource.Options!.ContainsKey("container")); @@ -877,7 +877,7 @@ public void TestDatabaseTypeUpdateMSSQLToCosmosDB_NoSQL() string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); Assert.IsNotNull(config.Runtime); - Assert.AreEqual(config.DataSource.DatabaseType, DatabaseType.CosmosDB_NoSQL); + Assert.AreEqual(config.DataSource!.DatabaseType, DatabaseType.CosmosDB_NoSQL); Assert.AreEqual(config.DataSource.Options!.GetValueOrDefault("database"), "testdb"); Assert.AreEqual(config.DataSource.Options!.GetValueOrDefault("container"), "testcontainer"); Assert.AreEqual(config.DataSource.Options!.GetValueOrDefault("schema"), "testschema.gql"); diff --git a/src/Cli.Tests/EndToEndTests.cs b/src/Cli.Tests/EndToEndTests.cs index 0f0622516d..4db38dede5 100644 --- a/src/Cli.Tests/EndToEndTests.cs +++ b/src/Cli.Tests/EndToEndTests.cs @@ -65,7 +65,7 @@ public Task TestInitForCosmosDBNoSql() Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.AllowIntrospection); - Assert.AreEqual(DatabaseType.CosmosDB_NoSQL, runtimeConfig.DataSource.DatabaseType); + Assert.AreEqual(DatabaseType.CosmosDB_NoSQL, runtimeConfig.DataSource!.DatabaseType); CosmosDbNoSQLDataSourceOptions? cosmosDataSourceOptions = runtimeConfig.DataSource.GetTypedOptions(); Assert.IsNotNull(cosmosDataSourceOptions); Assert.AreEqual("graphqldb", cosmosDataSourceOptions.Database); @@ -93,7 +93,7 @@ public void TestInitForCosmosDBPostgreSql() Assert.IsTrue(_runtimeConfigLoader!.TryLoadConfig(TEST_RUNTIME_CONFIG_FILE, out RuntimeConfig? runtimeConfig)); Assert.IsNotNull(runtimeConfig); - Assert.AreEqual(DatabaseType.CosmosDB_PostgreSQL, runtimeConfig.DataSource.DatabaseType); + Assert.AreEqual(DatabaseType.CosmosDB_PostgreSQL, runtimeConfig.DataSource!.DatabaseType); Assert.IsNotNull(runtimeConfig.Runtime); Assert.IsNotNull(runtimeConfig.Runtime.Rest); Assert.AreEqual("/rest-api", runtimeConfig.Runtime.Rest.Path); @@ -124,7 +124,7 @@ public void TestInitializingRestAndGraphQLGlobalSettings() out RuntimeConfig? runtimeConfig, replacementSettings: replacementSettings)); - SqlConnectionStringBuilder builder = new(runtimeConfig.DataSource.ConnectionString); + SqlConnectionStringBuilder builder = new(runtimeConfig.DataSource!.ConnectionString); Assert.AreEqual(ProductInfo.GetDataApiBuilderUserAgent(), builder.ApplicationName); Assert.IsNotNull(runtimeConfig); @@ -205,7 +205,7 @@ public void TestEnablingMultipleCreateOperation(CliBool isMultipleCreateEnabled, replacementSettings: replacementSettings)); Assert.IsNotNull(runtimeConfig); - Assert.AreEqual(expectedDbType, runtimeConfig.DataSource.DatabaseType); + Assert.AreEqual(expectedDbType, runtimeConfig.DataSource!.DatabaseType); Assert.IsNotNull(runtimeConfig.Runtime); Assert.IsNotNull(runtimeConfig.Runtime.GraphQL); if (runtimeConfig.DataSource.DatabaseType is DatabaseType.MSSQL && isMultipleCreateEnabled is not CliBool.None) @@ -244,7 +244,7 @@ public void TestAddEntity() Assert.IsTrue(_runtimeConfigLoader!.TryLoadConfig(TEST_RUNTIME_CONFIG_FILE, out RuntimeConfig? addRuntimeConfig)); Assert.IsNotNull(addRuntimeConfig); - Assert.AreEqual(TEST_ENV_CONN_STRING, addRuntimeConfig.DataSource.ConnectionString); + Assert.AreEqual(TEST_ENV_CONN_STRING, addRuntimeConfig.DataSource!.ConnectionString); Assert.AreEqual(1, addRuntimeConfig.Entities.Count()); // 1 new entity added Assert.IsTrue(addRuntimeConfig.Entities.ContainsKey("todo")); Entity entity = addRuntimeConfig.Entities["todo"]; @@ -1173,10 +1173,6 @@ public async Task TestExitOfRuntimeEngineWithInvalidConfig( Assert.IsNotNull(output); StringAssert.Contains(output, $"Deserialization of the configuration file failed.", StringComparison.Ordinal); - output = await process.StandardOutput.ReadLineAsync(); - Assert.IsNotNull(output); - StringAssert.Contains(output, $"Error: Failed to parse the config file: {TEST_RUNTIME_CONFIG_FILE}.", StringComparison.Ordinal); - output = await process.StandardOutput.ReadLineAsync(); Assert.IsNotNull(output); StringAssert.Contains(output, $"Failed to start the engine.", StringComparison.Ordinal); diff --git a/src/Cli.Tests/EnvironmentTests.cs b/src/Cli.Tests/EnvironmentTests.cs index d7b359783a..c03025c584 100644 --- a/src/Cli.Tests/EnvironmentTests.cs +++ b/src/Cli.Tests/EnvironmentTests.cs @@ -163,8 +163,8 @@ public async Task FailureToStartEngineWhenEnvVarNamedWrong() ); string? output = await process.StandardError.ReadLineAsync(); - Assert.AreEqual("Deserialization of the configuration file failed during a post-processing step.", output); - output = await process.StandardError.ReadToEndAsync(); + Assert.IsNotNull(output); + // Clean error message on stderr with no stack trace. StringAssert.Contains(output, "A valid Connection String should be provided.", StringComparison.Ordinal); process.Kill(); } diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index 3a10eeffde..4f4584a535 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -119,6 +119,14 @@ public static void Init() VerifierSettings.IgnoreMember(dataSource => dataSource.DatabaseTypeNotSupportedMessage); // Ignore DefaultDataSourceName as that's not serialized in our config file. VerifierSettings.IgnoreMember(config => config.DefaultDataSourceName); + // Ignore IsRootConfig as that's a computed property for validation, not serialized. + VerifierSettings.IgnoreMember(config => config.IsRootConfig); + // Ignore IsChildConfig as that's a runtime flag for validation, not serialized. + VerifierSettings.IgnoreMember(config => config.IsChildConfig); + // Ignore AutoentityResolutionCounts as that's populated at runtime during metadata initialization. + VerifierSettings.IgnoreMember(config => config.AutoentityResolutionCounts); + // Ignore ChildConfigs as that's populated at runtime during child config loading. + VerifierSettings.IgnoreMember(config => config.ChildConfigs); // Ignore MaxResponseSizeMB as as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(options => options.MaxResponseSizeMB); // Ignore UserProvidedMaxResponseSizeMB as that's not serialized in our config file. diff --git a/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs b/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs index 29110a5a7c..03fb0eb832 100644 --- a/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs +++ b/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs @@ -50,7 +50,7 @@ public void TestRuntimeCanParseUserDelegatedAuthConfig() // Assert Assert.IsTrue(success); Assert.IsNotNull(config); - Assert.IsNotNull(config.DataSource.UserDelegatedAuth); + Assert.IsNotNull(config.DataSource!.UserDelegatedAuth); Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled); Assert.AreEqual("https://database.windows.net", config.DataSource.UserDelegatedAuth.DatabaseAudience); } @@ -95,7 +95,7 @@ public void TestRuntimeCanParseConfigWithoutUserDelegatedAuth() // Assert Assert.IsTrue(success); Assert.IsNotNull(config); - Assert.IsNull(config.DataSource.UserDelegatedAuth); + Assert.IsNull(config.DataSource!.UserDelegatedAuth); } } } diff --git a/src/Cli.Tests/ValidateConfigTests.cs b/src/Cli.Tests/ValidateConfigTests.cs index e40a32e291..1dc609a0c8 100644 --- a/src/Cli.Tests/ValidateConfigTests.cs +++ b/src/Cli.Tests/ValidateConfigTests.cs @@ -199,6 +199,27 @@ public void TestValidateConfigFailsWithNoEntities() } } + /// + /// Validates that when the config has no entities or autoentities, the config + /// still parses successfully (constructor no longer throws), and IsConfigValid + /// returns false without throwing. + /// Adapted for https://github.com/Azure/data-api-builder/issues/3268 + /// + [TestMethod] + public void TestValidateConfigWithNoEntitiesProducesCleanError() + { + string configWithoutEntities = $"{{{SAMPLE_SCHEMA_DATA_SOURCE},{RUNTIME_SECTION}}}"; + + // Config with no entities should now parse successfully (validation catches it downstream). + bool parsed = RuntimeConfigLoader.TryParseConfig(configWithoutEntities, out _); + Assert.IsTrue(parsed, "Config with datasource and no entities should parse successfully."); + + // IsConfigValid should return false cleanly (no exception thrown). + ((MockFileSystem)_fileSystem!).AddFile(TEST_RUNTIME_CONFIG_FILE, configWithoutEntities); + ValidateOptions validateOptions = new(TEST_RUNTIME_CONFIG_FILE); + Assert.IsFalse(ConfigGenerator.IsConfigValid(validateOptions, _runtimeConfigLoader!, _fileSystem!)); + } + /// /// This Test is used to verify that the validate command is able to catch when data source field is missing. /// @@ -359,4 +380,584 @@ private async Task ValidatePropertyOptionsFails(ConfigureOptions options) JsonSchemaValidationResult result = await validator.ValidateConfigSchema(config, TEST_RUNTIME_CONFIG_FILE, mockLoggerFactory.Object); Assert.IsFalse(result.IsValid); } + + /// + /// Validates that a non-root config (has data-source but no data-source-files) with zero entities + /// and an invalid connection string gets a connection string validation error. + /// Entity validation is gated on successful DB connectivity, so no entity error fires. + /// The validation still returns false due to the connection string error. + /// Regression test for https://github.com/Azure/data-api-builder/issues/3267 + /// + [TestMethod] + public void TestValidateNonRootZeroEntitiesWithInvalidConnectionString() + { + ((MockFileSystem)_fileSystem!).AddFile(TEST_RUNTIME_CONFIG_FILE, INVALID_INTIAL_CONFIG); + ValidateOptions validateOptions = new(TEST_RUNTIME_CONFIG_FILE); + + Mock> mockLogger = new(); + SetLoggerForCliConfigGenerator(mockLogger.Object); + + bool isValid = ConfigGenerator.IsConfigValid(validateOptions, _runtimeConfigLoader!, _fileSystem!); + + // Validation should fail due to the empty connection string. + Assert.IsFalse(isValid); + } + + /// + /// Validates that a root config (with data-source-files pointing to children) + /// that has no data-source and no entities is considered structurally valid + /// for parsing. The root config delegates entity requirements to children. + /// + [TestMethod] + public void TestRootConfigWithNoDataSourceAndNoEntitiesParses() + { + string rootConfig = @" + { + ""$schema"": """ + DAB_DRAFT_SCHEMA_TEST_PATH + @""", + ""runtime"": { + ""rest"": { ""enabled"": true }, + ""graphql"": { ""enabled"": true }, + ""host"": { ""mode"": ""development"" } + }, + ""data-source-files"": [""child1.json""], + ""entities"": {} + }"; + + // The root config should parse without error (no data-source required for root). + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(rootConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config); + Assert.IsTrue(config.IsRootConfig); + } + + /// + /// Validates that a non-root config with a data-source and no entities parses + /// successfully. Validation of entity presence happens during dab validate, + /// not during parsing. + /// + [TestMethod] + public void TestNonRootConfigWithDataSourceAndNoEntitiesParses() + { + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(INITIAL_CONFIG, out RuntimeConfig? config)); + Assert.IsNotNull(config); + Assert.IsFalse(config.IsRootConfig); + } + + /// + /// Non-root with datasource and zero entities → error. + /// + [TestMethod] + public void TestNonRootWithDataSourceAndNoEntitiesProducesError() + { + RuntimeConfig config = BuildTestConfig(hasDataSource: true, entities: new()); + RuntimeConfigValidator validator = BuildValidator(config); + validator.ValidateDataSourceAndEntityPresence(config); + + Assert.IsTrue(validator.ConfigValidationExceptions.Count > 0, + "Expected validation error for non-root config with datasource but no entities."); + } + + /// + /// Non-root with no datasource → error. + /// + [TestMethod] + public void TestNonRootWithNoDataSourceProducesError() + { + RuntimeConfig config = BuildTestConfig(hasDataSource: false, entities: new()); + RuntimeConfigValidator validator = BuildValidator(config); + validator.ValidateDataSourceAndEntityPresence(config); + + Assert.AreEqual(1, validator.ConfigValidationExceptions.Count); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("data source is required")); + } + + /// + /// Non-root with datasource and entities → valid. + /// + [TestMethod] + public void TestNonRootWithDataSourceAndEntitiesIsValid() + { + RuntimeConfig config = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Book", BuildSimpleEntity("dbo.books") } }); + RuntimeConfigValidator validator = BuildValidator(config); + validator.ValidateDataSourceAndEntityPresence(config); + + Assert.AreEqual(0, validator.ConfigValidationExceptions.Count); + } + + /// + /// Root with no datasource and no entities → valid (children carry the load). + /// + [TestMethod] + public void TestRootWithNoDataSourceAndNoEntitiesIsValid() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Book", BuildSimpleEntity("dbo.books") } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: false, entities: new(), + dataSourceFiles: new DataSourceFiles(new[] { "child.json" })); + rootConfig.ChildConfigs.Add(("child.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.AreEqual(0, validator.ConfigValidationExceptions.Count); + } + + /// + /// Root with no datasource but with entities → error (entities need a datasource). + /// + [TestMethod] + public void TestRootWithNoDataSourceButEntitiesProducesError() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Author", BuildSimpleEntity("dbo.authors") } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: false, + entities: new() { { "Book", BuildSimpleEntity("dbo.books") } }, + dataSourceFiles: new DataSourceFiles(new[] { "child.json" })); + rootConfig.ChildConfigs.Add(("child.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.IsTrue(validator.ConfigValidationExceptions.Count > 0); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("must not define entities")); + } + + /// + /// Root with datasource and entities → valid (follows normal entity rules). + /// + [TestMethod] + public void TestRootWithDataSourceAndEntitiesIsValid() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Author", BuildSimpleEntity("dbo.authors") } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Book", BuildSimpleEntity("dbo.books") } }, + dataSourceFiles: new DataSourceFiles(new[] { "child.json" })); + rootConfig.ChildConfigs.Add(("child.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.AreEqual(0, validator.ConfigValidationExceptions.Count); + } + + /// + /// Child config with datasource but no entities → error naming the child file. + /// + [TestMethod] + public void TestChildWithDataSourceAndNoEntitiesProducesNamedError() + { + RuntimeConfig childConfig = BuildTestConfig(hasDataSource: true, entities: new()); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: false, entities: new(), + dataSourceFiles: new DataSourceFiles(new[] { "child-db.json" })); + rootConfig.ChildConfigs.Add(("child-db.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.AreEqual(1, validator.ConfigValidationExceptions.Count); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("child-db.json"), + "Error should name the child config file."); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("No entities found"), + "Error should mention no entities found."); + } + + /// + /// Child config with no datasource → error naming the child file. + /// + [TestMethod] + public void TestChildWithNoDataSourceProducesNamedError() + { + RuntimeConfig childConfig = BuildTestConfig(hasDataSource: false, entities: new()); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: false, entities: new(), + dataSourceFiles: new DataSourceFiles(new[] { "child-db.json" })); + rootConfig.ChildConfigs.Add(("child-db.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.AreEqual(1, validator.ConfigValidationExceptions.Count); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("child-db.json")); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("data source is required")); + } + + /// + /// Non-root with datasource and only autoentities that resolve zero entities → error + /// ("No entities found"). Covers truth-table row 6 (DSF=0, DS=1, E=0, AE=1, resolved=0). + /// + [TestMethod] + public void TestNonRootWithDataSourceAndAutoentitiesResolvingZeroProducesError() + { + RuntimeConfig config = BuildTestConfig( + hasDataSource: true, + entities: new(), + autoentities: new() { { "ae1", BuildSimpleAutoentity() } }, + autoentityResolutionCounts: new() { { "ae1", 0 } }); + RuntimeConfigValidator validator = BuildValidator(config); + validator.ValidateDataSourceAndEntityPresence(config); + + Assert.AreEqual(1, validator.ConfigValidationExceptions.Count); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("No entities found")); + } + + /// + /// Non-root with datasource and only autoentities that resolve to >0 entities → valid. + /// Covers truth-table row 6 (DSF=0, DS=1, E=0, AE=1, resolved>0). + /// + [TestMethod] + public void TestNonRootWithDataSourceAndAutoentitiesResolvingEntitiesIsValid() + { + RuntimeConfig config = BuildTestConfig( + hasDataSource: true, + entities: new(), + autoentities: new() { { "ae1", BuildSimpleAutoentity() } }, + autoentityResolutionCounts: new() { { "ae1", 3 } }); + RuntimeConfigValidator validator = BuildValidator(config); + validator.ValidateDataSourceAndEntityPresence(config); + + Assert.AreEqual(0, validator.ConfigValidationExceptions.Count); + } + + /// + /// Non-root with manual entities AND autoentities that resolve zero → valid, but a warning + /// is emitted. Covers truth-table row 8 (DSF=0, DS=1, E=1, AE=1, resolved=0). + /// + [TestMethod] + public void TestNonRootWithEntitiesAndAutoentitiesResolvingZeroLogsWarning() + { + RuntimeConfig config = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Book", BuildSimpleEntity("dbo.books") } }, + autoentities: new() { { "ae1", BuildSimpleAutoentity() } }, + autoentityResolutionCounts: new() { { "ae1", 0 } }); + RuntimeConfigValidator validator = BuildValidator(config, out Mock> loggerMock); + validator.ValidateDataSourceAndEntityPresence(config); + + Assert.AreEqual(0, validator.ConfigValidationExceptions.Count); + VerifyAutoentityZeroDiscoveredWarning(loggerMock, expectedFileNameInMessage: null); + } + + /// + /// Root config (DSF=1) with no data-source but with autoentities defined → error. + /// Covers truth-table row 10 (DSF=1, DS=0, E=0, AE=1). + /// + [TestMethod] + public void TestRootWithNoDataSourceButAutoentitiesProducesError() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Author", BuildSimpleEntity("dbo.authors") } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: false, + entities: new(), + dataSourceFiles: new DataSourceFiles(new[] { "child.json" }), + autoentities: new() { { "ae1", BuildSimpleAutoentity() } }); + rootConfig.ChildConfigs.Add(("child.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.IsTrue(validator.ConfigValidationExceptions.Count > 0); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("must not define entities")); + } + + /// + /// Root config with its own data-source but zero entities and zero autoentities → error. + /// When a root config defines a data-source, normal entity rules apply at the root. + /// Covers truth-table row 13 (DSF=1, DS=1, E=0, AE=0). + /// + [TestMethod] + public void TestRootWithDataSourceAndNoEntitiesProducesError() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Author", BuildSimpleEntity("dbo.authors") } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: true, + entities: new(), + dataSourceFiles: new DataSourceFiles(new[] { "child.json" })); + rootConfig.ChildConfigs.Add(("child.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.IsTrue(validator.ConfigValidationExceptions.Any(e => e.Message.Contains("No entities found")), + "Expected 'No entities found' error on root with own data-source and zero entities."); + } + + /// + /// Root config with its own data-source and autoentities that resolve zero → error. + /// Covers truth-table row 14 (DSF=1, DS=1, E=0, AE=1, resolved=0). + /// + [TestMethod] + public void TestRootWithDataSourceAndAutoentitiesResolvingZeroProducesError() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Author", BuildSimpleEntity("dbo.authors") } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: true, + entities: new(), + dataSourceFiles: new DataSourceFiles(new[] { "child.json" }), + autoentities: new() { { "ae1", BuildSimpleAutoentity() } }, + autoentityResolutionCounts: new() { { "ae1", 0 } }); + rootConfig.ChildConfigs.Add(("child.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.IsTrue(validator.ConfigValidationExceptions.Any(e => e.Message.Contains("No entities found"))); + } + + /// + /// Root config with its own data-source and autoentities that resolve to >0 entities → valid. + /// Covers truth-table row 14 (DSF=1, DS=1, E=0, AE=1, resolved>0). + /// + [TestMethod] + public void TestRootWithDataSourceAndAutoentitiesResolvingEntitiesIsValid() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Author", BuildSimpleEntity("dbo.authors") } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: true, + entities: new(), + dataSourceFiles: new DataSourceFiles(new[] { "child.json" }), + autoentities: new() { { "ae1", BuildSimpleAutoentity() } }, + autoentityResolutionCounts: new() { { "ae1", 5 } }); + rootConfig.ChildConfigs.Add(("child.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.AreEqual(0, validator.ConfigValidationExceptions.Count); + } + + /// + /// Root config with manual entities AND autoentities that resolve zero → valid, but a warning + /// is emitted at the root level. Covers truth-table row 16 (DSF=1, DS=1, E=1, AE=1, resolved=0). + /// + [TestMethod] + public void TestRootWithEntitiesAndAutoentitiesResolvingZeroLogsWarning() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Author", BuildSimpleEntity("dbo.authors") } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Book", BuildSimpleEntity("dbo.books") } }, + dataSourceFiles: new DataSourceFiles(new[] { "child.json" }), + autoentities: new() { { "ae1", BuildSimpleAutoentity() } }, + autoentityResolutionCounts: new() { { "ae1", 0 } }); + rootConfig.ChildConfigs.Add(("child.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig, out Mock> loggerMock); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.AreEqual(0, validator.ConfigValidationExceptions.Count); + VerifyAutoentityZeroDiscoveredWarning(loggerMock, expectedFileNameInMessage: null); + } + + /// + /// Child config with its own data-source and only autoentities that resolve zero → error + /// naming the child file. Covers child truth-table row C4 (DS=1, E=0, AE=1, resolved=0). + /// + [TestMethod] + public void TestChildWithDataSourceAndAutoentitiesResolvingZeroProducesNamedError() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new(), + autoentities: new() { { "ae1", BuildSimpleAutoentity() } }, + autoentityResolutionCounts: new() { { "ae1", 0 } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: false, entities: new(), + dataSourceFiles: new DataSourceFiles(new[] { "child-db.json" })); + rootConfig.ChildConfigs.Add(("child-db.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.AreEqual(1, validator.ConfigValidationExceptions.Count); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("child-db.json"), + "Error should name the child config file."); + Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("No entities found"), + "Error should mention no entities found."); + } + + /// + /// Child config with manual entities AND autoentities that resolve zero → valid, but a + /// warning naming the child file is emitted. Covers child truth-table row C6 + /// (DS=1, E=1, AE=1, resolved=0). + /// + [TestMethod] + public void TestChildWithEntitiesAndAutoentitiesResolvingZeroLogsNamedWarning() + { + RuntimeConfig childConfig = BuildTestConfig( + hasDataSource: true, + entities: new() { { "Book", BuildSimpleEntity("dbo.books") } }, + autoentities: new() { { "ae1", BuildSimpleAutoentity() } }, + autoentityResolutionCounts: new() { { "ae1", 0 } }); + childConfig.IsChildConfig = true; + + RuntimeConfig rootConfig = BuildTestConfig( + hasDataSource: false, entities: new(), + dataSourceFiles: new DataSourceFiles(new[] { "child-db.json" })); + rootConfig.ChildConfigs.Add(("child-db.json", childConfig)); + + RuntimeConfigValidator validator = BuildValidator(rootConfig, out Mock> loggerMock); + validator.ValidateDataSourceAndEntityPresence(rootConfig); + + Assert.AreEqual(0, validator.ConfigValidationExceptions.Count); + VerifyAutoentityZeroDiscoveredWarning(loggerMock, expectedFileNameInMessage: "child-db.json"); + } + + /// + /// Helper: verifies that the autoentity-discovered-zero warning was logged at least once, + /// optionally also checking that the formatted message contains a child config file name. + /// + private static void VerifyAutoentityZeroDiscoveredWarning( + Mock> loggerMock, + string? expectedFileNameInMessage) + { + const string FRAGMENT = "Autoentities are configured but no entities were discovered"; + // Using string.Empty when no file name is expected makes Contains() always true, + // letting us keep a single Moq expression tree (which can't use 'is null'). + string fileFragment = expectedFileNameInMessage ?? string.Empty; + + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((o, t) => + o.ToString()!.Contains(FRAGMENT) + && o.ToString()!.Contains(fileFragment)), + It.IsAny(), + (Func)It.IsAny()), + Times.AtLeastOnce); + } + + /// + /// Helper: builds a RuntimeConfigValidator in validate-only mode over the given config. + /// + private static RuntimeConfigValidator BuildValidator(RuntimeConfig config) + => BuildValidator(config, out _); + + /// + /// Helper: builds a RuntimeConfigValidator in validate-only mode and exposes its logger mock + /// so the test can verify warning calls. + /// + private static RuntimeConfigValidator BuildValidator( + RuntimeConfig config, + out Mock> loggerMock) + { + MockFileSystem fs = new(); + FileSystemRuntimeConfigLoader loader = new(fs) { RuntimeConfig = config }; + RuntimeConfigProvider provider = new(loader); + loggerMock = new(); + return new RuntimeConfigValidator(provider, fs, loggerMock.Object, isValidateOnly: true); + } + + /// + /// Helper: builds a minimal RuntimeConfig for testing. + /// + /// Whether to include a data source. + /// Manual entities to include. + /// Optional data-source-files block (used for root configs). + /// Optional autoentity definitions (the "AE present" axis). + /// + /// Optional pre-populated resolution counts. In production these are filled by the metadata + /// provider during autoentity expansion; tests pre-populate them to deterministically exercise + /// the "AE resolved 0" vs "AE resolved N" branches without needing DB connectivity. + /// + private static RuntimeConfig BuildTestConfig( + bool hasDataSource, + Dictionary entities, + DataSourceFiles? dataSourceFiles = null, + Dictionary? autoentities = null, + Dictionary? autoentityResolutionCounts = null) + { + DataSource? ds = hasDataSource + ? new DataSource(DatabaseType.MSSQL, "Server=localhost;Database=test;", Options: null) + : null; + + RuntimeConfig config = new( + Schema: null, + DataSource: ds, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)), + Entities: new RuntimeEntities(entities), + Autoentities: autoentities is not null ? new RuntimeAutoentities(autoentities) : null, + DataSourceFiles: dataSourceFiles); + + if (autoentityResolutionCounts is not null) + { + foreach (KeyValuePair kvp in autoentityResolutionCounts) + { + config.AutoentityResolutionCounts[kvp.Key] = kvp.Value; + } + } + + return config; + } + + /// + /// Helper: builds a simple entity for testing. + /// + private static Entity BuildSimpleEntity(string source) + { + return new Entity( + Source: new EntitySource(Object: source, Type: EntitySourceType.Table, Parameters: null, KeyFields: null), + GraphQL: new(Singular: null, Plural: null), + Fields: null, + Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS), + Permissions: new[] { new EntityPermission("anonymous", new[] { new EntityAction(EntityActionOperation.Read, null, null) }) }, + Relationships: null, + Mappings: null); + } + + /// + /// Helper: builds a minimal autoentity definition (defaults are used for patterns/template). + /// + private static Autoentity BuildSimpleAutoentity() + { + return new Autoentity( + Patterns: null, + Template: null, + Permissions: new[] { new EntityPermission("anonymous", new[] { new EntityAction(EntityActionOperation.Read, null, null) }) }); + } } diff --git a/src/Cli/Commands/ValidateOptions.cs b/src/Cli/Commands/ValidateOptions.cs index b522383945..e3d54666c4 100644 --- a/src/Cli/Commands/ValidateOptions.cs +++ b/src/Cli/Commands/ValidateOptions.cs @@ -38,7 +38,7 @@ public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSy } else { - logger.LogError("Config is invalid. Check above logs for details."); + logger.LogError("Config is invalid."); } return isValidConfig ? CliReturnCode.SUCCESS : CliReturnCode.GENERAL_ERROR; diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index a1c212ed8c..aff6983f22 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -371,6 +371,15 @@ public static bool TryAddEntityToConfigWithOptions(AddOptions options, FileSyste return false; } + if (runtimeConfig.DataSource is null) + { + _logger.LogError( + "Cannot add an entity to '{runtimeConfigFile}' because it has no data source. " + + "If this is a root config (uses data-source-files), run 'dab add' against the specific child config file instead.", + runtimeConfigFile); + return false; + } + if (!TryAddNewEntity(options, runtimeConfig, out RuntimeConfig updatedRuntimeConfig)) { _logger.LogError("Failed to add a new entity."); @@ -402,7 +411,7 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt // Try to get the source object as string or DatabaseObjectSource for new Entity if (!TryCreateSourceObjectForNewEntity( options, - initialRuntimeConfig.DataSource.DatabaseType == DatabaseType.CosmosDB_NoSQL, + initialRuntimeConfig.DataSource!.DatabaseType == DatabaseType.CosmosDB_NoSQL, out EntitySource? source)) { _logger.LogError("Unable to create the source object."); @@ -677,6 +686,15 @@ public static bool TryConfigureSettings(ConfigureOptions options, FileSystemRunt return false; } + if (runtimeConfig.DataSource is null) + { + _logger.LogError( + "Cannot configure '{runtimeConfigFile}' because it has no data source. " + + "If this is a root config (uses data-source-files), run 'dab configure' against the specific child config file instead.", + runtimeConfigFile); + return false; + } + if (!TryUpdateConfiguredDataSourceOptions(options, ref runtimeConfig)) { return false; @@ -715,7 +733,7 @@ private static bool TryUpdateConfiguredDataSourceOptions( ConfigureOptions options, [NotNullWhen(true)] ref RuntimeConfig runtimeConfig) { - DatabaseType dbType = runtimeConfig.DataSource.DatabaseType; + DatabaseType dbType = runtimeConfig.DataSource!.DatabaseType; string dataSourceConnectionString = runtimeConfig.DataSource.ConnectionString; DatasourceHealthCheckConfig? datasourceHealthCheckConfig = runtimeConfig.DataSource.Health; UserDelegatedAuthOptions? userDelegatedAuthConfig = runtimeConfig.DataSource.UserDelegatedAuth; @@ -1903,6 +1921,15 @@ public static bool TryUpdateEntityWithOptions(UpdateOptions options, FileSystemR return false; } + if (runtimeConfig.DataSource is null) + { + _logger.LogError( + "Cannot update an entity in '{runtimeConfigFile}' because it has no data source. " + + "If this is a root config (uses data-source-files), run 'dab update' against the specific child config file instead.", + runtimeConfigFile); + return false; + } + if (!TryUpdateExistingEntity(options, runtimeConfig, out RuntimeConfig updatedConfig)) { _logger.LogError("Failed to update the Entity: {entityName}.", options.Entity); @@ -1970,7 +1997,7 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig } } - EntityRestOptions updatedRestDetails = ConstructUpdatedRestDetails(entity, options, initialConfig.DataSource.DatabaseType == DatabaseType.CosmosDB_NoSQL); + EntityRestOptions updatedRestDetails = ConstructUpdatedRestDetails(entity, options, initialConfig.DataSource!.DatabaseType == DatabaseType.CosmosDB_NoSQL); EntityGraphQLOptions updatedGraphQLDetails = ConstructUpdatedGraphQLDetails(entity, options); EntityPermission[]? updatedPermissions = entity!.Permissions; Dictionary? updatedRelationships = entity.Relationships; @@ -2591,7 +2618,7 @@ private static bool TryGetUpdatedSourceObjectWithOptions( public static bool VerifyCanUpdateRelationship(RuntimeConfig runtimeConfig, string? cardinality, string? targetEntity) { // CosmosDB doesn't support Relationship - if (runtimeConfig.DataSource.DatabaseType.Equals(DatabaseType.CosmosDB_NoSQL)) + if (runtimeConfig.DataSource!.DatabaseType.Equals(DatabaseType.CosmosDB_NoSQL)) { _logger.LogError("Adding/updating Relationships is currently not supported in CosmosDB."); return false; @@ -2693,7 +2720,14 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun // Replaces all the environment variables while deserializing when starting DAB. if (!loader.TryLoadKnownConfig(out RuntimeConfig? deserializedRuntimeConfig, replaceEnvVar: true)) { - _logger.LogError("Failed to parse the config file: {runtimeConfigFile}.", runtimeConfigFile); + // When IsParseErrorEmitted is true, TryLoadConfig already emitted the + // detailed error to Console.Error. Only log a generic message to avoid + // duplicate output (stderr + stdout). + if (!loader.IsParseErrorEmitted) + { + _logger.LogError("Failed to parse the config file: {runtimeConfigFile}.", runtimeConfigFile); + } + return false; } else @@ -2701,7 +2735,7 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun _logger.LogInformation("Loaded config file: {runtimeConfigFile}", runtimeConfigFile); } - if (string.IsNullOrWhiteSpace(deserializedRuntimeConfig.DataSource.ConnectionString)) + if (string.IsNullOrWhiteSpace(deserializedRuntimeConfig.DataSource?.ConnectionString)) { _logger.LogError("Invalid connection-string provided in the config."); return false; @@ -2770,15 +2804,28 @@ public static bool IsConfigValid(ValidateOptions options, FileSystemRuntimeConfi RuntimeConfigProvider runtimeConfigProvider = new(loader); + if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? _)) + { + // When IsParseErrorEmitted is true, TryLoadConfig already emitted the + // detailed error to Console.Error. Only log a generic message to avoid + // duplicate output (stderr + stdout). + if (!loader.IsParseErrorEmitted) + { + _logger.LogError("Failed to parse the config file."); + } + + return false; + } + ILogger runtimeConfigValidatorLogger = LoggerFactoryForCli.CreateLogger(); RuntimeConfigValidator runtimeConfigValidator = new(runtimeConfigProvider, fileSystem, runtimeConfigValidatorLogger, true); bool isValid = runtimeConfigValidator.TryValidateConfig(runtimeConfigFile, LoggerFactoryForCli).Result; - // Additional validation: warn if fields are missing and MCP is enabled - if (isValid) + if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? config) && config is not null) { - if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? config) && config is not null) + // Additional validation: warn if fields are missing and MCP is enabled + if (isValid) { bool mcpEnabled = config.IsMcpEnabled; if (mcpEnabled) @@ -3393,9 +3440,9 @@ public static bool TrySimulateAutoentities(AutoConfigSimulateOptions options, Fi return false; } - if (runtimeConfig.DataSource.DatabaseType != DatabaseType.MSSQL) + if (runtimeConfig.DataSource?.DatabaseType != DatabaseType.MSSQL) { - _logger.LogError("The autoentities simulation is only supported for MSSQL databases. Current database type: {DatabaseType}.", runtimeConfig.DataSource.DatabaseType); + _logger.LogError("The autoentities simulation is only supported for MSSQL databases. Current database type: {DatabaseType}.", runtimeConfig.DataSource?.DatabaseType); return false; } @@ -3749,5 +3796,6 @@ private static bool ValidateFields( return true; } + } } diff --git a/src/Config/FileSystemRuntimeConfigLoader.cs b/src/Config/FileSystemRuntimeConfigLoader.cs index 7b888a82bf..933fa6369e 100644 --- a/src/Config/FileSystemRuntimeConfigLoader.cs +++ b/src/Config/FileSystemRuntimeConfigLoader.cs @@ -85,6 +85,12 @@ public class FileSystemRuntimeConfigLoader : RuntimeConfigLoader, IDisposable /// public string ConfigFilePath { get; internal set; } + /// + /// Indicates whether the most recent TryLoadConfig call encountered a parse error + /// that was already emitted to Console.Error. + /// + public bool IsParseErrorEmitted { get; private set; } + public FileSystemRuntimeConfigLoader( IFileSystem fileSystem, HotReloadEventHandler? handler = null, @@ -227,6 +233,7 @@ public bool TryLoadConfig( bool? isDevMode = null, DeserializationVariableReplacementSettings? replacementSettings = null) { + IsParseErrorEmitted = false; if (_fileSystem.File.Exists(path)) { SendLogToBufferOrLogger(LogLevel.Information, $"Loading config file from {_fileSystem.Path.GetFullPath(path)}."); @@ -263,11 +270,12 @@ public bool TryLoadConfig( // Use default replacement settings if none provided replacementSettings ??= new DeserializationVariableReplacementSettings(); + string? parseError = null; if (!string.IsNullOrEmpty(json) && TryParseConfig( json, out RuntimeConfig, + out parseError, replacementSettings, - logger: null, connectionString: _connectionString)) { if (TrySetupConfigFileWatcher()) @@ -303,6 +311,12 @@ public bool TryLoadConfig( RuntimeConfig = LastValidRuntimeConfig; } + if (parseError is not null) + { + Console.Error.WriteLine(parseError); + IsParseErrorEmitted = true; + } + config = null; return false; } diff --git a/src/Config/ObjectModel/ChildConfigMetadata.cs b/src/Config/ObjectModel/ChildConfigMetadata.cs new file mode 100644 index 0000000000..c6d5053c02 --- /dev/null +++ b/src/Config/ObjectModel/ChildConfigMetadata.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Captures metadata about a child config loaded via data-source-files. +/// Used during validation to check each child independently with filename context. +/// +/// The file path of the child config. +/// Names of manually defined entities in the child. +/// Names of autoentity definitions in the child. +/// Whether the child config defines a data source. +public record ChildConfigMetadata( + string FileName, + IReadOnlySet EntityNames, + IReadOnlySet AutoentityDefinitionNames, + bool HasDataSource); diff --git a/src/Config/ObjectModel/MultipleCreateOptions.cs b/src/Config/ObjectModel/MultipleCreateOptions.cs index c4a566bf29..7ad1ef28e0 100644 --- a/src/Config/ObjectModel/MultipleCreateOptions.cs +++ b/src/Config/ObjectModel/MultipleCreateOptions.cs @@ -18,4 +18,3 @@ public MultipleCreateOptions(bool enabled) Enabled = enabled; } }; - diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index ff8552d12b..0f971293ee 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -6,6 +6,7 @@ using System.Net; using System.Text.Json; using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.Extensions.Logging; @@ -18,7 +19,7 @@ public record RuntimeConfig public const string DEFAULT_CONFIG_SCHEMA_LINK = "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"; - public DataSource DataSource { get; init; } + public DataSource? DataSource { get; init; } public RuntimeOptions? Runtime { get; init; } @@ -31,6 +32,34 @@ public record RuntimeConfig public DataSourceFiles? DataSourceFiles { get; init; } + /// + /// Indicates whether this config was loaded as a child via another config's data-source-files. + /// + [JsonIgnore] + public bool IsChildConfig { get; set; } + + /// + /// Indicates whether this is the root config — the top-level config that has child data-source-files. + /// A child config that itself has data-source-files is NOT a root; only the top-level config is. + /// + [JsonIgnore] + public bool IsRootConfig => DataSourceFiles?.SourceFiles?.Any() == true && !IsChildConfig; + + /// + /// Tracks how many entities each autoentity definition resolved during metadata initialization. + /// Populated during autoentity expansion in metadata providers. + /// + [JsonIgnore] + public Dictionary AutoentityResolutionCounts { get; } = new(); + + /// + /// Child configs loaded via data-source-files, stored with their filenames. + /// Retained for per-child validation after merge. These are the original child configs + /// before their entities were merged into the parent. + /// + [JsonIgnore] + public List<(string FileName, RuntimeConfig Config)> ChildConfigs { get; } = new(); + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] public bool CosmosDataSourceUsed { get; private set; } @@ -72,7 +101,7 @@ Runtime.GraphQL is null || (Runtime is null || Runtime.Rest is null || Runtime.Rest.Enabled) && - DataSource.DatabaseType != DatabaseType.CosmosDB_NoSQL; + DataSource?.DatabaseType != DatabaseType.CosmosDB_NoSQL; /// /// Retrieves the value of runtime.mcp.enabled property if present, default is true. @@ -279,14 +308,14 @@ public bool RemoveGeneratedAutoentityNameFromDataSourceName(string entityName) /// To be used when setting up from cli json scenario. /// /// schema for config. - /// Default datasource. + /// Default datasource. May be null for root configs that use and delegate the data source to child configs. /// Entities /// Runtime settings. /// List of datasource files for multiple db scenario. Null for single db scenario. [JsonConstructor] public RuntimeConfig( string? Schema, - DataSource DataSource, + DataSource? DataSource, RuntimeEntities Entities, RuntimeAutoentities? Autoentities = null, RuntimeOptions? Runtime = null, @@ -301,43 +330,34 @@ public RuntimeConfig( this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); this.DefaultDataSourceName = Guid.NewGuid().ToString(); - if (this.DataSource is null) + // Set up datasource mapping only when a data source is provided. + // Root configs (with data-source-files) may omit the data source. + _dataSourceNameToDataSource = new Dictionary(); + if (this.DataSource is not null) { - throw new DataApiBuilderException( - message: "data-source is a mandatory property in DAB Config", - statusCode: HttpStatusCode.UnprocessableEntity, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + _dataSourceNameToDataSource.Add(this.DefaultDataSourceName, this.DataSource); } - // we will set them up with default values - _dataSourceNameToDataSource = new Dictionary - { - { this.DefaultDataSourceName, this.DataSource } - }; - _entityNameToDataSourceName = new Dictionary(); - if (Entities is null && this.Entities.Entities.Count == 0 && - Autoentities is null && this.Autoentities.Autoentities.Count == 0) - { - throw new DataApiBuilderException( - message: "Configuration file should contain either at least the entities or autoentities property", - statusCode: HttpStatusCode.UnprocessableEntity, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); - } - if (Entities is not null) + // Map entities and autoentities to the default datasource when a datasource is available. + // Without a datasource, entity/autoentity mappings are not created since they cannot be resolved. + if (this.DataSource is not null) { - foreach (KeyValuePair entity in Entities) + if (Entities is not null) { - _entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName); + foreach (KeyValuePair entity in Entities) + { + _entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName); + } } - } - if (Autoentities is not null) - { - foreach (KeyValuePair autoentity in Autoentities) + if (Autoentities is not null) { - _autoentityNameToDataSourceName.TryAdd(autoentity.Key, this.DefaultDataSourceName); + foreach (KeyValuePair autoentity in Autoentities) + { + _autoentityNameToDataSourceName.TryAdd(autoentity.Key, this.DefaultDataSourceName); + } } } @@ -355,13 +375,20 @@ public RuntimeConfig( foreach (string dataSourceFile in DataSourceFiles.SourceFiles) { - // Use default replacement settings for environment variable replacement - DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true); + // Use Ignore mode so missing env vars are left as literal @env() strings, + // consistent with how the parent config is loaded in TryLoadKnownConfig. + DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true, envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore); if (loader.TryLoadConfig(dataSourceFile, out RuntimeConfig? config, replacementSettings: replacementSettings)) { try { + // Mark the child so it's not treated as a root during validation. + config.IsChildConfig = true; + + // Store the child config reference for per-child validation. + ChildConfigs.Add((dataSourceFile, config)); + _dataSourceNameToDataSource = _dataSourceNameToDataSource.Concat(config._dataSourceNameToDataSource).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); _entityNameToDataSourceName = _entityNameToDataSourceName.Concat(config._entityNameToDataSourceName).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); _autoentityNameToDataSourceName = _autoentityNameToDataSourceName.Concat(config._autoentityNameToDataSourceName).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); @@ -378,6 +405,17 @@ public RuntimeConfig( e.InnerException); } } + else if (fileSystem.File.Exists(dataSourceFile)) + { + // The file exists but failed to load (e.g. invalid JSON, deserialization error). + // Throw to prevent silently skipping a broken child config. + // Non-existent files are skipped gracefully to support late-configured scenarios + // where data-source-files may reference files not present on the host. + throw new DataApiBuilderException( + message: $"Failed to load datasource file: {dataSourceFile}. Ensure the file is accessible and contains a valid DAB configuration.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } } this.Entities = new RuntimeEntities(allEntities != null ? allEntities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary()); @@ -456,7 +494,7 @@ public void UpdateDataSourceNameToDataSource(string dataSourceName, DataSource d public void UpdateDefaultDataSourceName(string initialDefaultDataSourceName) { _dataSourceNameToDataSource.Remove(DefaultDataSourceName); - if (!_dataSourceNameToDataSource.TryAdd(initialDefaultDataSourceName, this.DataSource)) + if (!_dataSourceNameToDataSource.TryAdd(initialDefaultDataSourceName, this.DataSource!)) { // An exception here means that a default data source name was generated as a GUID that // matches the original default data source name. This should never happen but we add this @@ -626,7 +664,7 @@ public virtual int GlobalCacheEntryTtl() /// Whether cache operations should proceed. public virtual bool CanUseCache() { - bool setSessionContextEnabled = DataSource.GetTypedOptions()?.SetSessionContext ?? true; + bool setSessionContextEnabled = DataSource?.GetTypedOptions()?.SetSessionContext ?? true; return IsCachingEnabled && !setSessionContextEnabled; } @@ -681,7 +719,7 @@ public static bool IsHotReloadable() /// public bool IsMultipleCreateOperationEnabled() { - return Enum.GetNames(typeof(MultipleCreateSupportingDatabaseType)).Any(x => x.Equals(DataSource.DatabaseType.ToString(), StringComparison.OrdinalIgnoreCase)) && + return Enum.GetNames(typeof(MultipleCreateSupportingDatabaseType)).Any(x => x.Equals(DataSource?.DatabaseType.ToString(), StringComparison.OrdinalIgnoreCase)) && (Runtime is not null && Runtime.GraphQL is not null && Runtime.GraphQL.MultipleMutationOptions is not null && diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index ae5c2dde95..57d1695fab 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -13,7 +13,6 @@ using Azure.DataApiBuilder.Product; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Npgsql; using static Azure.DataApiBuilder.Config.DabConfigEvents; @@ -179,16 +178,34 @@ protected void SignalConfigChanged(string message = "") /// /// JSON that represents the config file. /// The parsed config, or null if it parsed unsuccessfully. + /// A clean error message when parsing fails, or null on success. /// Settings for variable replacement during deserialization. If null, no variable replacement will be performed. - /// logger to log messages /// connectionString to add to config if specified /// True if the config was parsed, otherwise false. public static bool TryParseConfig(string json, [NotNullWhen(true)] out RuntimeConfig? config, DeserializationVariableReplacementSettings? replacementSettings = null, - ILogger? logger = null, string? connectionString = null) { + return TryParseConfig(json, out config, out _, replacementSettings, connectionString); + } + + /// + /// Parses a JSON string into a RuntimeConfig object for single database scenario. + /// + /// JSON that represents the config file. + /// The parsed config, or null if it parsed unsuccessfully. + /// A clean error message when parsing fails, or null on success. + /// Settings for variable replacement during deserialization. If null, no variable replacement will be performed. + /// connectionString to add to config if specified + /// True if the config was parsed, otherwise false. + public static bool TryParseConfig(string json, + [NotNullWhen(true)] out RuntimeConfig? config, + out string? parseError, + DeserializationVariableReplacementSettings? replacementSettings = null, + string? connectionString = null) + { + parseError = null; // First pass: extract AzureKeyVault options if AKV replacement is requested if (replacementSettings?.DoReplaceAkvVar is true) { @@ -220,7 +237,7 @@ public static bool TryParseConfig(string json, } // retreive current connection string from config - string updatedConnectionString = config.DataSource.ConnectionString; + string updatedConnectionString = config.DataSource?.ConnectionString ?? string.Empty; if (!string.IsNullOrEmpty(connectionString)) { @@ -228,34 +245,39 @@ public static bool TryParseConfig(string json, updatedConnectionString = connectionString; } - Dictionary datasourceNameToConnectionString = new(); - - // add to dictionary if datasourceName is present - datasourceNameToConnectionString.TryAdd(config.DefaultDataSourceName, updatedConnectionString); - - // iterate over dictionary and update runtime config with connection strings. - foreach ((string dataSourceKey, string connectionValue) in datasourceNameToConnectionString) + // Post-processing for connection strings only applies when a data source is present. + // Root configs (with data-source-files) may not have a data source. + if (config.DataSource is not null) { - string updatedConnection = connectionValue; + Dictionary datasourceNameToConnectionString = new(); - DataSource ds = config.GetDataSourceFromDataSourceName(dataSourceKey); + // add to dictionary if datasourceName is present + datasourceNameToConnectionString.TryAdd(config.DefaultDataSourceName, updatedConnectionString); - // Add Application Name for telemetry for MsSQL or PgSql - if (ds.DatabaseType is DatabaseType.MSSQL && replacementSettings?.DoReplaceEnvVar == true) + // iterate over dictionary and update runtime config with connection strings. + foreach ((string dataSourceKey, string connectionValue) in datasourceNameToConnectionString) { - updatedConnection = GetConnectionStringWithApplicationName(connectionValue); - } - else if (ds.DatabaseType is DatabaseType.PostgreSQL && replacementSettings?.DoReplaceEnvVar == true) - { - updatedConnection = GetPgSqlConnectionStringWithApplicationName(connectionValue); - } + string updatedConnection = connectionValue; - ds = ds with { ConnectionString = updatedConnection }; - config.UpdateDataSourceNameToDataSource(config.DefaultDataSourceName, ds); + DataSource ds = config.GetDataSourceFromDataSourceName(dataSourceKey); - if (string.Equals(dataSourceKey, config.DefaultDataSourceName, StringComparison.OrdinalIgnoreCase)) - { - config = config with { DataSource = ds }; + // Add Application Name for telemetry for MsSQL or PgSql + if (ds.DatabaseType is DatabaseType.MSSQL && replacementSettings?.DoReplaceEnvVar == true) + { + updatedConnection = GetConnectionStringWithApplicationName(connectionValue); + } + else if (ds.DatabaseType is DatabaseType.PostgreSQL && replacementSettings?.DoReplaceEnvVar == true) + { + updatedConnection = GetPgSqlConnectionStringWithApplicationName(connectionValue); + } + + ds = ds with { ConnectionString = updatedConnection }; + config.UpdateDataSourceNameToDataSource(config.DefaultDataSourceName, ds); + + if (string.Equals(dataSourceKey, config.DefaultDataSourceName, StringComparison.OrdinalIgnoreCase)) + { + config = config with { DataSource = ds }; + } } } } @@ -263,18 +285,9 @@ public static bool TryParseConfig(string json, ex is JsonException || ex is DataApiBuilderException) { - string errorMessage = ex is JsonException ? "Deserialization of the configuration file failed." : - "Deserialization of the configuration file failed during a post-processing step."; - - // logger can be null when called from CLI - if (logger is null) - { - Console.Error.WriteLine(errorMessage + $"\n" + $"Message:\n {ex.Message}\n" + $"Stack Trace:\n {ex.StackTrace}"); - } - else - { - logger.LogError(exception: ex, message: errorMessage); - } + parseError = ex is DataApiBuilderException + ? ex.Message + : $"Deserialization of the configuration file failed. {ex.Message}"; config = null; return false; diff --git a/src/Core/Configurations/RuntimeConfigProvider.cs b/src/Core/Configurations/RuntimeConfigProvider.cs index 70327326ba..7b06b7133a 100644 --- a/src/Core/Configurations/RuntimeConfigProvider.cs +++ b/src/Core/Configurations/RuntimeConfigProvider.cs @@ -188,11 +188,12 @@ public async Task Initialize( if (RuntimeConfigLoader.TryParseConfig( configuration, out RuntimeConfig? runtimeConfig, + out _, replacementSettings: null)) { _configLoader.RuntimeConfig = runtimeConfig; - if (string.IsNullOrEmpty(runtimeConfig.DataSource.ConnectionString)) + if (string.IsNullOrEmpty(runtimeConfig.DataSource?.ConnectionString)) { throw new ArgumentException($"'{nameof(runtimeConfig.DataSource.ConnectionString)}' cannot be null or empty.", nameof(runtimeConfig.DataSource.ConnectionString)); } @@ -269,15 +270,26 @@ public async Task Initialize( IsLateConfigured = true; - if (RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? runtimeConfig, replacementSettings)) + if (RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? runtimeConfig, out _, replacementSettings)) { + // Late configuration injects a connection string into the parsed config's data source. + // A config with no data source (e.g. a root config that delegates to data-source-files) + // is not meaningful here. Return false to preserve pre-existing behavior — on main, the + // RuntimeConfig constructor threw when DataSource was null and TryParseConfig converted + // that into a 'false' return. Since DataSource is now nullable, we make the same + // determination explicitly rather than NRE'ing in the 'with' expression below. + if (runtimeConfig.DataSource is null) + { + return false; + } + _configLoader.RuntimeConfig = runtimeConfig.DataSource.DatabaseType switch { DatabaseType.CosmosDB_NoSQL => HandleCosmosNoSqlConfiguration(graphQLSchema, runtimeConfig, connectionString), _ => runtimeConfig with { DataSource = runtimeConfig.DataSource with { ConnectionString = connectionString } } }; ManagedIdentityAccessToken[_configLoader.RuntimeConfig.DefaultDataSourceName] = accessToken; - _configLoader.RuntimeConfig.UpdateDataSourceNameToDataSource(_configLoader.RuntimeConfig.DefaultDataSourceName, _configLoader.RuntimeConfig.DataSource); + _configLoader.RuntimeConfig.UpdateDataSourceNameToDataSource(_configLoader.RuntimeConfig.DefaultDataSourceName, _configLoader.RuntimeConfig.DataSource!); return await InvokeConfigLoadedHandlersAsync(); } diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index c8d86e8e11..0672eebc8f 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -329,6 +329,19 @@ public async Task TryValidateConfig( // Any exceptions caught during this process are added to the ConfigValidationExceptions list and logged at the end of this function. await ValidateEntitiesMetadata(runtimeConfig, loggerFactory); + // Validate entity configuration (root vs non-root rules, entity counts) after autoentity resolution. + // Only run when there are no connection string errors, since autoentity resolution requires DB access. + if (!ConfigValidationExceptions.Any(x => x.Message.StartsWith(DataApiBuilderException.CONNECTION_STRING_ERROR_MESSAGE))) + { + // Re-read the config since autoentity resolution may have added new entities. + if (_runtimeConfigProvider.TryGetConfig(out RuntimeConfig? updatedConfig) && updatedConfig is not null) + { + runtimeConfig = updatedConfig; + } + + ValidateDataSourceAndEntityPresence(runtimeConfig); + } + if (validationResult.IsValid && !ConfigValidationExceptions.Any()) { return true; @@ -511,6 +524,143 @@ public async Task ValidateEntitiesMetadata(RuntimeConfig runtimeConfig, ILoggerF } } + /// + /// Validates entity and data source configuration based on whether the config is a root or not. + /// + /// Root config (top-level with children via data-source-files): + /// - Does not need a data source (children provide their own) + /// - Must NOT have entities if it has no data source (entities need a data source) + /// - If it HAS a data source, normal entity rules apply (must have at least 1 entity) + /// - Each child is validated independently + /// + /// Non-root config (standalone or child): + /// - Must have a data source + /// - Must have at least 1 real entity (manual or resolved from autoentities) + /// - If autoentities property exists but discovers no entities, warn + /// - If autoentities discovers no entities but manual entities exist, warn (not error) + /// - If neither manual entities nor autoentity discoveries produce any entities, error + /// + /// This method should be called after autoentity resolution so that resolved entity counts are available. + /// It should be gated on no database connection errors. + /// + public void ValidateDataSourceAndEntityPresence(RuntimeConfig runtimeConfig) + { + if (runtimeConfig.IsRootConfig) + { + ValidateRootConfig(runtimeConfig); + } + else + { + ValidateNonRootConfig(runtimeConfig, configName: null); + } + } + + /// + /// Validates a root config (top-level with children). + /// If the root has a data source, it must have entities (same as non-root). + /// If the root has no data source, it must NOT have entities or autoentities (they'd have no data source). + /// Each child config is validated independently. + /// + private void ValidateRootConfig(RuntimeConfig runtimeConfig) + { + bool hasDataSource = runtimeConfig.DataSource is not null; + bool hasEntities = runtimeConfig.Entities.Entities.Count > 0; + bool hasAutoentities = runtimeConfig.Autoentities.Autoentities.Count > 0; + + if (hasDataSource) + { + // Root with its own data source follows normal entity rules. + ValidateEntityPresence(runtimeConfig, configName: null); + } + else if (hasEntities || hasAutoentities) + { + // Root without a data source but with entities/autoentities — invalid. + HandleOrRecordException(new DataApiBuilderException( + message: "Entities or autoentities are defined in the root config but no data source is configured. " + + "A root config without a data source must not define entities or autoentities.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + + // Validate each child config independently. + foreach ((string fileName, RuntimeConfig childConfig) in runtimeConfig.ChildConfigs) + { + ValidateNonRootConfig(childConfig, configName: fileName); + } + } + + /// + /// Validates a non-root config (standalone or child). + /// Must have a data source. Must have at least 1 real entity. + /// + /// The config to validate. + /// Filename for error context (null for top-level standalone). + private void ValidateNonRootConfig(RuntimeConfig config, string? configName) + { + string prefix = configName is not null ? $"Config '{configName}': " : string.Empty; + + if (config.DataSource is null) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"{prefix}A data source is required.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + return; + } + + ValidateEntityPresence(config, configName); + } + + /// + /// Validates that a config with a data source has at least 1 real entity. + /// + /// Rules: + /// - If the autoentities property exists (even if empty/no definitions) and no entities + /// were discovered through it, warn. + /// - If total real entities (manual + discovered) is 0, error. + /// - If manual entities exist but autoentities discovered nothing, warn (not error). + /// + /// The config to validate (must have a data source). + /// Filename for error context (null for top-level). + private void ValidateEntityPresence(RuntimeConfig config, string? configName) + { + string prefix = configName is not null ? $"Config '{configName}': " : string.Empty; + + // Check autoentities: if the property exists, report on discovery results. + bool autoentitiesPropertyExists = config.Autoentities.Autoentities.Count > 0; + int resolvedAutoentityCount = 0; + + if (autoentitiesPropertyExists) + { + foreach (KeyValuePair autoentityDef in config.Autoentities) + { + if (config.AutoentityResolutionCounts.TryGetValue(autoentityDef.Key, out int resolvedCount)) + { + resolvedAutoentityCount += resolvedCount; + } + } + } + + // Count total real entities: manual entities + resolved autoentities. + int totalEntityCount = config.Entities.Entities.Count + resolvedAutoentityCount; + + if (totalEntityCount == 0) + { + // Error — nothing to serve. Don't also warn about autoentities; the error covers it. + HandleOrRecordException(new DataApiBuilderException( + message: $"{prefix}No entities found. At least one entity must be defined or discovered " + + "from autoentities when a data source is configured.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + else if (autoentitiesPropertyExists && resolvedAutoentityCount == 0) + { + // Manual entities exist so we're not erroring, but autoentities discovered nothing — warn. + _logger.LogWarning("{prefix}Autoentities are configured but no entities were discovered. " + + "Verify that autoentity patterns match database objects.", prefix); + } + } + /// /// Helper method to log exceptions occured during validation of the config file. /// @@ -1614,7 +1764,7 @@ public void ValidateEntityAndAutoentityConfigurations(RuntimeConfig runtimeConfi { ValidateEntityConfiguration(runtimeConfig); - if (runtimeConfig.IsGraphQLEnabled) + if (runtimeConfig.IsGraphQLEnabled && runtimeConfig.DataSource is not null) { ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(runtimeConfig.DataSource.DatabaseType, runtimeConfig.Entities); } diff --git a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs index 5b9b2f935a..b6d5dd0111 100644 --- a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs @@ -64,7 +64,7 @@ public CosmosSqlMetadataProvider(RuntimeConfigProvider runtimeConfigProvider, Ru // to store internally. _runtimeConfigEntities = new RuntimeEntities(runtimeConfig.Entities.Entities); _isDevelopmentMode = runtimeConfig.IsDevelopmentMode(); - _databaseType = runtimeConfig.DataSource.DatabaseType; + _databaseType = runtimeConfig.DataSource!.DatabaseType; CosmosDbNoSQLDataSourceOptions? cosmosDb = runtimeConfig.DataSource.GetTypedOptions(); diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 1f16c81e28..cdb54a2ac2 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -378,6 +378,9 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona { _logger.LogWarning("No new entities were generated from the autoentities definition '{autoentityName}'.", autoentityName); } + + // Track resolution count for validation. + runtimeConfig.AutoentityResolutionCounts[autoentityName] = addedEntities; } _runtimeConfigProvider.AddMergedEntitiesToConfig(entities); diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 951b5984e4..3a85ba823e 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -324,9 +324,13 @@ public async Task InitializeAsync() { await ValidateDatabaseConnection(); } - catch (DataApiBuilderException e) + catch (Exception e) { - HandleOrRecordException(e); + HandleOrRecordException(e is DataApiBuilderException dabe ? dabe : new DataApiBuilderException( + message: DataApiBuilderException.CONNECTION_STRING_ERROR_MESSAGE + $" {e.Message}", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization, + innerException: e)); return; } } diff --git a/src/Core/Services/OpenAPI/JsonDataType.cs b/src/Core/Services/OpenAPI/JsonDataType.cs index f8decd0317..9487929261 100644 --- a/src/Core/Services/OpenAPI/JsonDataType.cs +++ b/src/Core/Services/OpenAPI/JsonDataType.cs @@ -35,6 +35,10 @@ public enum JsonDataType /// Boolean = 5, /// + /// A JSON integer (subset of number without a fraction or exponent part) + /// + Integer = 7, + /// /// The JSON value null /// Null = 6 diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index 2aaba3eed3..979da52eb6 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -235,7 +235,7 @@ private OpenApiDocument BuildOpenApiDocument(RuntimeConfig runtimeConfig, string { new() { Url = url } }, - Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, globalTagsDict, role), + Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, globalTagsDict, role, isRequestBodyStrict: runtimeConfig.IsRequestBodyStrict), Components = components, Tags = globalTagsDict.Values.ToList() }; @@ -300,7 +300,7 @@ public void CreateDocument(bool doOverrideExistingDocument = false) /// Dictionary of global tags keyed by normalized REST path for reuse. /// Optional role to filter permissions. If null, returns superset of all roles. /// All possible paths in the DAB engine's REST API endpoint. - private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName, Dictionary globalTags, string? role = null) + private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName, Dictionary globalTags, string? role = null, bool isRequestBodyStrict = true) { OpenApiPaths pathsCollection = new(); @@ -377,7 +377,8 @@ private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSour sourceDefinition: sourceDefinition, includePrimaryKeyPathComponent: true, configuredRestOperations: configuredRestOperations, - tags: tags); + tags: tags, + isRequestBodyStrict: isRequestBodyStrict); if (pkOperations.Count > 0) { @@ -400,7 +401,8 @@ private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSour sourceDefinition: sourceDefinition, includePrimaryKeyPathComponent: false, configuredRestOperations: configuredRestOperations, - tags: tags); + tags: tags, + isRequestBodyStrict: isRequestBodyStrict); if (operations.Count > 0) { @@ -435,7 +437,8 @@ private Dictionary CreateOperations( SourceDefinition sourceDefinition, bool includePrimaryKeyPathComponent, Dictionary configuredRestOperations, - List tags) + List tags, + bool isRequestBodyStrict = true) { Dictionary openApiPathItemOperations = new(); @@ -457,7 +460,8 @@ private Dictionary CreateOperations( if (configuredRestOperations[OperationType.Put]) { OpenApiOperation putOperation = CreateBaseOperation(description: PUT_DESCRIPTION, tags: tags); - putOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired); + string putPatchSchemaRef = isRequestBodyStrict ? $"{entityName}_NoPK" : entityName; + putOperation.RequestBody = CreateOpenApiRequestBodyPayload(putPatchSchemaRef, requestBodyRequired); putOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); putOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); openApiPathItemOperations.Add(OperationType.Put, putOperation); @@ -466,7 +470,8 @@ private Dictionary CreateOperations( if (configuredRestOperations[OperationType.Patch]) { OpenApiOperation patchOperation = CreateBaseOperation(description: PATCH_DESCRIPTION, tags: tags); - patchOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired); + string patchSchemaRef = isRequestBodyStrict ? $"{entityName}_NoPK" : entityName; + patchOperation.RequestBody = CreateOpenApiRequestBodyPayload(patchSchemaRef, requestBodyRequired); patchOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); patchOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); openApiPathItemOperations.Add(OperationType.Patch, patchOperation); @@ -496,7 +501,7 @@ private Dictionary CreateOperations( if (configuredRestOperations[OperationType.Post]) { - string postBodySchemaReferenceId = DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoAutoPK" : $"{entityName}"; + string postBodySchemaReferenceId = isRequestBodyStrict && DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoAutoPK" : $"{entityName}"; OpenApiOperation postOperation = CreateBaseOperation(description: POST_DESCRIPTION, tags: tags); postOperation.RequestBody = CreateOpenApiRequestBodyPayload(postBodySchemaReferenceId, IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true)); postOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); @@ -509,7 +514,7 @@ private Dictionary CreateOperations( // which is useful for entities with identity/auto-generated keys. if (DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition)) { - string keylessBodySchemaReferenceId = $"{entityName}_NoAutoPK"; + string keylessBodySchemaReferenceId = isRequestBodyStrict ? $"{entityName}_NoAutoPK" : entityName; bool keylessRequestBodyRequired = IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true); if (configuredRestOperations[OperationType.Put]) @@ -1276,14 +1281,16 @@ private static OpenApiMediaType CreateResponseContainer(string responseObjectSch /// /// Builds the schema objects for all entities present in the runtime configuration. - /// Two schemas per entity are created: - /// 1) {EntityName} -> Primary keys present in schema, used for request bodies (excluding GET) and all response bodies. - /// 2) {EntityName}_NoAutoPK -> No auto-generated primary keys present in schema, used for POST requests where PK is not autogenerated and GET (all). - /// 3) {EntityName}_NoPK -> No primary keys present in schema, used for POST requests where PK is autogenerated and GET (all). + /// When isRequestBodyStrict is true, up to three schemas per entity are created: + /// 1) {EntityName} -> All columns including primary keys. Used for response bodies and as request body when strict mode is off. + /// 2) {EntityName}_NoAutoPK -> Excludes auto-generated primary keys. Used for POST request bodies (strict mode only). + /// 3) {EntityName}_NoPK -> Excludes all primary keys. Used for PUT/PATCH request bodies (strict mode only). + /// When isRequestBodyStrict is false, only the base {EntityName} schema is created and all + /// request body operations reference it directly, since extra properties are allowed. /// Schema objects can be referenced elsewhere in the OpenAPI document with the intent to reduce document verbosity. /// /// Optional role to filter permissions. If null, returns superset of all roles. - /// When true, request body schemas disallow extra fields. + /// When true, generates separate request body schemas that disallow extra fields. /// Collection of schemas for entities defined in the runtime configuration. private Dictionary CreateComponentSchemas(RuntimeEntities entities, string defaultDataSourceName, string? role = null, bool isRequestBodyStrict = true) { @@ -1342,7 +1349,7 @@ private Dictionary CreateComponentSchemas(RuntimeEntities schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: false)); // Only generate request body schemas if mutation operations are available - if (hasPostOperation || hasPutPatchOperation) + if (isRequestBodyStrict && (hasPostOperation || hasPutPatchOperation)) { // Create an entity's request body component schema excluding autogenerated primary keys. // A POST request requires any non-autogenerated primary key references to be in the request body. diff --git a/src/Core/Services/TypeHelper.cs b/src/Core/Services/TypeHelper.cs index 40d940c807..285e165669 100644 --- a/src/Core/Services/TypeHelper.cs +++ b/src/Core/Services/TypeHelper.cs @@ -57,14 +57,14 @@ public static class TypeHelper /// private static Dictionary _systemTypeToJsonDataTypeMap = new() { - [typeof(byte)] = JsonDataType.String, - [typeof(sbyte)] = JsonDataType.String, - [typeof(short)] = JsonDataType.Number, - [typeof(ushort)] = JsonDataType.Number, - [typeof(int)] = JsonDataType.Number, - [typeof(uint)] = JsonDataType.Number, - [typeof(long)] = JsonDataType.Number, - [typeof(ulong)] = JsonDataType.Number, + [typeof(byte)] = JsonDataType.Integer, + [typeof(sbyte)] = JsonDataType.Integer, + [typeof(short)] = JsonDataType.Integer, + [typeof(ushort)] = JsonDataType.Integer, + [typeof(int)] = JsonDataType.Integer, + [typeof(uint)] = JsonDataType.Integer, + [typeof(long)] = JsonDataType.Integer, + [typeof(ulong)] = JsonDataType.Integer, [typeof(float)] = JsonDataType.Number, [typeof(double)] = JsonDataType.Number, [typeof(decimal)] = JsonDataType.Number, diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index fbd8b759a9..7b04ce8189 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -2521,8 +2521,7 @@ public async Task TestSPRestDefaultsForManuallyConstructedConfigs( configJson, out RuntimeConfig deserializedConfig, replacementSettings: new(), - logger: null, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); + connectionString: GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); string configFileName = "custom-config.json"; File.WriteAllText(configFileName, deserializedConfig.ToJson()); string[] args = new[] @@ -2609,8 +2608,7 @@ public async Task SanityTestForRestAndGQLRequestsWithoutMultipleMutationFeatureF configJson, out RuntimeConfig deserializedConfig, replacementSettings: new(), - logger: null, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))); + connectionString: GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))); string configFileName = "custom-config.json"; File.WriteAllText(configFileName, deserializedConfig.ToJson()); string[] args = new[] @@ -3678,8 +3676,7 @@ public async Task ValidateStrictModeAsDefaultForRestRequestBody(bool includeExtr configJson, out RuntimeConfig deserializedConfig, replacementSettings: new(), - logger: null, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); + connectionString: GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, deserializedConfig.ToJson()); string[] args = new[] @@ -4715,7 +4712,7 @@ public void TestAutoEntitiesSerializationDeserialization( RuntimeConfig config = new( Schema: baseConfig!.Schema, - DataSource: baseConfig.DataSource, + DataSource: baseConfig.DataSource!, Runtime: new( Rest: new(), GraphQL: new(), @@ -6030,16 +6027,23 @@ public async Task ValidateAutoentitiesConfiguration() RuntimeConfigProvider provider = new(loader); Mock> loggerMock = new(); - RuntimeConfigValidator configValidator = new(provider, fileSystem, loggerMock.Object); - - try - { - await configValidator.TryValidateConfig(CUSTOM_CONFIG, TestHelper.ProvisionLoggerFactory()); - } - catch (Exception ex) - { - Assert.Fail(ex.Message); - } + RuntimeConfigValidator configValidator = new(provider, fileSystem, loggerMock.Object, isValidateOnly: true); + + bool isValid = await configValidator.TryValidateConfig(CUSTOM_CONFIG, TestHelper.ProvisionLoggerFactory()); + + // Validation may legitimately fail in this test (autoentity patterns won't match + // any tables in the test DB), so isValid is intentionally not asserted. What we + // require is that: + // 1. TryValidateConfig completes without raising an exception (validation errors + // are recorded into ConfigValidationExceptions, not thrown). + // 2. No autoentity-shaped error is recorded other than the expected + // "No entities found" message that fires when autoentities resolve zero + // entities and no manual entities are defined. + Assert.IsTrue( + configValidator.ConfigValidationExceptions.All( + e => !e.Message.Contains("autoentities", StringComparison.OrdinalIgnoreCase) + || e.Message.Contains("No entities found", StringComparison.OrdinalIgnoreCase)), + "Unexpected autoentity-related validation error."); } /// diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index d6f19ec65f..d724753f97 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Threading.Tasks; using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Config.ObjectModel; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json.Linq; @@ -96,10 +97,11 @@ public async Task FailLoadMultiDataSourceConfigDuplicateEntities(string configPa Console.SetError(sw); loader.TryLoadConfig("dab-config.json", out RuntimeConfig _); - string error = sw.ToString(); - Assert.IsTrue(error.StartsWith("Deserialization of the configuration file failed during a post-processing step.")); - Assert.IsTrue(error.Contains("An item with the same key has already been added.")); + Assert.IsTrue(loader.IsParseErrorEmitted, + "IsParseErrorEmitted should be true when config parsing fails."); + Assert.IsFalse(string.IsNullOrWhiteSpace(sw.ToString()), + "An error message should have been emitted to Console.Error."); } /// @@ -131,4 +133,154 @@ public async Task CanLoadValidMultiSourceConfigWithAutoentities(string configPat Assert.IsTrue(runtimeConfig.SqlDataSourceUsed, "Should have Sql data source"); Assert.AreEqual(expectedEntities, runtimeConfig.Entities.Entities.Count, "Number of entities is not what is expected."); } + + /// + /// Validates that when a child config contains @env('...') references to environment variables + /// that do not exist, the config still loads successfully because the child config uses + /// EnvironmentVariableReplacementFailureMode.Ignore (matching the parent config behavior). + /// Regression test for https://github.com/Azure/data-api-builder/issues/3271 + /// + [TestMethod] + public async Task ChildConfigWithMissingEnvVarsLoadsSuccessfully() + { + string parentConfig = await File.ReadAllTextAsync("Multidab-config.MsSql.json"); + + // Child config references env vars that do not exist in the environment. + string childConfig = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true }, + ""graphql"": { ""enabled"": true }, + ""host"": { + ""cors"": { ""origins"": [] }, + ""authentication"": { ""provider"": ""StaticWebApps"" } + }, + ""telemetry"": { + ""open-telemetry"": { + ""enabled"": true, + ""endpoint"": ""@env('NONEXISTENT_OTEL_ENDPOINT')"", + ""headers"": ""@env('NONEXISTENT_OTEL_HEADERS')"", + ""service-name"": ""@env('NONEXISTENT_OTEL_SERVICE_NAME')"" + } + } + }, + ""entities"": { + ""ChildEntity"": { + ""source"": ""dbo.ChildTable"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""read""] }] + } + } + }"; + + // Save original env var values and clear them to ensure they don't exist. + string? origEndpoint = Environment.GetEnvironmentVariable("NONEXISTENT_OTEL_ENDPOINT"); + string? origHeaders = Environment.GetEnvironmentVariable("NONEXISTENT_OTEL_HEADERS"); + string? origServiceName = Environment.GetEnvironmentVariable("NONEXISTENT_OTEL_SERVICE_NAME"); + Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_ENDPOINT", null); + Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_HEADERS", null); + Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_SERVICE_NAME", null); + + // Write the child config to a unique temp file because the RuntimeConfig + // constructor creates a real FileSystem to load child data-source-files. + string childFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json"); + try + { + await File.WriteAllTextAsync(childFilePath, childConfig); + + JObject parentJson = JObject.Parse(parentConfig); + parentJson.Add("data-source-files", new JArray(childFilePath)); + string parentJsonStr = parentJson.ToString(); + + MockFileSystem fs = new(new Dictionary() + { + { "dab-config.json", new MockFileData(parentJsonStr) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: null, + doReplaceEnvVar: true, + doReplaceAkvVar: false, + envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore); + + Assert.IsTrue( + loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig, replacementSettings: replacementSettings), + "Config should load successfully even when child config has missing env vars."); + + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("ChildEntity"), "Child config entity should be merged into the parent config."); + } + finally + { + Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_ENDPOINT", origEndpoint); + Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_HEADERS", origHeaders); + Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_SERVICE_NAME", origServiceName); + + if (File.Exists(childFilePath)) + { + File.Delete(childFilePath); + } + } + } + + /// + /// Validates that when a child config file exists but contains invalid content, + /// the parent config loading fails instead of silently skipping the child. + /// Non-existent child files are intentionally skipped to support late-configured scenarios. + /// Regression test for https://github.com/Azure/data-api-builder/issues/3271 + /// + [TestMethod] + public async Task ChildConfigLoadFailureHaltsParentConfigLoading() + { + string parentConfig = await File.ReadAllTextAsync("Multidab-config.MsSql.json"); + + // Use a real temp file with invalid JSON so the file exists but fails to parse. + string invalidChildPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json"); + + try + { + await File.WriteAllTextAsync(invalidChildPath, "{ this is not valid json }"); + + JObject parentJson = JObject.Parse(parentConfig); + parentJson.Add("data-source-files", new JArray(invalidChildPath)); + string parentJsonStr = parentJson.ToString(); + + MockFileSystem fs = new(new Dictionary() + { + { "dab-config.json", new MockFileData(parentJsonStr) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + TextWriter originalError = Console.Error; + StringWriter sw = new(); + + try + { + Console.SetError(sw); + + bool loaded = loader.TryLoadConfig("dab-config.json", out RuntimeConfig _); + string error = sw.ToString(); + + Assert.IsFalse(loaded, "Config loading should fail when a child config file exists but cannot be parsed."); + Assert.IsTrue(error.Contains("Failed to load datasource file"), "Error message should indicate the child config file that failed to load."); + } + finally + { + Console.SetError(originalError); + sw.Dispose(); + } + } + finally + { + if (File.Exists(invalidChildPath)) + { + File.Delete(invalidChildPath); + } + } + } } diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index 9d8c213b7c..4cc6912b2b 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -117,6 +117,14 @@ public static void Init() VerifierSettings.IgnoreMember(dataSource => dataSource.DatabaseTypeNotSupportedMessage); // Ignore DefaultDataSourceName as that's not serialized in our config file. VerifierSettings.IgnoreMember(config => config.DefaultDataSourceName); + // Ignore IsRootConfig as that's a computed property for validation, not serialized. + VerifierSettings.IgnoreMember(config => config.IsRootConfig); + // Ignore IsChildConfig as that's a runtime flag for validation, not serialized. + VerifierSettings.IgnoreMember(config => config.IsChildConfig); + // Ignore AutoentityResolutionCounts as that's populated at runtime during metadata initialization. + VerifierSettings.IgnoreMember(config => config.AutoentityResolutionCounts); + // Ignore ChildConfigs as that's populated at runtime during child config loading. + VerifierSettings.IgnoreMember(config => config.ChildConfigs); // Ignore MaxResponseSizeMB as as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(options => options.MaxResponseSizeMB); // Ignore UserProvidedMaxResponseSizeMB as that's not serialized in our config file. diff --git a/src/Service.Tests/OpenApiDocumentor/MissingRoleNotFoundTests.cs b/src/Service.Tests/OpenApiDocumentor/MissingRoleNotFoundTests.cs new file mode 100644 index 0000000000..dd9be13c35 --- /dev/null +++ b/src/Service.Tests/OpenApiDocumentor/MissingRoleNotFoundTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.AspNetCore.TestHost; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.OpenApiIntegration +{ + /// + /// Tests validating that requesting an OpenAPI document for a role not present + /// in the configuration returns a 404 ProblemDetails response with a descriptive message. + /// + [TestCategory(TestCategory.MSSQL)] + [TestClass] + public class MissingRoleNotFoundTests + { + private const string CONFIG_FILE = "missing-role-notfound-config.MsSql.json"; + private const string DB_ENV = TestCategory.MSSQL; + + /// + /// Validates that a request for /api/openapi/{role} returns a 404 + /// ProblemDetails response containing the role name in the detail message + /// when the role is not present in the configuration or contains invalid characters. + /// + [DataTestMethod] + [DataRow("nonexistentrole", DisplayName = "Missing role returns 404 ProblemDetails")] + [DataRow("foo/bar", DisplayName = "Role with path separator returns 404 ProblemDetails")] + public async Task MissingRole_Returns404ProblemDetailsWithMessage(string roleName) + { + TestHelper.SetupDatabaseEnvironment(DB_ENV); + FileSystem fileSystem = new(); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + loader.TryLoadKnownConfig(out RuntimeConfig config); + + Entity entity = new( + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + GraphQL: new(null, null, false), + Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS), + Permissions: OpenApiTestBootstrap.CreateBasicPermissions(), + Mappings: null, + Relationships: null); + + RuntimeConfig testConfig = config with + { + Runtime = config.Runtime with + { + Host = config.Runtime?.Host with { Mode = HostMode.Development } + }, + Entities = new RuntimeEntities(new Dictionary { { "book", entity } }) + }; + + File.WriteAllText(CONFIG_FILE, testConfig.ToJson()); + string[] args = new[] { $"--ConfigFileName={CONFIG_FILE}" }; + + try + { + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + + HttpResponseMessage response = await client.GetAsync($"/api/openapi/{Uri.EscapeDataString(roleName)}"); + + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode, "Expected 404 for a role not in the configuration."); + + string responseBody = await response.Content.ReadAsStringAsync(); + using JsonDocument doc = JsonDocument.Parse(responseBody); + JsonElement root = doc.RootElement; + + Assert.AreEqual("Not Found", root.GetProperty("title").GetString(), "ProblemDetails title should be 'Not Found'."); + Assert.AreEqual(404, root.GetProperty("status").GetInt32(), "ProblemDetails status should be 404."); + Assert.IsTrue(root.TryGetProperty("type", out _), "ProblemDetails should contain a 'type' field."); + Assert.IsTrue(root.TryGetProperty("traceId", out _), "ProblemDetails should contain a 'traceId' field."); + + string detail = root.GetProperty("detail").GetString(); + Assert.IsTrue(detail.Contains(roleName), $"Detail should contain the role name '{roleName}'. Actual: {detail}"); + } + finally + { + if (File.Exists(CONFIG_FILE)) + { + File.Delete(CONFIG_FILE); + } + } + + TestHelper.UnsetAllDABEnvironmentVariables(); + } + } +} diff --git a/src/Service.Tests/OpenApiDocumentor/ParameterValidationTests.cs b/src/Service.Tests/OpenApiDocumentor/ParameterValidationTests.cs index 5caed5a6b9..551a7b0132 100644 --- a/src/Service.Tests/OpenApiDocumentor/ParameterValidationTests.cs +++ b/src/Service.Tests/OpenApiDocumentor/ParameterValidationTests.cs @@ -182,7 +182,7 @@ public async Task TestInputParametersForStoredProcedures() Assert.IsTrue(operation.Parameters.Any(param => param.In is ParameterLocation.Query && param.Name.Equals("id") - && param.Schema.Type.Equals("number") + && param.Schema.Type.Equals("integer") && param.Required is false)); // Parameter with default value will be an optional query parameter. diff --git a/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs b/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs index ccbe50ddc6..5e77039735 100644 --- a/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs +++ b/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs @@ -43,22 +43,61 @@ public async Task RequestBodyStrict_True_DisallowsExtraFields() } /// - /// Validates that when request-body-strict is false, request body schemas - /// have additionalProperties set to true. + /// Validates that when request-body-strict is false, the redundant _NoAutoPK and _NoPK + /// schemas are not generated. Operations reference the base entity schema instead. /// [TestMethod] - public async Task RequestBodyStrict_False_AllowsExtraFields() + public async Task RequestBodyStrict_False_OmitsRedundantSchemas() { OpenApiDocument doc = await GenerateDocumentWithPermissions( OpenApiTestBootstrap.CreateBasicPermissions(), requestBodyStrict: false); - // Request body schemas should have additionalProperties = true - Assert.IsTrue(doc.Components.Schemas.ContainsKey("book_NoAutoPK"), "POST request body schema should exist"); - Assert.IsTrue(doc.Components.Schemas["book_NoAutoPK"].AdditionalPropertiesAllowed, "POST request body should allow extra fields in non-strict mode"); + // _NoAutoPK and _NoPK schemas should not be generated when strict mode is off + Assert.IsFalse(doc.Components.Schemas.ContainsKey("book_NoAutoPK"), "POST request body schema should not exist in non-strict mode"); + Assert.IsFalse(doc.Components.Schemas.ContainsKey("book_NoPK"), "PUT/PATCH request body schema should not exist in non-strict mode"); - Assert.IsTrue(doc.Components.Schemas.ContainsKey("book_NoPK"), "PUT/PATCH request body schema should exist"); - Assert.IsTrue(doc.Components.Schemas["book_NoPK"].AdditionalPropertiesAllowed, "PUT/PATCH request body should allow extra fields in non-strict mode"); + // Base entity schema should still exist + Assert.IsTrue(doc.Components.Schemas.ContainsKey("book"), "Base entity schema should exist"); + + // Operations (POST/PUT/PATCH) should reference the base 'book' schema for their request bodies + bool foundRequestBodyForWritableOperation = false; + foreach (OpenApiPathItem pathItem in doc.Paths.Values) + { + foreach (KeyValuePair operationKvp in pathItem.Operations) + { + OperationType operationType = operationKvp.Key; + OpenApiOperation operation = operationKvp.Value; + + if (operationType != OperationType.Post + && operationType != OperationType.Put + && operationType != OperationType.Patch) + { + continue; + } + + if (operation.RequestBody is null) + { + continue; + } + + if (!operation.RequestBody.Content.TryGetValue("application/json", out OpenApiMediaType mediaType) + || mediaType.Schema is null) + { + continue; + } + + foundRequestBodyForWritableOperation = true; + OpenApiSchema schema = mediaType.Schema; + + Assert.IsNotNull(schema.Reference, "Request body schema should reference a component schema when request-body-strict is false."); + Assert.AreEqual("book", schema.Reference.Id, "Request body should reference the base 'book' schema when request-body-strict is false."); + Assert.AreNotEqual("book_NoAutoPK", schema.Reference.Id, "Request body should not reference the 'book_NoAutoPK' schema when request-body-strict is false."); + Assert.AreNotEqual("book_NoPK", schema.Reference.Id, "Request body should not reference the 'book_NoPK' schema when request-body-strict is false."); + } + } + + Assert.IsTrue(foundRequestBodyForWritableOperation, "Expected at least one POST/PUT/PATCH operation with a JSON request body."); } private static async Task GenerateDocumentWithPermissions(EntityPermission[] permissions, bool? requestBodyStrict = null) diff --git a/src/Service.Tests/OpenApiDocumentor/StoredProcedureGeneration.cs b/src/Service.Tests/OpenApiDocumentor/StoredProcedureGeneration.cs index ffd5aaadde..c22ddddc2c 100644 --- a/src/Service.Tests/OpenApiDocumentor/StoredProcedureGeneration.cs +++ b/src/Service.Tests/OpenApiDocumentor/StoredProcedureGeneration.cs @@ -111,7 +111,7 @@ public void ValidateRequestBodyContents(string entityName, string[] expectedPara /// Entity to test, requires updating the CreateEntities() helper. /// Expected first result set columns /// Expected first result set column types (JSON) - [DataRow("sp1", new string[] { "id", "title", "publisher_id" }, new string[] { "number", "string", "number" }, DisplayName = "Validate response body parameters and parameter Json data types.")] + [DataRow("sp1", new string[] { "id", "title", "publisher_id" }, new string[] { "integer", "string", "integer" }, DisplayName = "Validate response body parameters and parameter Json data types.")] [DataTestMethod] public void ValidateResponseBodyContents(string entityName, string[] expectedColumns, string[] expectedColumnJsonTypes) { diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs index ba5971c02c..c16448b2b7 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs @@ -426,6 +426,37 @@ await SetupAndRunRestApiTest( ); } + /// + /// Tests that a PATCH request with an invalid If-Match header value + /// (anything other than "*") returns a 400 Bad Request response + /// because ETags are not supported. + /// + [TestMethod] + public virtual async Task PatchOne_Update_InvalidIfMatchHeader_Returns400_Test() + { + Dictionary headerDictionary = new(); + headerDictionary.Add("If-Match", "\"abc123\""); + string requestBody = @" + { + ""title"": ""The Hobbit Returns to The Shire"", + ""publisher_id"": 1234 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/1", + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: string.Empty, + operationType: EntityActionOperation.UpsertIncremental, + headers: new HeaderDictionary(headerDictionary), + requestBody: requestBody, + exceptionExpected: true, + expectedErrorMessage: "Etags not supported, use '*'", + expectedStatusCode: HttpStatusCode.BadRequest, + expectedSubStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest.ToString() + ); + } + /// /// Test to validate successful execution of PATCH operation which satisfies the database policy for the update operation it resolves into. /// diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs index 503a8388a4..59b673fada 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs @@ -1053,6 +1053,37 @@ await SetupAndRunRestApiTest( ); } + /// + /// Tests that a PUT request with an invalid If-Match header value + /// (anything other than "*") returns a 400 Bad Request response + /// because ETags are not supported. + /// + [TestMethod] + public virtual async Task PutOne_Update_InvalidIfMatchHeader_Returns400_Test() + { + Dictionary headerDictionary = new(); + headerDictionary.Add("If-Match", "\"abc123\""); + string requestBody = @" + { + ""title"": ""The Return of the King"", + ""publisher_id"": 1234 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/1", + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: string.Empty, + operationType: EntityActionOperation.Upsert, + headers: new HeaderDictionary(headerDictionary), + requestBody: requestBody, + exceptionExpected: true, + expectedErrorMessage: "Etags not supported, use '*'", + expectedStatusCode: HttpStatusCode.BadRequest, + expectedSubStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest.ToString() + ); + } + /// /// Tests that a PUT request with If-Match header (strict update semantics) /// still requires a primary key route. When If-Match is present, the operation diff --git a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs index 347bf5e328..3dfaf71b2e 100644 --- a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs +++ b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs @@ -14,9 +14,7 @@ using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; namespace Azure.DataApiBuilder.Service.Tests.UnitTests { @@ -325,28 +323,21 @@ public void TestTelemetryApplicationInsightsEnabledShouldError(string configValu ""entities"": { } }"; - // Arrange - Mock mockLogger = new(); - // Act bool isParsed = RuntimeConfigLoader.TryParseConfig( configJson, out RuntimeConfig runtimeConfig, + out string parseError, replacementSettings: new DeserializationVariableReplacementSettings( azureKeyVaultOptions: null, doReplaceEnvVar: true, - doReplaceAkvVar: false), - logger: mockLogger.Object); + doReplaceAkvVar: false)); // Assert Assert.IsFalse(isParsed); Assert.IsNull(runtimeConfig); - - Assert.AreEqual(1, mockLogger.Invocations.Count, "Should raise 1 exception"); - Assert.AreEqual(5, mockLogger.Invocations[0].Arguments.Count, "Log should have 4 arguments"); - var ConfigException = mockLogger.Invocations[0].Arguments[3] as JsonException; - Assert.IsInstanceOfType(ConfigException, typeof(JsonException), "Should have raised a Json Exception"); - Assert.AreEqual(message, ConfigException.Message); + Assert.IsNotNull(parseError, "parseError should be set when parsing fails."); + StringAssert.Contains(parseError, message, "parseError should contain the expected error message."); } /// @@ -486,7 +477,10 @@ public void TestLoadRuntimeConfigFailures( } /// - /// Method to validate that FileNotFoundException is thrown if sub-data source file is not found. + /// Method to validate that when a sub-data source file is not found, it is gracefully + /// skipped and the parent config loads successfully. Non-existent child files are + /// tolerated to support late-configured scenarios where data-source-files may reference + /// files not present on the host. /// [TestMethod] public void TestLoadRuntimeConfigSubFilesFails() diff --git a/src/Service/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index 07841fd8c3..2fadc636d0 100644 --- a/src/Service/Controllers/RestController.cs +++ b/src/Service/Controllers/RestController.cs @@ -161,7 +161,7 @@ public async Task Upsert( { return await HandleOperation( route, - DeterminePatchPutSemantics(EntityActionOperation.Upsert)); + EntityActionOperation.Upsert); } /// @@ -181,7 +181,7 @@ public async Task UpsertIncremental( { return await HandleOperation( route, - DeterminePatchPutSemantics(EntityActionOperation.UpsertIncremental)); + EntityActionOperation.UpsertIncremental); } /// @@ -206,6 +206,11 @@ private async Task HandleOperation( { TelemetryMetricsHelper.IncrementActiveRequests(ApiType.REST); + if (operationType is EntityActionOperation.Upsert or EntityActionOperation.UpsertIncremental) + { + operationType = DeterminePatchPutSemantics(operationType); + } + if (activity is not null) { activity.TrackMainControllerActivityStarted( @@ -249,7 +254,10 @@ private async Task HandleOperation( // Validate role doesn't contain path separators (reject /openapi/foo/bar) if (string.IsNullOrEmpty(role) || role.Contains('/')) { - return NotFound(); + return Problem( + detail: $"Invalid role name '{role}'. Role names must not be empty or contain path separators.", + statusCode: StatusCodes.Status404NotFound, + title: "Not Found"); } if (_openApiDocumentor.TryGetDocumentForRole(role, out string? roleDocument)) @@ -257,7 +265,10 @@ private async Task HandleOperation( return Content(roleDocument, MediaTypeNames.Application.Json); } - return NotFound(); + return Problem( + detail: $"Role '{role}' is not present in the configuration.", + statusCode: StatusCodes.Status404NotFound, + title: "Not Found"); } (string entityName, string primaryKeyRoute) = _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(routeAfterPathBase); diff --git a/src/Service/HealthCheck/HealthCheckHelper.cs b/src/Service/HealthCheck/HealthCheckHelper.cs index 2a5f6f5ddf..f616018aa3 100644 --- a/src/Service/HealthCheck/HealthCheckHelper.cs +++ b/src/Service/HealthCheck/HealthCheckHelper.cs @@ -163,7 +163,7 @@ private async Task UpdateHealthCheckDetailsAsync(ComprehensiveHealthCheckReport // Updates the DataSource Health Check Results in the response. private async Task UpdateDataSourceHealthCheckResultsAsync(ComprehensiveHealthCheckReport comprehensiveHealthCheckReport, RuntimeConfig runtimeConfig) { - if (comprehensiveHealthCheckReport.Checks != null && runtimeConfig.DataSource.IsDatasourceHealthEnabled) + if (comprehensiveHealthCheckReport.Checks != null && runtimeConfig.DataSource is not null && runtimeConfig.DataSource.IsDatasourceHealthEnabled) { string query = Utilities.GetDatSourceQuery(runtimeConfig.DataSource.DatabaseType); (int, string?) response = await ExecuteDatasourceQueryCheckAsync(query, runtimeConfig.DataSource.ConnectionString, Utilities.GetDbProviderFactory(runtimeConfig.DataSource.DatabaseType), runtimeConfig.DataSource.DatabaseType);