Skip to content

Commit 6783acb

Browse files
Merge branch 'main' into Usr/sogh/bug3373
2 parents 05842c7 + 5fa2bfc commit 6783acb

5 files changed

Lines changed: 276 additions & 5 deletions

File tree

src/Config/ObjectModel/RuntimeConfig.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Net;
77
using System.Text.Json;
88
using System.Text.Json.Serialization;
9+
using Azure.DataApiBuilder.Config.Converters;
910
using Azure.DataApiBuilder.Service.Exceptions;
1011
using Microsoft.Extensions.Logging;
1112

@@ -355,8 +356,9 @@ public RuntimeConfig(
355356

356357
foreach (string dataSourceFile in DataSourceFiles.SourceFiles)
357358
{
358-
// Use default replacement settings for environment variable replacement
359-
DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true);
359+
// Use Ignore mode so missing env vars are left as literal @env() strings,
360+
// consistent with how the parent config is loaded in TryLoadKnownConfig.
361+
DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true, envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore);
360362

361363
if (loader.TryLoadConfig(dataSourceFile, out RuntimeConfig? config, replacementSettings: replacementSettings))
362364
{
@@ -378,6 +380,17 @@ public RuntimeConfig(
378380
e.InnerException);
379381
}
380382
}
383+
else if (fileSystem.File.Exists(dataSourceFile))
384+
{
385+
// The file exists but failed to load (e.g. invalid JSON, deserialization error).
386+
// Throw to prevent silently skipping a broken child config.
387+
// Non-existent files are skipped gracefully to support late-configured scenarios
388+
// where data-source-files may reference files not present on the host.
389+
throw new DataApiBuilderException(
390+
message: $"Failed to load datasource file: {dataSourceFile}. Ensure the file is accessible and contains a valid DAB configuration.",
391+
statusCode: HttpStatusCode.ServiceUnavailable,
392+
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError);
393+
}
381394
}
382395

383396
this.Entities = new RuntimeEntities(allEntities != null ? allEntities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary<string, Entity>());

src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Linq;
1010
using System.Threading.Tasks;
1111
using Azure.DataApiBuilder.Config;
12+
using Azure.DataApiBuilder.Config.Converters;
1213
using Azure.DataApiBuilder.Config.ObjectModel;
1314
using Microsoft.VisualStudio.TestTools.UnitTesting;
1415
using Newtonsoft.Json.Linq;
@@ -131,4 +132,154 @@ public async Task CanLoadValidMultiSourceConfigWithAutoentities(string configPat
131132
Assert.IsTrue(runtimeConfig.SqlDataSourceUsed, "Should have Sql data source");
132133
Assert.AreEqual(expectedEntities, runtimeConfig.Entities.Entities.Count, "Number of entities is not what is expected.");
133134
}
135+
136+
/// <summary>
137+
/// Validates that when a child config contains @env('...') references to environment variables
138+
/// that do not exist, the config still loads successfully because the child config uses
139+
/// EnvironmentVariableReplacementFailureMode.Ignore (matching the parent config behavior).
140+
/// Regression test for https://github.com/Azure/data-api-builder/issues/3271
141+
/// </summary>
142+
[TestMethod]
143+
public async Task ChildConfigWithMissingEnvVarsLoadsSuccessfully()
144+
{
145+
string parentConfig = await File.ReadAllTextAsync("Multidab-config.MsSql.json");
146+
147+
// Child config references env vars that do not exist in the environment.
148+
string childConfig = @"{
149+
""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"",
150+
""data-source"": {
151+
""database-type"": ""mssql"",
152+
""connection-string"": ""Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;""
153+
},
154+
""runtime"": {
155+
""rest"": { ""enabled"": true },
156+
""graphql"": { ""enabled"": true },
157+
""host"": {
158+
""cors"": { ""origins"": [] },
159+
""authentication"": { ""provider"": ""StaticWebApps"" }
160+
},
161+
""telemetry"": {
162+
""open-telemetry"": {
163+
""enabled"": true,
164+
""endpoint"": ""@env('NONEXISTENT_OTEL_ENDPOINT')"",
165+
""headers"": ""@env('NONEXISTENT_OTEL_HEADERS')"",
166+
""service-name"": ""@env('NONEXISTENT_OTEL_SERVICE_NAME')""
167+
}
168+
}
169+
},
170+
""entities"": {
171+
""ChildEntity"": {
172+
""source"": ""dbo.ChildTable"",
173+
""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""read""] }]
174+
}
175+
}
176+
}";
177+
178+
// Save original env var values and clear them to ensure they don't exist.
179+
string? origEndpoint = Environment.GetEnvironmentVariable("NONEXISTENT_OTEL_ENDPOINT");
180+
string? origHeaders = Environment.GetEnvironmentVariable("NONEXISTENT_OTEL_HEADERS");
181+
string? origServiceName = Environment.GetEnvironmentVariable("NONEXISTENT_OTEL_SERVICE_NAME");
182+
Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_ENDPOINT", null);
183+
Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_HEADERS", null);
184+
Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_SERVICE_NAME", null);
185+
186+
// Write the child config to a unique temp file because the RuntimeConfig
187+
// constructor creates a real FileSystem to load child data-source-files.
188+
string childFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
189+
try
190+
{
191+
await File.WriteAllTextAsync(childFilePath, childConfig);
192+
193+
JObject parentJson = JObject.Parse(parentConfig);
194+
parentJson.Add("data-source-files", new JArray(childFilePath));
195+
string parentJsonStr = parentJson.ToString();
196+
197+
MockFileSystem fs = new(new Dictionary<string, MockFileData>()
198+
{
199+
{ "dab-config.json", new MockFileData(parentJsonStr) }
200+
});
201+
202+
FileSystemRuntimeConfigLoader loader = new(fs);
203+
204+
DeserializationVariableReplacementSettings replacementSettings = new(
205+
azureKeyVaultOptions: null,
206+
doReplaceEnvVar: true,
207+
doReplaceAkvVar: false,
208+
envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore);
209+
210+
Assert.IsTrue(
211+
loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig, replacementSettings: replacementSettings),
212+
"Config should load successfully even when child config has missing env vars.");
213+
214+
Assert.IsTrue(runtimeConfig.Entities.ContainsKey("ChildEntity"), "Child config entity should be merged into the parent config.");
215+
}
216+
finally
217+
{
218+
Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_ENDPOINT", origEndpoint);
219+
Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_HEADERS", origHeaders);
220+
Environment.SetEnvironmentVariable("NONEXISTENT_OTEL_SERVICE_NAME", origServiceName);
221+
222+
if (File.Exists(childFilePath))
223+
{
224+
File.Delete(childFilePath);
225+
}
226+
}
227+
}
228+
229+
/// <summary>
230+
/// Validates that when a child config file exists but contains invalid content,
231+
/// the parent config loading fails instead of silently skipping the child.
232+
/// Non-existent child files are intentionally skipped to support late-configured scenarios.
233+
/// Regression test for https://github.com/Azure/data-api-builder/issues/3271
234+
/// </summary>
235+
[TestMethod]
236+
public async Task ChildConfigLoadFailureHaltsParentConfigLoading()
237+
{
238+
string parentConfig = await File.ReadAllTextAsync("Multidab-config.MsSql.json");
239+
240+
// Use a real temp file with invalid JSON so the file exists but fails to parse.
241+
string invalidChildPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
242+
243+
try
244+
{
245+
await File.WriteAllTextAsync(invalidChildPath, "{ this is not valid json }");
246+
247+
JObject parentJson = JObject.Parse(parentConfig);
248+
parentJson.Add("data-source-files", new JArray(invalidChildPath));
249+
string parentJsonStr = parentJson.ToString();
250+
251+
MockFileSystem fs = new(new Dictionary<string, MockFileData>()
252+
{
253+
{ "dab-config.json", new MockFileData(parentJsonStr) }
254+
});
255+
256+
FileSystemRuntimeConfigLoader loader = new(fs);
257+
258+
TextWriter originalError = Console.Error;
259+
StringWriter sw = new();
260+
261+
try
262+
{
263+
Console.SetError(sw);
264+
265+
bool loaded = loader.TryLoadConfig("dab-config.json", out RuntimeConfig _);
266+
string error = sw.ToString();
267+
268+
Assert.IsFalse(loaded, "Config loading should fail when a child config file exists but cannot be parsed.");
269+
Assert.IsTrue(error.Contains("Failed to load datasource file"), "Error message should indicate the child config file that failed to load.");
270+
}
271+
finally
272+
{
273+
Console.SetError(originalError);
274+
sw.Dispose();
275+
}
276+
}
277+
finally
278+
{
279+
if (File.Exists(invalidChildPath))
280+
{
281+
File.Delete(invalidChildPath);
282+
}
283+
}
284+
}
134285
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.IO.Abstractions;
8+
using System.Net;
9+
using System.Net.Http;
10+
using System.Text.Json;
11+
using System.Threading.Tasks;
12+
using Azure.DataApiBuilder.Config;
13+
using Azure.DataApiBuilder.Config.ObjectModel;
14+
using Microsoft.AspNetCore.TestHost;
15+
using Microsoft.VisualStudio.TestTools.UnitTesting;
16+
17+
namespace Azure.DataApiBuilder.Service.Tests.OpenApiIntegration
18+
{
19+
/// <summary>
20+
/// Tests validating that requesting an OpenAPI document for a role not present
21+
/// in the configuration returns a 404 ProblemDetails response with a descriptive message.
22+
/// </summary>
23+
[TestCategory(TestCategory.MSSQL)]
24+
[TestClass]
25+
public class MissingRoleNotFoundTests
26+
{
27+
private const string CONFIG_FILE = "missing-role-notfound-config.MsSql.json";
28+
private const string DB_ENV = TestCategory.MSSQL;
29+
30+
/// <summary>
31+
/// Validates that a request for /api/openapi/{role} returns a 404
32+
/// ProblemDetails response containing the role name in the detail message
33+
/// when the role is not present in the configuration or contains invalid characters.
34+
/// </summary>
35+
[DataTestMethod]
36+
[DataRow("nonexistentrole", DisplayName = "Missing role returns 404 ProblemDetails")]
37+
[DataRow("foo/bar", DisplayName = "Role with path separator returns 404 ProblemDetails")]
38+
public async Task MissingRole_Returns404ProblemDetailsWithMessage(string roleName)
39+
{
40+
TestHelper.SetupDatabaseEnvironment(DB_ENV);
41+
FileSystem fileSystem = new();
42+
FileSystemRuntimeConfigLoader loader = new(fileSystem);
43+
loader.TryLoadKnownConfig(out RuntimeConfig config);
44+
45+
Entity entity = new(
46+
Source: new("books", EntitySourceType.Table, null, null),
47+
Fields: null,
48+
GraphQL: new(null, null, false),
49+
Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
50+
Permissions: OpenApiTestBootstrap.CreateBasicPermissions(),
51+
Mappings: null,
52+
Relationships: null);
53+
54+
RuntimeConfig testConfig = config with
55+
{
56+
Runtime = config.Runtime with
57+
{
58+
Host = config.Runtime?.Host with { Mode = HostMode.Development }
59+
},
60+
Entities = new RuntimeEntities(new Dictionary<string, Entity> { { "book", entity } })
61+
};
62+
63+
File.WriteAllText(CONFIG_FILE, testConfig.ToJson());
64+
string[] args = new[] { $"--ConfigFileName={CONFIG_FILE}" };
65+
66+
try
67+
{
68+
using TestServer server = new(Program.CreateWebHostBuilder(args));
69+
using HttpClient client = server.CreateClient();
70+
71+
HttpResponseMessage response = await client.GetAsync($"/api/openapi/{Uri.EscapeDataString(roleName)}");
72+
73+
Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode, "Expected 404 for a role not in the configuration.");
74+
75+
string responseBody = await response.Content.ReadAsStringAsync();
76+
using JsonDocument doc = JsonDocument.Parse(responseBody);
77+
JsonElement root = doc.RootElement;
78+
79+
Assert.AreEqual("Not Found", root.GetProperty("title").GetString(), "ProblemDetails title should be 'Not Found'.");
80+
Assert.AreEqual(404, root.GetProperty("status").GetInt32(), "ProblemDetails status should be 404.");
81+
Assert.IsTrue(root.TryGetProperty("type", out _), "ProblemDetails should contain a 'type' field.");
82+
Assert.IsTrue(root.TryGetProperty("traceId", out _), "ProblemDetails should contain a 'traceId' field.");
83+
84+
string detail = root.GetProperty("detail").GetString();
85+
Assert.IsTrue(detail.Contains(roleName), $"Detail should contain the role name '{roleName}'. Actual: {detail}");
86+
}
87+
finally
88+
{
89+
if (File.Exists(CONFIG_FILE))
90+
{
91+
File.Delete(CONFIG_FILE);
92+
}
93+
}
94+
95+
TestHelper.UnsetAllDABEnvironmentVariables();
96+
}
97+
}
98+
}

src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,10 @@ public void TestLoadRuntimeConfigFailures(
486486
}
487487

488488
/// <summary>
489-
/// Method to validate that FileNotFoundException is thrown if sub-data source file is not found.
489+
/// Method to validate that when a sub-data source file is not found, it is gracefully
490+
/// skipped and the parent config loads successfully. Non-existent child files are
491+
/// tolerated to support late-configured scenarios where data-source-files may reference
492+
/// files not present on the host.
490493
/// </summary>
491494
[TestMethod]
492495
public void TestLoadRuntimeConfigSubFilesFails()

src/Service/Controllers/RestController.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,15 +249,21 @@ private async Task<IActionResult> HandleOperation(
249249
// Validate role doesn't contain path separators (reject /openapi/foo/bar)
250250
if (string.IsNullOrEmpty(role) || role.Contains('/'))
251251
{
252-
return NotFound();
252+
return Problem(
253+
detail: $"Invalid role name '{role}'. Role names must not be empty or contain path separators.",
254+
statusCode: StatusCodes.Status404NotFound,
255+
title: "Not Found");
253256
}
254257

255258
if (_openApiDocumentor.TryGetDocumentForRole(role, out string? roleDocument))
256259
{
257260
return Content(roleDocument, MediaTypeNames.Application.Json);
258261
}
259262

260-
return NotFound();
263+
return Problem(
264+
detail: $"Role '{role}' is not present in the configuration.",
265+
statusCode: StatusCodes.Status404NotFound,
266+
title: "Not Found");
261267
}
262268

263269
(string entityName, string primaryKeyRoute) = _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(routeAfterPathBase);

0 commit comments

Comments
 (0)