From 5aa5ce7a19846ef3c2710a7c8cd0a9968516fe92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:05:02 +0000 Subject: [PATCH 1/3] Initial plan From 5e5a86574502a645d7d3cc35c51b1efdf343155d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:33:13 +0000 Subject: [PATCH 2/3] Add response compression support with configuration, CLI, and tests Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> --- schemas/dab.draft.schema.json | 13 +++ src/Cli.Tests/ConfigureOptionsTests.cs | 28 ++++++ src/Cli/Commands/ConfigureOptions.cs | 6 ++ src/Cli/ConfigGenerator.cs | 46 ++++++++++ .../CompressionOptionsConverterFactory.cs | 92 +++++++++++++++++++ src/Config/ObjectModel/CompressionLevel.cs | 28 ++++++ src/Config/ObjectModel/CompressionOptions.cs | 46 ++++++++++ src/Config/ObjectModel/RuntimeOptions.cs | 5 +- src/Config/RuntimeConfigLoader.cs | 1 + src/Service/Startup.cs | 52 +++++++++++ 10 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src/Config/Converters/CompressionOptionsConverterFactory.cs create mode 100644 src/Config/ObjectModel/CompressionLevel.cs create mode 100644 src/Config/ObjectModel/CompressionOptions.cs diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 80cfd953ad..85a24dde2e 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -424,6 +424,19 @@ } } }, + "compression": { + "type": "object", + "description": "Configures HTTP response compression settings.", + "additionalProperties": false, + "properties": { + "level": { + "type": "string", + "enum": ["optimal", "fastest", "none"], + "default": "optimal", + "description": "Specifies the response compression level. 'optimal' provides best compression ratio, 'fastest' prioritizes speed, 'none' disables compression." + } + } + }, "telemetry": { "type": "object", "description": "Telemetry configuration", diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 073f349a67..07b6026c68 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -540,6 +540,34 @@ public void TestUpdateTTLForCacheSettings(int updatedTtlValue) Assert.AreEqual(updatedTtlValue, runtimeConfig.Runtime.Cache.TtlSeconds); } + /// + /// Tests that running "dab configure --runtime.compression.level {value}" on a config with various values results + /// in runtime config update. Takes in updated value for compression.level and + /// validates whether the runtime config reflects those updated values + [DataTestMethod] + [DataRow(CompressionLevel.Fastest, DisplayName = "Update Compression.Level to fastest.")] + [DataRow(CompressionLevel.Optimal, DisplayName = "Update Compression.Level to optimal.")] + [DataRow(CompressionLevel.None, DisplayName = "Update Compression.Level to none.")] + public void TestUpdateLevelForCompressionSettings(CompressionLevel updatedLevelValue) + { + // Arrange -> all the setup which includes creating options. + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + // Act: Attempts to update compression level value + ConfigureOptions options = new( + runtimeCompressionLevel: updatedLevelValue, + config: TEST_RUNTIME_CONFIG_FILE + ); + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert: Validate the Level Value is updated + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? runtimeConfig)); + Assert.IsNotNull(runtimeConfig.Runtime?.Compression?.Level); + Assert.AreEqual(updatedLevelValue, runtimeConfig.Runtime.Compression.Level); + } + /// /// Tests that running "dab configure --runtime.host.mode {value}" on a config with various values results /// in runtime config update. Takes in updated value for host.mode and diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 60cb12c3f8..58c2b0f304 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -47,6 +47,7 @@ public ConfigureOptions( bool? runtimeMcpDmlToolsExecuteEntityEnabled = null, bool? runtimeCacheEnabled = null, int? runtimeCacheTtl = null, + CompressionLevel? runtimeCompressionLevel = null, HostMode? runtimeHostMode = null, IEnumerable? runtimeHostCorsOrigins = null, bool? runtimeHostCorsAllowCredentials = null, @@ -103,6 +104,8 @@ public ConfigureOptions( // Cache RuntimeCacheEnabled = runtimeCacheEnabled; RuntimeCacheTTL = runtimeCacheTtl; + // Compression + RuntimeCompressionLevel = runtimeCompressionLevel; // Host RuntimeHostMode = runtimeHostMode; RuntimeHostCorsOrigins = runtimeHostCorsOrigins; @@ -207,6 +210,9 @@ public ConfigureOptions( [Option("runtime.cache.ttl-seconds", Required = false, HelpText = "Customize the DAB cache's global default time to live in seconds. Default: 5 seconds (Integer).")] public int? RuntimeCacheTTL { get; } + [Option("runtime.compression.level", Required = false, HelpText = "Set the response compression level. Allowed values: optimal (default), fastest, none.")] + public CompressionLevel? RuntimeCompressionLevel { get; } + [Option("runtime.host.mode", Required = false, HelpText = "Set the host running mode of DAB in Development or Production. Default: Development.")] public HostMode? RuntimeHostMode { get; } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 7c35335089..e4c0061486 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -828,6 +828,21 @@ private static bool TryUpdateConfiguredRuntimeOptions( } } + // Compression: Level + if (options.RuntimeCompressionLevel != null) + { + CompressionOptions updatedCompressionOptions = runtimeConfig?.Runtime?.Compression ?? new(); + bool status = TryUpdateConfiguredCompressionValues(options, ref updatedCompressionOptions); + if (status) + { + runtimeConfig = runtimeConfig! with { Runtime = runtimeConfig.Runtime! with { Compression = updatedCompressionOptions } }; + } + else + { + return false; + } + } + // Host: Mode, Cors.Origins, Cors.AllowCredentials, Authentication.Provider, Authentication.Jwt.Audience, Authentication.Jwt.Issuer if (options.RuntimeHostMode != null || options.RuntimeHostCorsOrigins != null || @@ -1197,6 +1212,37 @@ private static bool TryUpdateConfiguredCacheValues( } } + /// + /// Attempts to update the Config parameters in the Compression runtime settings based on the provided value. + /// Validates user-provided parameters and then returns true if the updated Compression options + /// need to be overwritten on the existing config parameters. + /// + /// options. + /// updatedCompressionOptions. + /// True if the value needs to be updated in the runtime config, else false + private static bool TryUpdateConfiguredCompressionValues( + ConfigureOptions options, + ref CompressionOptions updatedCompressionOptions) + { + try + { + // Runtime.Compression.Level + CompressionLevel? updatedValue = options?.RuntimeCompressionLevel; + if (updatedValue != null) + { + updatedCompressionOptions = updatedCompressionOptions with { Level = updatedValue.Value, UserProvidedLevel = true }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Compression.Level as '{updatedValue}'", updatedValue); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError("Failed to update RuntimeConfig.Compression with exception message: {exceptionMessage}.", ex.Message); + return false; + } + } + /// /// Attempts to update the Config parameters in the Host runtime settings based on the provided value. /// Validates that any user-provided parameter value is valid and then returns true if the updated Host options diff --git a/src/Config/Converters/CompressionOptionsConverterFactory.cs b/src/Config/Converters/CompressionOptionsConverterFactory.cs new file mode 100644 index 0000000000..0e129f7847 --- /dev/null +++ b/src/Config/Converters/CompressionOptionsConverterFactory.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// Defines how DAB reads and writes the compression options (JSON). +/// +internal class CompressionOptionsConverterFactory : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(CompressionOptions)); + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new CompressionOptionsConverter(); + } + + private class CompressionOptionsConverter : JsonConverter + { + /// + /// Defines how DAB reads the compression options and defines which values are + /// used to instantiate CompressionOptions. + /// + public override CompressionOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected start of object."); + } + + CompressionLevel level = CompressionOptions.DEFAULT_LEVEL; + bool userProvidedLevel = false; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType == JsonTokenType.PropertyName) + { + string? propertyName = reader.GetString(); + reader.Read(); + + if (string.Equals(propertyName, "level", StringComparison.OrdinalIgnoreCase)) + { + string? levelStr = reader.GetString(); + if (levelStr is not null && Enum.TryParse(levelStr, ignoreCase: true, out CompressionLevel parsedLevel)) + { + level = parsedLevel; + userProvidedLevel = true; + } + } + } + } + + return new CompressionOptions(level) with { UserProvidedLevel = userProvidedLevel }; + } + + /// + /// When writing the CompressionOptions back to a JSON file, only write the level + /// property and value when it was provided by the user. + /// + public override void Write(Utf8JsonWriter writer, CompressionOptions value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value is not null && value.UserProvidedLevel) + { + writer.WritePropertyName("level"); + writer.WriteStringValue(value.Level.ToString().ToLowerInvariant()); + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Config/ObjectModel/CompressionLevel.cs b/src/Config/ObjectModel/CompressionLevel.cs new file mode 100644 index 0000000000..f60a81c45f --- /dev/null +++ b/src/Config/ObjectModel/CompressionLevel.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Specifies the compression level for HTTP response compression. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CompressionLevel +{ + /// + /// Provides the best compression ratio at the cost of speed. + /// + Optimal, + + /// + /// Provides the fastest compression at the cost of compression ratio. + /// + Fastest, + + /// + /// Disables compression. + /// + None +} diff --git a/src/Config/ObjectModel/CompressionOptions.cs b/src/Config/ObjectModel/CompressionOptions.cs new file mode 100644 index 0000000000..c06f926673 --- /dev/null +++ b/src/Config/ObjectModel/CompressionOptions.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Configuration options for HTTP response compression. +/// +public record CompressionOptions +{ + /// + /// Default compression level is Optimal. + /// + public const CompressionLevel DEFAULT_LEVEL = CompressionLevel.Optimal; + + /// + /// The compression level to use for HTTP response compression. + /// + [JsonPropertyName("level")] + public CompressionLevel Level { get; init; } = DEFAULT_LEVEL; + + /// + /// Flag which informs CLI and JSON serializer whether to write Level + /// property and value to the runtime config file. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UserProvidedLevel { get; init; } = false; + + [JsonConstructor] + public CompressionOptions(CompressionLevel Level = DEFAULT_LEVEL) + { + this.Level = Level; + this.UserProvidedLevel = true; + } + + /// + /// Default parameterless constructor for cases where no compression level is specified. + /// + public CompressionOptions() + { + this.Level = DEFAULT_LEVEL; + this.UserProvidedLevel = false; + } +} diff --git a/src/Config/ObjectModel/RuntimeOptions.cs b/src/Config/ObjectModel/RuntimeOptions.cs index 6f6c046651..525ea8d089 100644 --- a/src/Config/ObjectModel/RuntimeOptions.cs +++ b/src/Config/ObjectModel/RuntimeOptions.cs @@ -17,6 +17,7 @@ public record RuntimeOptions public RuntimeCacheOptions? Cache { get; init; } public PaginationOptions? Pagination { get; init; } public RuntimeHealthCheckConfig? Health { get; init; } + public CompressionOptions? Compression { get; init; } [JsonConstructor] public RuntimeOptions( @@ -28,7 +29,8 @@ public RuntimeOptions( TelemetryOptions? Telemetry = null, RuntimeCacheOptions? Cache = null, PaginationOptions? Pagination = null, - RuntimeHealthCheckConfig? Health = null) + RuntimeHealthCheckConfig? Health = null, + CompressionOptions? Compression = null) { this.Rest = Rest; this.GraphQL = GraphQL; @@ -39,6 +41,7 @@ public RuntimeOptions( this.Cache = Cache; this.Pagination = Pagination; this.Health = Health; + this.Compression = Compression; } /// diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index bad5aa8680..7250618c5e 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -316,6 +316,7 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityCacheOptionsConverterFactory(replacementSettings)); options.Converters.Add(new RuntimeCacheOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheLevel2OptionsConverterFactory()); + options.Converters.Add(new CompressionOptionsConverterFactory()); options.Converters.Add(new MultipleCreateOptionsConverter()); options.Converters.Add(new MultipleMutationOptionsConverter(options)); options.Converters.Add(new DataSourceConverterFactory(replacementSettings)); diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 48a39d31d0..e5d4907f86 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -456,9 +456,54 @@ public void ConfigureServices(IServiceCollection services) services.AddDabMcpServer(configProvider); + // Add Response Compression services based on config + ConfigureResponseCompression(services, runtimeConfig); + services.AddControllers(); } + /// + /// Configures HTTP response compression based on the runtime configuration. + /// Compression is applied at the middleware level and supports Gzip and Brotli. + /// Applies to REST, GraphQL, and MCP endpoints. + /// + private void ConfigureResponseCompression(IServiceCollection services, RuntimeConfig? runtimeConfig) + { + CompressionLevel compressionLevel = runtimeConfig?.Runtime?.Compression?.Level ?? CompressionOptions.DEFAULT_LEVEL; + + // Only configure compression if level is not None + if (compressionLevel == CompressionLevel.None) + { + return; + } + + System.IO.Compression.CompressionLevel systemCompressionLevel = compressionLevel switch + { + CompressionLevel.Fastest => System.IO.Compression.CompressionLevel.Fastest, + CompressionLevel.Optimal => System.IO.Compression.CompressionLevel.Optimal, + _ => System.IO.Compression.CompressionLevel.Optimal + }; + + services.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + }); + + services.Configure(options => + { + options.Level = systemCompressionLevel; + }); + + services.Configure(options => + { + options.Level = systemCompressionLevel; + }); + + _logger.LogInformation("Response compression enabled with level '{compressionLevel}' for REST, GraphQL, and MCP endpoints.", compressionLevel); + } + /// /// Configure GraphQL services within the service collection of the /// request pipeline. @@ -604,6 +649,13 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC ); } + // Response compression middleware should be placed early in the pipeline. + // Only use if compression is not set to None. + if (runtimeConfig?.Runtime?.Compression?.Level is not CompressionLevel.None) + { + app.UseResponseCompression(); + } + // URL Rewrite middleware MUST be called prior to UseRouting(). // https://andrewlock.net/understanding-pathbase-in-aspnetcore/#placing-usepathbase-in-the-correct-location app.UseCorrelationIdMiddleware(); From 1a8cd138b87f5333fc17de9a7e31b0e497726f68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:42:08 +0000 Subject: [PATCH 3/3] Add validation for invalid compression levels Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> --- .../CompressionOptionsConverterFactory.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Config/Converters/CompressionOptionsConverterFactory.cs b/src/Config/Converters/CompressionOptionsConverterFactory.cs index 0e129f7847..64fddc69d4 100644 --- a/src/Config/Converters/CompressionOptionsConverterFactory.cs +++ b/src/Config/Converters/CompressionOptionsConverterFactory.cs @@ -60,10 +60,17 @@ private class CompressionOptionsConverter : JsonConverter if (string.Equals(propertyName, "level", StringComparison.OrdinalIgnoreCase)) { string? levelStr = reader.GetString(); - if (levelStr is not null && Enum.TryParse(levelStr, ignoreCase: true, out CompressionLevel parsedLevel)) + if (levelStr is not null) { - level = parsedLevel; - userProvidedLevel = true; + if (Enum.TryParse(levelStr, ignoreCase: true, out CompressionLevel parsedLevel)) + { + level = parsedLevel; + userProvidedLevel = true; + } + else + { + throw new JsonException($"Invalid compression level: '{levelStr}'. Valid values are: optimal, fastest, none."); + } } } }