diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 29838a9abe..4c639ec964 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -275,7 +275,11 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime DataSource: dataSource, Runtime: new( Rest: new(restEnabled, restPath ?? RestRuntimeOptions.DEFAULT_PATH, options.RestRequestBodyStrict is CliBool.True ? true : false), - GraphQL: new(Enabled: graphQLEnabled, Path: graphQLPath, MultipleMutationOptions: multipleMutationOptions), + GraphQL: new( + Enabled: graphQLEnabled, + Path: graphQLPath, + AllowIntrospection: true, + MultipleMutationOptions: multipleMutationOptions), Mcp: new( Enabled: mcpEnabled, Path: mcpPath ?? McpRuntimeOptions.DEFAULT_PATH, diff --git a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs index 109caef0d5..be317998b7 100644 --- a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs @@ -45,7 +45,12 @@ internal GraphQLRuntimeOptionsConverter(DeserializationVariableReplacementSettin public override GraphQLRuntimeOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.Null) + if (reader.TokenType == JsonTokenType.True) + { + return new GraphQLRuntimeOptions(Enabled: true); + } + + if (reader.TokenType == JsonTokenType.Null) { return new GraphQLRuntimeOptions(); } @@ -181,9 +186,21 @@ internal GraphQLRuntimeOptionsConverter(DeserializationVariableReplacementSettin public override void Write(Utf8JsonWriter writer, GraphQLRuntimeOptions value, JsonSerializerOptions options) { writer.WriteStartObject(); - writer.WriteBoolean("enabled", value.Enabled); - writer.WriteString("path", value.Path); - writer.WriteBoolean("allow-introspection", value.AllowIntrospection); + + if (value.Enabled is not null) + { + writer.WriteBoolean("enabled", value.Enabled.Value); + } + + if (value.Path is not null) + { + writer.WriteString("path", value.Path); + } + + if (value.AllowIntrospection is not null) + { + writer.WriteBoolean("allow-introspection", value.AllowIntrospection.Value); + } if (value.UserProvidedDepthLimit) { diff --git a/src/Config/Converters/RestRuntimeOptionsConverterFactory.cs b/src/Config/Converters/RestRuntimeOptionsConverterFactory.cs index 4be8ab0141..78a2f732e2 100644 --- a/src/Config/Converters/RestRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/RestRuntimeOptionsConverterFactory.cs @@ -25,7 +25,12 @@ private class RestRuntimeOptionsConverter : JsonConverter { public override RestRuntimeOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.Null) + if (reader.TokenType == JsonTokenType.True) + { + return new RestRuntimeOptions(Enabled: true); + } + + if (reader.TokenType == JsonTokenType.Null) { return new RestRuntimeOptions(); } @@ -45,9 +50,22 @@ private class RestRuntimeOptionsConverter : JsonConverter public override void Write(Utf8JsonWriter writer, RestRuntimeOptions value, JsonSerializerOptions options) { writer.WriteStartObject(); - writer.WriteBoolean("enabled", value.Enabled); - writer.WriteString("path", value.Path); - writer.WriteBoolean("request-body-strict", value.RequestBodyStrict); + + if (value.Enabled is not null) + { + writer.WriteBoolean("enabled", value.Enabled.Value); + } + + if (value.Path is not null) + { + writer.WriteString("path", value.Path); + } + + if (value.RequestBodyStrict is not null) + { + writer.WriteBoolean("request-body-strict", value.RequestBodyStrict.Value); + } + writer.WriteEndObject(); } } diff --git a/src/Config/ObjectModel/GraphQLRuntimeOptions.cs b/src/Config/ObjectModel/GraphQLRuntimeOptions.cs index 5ac5e24249..bec8c90470 100644 --- a/src/Config/ObjectModel/GraphQLRuntimeOptions.cs +++ b/src/Config/ObjectModel/GraphQLRuntimeOptions.cs @@ -6,9 +6,9 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; -public record GraphQLRuntimeOptions(bool Enabled = true, - string Path = GraphQLRuntimeOptions.DEFAULT_PATH, - bool AllowIntrospection = true, +public record GraphQLRuntimeOptions(bool? Enabled = null, + string? Path = null, + bool? AllowIntrospection = null, int? DepthLimit = null, MultipleMutationOptions? MultipleMutationOptions = null, bool EnableAggregation = true, diff --git a/src/Config/ObjectModel/RestRuntimeOptions.cs b/src/Config/ObjectModel/RestRuntimeOptions.cs index ef15c39099..4a238ead5c 100644 --- a/src/Config/ObjectModel/RestRuntimeOptions.cs +++ b/src/Config/ObjectModel/RestRuntimeOptions.cs @@ -11,9 +11,9 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// for all entities will be exposed. /// When true, extraneous/unmapped fields in the REST request body are rejected. /// When false, extraneous fields are allowed and ignored. -/// The record default (true) preserves backward compatibility for existing configs that omit this property. +/// The effective runtime default (true) preserves backward compatibility for existing configs that omit this property. /// When dab init generates a new config, request-body-strict is set to false to allow extraneous fields by default. -public record RestRuntimeOptions(bool Enabled = true, string Path = RestRuntimeOptions.DEFAULT_PATH, bool RequestBodyStrict = true) +public record RestRuntimeOptions(bool? Enabled = null, string? Path = null, bool? RequestBodyStrict = null) { public const string DEFAULT_PATH = "/api"; }; diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index a8b71d10c9..9ecd7f2fd9 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -81,27 +81,22 @@ Runtime is not null && /// [JsonIgnore] public bool IsRequestBodyStrict => - Runtime is null || - Runtime.Rest is null || - Runtime.Rest.RequestBodyStrict; + Runtime?.Rest?.RequestBodyStrict ?? true; /// /// Retrieves the value of runtime.graphql.enabled property if present, default is true. /// [JsonIgnore] - public bool IsGraphQLEnabled => Runtime is null || - Runtime.GraphQL is null || - Runtime.GraphQL.Enabled; + public bool IsGraphQLEnabled => + Runtime?.GraphQL?.Enabled ?? true; /// /// Retrieves the value of runtime.rest.enabled property if present, default is true if its not cosmosdb. /// [JsonIgnore] public bool IsRestEnabled => - (Runtime is null || - Runtime.Rest is null || - Runtime.Rest.Enabled) && - DataSource?.DatabaseType != DatabaseType.CosmosDB_NoSQL; + (Runtime?.Rest?.Enabled ?? true) && + DataSource?.DatabaseType != DatabaseType.CosmosDB_NoSQL; /// /// Retrieves the value of runtime.mcp.enabled property if present, default is true. @@ -161,7 +156,7 @@ public string RestPath } else { - return Runtime.Rest.Path; + return Runtime.Rest.Path ?? RestRuntimeOptions.DEFAULT_PATH; } } } @@ -180,7 +175,7 @@ public string GraphQLPath } else { - return Runtime.GraphQL.Path; + return Runtime.GraphQL.Path ?? GraphQLRuntimeOptions.DEFAULT_PATH; } } } @@ -212,9 +207,7 @@ public bool AllowIntrospection { get { - return Runtime is null || - Runtime.GraphQL is null || - Runtime.GraphQL.AllowIntrospection; + return Runtime?.GraphQL?.AllowIntrospection ?? true; } } diff --git a/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs b/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs index d85c3ddf01..2f5fdc4718 100644 --- a/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs +++ b/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs @@ -913,8 +913,8 @@ public async Task HotReloadValidationFail() Assert.IsNotNull(lkgRuntimeConfig); // Capture properties to verify config hasn't changed - bool originalRestEnabled = lkgRuntimeConfig.Runtime.Rest.Enabled; - bool originalGraphQLEnabled = lkgRuntimeConfig.Runtime.GraphQL.Enabled; + bool originalRestEnabled = lkgRuntimeConfig.IsRestEnabled; + bool originalGraphQLEnabled = lkgRuntimeConfig.IsGraphQLEnabled; bool originalMcpEnabled = lkgRuntimeConfig.Runtime.Mcp.Enabled; // Act @@ -935,9 +935,9 @@ await WaitForConditionAsync( // Assert - Verify the configuration hasn't changed by comparing properties Assert.IsNotNull(newRuntimeConfig, "RuntimeConfig should not be null after failed hot-reload."); - Assert.AreEqual(originalRestEnabled, newRuntimeConfig.Runtime.Rest.Enabled, + Assert.AreEqual(originalRestEnabled, newRuntimeConfig.IsRestEnabled, "REST enabled setting should remain unchanged after hot-reload failure."); - Assert.AreEqual(originalGraphQLEnabled, newRuntimeConfig.Runtime.GraphQL.Enabled, + Assert.AreEqual(originalGraphQLEnabled, newRuntimeConfig.IsGraphQLEnabled, "GraphQL enabled setting should remain unchanged after hot-reload failure."); Assert.AreEqual(originalMcpEnabled, newRuntimeConfig.Runtime.Mcp.Enabled, "MCP enabled setting should remain unchanged after hot-reload failure."); @@ -963,8 +963,8 @@ public async Task HotReloadParsingFail() Assert.IsNotNull(lkgRuntimeConfig); // Capture properties to verify config hasn't changed - bool originalRestEnabled = lkgRuntimeConfig.Runtime.Rest.Enabled; - bool originalGraphQLEnabled = lkgRuntimeConfig.Runtime.GraphQL.Enabled; + bool originalRestEnabled = lkgRuntimeConfig.IsRestEnabled; + bool originalGraphQLEnabled = lkgRuntimeConfig.IsGraphQLEnabled; // Act GenerateConfigFile( @@ -982,9 +982,9 @@ await WaitForConditionAsync( // Assert - Verify the configuration hasn't changed by comparing properties Assert.IsNotNull(newRuntimeConfig, "RuntimeConfig should not be null after failed hot-reload."); - Assert.AreEqual(originalRestEnabled, newRuntimeConfig.Runtime.Rest.Enabled, + Assert.AreEqual(originalRestEnabled, newRuntimeConfig.IsRestEnabled, "REST enabled setting should remain unchanged after hot-reload failure."); - Assert.AreEqual(originalGraphQLEnabled, newRuntimeConfig.Runtime.GraphQL.Enabled, + Assert.AreEqual(originalGraphQLEnabled, newRuntimeConfig.IsGraphQLEnabled, "GraphQL enabled setting should remain unchanged after hot-reload failure."); } diff --git a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs index 15962c7dfb..77faaf554e 100644 --- a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs +++ b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs @@ -408,6 +408,108 @@ public void TestNullableOptionalProps() TryParseAndAssertOnDefaults("{" + emptyTelemetrySubProps, out _); } + /// + /// Verifies that optional scalar runtime properties which are omitted from + /// the input configuration remain omitted after serialization, while their + /// effective runtime behavior still uses the documented defaults. + /// + [TestMethod] + public void TestOptionalScalarPropsRemainOmittedAfterSerialization() + { + string json = @"{ + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""@env('test-connection-string')"" + }, + ""runtime"": { + ""rest"": { }, + ""graphql"": { } + }, + ""entities"": { } + }"; + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig runtimeConfig)); + + string serialized = runtimeConfig.ToJson(); + using JsonDocument document = JsonDocument.Parse(serialized); + + JsonElement runtime = document.RootElement.GetProperty("runtime"); + + JsonElement rest = runtime.GetProperty("rest"); + Assert.IsFalse(rest.TryGetProperty("enabled", out _)); + Assert.IsFalse(rest.TryGetProperty("path", out _)); + Assert.IsFalse(rest.TryGetProperty("request-body-strict", out _)); + + JsonElement graphql = runtime.GetProperty("graphql"); + Assert.IsFalse(graphql.TryGetProperty("enabled", out _)); + Assert.IsFalse(graphql.TryGetProperty("path", out _)); + Assert.IsFalse(graphql.TryGetProperty("allow-introspection", out _)); + + Assert.IsTrue(runtimeConfig.IsRestEnabled); + Assert.AreEqual(RestRuntimeOptions.DEFAULT_PATH, runtimeConfig.RestPath); + Assert.IsTrue(runtimeConfig.IsRequestBodyStrict); + + Assert.IsTrue(runtimeConfig.IsGraphQLEnabled); + Assert.AreEqual(GraphQLRuntimeOptions.DEFAULT_PATH, runtimeConfig.GraphQLPath); + Assert.IsTrue(runtimeConfig.AllowIntrospection); + } + + /// + /// Verifies that omitted optional scalar properties do not materialize + /// defaults during serialization and overwrite values from a base config. + /// + [TestMethod] + public void TestOptionalScalarPropsDoNotOverrideBaseValuesAfterSerialization() + { + string baseJson = @"{ + ""runtime"": { + ""rest"": { + ""enabled"": false, + ""path"": ""/base-rest"", + ""request-body-strict"": false + }, + ""graphql"": { + ""enabled"": false, + ""path"": ""/base-graphql"", + ""allow-introspection"": false + } + } + }"; + + string overrideJson = @"{ + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""@env('test-connection-string')"" + }, + ""runtime"": { + ""rest"": { }, + ""graphql"": { } + }, + ""entities"": { } + }"; + + Assert.IsTrue( + RuntimeConfigLoader.TryParseConfig( + overrideJson, + out RuntimeConfig overrideConfig)); + + string serializedOverride = overrideConfig.ToJson(); + string mergedJson = MergeJsonProvider.Merge(baseJson, serializedOverride); + + using JsonDocument document = JsonDocument.Parse(mergedJson); + JsonElement runtime = document.RootElement.GetProperty("runtime"); + + JsonElement rest = runtime.GetProperty("rest"); + Assert.IsFalse(rest.GetProperty("enabled").GetBoolean()); + Assert.AreEqual("/base-rest", rest.GetProperty("path").GetString()); + Assert.IsFalse(rest.GetProperty("request-body-strict").GetBoolean()); + + JsonElement graphql = runtime.GetProperty("graphql"); + Assert.IsFalse(graphql.GetProperty("enabled").GetBoolean()); + Assert.AreEqual("/base-graphql", graphql.GetProperty("path").GetString()); + Assert.IsFalse(graphql.GetProperty("allow-introspection").GetBoolean()); + } + #endregion Positive Tests #region Negative Tests