From 0d90470b920641a354b6ff28dff02f32cda5c28e Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:27:56 -0700 Subject: [PATCH 01/12] Add level-2 property to runtime.cache in dab.draft.schema.json (#3317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why make this change? Closes https://github.com/Azure/data-api-builder/issues/3314 ## What is this change? Adds the `level-2` property, which is already a part of our object model, to the `dab.draft.schema.json` file. Also adds `minimum: 1` to `runtime.cache.ttl-seconds` to align schema validation with the runtime enforcement that rejects values ≤ 0. The `level-2.provider` property is kept as a plain string (no enum, no default) to match the nullable string with `null` default in the object model. ## How was this tested? - [ ] Integration Tests - [ ] Unit Tests ## Sample Request(s) N/A — schema-only change with no REST/GraphQL/CLI surface. --- 📍 Connect Copilot coding agent with [Jira](https://gh.io/cca-jira-docs), [Azure Boards](https://gh.io/cca-azure-boards-docs) or [Linear](https://gh.io/cca-linear-docs) to delegate work to Copilot in one click without leaving your project management tool. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Souvik Ghosh (cherry picked from commit b301eeb5c3b8cbf61e999ba7e945afb90ab3bd64) --- schemas/dab.draft.schema.json | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 84f8e5cfbd..82daf04412 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -486,7 +486,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"] + } } } }, From 6d75ddba8009c2006d0d82d08dd509c0b2cfde31 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:33:15 -0700 Subject: [PATCH 02/12] Make CLI Log Labels consistent with ASP.NET Core and use appropriate label on startup (#3307) ## Why make this change? Closes https://github.com/Azure/data-api-builder/issues/3269 ## What is this change? Align the CLI logger labels with the labels used in ASP.Net Core. Downgraded internal plumbing message from `Information` to `Debug` Add a test case to cover the changed behavior. ## How was this tested? Added a test case to validate the new behavior. (cherry picked from commit 75b8eeceb68cb56fc318e8093a01eb003392ca9a) --- src/Cli.Tests/CustomLoggerTests.cs | 49 ++++++++++++++++++++++++++++++ src/Cli/CustomLoggerProvider.cs | 24 +++++++++++++-- src/Service/Startup.cs | 2 +- 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/Cli.Tests/CustomLoggerTests.cs diff --git a/src/Cli.Tests/CustomLoggerTests.cs b/src/Cli.Tests/CustomLoggerTests.cs new file mode 100644 index 0000000000..c7989f4f8a --- /dev/null +++ b/src/Cli.Tests/CustomLoggerTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Cli.Tests; + +/// +/// Tests for CustomLoggerProvider and CustomConsoleLogger, verifying +/// that log level labels use ASP.NET Core abbreviated format. +/// +[TestClass] +public class CustomLoggerTests +{ + /// + /// Validates that each enabled log level produces the correct abbreviated label + /// matching ASP.NET Core's default console formatter convention. + /// Trace and Debug are below the logger's minimum level and produce no output. + /// + [DataTestMethod] + [DataRow(LogLevel.Information, "info:")] + [DataRow(LogLevel.Warning, "warn:")] + [DataRow(LogLevel.Error, "fail:")] + [DataRow(LogLevel.Critical, "crit:")] + public void LogOutput_UsesAbbreviatedLogLevelLabels(LogLevel logLevel, string expectedPrefix) + { + CustomLoggerProvider provider = new(); + ILogger logger = provider.CreateLogger("TestCategory"); + + TextWriter originalOut = Console.Out; + try + { + StringWriter writer = new(); + Console.SetOut(writer); + + logger.Log(logLevel, "test message"); + + string output = writer.ToString(); + Assert.IsTrue( + output.StartsWith(expectedPrefix), + $"Expected output to start with '{expectedPrefix}' but got: '{output}'"); + Assert.IsTrue( + output.Contains("test message"), + $"Expected output to contain 'test message' but got: '{output}'"); + } + finally + { + Console.SetOut(originalOut); + } + } +} diff --git a/src/Cli/CustomLoggerProvider.cs b/src/Cli/CustomLoggerProvider.cs index c06918b93f..0f7a881da8 100644 --- a/src/Cli/CustomLoggerProvider.cs +++ b/src/Cli/CustomLoggerProvider.cs @@ -56,6 +56,19 @@ public class CustomConsoleLogger : ILogger {LogLevel.Critical, ConsoleColor.DarkRed} }; + /// + /// Maps LogLevel to abbreviated labels matching ASP.NET Core's default console formatter. + /// + private static readonly Dictionary _logLevelToAbbreviation = new() + { + {LogLevel.Trace, "trce"}, + {LogLevel.Debug, "dbug"}, + {LogLevel.Information, "info"}, + {LogLevel.Warning, "warn"}, + {LogLevel.Error, "fail"}, + {LogLevel.Critical, "crit"} + }; + /// /// Creates Log message by setting console message color based on LogLevel. /// @@ -66,11 +79,16 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except return; } + if (!_logLevelToAbbreviation.TryGetValue(logLevel, out string? abbreviation)) + { + return; + } + ConsoleColor originalForeGroundColor = Console.ForegroundColor; ConsoleColor originalBackGroundColor = Console.BackgroundColor; - Console.ForegroundColor = _logLevelToForeGroundConsoleColorMap[logLevel]; - Console.BackgroundColor = _logLevelToBackGroundConsoleColorMap[logLevel]; - Console.Write($"{logLevel}:"); + Console.ForegroundColor = _logLevelToForeGroundConsoleColorMap.GetValueOrDefault(logLevel, ConsoleColor.White); + Console.BackgroundColor = _logLevelToBackGroundConsoleColorMap.GetValueOrDefault(logLevel, ConsoleColor.Black); + Console.Write($"{abbreviation}:"); Console.ForegroundColor = originalForeGroundColor; Console.BackgroundColor = originalBackGroundColor; Console.WriteLine($" {formatter(state, exception)}"); diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 12cbd18ab0..a1553d0a71 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -600,7 +600,7 @@ private void ConfigureResponseCompression(IServiceCollection services, RuntimeCo options.Level = systemCompressionLevel; }); - _logger.LogInformation("Response compression enabled with level '{compressionLevel}' for REST, GraphQL, and MCP endpoints.", compressionLevel); + _logger.LogDebug("Response compression enabled with level '{compressionLevel}' for REST, GraphQL, and MCP endpoints.", compressionLevel); } /// From 1ce6e7ea49b19d9adc2a6b68c4dffbd74a90b4c5 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:00:03 -0700 Subject: [PATCH 03/12] Align behavior between Parent and Child configs during deserialization and startup (#3321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why make this change? Closes https://github.com/Azure/data-api-builder/issues/3271 ## What is this change? Previously, child configs were defaulting to `throw` for their exception behavior during deserialization, but parent configs correctly use `ignore`. We align this behavior. Furthermore, during startup of the engine we throw an exception if a child config itself can not be loaded, halting startup, as would happen with the parent. When a child config file exists but fails to load, a generic diagnostic message is surfaced: `"Failed to load datasource file: {path}. Ensure the file is accessible and contains a valid DAB configuration."` — covering all failure modes including invalid JSON, IO errors, and permission issues. ## How was this tested? Added 2 new tests: 1. `ChildConfigWithMissingEnvVarsLoadsSuccessfully` — verifies that a child config with unresolved `@env()` references loads successfully (env vars saved and restored in `finally`; uses unique temp file path) 2. `ChildConfigLoadFailureHaltsParentConfigLoading` — verifies that a child config file that exists but contains invalid JSON causes parent config loading to fail (saves/restores `Console.Error` and disposes `StringWriter` in `finally`; uses unique temp file path) Updated `TestLoadRuntimeConfigSubFilesFails` to assert that non-existent child config files are gracefully skipped (not treated as failures). - [x] Unit Tests --------- Co-authored-by: Souvik Ghosh Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> (cherry picked from commit c014e52a3c87cbaf2c9608c533f66bf90ec0a034) --- src/Config/ObjectModel/RuntimeConfig.cs | 17 +- .../Configuration/RuntimeConfigLoaderTests.cs | 151 ++++++++++++++++++ ...untimeConfigLoaderJsonDeserializerTests.cs | 5 +- 3 files changed, 170 insertions(+), 3 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 54c3e77556..d6bdefc1b8 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; @@ -355,8 +356,9 @@ 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)) { @@ -378,6 +380,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()); diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index d6f19ec65f..28b6fbb88d 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; @@ -131,4 +132,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/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs index c34290999a..9e99387354 100644 --- a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs +++ b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs @@ -486,7 +486,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() From 22c9f0c4a791358cfb7dc7451c7657039a4fc5a5 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:50:09 -0700 Subject: [PATCH 04/12] Add descriptive message when OpenAPI role filter returns 404 (#3305) ## Why make this change? Closes https://github.com/Azure/data-api-builder/issues/3261 ## What is this change? In the `RestController`, add descriptive messaging for `NotFound()` when role is missing. Add a test case to cover this new behavior. ## How was this tested? Test case added. Manually verified the endpoint provided the correct response. image ## Sample Request(s) Can manually verify by going to this endpoint without a "foo" role defined. `https://localhost:5001/rest/openapi/foo` --------- Co-authored-by: Souvik Ghosh (cherry picked from commit 5fa2bfc8c65c5b17963604025f051ad3f7a97cf1) --- .../MissingRoleNotFoundTests.cs | 98 +++++++++++++++++++ src/Service/Controllers/RestController.cs | 10 +- 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 src/Service.Tests/OpenApiDocumentor/MissingRoleNotFoundTests.cs 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/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index 07841fd8c3..48a1b98e49 100644 --- a/src/Service/Controllers/RestController.cs +++ b/src/Service/Controllers/RestController.cs @@ -249,7 +249,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 +260,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); From 000cbc4c0df3abbcfa85ffa072b7a406a0e94d23 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:57:35 -0700 Subject: [PATCH 05/12] Return 400 for invalid If-Match values (#3415) ## Why make this change? Closes https://github.com/Azure/data-api-builder/issues/3397 ## What is this change? When `DeterminePatchPutSemantics` threw a `DataApiBuilderException` for an invalid `If-Match` value, the exception was thrown before entering `HandleOperation` and the `try-catch` block within. This is where we convert `DataApiBuilderException` into structured JSON error response. We therefore move the `DeterminePatchPutSemantics` call inside of the `try-catch` block within `HandleOperation`, gating the function behind a check of the operation type so that it only applies to `Upsert` and `UpsertIncremental` operations. ## How was this tested? Added a test for `Put` and a test for `Patch` to validate the new behavior. ## Sample Request(s) Any valid `Put` or `Patch` with headers using `If-Match` with a value other than `*` --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Aniruddh Munde (cherry picked from commit 0cb58f02c324b81b5dd3e0035ff44498875bac50) --- .../RestApiTests/Patch/PatchApiTestBase.cs | 31 +++++++++++++++++++ .../RestApiTests/Put/PutApiTestBase.cs | 31 +++++++++++++++++++ src/Service/Controllers/RestController.cs | 9 ++++-- 3 files changed, 69 insertions(+), 2 deletions(-) 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/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index 48a1b98e49..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( From 926dbb20709c198f507051e754774c138b91d138 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:10:23 -0700 Subject: [PATCH 06/12] Fix dab validate error messaging when logger is not available (#3311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why make this change? Closes https://github.com/Azure/data-api-builder/issues/3268 ## What is this change? When `dab validate` or `dab start` encounters a config parsing error (e.g. missing entities/autoentities), the CLI previously dumped the full exception message and stack trace to stderr. This made the output noisy and unhelpful. After this change, only a clean, descriptive validation message is shown. The key design changes: - `TryParseConfig` is now a pure function that returns an `out string? parseError` message on failure instead of writing to `Console.Error` or `ILogger` internally. Error reporting is the caller's responsibility. - `FileSystemRuntimeConfigLoader.TryLoadConfig` writes the `parseError` to `Console.Error` (because config is parsed before the DI container and logger are available, so the log buffer would never be flushed on a parse failure) and sets an instance-scoped `IsParseErrorEmitted` flag so CLI callers (`ConfigGenerator`) can avoid logging duplicate messages. - `ConfigGenerator.IsConfigValid` now has an explicit early-return path when config parsing fails (via `runtimeConfigProvider.TryGetConfig`), using `IsParseErrorEmitted` to suppress duplicate output. - `ValidateOptions.Handler` uses `LogError("Config is invalid.")`. - Comments referencing which method emits the error to `Console.Error` corrected to `TryLoadConfig` (not `TryParseConfig`). ## How was this tested? * `ValidateConfigTests.cs` — Added `TestValidateConfigWithNoEntitiesProducesCleanError` (new test verifying clean error message, no stack traces). * `EnvironmentTests.cs` — Updated `FailureToStartEngineWhenEnvVarNamedWrong` to match the new single-line clean stderr format. * `EndToEndTests.cs` — Simplified assertions in `TestExitOfRuntimeEngineWithInvalidConfig` (this test is Ignored but updated for consistency). * `RuntimeConfigLoaderTests.cs` — Updated `FailLoadMultiDataSourceConfigDuplicateEntities` to assert `loader.IsParseErrorEmitted` is true. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Souvik Ghosh (cherry picked from commit f2b4cbde9844e9b604d8bf5e397935d4e3087d4f) --- src/Cli.Tests/EndToEndTests.cs | 4 -- src/Cli.Tests/EnvironmentTests.cs | 4 +- src/Cli.Tests/ValidateConfigTests.cs | 28 ++++++++++++++ src/Cli/Commands/ValidateOptions.cs | 2 +- src/Cli/ConfigGenerator.cs | 22 ++++++++++- src/Config/FileSystemRuntimeConfigLoader.cs | 16 +++++++- src/Config/RuntimeConfigLoader.cs | 38 +++++++++++-------- .../Configurations/RuntimeConfigProvider.cs | 3 +- .../Configuration/ConfigurationTests.cs | 9 ++--- .../Configuration/RuntimeConfigLoaderTests.cs | 7 ++-- ...untimeConfigLoaderJsonDeserializerTests.cs | 17 ++------- 11 files changed, 103 insertions(+), 47 deletions(-) diff --git a/src/Cli.Tests/EndToEndTests.cs b/src/Cli.Tests/EndToEndTests.cs index d5bd937fe9..9591803275 100644 --- a/src/Cli.Tests/EndToEndTests.cs +++ b/src/Cli.Tests/EndToEndTests.cs @@ -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/ValidateConfigTests.cs b/src/Cli.Tests/ValidateConfigTests.cs index e40a32e291..0383d9072d 100644 --- a/src/Cli.Tests/ValidateConfigTests.cs +++ b/src/Cli.Tests/ValidateConfigTests.cs @@ -199,6 +199,34 @@ public void TestValidateConfigFailsWithNoEntities() } } + /// + /// Validates that when the config has no entities or autoentities, TryParseConfig + /// sets a clean error message (not a raw exception with stack trace) and + /// IsConfigValid returns false without throwing. + /// Regression test for https://github.com/Azure/data-api-builder/issues/3268 + /// + [TestMethod] + public void TestValidateConfigWithNoEntitiesProducesCleanError() + { + string configWithoutEntities = $"{{{SAMPLE_SCHEMA_DATA_SOURCE},{RUNTIME_SECTION}}}"; + + // Verify TryParseConfig produces a clean error without stack traces. + bool parsed = RuntimeConfigLoader.TryParseConfig(configWithoutEntities, out _, out string? parseError); + + Assert.IsFalse(parsed, "Config with no entities should fail to parse."); + Assert.IsNotNull(parseError, "parseError should be set when config parsing fails."); + StringAssert.Contains(parseError, + "Configuration file should contain either at least the entities or autoentities property", + "Parse error should contain the clean validation message."); + Assert.IsFalse(parseError.Contains("StackTrace"), + "Stack trace should not be present in parse error."); + + // Verify IsConfigValid also returns 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. /// 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 af98be05ff..f90b31671e 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -2564,7 +2564,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 @@ -2635,6 +2642,19 @@ 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); diff --git a/src/Config/FileSystemRuntimeConfigLoader.cs b/src/Config/FileSystemRuntimeConfigLoader.cs index ecefd6a9c2..e1fe94b109 100644 --- a/src/Config/FileSystemRuntimeConfigLoader.cs +++ b/src/Config/FileSystemRuntimeConfigLoader.cs @@ -84,6 +84,12 @@ public class FileSystemRuntimeConfigLoader : RuntimeConfigLoader /// 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, @@ -205,6 +211,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)}."); @@ -241,11 +248,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()) @@ -281,6 +289,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/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index ae5c2dde95..83b7b3969e 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) { @@ -263,18 +280,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..0fce915edf 100644 --- a/src/Core/Configurations/RuntimeConfigProvider.cs +++ b/src/Core/Configurations/RuntimeConfigProvider.cs @@ -188,6 +188,7 @@ public async Task Initialize( if (RuntimeConfigLoader.TryParseConfig( configuration, out RuntimeConfig? runtimeConfig, + out _, replacementSettings: null)) { _configLoader.RuntimeConfig = runtimeConfig; @@ -269,7 +270,7 @@ public async Task Initialize( IsLateConfigured = true; - if (RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? runtimeConfig, replacementSettings)) + if (RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? runtimeConfig, out _, replacementSettings)) { _configLoader.RuntimeConfig = runtimeConfig.DataSource.DatabaseType switch { diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index e37b0920e2..3eb6a9dfc4 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -2500,8 +2500,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[] @@ -2588,8 +2587,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[] @@ -3619,8 +3617,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[] diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index 28b6fbb88d..d724753f97 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -97,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."); } /// diff --git a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs index 9e99387354..564dcf4800 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."); } /// From 5115ae982383abc94ee3b5b563f4abf8bafb567f Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:40:33 -0700 Subject: [PATCH 07/12] Omit redundant _NoAutoPK and _NoPK OpenAPI schemas when request-body-strict is false (#3325) ## Why make this change? Closes https://github.com/Azure/data-api-builder/issues/3260 ## What is this change? When request-body-strict is set to false, the OpenAPI document was still generating separate _NoAutoPK and _NoPK component schemas for each entity. These schemas only differ from the base entity schema by excluding primary key fields, a distinction that is meaningless when the request body already allows additional properties. This made the generated OpenAPI document unnecessarily verbose and confusing for consumers. The `isRequestBodyStrict` flag is now threaded through `BuildPaths` and `CreateOperations` so that POST/PUT/PATCH request body schema references point to the base entity schema when strict mode is off. The `CreateComponentSchemas` method now gates `_NoAutoPK` and `_NoPK` schema generation on strict mode, and its XML documentation has been updated to accurately describe both strict and non-strict behavior. ## How was this tested? - [ ] Integration Tests - [x] Unit Tests Updated existing unit test `RequestBodyStrict_False_OmitsRedundantSchemas` (renamed from `RequestBodyStrict_False_AllowsExtraFields`) to verify that when request-body-strict is false: - `_NoAutoPK` and `_NoPK` component schemas are not generated. - The base entity schema is still present. - POST/PUT/PATCH operations reference the base entity schema (not `_NoAutoPK` or `_NoPK`). --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Aniruddh Munde Co-authored-by: Anusha Kolan (cherry picked from commit c860fc515f5a4582fac3d75ea1eccab8417f9e5e) --- .../Services/OpenAPI/OpenApiDocumentor.cs | 37 ++++++++----- .../RequestBodyStrictTests.cs | 55 ++++++++++++++++--- 2 files changed, 69 insertions(+), 23 deletions(-) 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/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) From 62155b72fbb7e2a4c621a5aa58560bb34c1845c7 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:11:51 -0700 Subject: [PATCH 08/12] Map int types to integer in System Type to Json Type Map (#3327) ## Why make this change? Closes https://github.com/Azure/data-api-builder/issues/3324 ## What is this change? Expand the enums for our json data type to include `integer`. Use this new integer type in the system to json type map. ## How was this tested? Existing test coverage. --------- Co-authored-by: Aniruddh Munde (cherry picked from commit 062ddc69198ca6eabf242ee4503950976e6d6e1b) --- src/Core/Services/OpenAPI/JsonDataType.cs | 4 ++++ src/Core/Services/TypeHelper.cs | 16 ++++++++-------- .../ParameterValidationTests.cs | 2 +- .../StoredProcedureGeneration.cs | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) 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/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/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/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) { From 4c13d3a4b567b4647ba2995253ab2bd0bcd34b39 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Tue, 5 May 2026 16:59:39 -0700 Subject: [PATCH 09/12] Update config validation logic for entities (#3306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why make this change? Closes https://github.com/Azure/data-api-builder/issues/3267 ## What is this change? Alters the validation logic in the following way. Is top-level config with data-source-files? (we call this a `Root` config file) ├── YES │ ├── Has datasource? → ValidateEntityPresence (same rules as non-root) │ ├── No datasource but has entities/autoentities? → ERROR │ └── No datasource, no entities → VALID (children provide everything) │ └── For each child → ValidateNonRootConfig(child, filename) │ └── NO (standalone or child config) ├── No datasource? → ERROR: "data source is required" └── Has datasource → ValidateEntityPresence Note: A top-level config file without any children data-source files is NOT considered a root. And an intermediary config file, ie: is a child, that also has child configs is NOT a root. Only a top-level config with children configs is a Root. #### ValidateEntityPresence Count resolved autoentities from AutoentityResolutionCounts total = manual entities + resolved autoentities total == 0? → ERROR: "No entities found" total > 0 but autoentities discovered nothing? → WARN: "Autoentities configured but none discovered" No double messaging. If total is 0, only the error is recorded, not the warning. ## How was this tested? ### Truth table — top-level config Variables (`1` = present / non-empty, `0` = absent / empty): - **DSF** — `data-source-files` present - **DS** — `data-source` present - **E** — manual `entities` count > 0 - **AE** — `autoentities` count > 0 (presence, *not* resolved count) Path is determined by `IsRootConfig = (DSF == 1) && !IsChildConfig`. | # | DSF | DS | E | AE | AE resolved | Path | Expected | Test | |---|:---:|:--:|:-:|:--:|:-----------:|------|----------|------| | 1 | 0 | 0 | 0 | 0 | — | Non-root | **Error**: "data source is required" | `TestNonRootWithNoDataSourceProducesError` | | 2 | 0 | 0 | 0 | 1 | — | Non-root | **Error**: "data source is required" | _covered by #1 — DS check fires first_ | | 3 | 0 | 0 | 1 | 0 | — | Non-root | **Error**: "data source is required" | _covered by #1_ | | 4 | 0 | 0 | 1 | 1 | — | Non-root | **Error**: "data source is required" | _covered by #1_ | | 5 | 0 | 1 | 0 | 0 | — | Non-root | **Error**: "No entities found" | `TestNonRootWithDataSourceAndNoEntitiesProducesError` | | 6a | 0 | 1 | 0 | 1 | 0 | Non-root | **Error**: "No entities found" | `TestNonRootWithDataSourceAndAutoentitiesResolvingZeroProducesError` | | 6b | 0 | 1 | 0 | 1 | >0 | Non-root | **Valid** | `TestNonRootWithDataSourceAndAutoentitiesResolvingEntitiesIsValid` | | 7 | 0 | 1 | 1 | 0 | — | Non-root | **Valid** | `TestNonRootWithDataSourceAndEntitiesIsValid` | | 8a | 0 | 1 | 1 | 1 | 0 | Non-root | **Valid** + **Warn** | `TestNonRootWithEntitiesAndAutoentitiesResolvingZeroLogsWarning` | | 8b | 0 | 1 | 1 | 1 | >0 | Non-root | **Valid** | _covered by #7 / #6b combined_ | | 9 | 1 | 0 | 0 | 0 | — | Root | **Valid** (children carry the load) | `TestRootWithNoDataSourceAndNoEntitiesIsValid`, `TestRootConfigWithNoDataSourceAndNoEntitiesParses` | | 10 | 1 | 0 | 0 | 1 | — | Root | **Error**: "must not define entities or autoentities" | `TestRootWithNoDataSourceButAutoentitiesProducesError` | | 11 | 1 | 0 | 1 | 0 | — | Root | **Error**: "must not define entities" | `TestRootWithNoDataSourceButEntitiesProducesError` | | 12 | 1 | 0 | 1 | 1 | — | Root | **Error** | _covered by #11_ | | 13 | 1 | 1 | 0 | 0 | — | Root (with own DS) | **Error**: "No entities found" | `TestRootWithDataSourceAndNoEntitiesProducesError` | | 14a | 1 | 1 | 0 | 1 | 0 | Root (with own DS) | **Error**: "No entities found" | `TestRootWithDataSourceAndAutoentitiesResolvingZeroProducesError` | | 14b | 1 | 1 | 0 | 1 | >0 | Root (with own DS) | **Valid** | `TestRootWithDataSourceAndAutoentitiesResolvingEntitiesIsValid` | | 15 | 1 | 1 | 1 | 0 | — | Root (with own DS) | **Valid** | `TestRootWithDataSourceAndEntitiesIsValid` | | 16a | 1 | 1 | 1 | 1 | 0 | Root (with own DS) | **Valid** + **Warn** | `TestRootWithEntitiesAndAutoentitiesResolvingZeroLogsWarning` | | 16b | 1 | 1 | 1 | 1 | >0 | Root (with own DS) | **Valid** | _covered by #15 / #14b combined_ | ### Truth table — child config (validated when iterating `root.ChildConfigs`) Children are always treated as non-root regardless of their own `data-source-files`. | # | DS | E | AE | AE resolved | Expected | Test | |---|:--:|:-:|:--:|:-----------:|----------|------| | C1 | 0 | 0 | 0 | — | **Error** naming the child file: "data source is required" | `TestChildWithNoDataSourceProducesNamedError` | | C2 | 0 | * | * | — | **Error** naming the child file: "data source is required" | _covered by C1_ | | C3 | 1 | 0 | 0 | — | **Error** naming the child file: "No entities found" | `TestChildWithDataSourceAndNoEntitiesProducesNamedError` | | C4a | 1 | 0 | 1 | 0 | **Error** naming the child file: "No entities found" | `TestChildWithDataSourceAndAutoentitiesResolvingZeroProducesNamedError` | | C4b | 1 | 0 | 1 | >0 | **Valid** | _covered by C5 (resolved entities behave the same as manual entities)_ | | C5 | 1 | 1 | 0 | — | **Valid** | _implicitly via `TestRootWithDataSourceAndEntitiesIsValid` setup_ | | C6a | 1 | 1 | 1 | 0 | **Valid** + **Warn** naming the child file | `TestChildWithEntitiesAndAutoentitiesResolvingZeroLogsNamedWarning` | | C6b | 1 | 1 | 1 | >0 | **Valid** | _covered by C5_ | ### Other scenarios | Scenario | Expected | Test | |----------|----------|------| | Connection-string error gates entity validation (no entity error fires when DB unreachable) | `IsConfigValid == false` due to connection error only | `TestValidateNonRootZeroEntitiesWithInvalidConnectionString` | | Config with no entities parses cleanly (constructor no longer throws) and `IsConfigValid` returns false without throwing | parse OK, validate fails | `TestValidateConfigWithNoEntitiesProducesCleanError` _(modified)_ | | Root parses successfully without a data source | parse OK, `IsRootConfig == true` | `TestRootConfigWithNoDataSourceAndNoEntitiesParses` | | Non-root with DS and no entities parses successfully | parse OK, `IsRootConfig == false` | `TestNonRootConfigWithDataSourceAndNoEntitiesParses` | | Autoentities present but resolve to nothing — must not crash, must not double-message with "No entities found" | no crash; only "No entities found" if total = 0 | `ValidateAutoentitiesConfiguration` _(modified to `isValidateOnly: true`)_ | New tests: `TestRootConfigWithNoDataSourceAndNoEntitiesParses` Root config (has data-source-files) without datasource parses OK `TestNonRootConfigWithDataSourceAndNoEntitiesParses` Non-root config with datasource + no entities parses OK (validation catches it later) `TestNonRootWithDataSourceAndNoEntitiesProducesError` Calls ValidateDataSourceAndEntityPresence directly, error recorded `TestNonRootWithNoDataSourceProducesError` No datasource, error with "data source is required" `TestNonRootWithDataSourceAndEntitiesIsValid` Datasource + entities, no errors `TestRootWithNoDataSourceAndNoEntitiesIsValid` Root with child, no own datasource, valid `TestRootWithNoDataSourceButEntitiesProducesError` Root with entities but no datasource, error `TestRootWithDataSourceAndEntitiesIsValid` Root with own datasource + entities, valid `TestChildWithDataSourceAndNoEntitiesProducesNamedError` Child with no entities, error names the child file `TestChildWithNoDataSourceProducesNamedError` Child with no datasource, error names the child file `TestNonRootWithDataSourceAndAutoentitiesResolvingZeroProducesError` Non-root with only autoentities that resolve to 0 `TestNonRootWithDataSourceAndAutoentitiesResolvingEntitiesIsValid` Non-root with only autoentities resolving > 0 entities `TestNonRootWithEntitiesAndAutoentitiesResolvingZeroLogsWarning` Non-root with manual entities + autoentities resolving 0 `TestRootWithNoDataSourceButAutoentitiesProducesError` Root with no datasource but autoentities defined `TestRootWithDataSourceAndNoEntitiesProducesError` Root with own datasource and zero entities/autoentities `TestRootWithDataSourceAndAutoentitiesResolvingZeroProducesError` Root with own datasource and autoentities resolving 0 `TestRootWithDataSourceAndAutoentitiesResolvingEntitiesIsValid` Root with own datasource and autoentities resolving > 0 `TestRootWithEntitiesAndAutoentitiesResolvingZeroLogsWarning` Root with own datasource, manual entities, and autoentities resolving 0 `TestChildWithDataSourceAndAutoentitiesResolvingZeroProducesNamedError` Child with autoentities-only resolving 0 `TestChildWithEntitiesAndAutoentitiesResolvingZeroLogsNamedWarning` Child with manual entities + autoentities resolving 0 Modified tests: `TestValidateConfigWithNoEntitiesProducesCleanError` Replaced main's version (expected parse failure) with ours: parse succeeds, IsConfigValid returns false `ValidateAutoentitiesConfiguration` Changed to isValidateOnly: true, asserts no crashes instead of zero errors --------- Co-authored-by: Anusha Kolan (cherry picked from commit c2494360dce80e1f9c18180f2608027bf262f09b) --- src/Cli.Tests/ConfigureOptionsTests.cs | 6 +- src/Cli.Tests/EndToEndTests.cs | 10 +- src/Cli.Tests/ModuleInitializer.cs | 8 + .../UserDelegatedAuthRuntimeParsingTests.cs | 4 +- src/Cli.Tests/ValidateConfigTests.cs | 603 +++++++++++++++++- src/Cli/ConfigGenerator.cs | 48 +- src/Config/ObjectModel/ChildConfigMetadata.cs | 18 + .../ObjectModel/MultipleCreateOptions.cs | 1 - src/Config/ObjectModel/RuntimeConfig.cs | 89 ++- src/Config/RuntimeConfigLoader.cs | 51 +- .../Configurations/RuntimeConfigProvider.cs | 15 +- .../Configurations/RuntimeConfigValidator.cs | 152 ++++- .../CosmosSqlMetadataProvider.cs | 2 +- .../MsSqlMetadataProvider.cs | 3 + .../MetadataProviders/SqlMetadataProvider.cs | 8 +- .../Configuration/ConfigurationTests.cs | 29 +- src/Service.Tests/ModuleInitializer.cs | 8 + src/Service/HealthCheck/HealthCheckHelper.cs | 2 +- 18 files changed, 948 insertions(+), 109 deletions(-) create mode 100644 src/Config/ObjectModel/ChildConfigMetadata.cs diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 5824b2e054..4f7889be1c 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 9591803275..a24fbde366 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"]; 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 0383d9072d..1dc609a0c8 100644 --- a/src/Cli.Tests/ValidateConfigTests.cs +++ b/src/Cli.Tests/ValidateConfigTests.cs @@ -200,28 +200,21 @@ public void TestValidateConfigFailsWithNoEntities() } /// - /// Validates that when the config has no entities or autoentities, TryParseConfig - /// sets a clean error message (not a raw exception with stack trace) and - /// IsConfigValid returns false without throwing. - /// Regression test for https://github.com/Azure/data-api-builder/issues/3268 + /// 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}}}"; - // Verify TryParseConfig produces a clean error without stack traces. - bool parsed = RuntimeConfigLoader.TryParseConfig(configWithoutEntities, out _, out string? parseError); + // 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."); - Assert.IsFalse(parsed, "Config with no entities should fail to parse."); - Assert.IsNotNull(parseError, "parseError should be set when config parsing fails."); - StringAssert.Contains(parseError, - "Configuration file should contain either at least the entities or autoentities property", - "Parse error should contain the clean validation message."); - Assert.IsFalse(parseError.Contains("StackTrace"), - "Stack trace should not be present in parse error."); - - // Verify IsConfigValid also returns false cleanly (no exception thrown). + // 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!)); @@ -387,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/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index f90b31671e..0f1c0c91a4 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."); @@ -675,6 +684,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; @@ -713,7 +731,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; @@ -1776,6 +1794,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); @@ -1843,7 +1870,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; @@ -2462,7 +2489,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; @@ -2579,7 +2606,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; @@ -2660,10 +2687,10 @@ public static bool IsConfigValid(ValidateOptions options, FileSystemRuntimeConfi 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.Runtime?.Mcp?.Enabled == true; if (mcpEnabled) @@ -3278,9 +3305,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; } @@ -3634,5 +3661,6 @@ private static bool ValidateFields( return true; } + } } 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 d6bdefc1b8..b88231d0c3 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -19,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; } @@ -32,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; } @@ -73,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. @@ -302,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); + } } } @@ -364,6 +383,12 @@ public RuntimeConfig( { 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); @@ -469,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 @@ -639,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; } @@ -694,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 83b7b3969e..57d1695fab 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -237,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)) { @@ -245,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) - { - updatedConnection = GetConnectionStringWithApplicationName(connectionValue); - } - else if (ds.DatabaseType is DatabaseType.PostgreSQL && replacementSettings?.DoReplaceEnvVar == true) + // iterate over dictionary and update runtime config with connection strings. + foreach ((string dataSourceKey, string connectionValue) in datasourceNameToConnectionString) { - 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 }; + } } } } diff --git a/src/Core/Configurations/RuntimeConfigProvider.cs b/src/Core/Configurations/RuntimeConfigProvider.cs index 0fce915edf..7b06b7133a 100644 --- a/src/Core/Configurations/RuntimeConfigProvider.cs +++ b/src/Core/Configurations/RuntimeConfigProvider.cs @@ -193,7 +193,7 @@ public async Task Initialize( { _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)); } @@ -272,13 +272,24 @@ public async Task Initialize( 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 23d12ec31f..6d3c5281ee 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/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 3eb6a9dfc4..0a39887857 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -4653,7 +4653,7 @@ public void TestAutoEntitiesSerializationDeserialization( RuntimeConfig config = new( Schema: baseConfig!.Schema, - DataSource: baseConfig.DataSource, + DataSource: baseConfig.DataSource!, Runtime: new( Rest: new(), GraphQL: new(), @@ -5752,16 +5752,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/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/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); From d406caec790faa227772d21ed76ad5ea99634ee0 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Wed, 6 May 2026 15:50:11 -0700 Subject: [PATCH 10/12] Make JsonConstructor DataSource parameter nullable to match property annotation --- src/Config/ObjectModel/RuntimeConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 34eb24064c..475f8a13d3 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -315,7 +315,7 @@ public bool RemoveGeneratedAutoentityNameFromDataSourceName(string entityName) [JsonConstructor] public RuntimeConfig( string? Schema, - DataSource DataSource, + DataSource? DataSource, RuntimeEntities Entities, RuntimeAutoentities? Autoentities = null, RuntimeOptions? Runtime = null, From 1c15e1868e988c8e5283fdc0d7b027a5154ea35c Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Wed, 6 May 2026 17:02:35 -0700 Subject: [PATCH 11/12] Fix duplicate abbreviation block from merge conflict resolution --- src/Cli/CustomLoggerProvider.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Cli/CustomLoggerProvider.cs b/src/Cli/CustomLoggerProvider.cs index 37d99deb43..e489dd8df3 100644 --- a/src/Cli/CustomLoggerProvider.cs +++ b/src/Cli/CustomLoggerProvider.cs @@ -118,11 +118,6 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except return; } - if (!_logLevelToAbbreviation.TryGetValue(logLevel, out string? abbreviation)) - { - return; - } - ConsoleColor originalForeGroundColor = Console.ForegroundColor; ConsoleColor originalBackGroundColor = Console.BackgroundColor; Console.ForegroundColor = _logLevelToForeGroundConsoleColorMap.GetValueOrDefault(logLevel, ConsoleColor.White); From 5fe65aa60c4c2a4728f20bf1f640fa67694b283d Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Wed, 6 May 2026 17:39:48 -0700 Subject: [PATCH 12/12] Update DataSource parameter doc to reflect nullable annotation --- src/Config/ObjectModel/RuntimeConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 475f8a13d3..0f971293ee 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -308,7 +308,7 @@ 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.