diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index d6bdefc1b8..301470b84c 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -1,878 +1 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Diagnostics.CodeAnalysis; -using System.IO.Abstractions; -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; - -namespace Azure.DataApiBuilder.Config.ObjectModel; - -public record RuntimeConfig -{ - [JsonPropertyName("$schema")] - public string Schema { get; init; } - - 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 RuntimeOptions? Runtime { get; init; } - - [JsonPropertyName("azure-key-vault")] - public AzureKeyVaultOptions? AzureKeyVault { get; init; } - - public RuntimeAutoentities Autoentities { get; init; } - - public virtual RuntimeEntities Entities { get; init; } - - public DataSourceFiles? DataSourceFiles { get; init; } - - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool CosmosDataSourceUsed { get; private set; } - - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool SqlDataSourceUsed { get; private set; } - - /// - /// Retrieves the value of runtime.CacheEnabled property if present, default is false. - /// Caching is enabled only when explicitly set to true. - /// - /// Whether caching is globally enabled. - [JsonIgnore] - public bool IsCachingEnabled => - Runtime is not null && - Runtime.IsCachingEnabled; - - /// - /// Retrieves the value of runtime.rest.request-body-strict property if present, default is true. - /// - [JsonIgnore] - public bool IsRequestBodyStrict => - Runtime is null || - Runtime.Rest is null || - Runtime.Rest.RequestBodyStrict; - - /// - /// 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; - - /// - /// 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; - - /// - /// Retrieves the value of runtime.mcp.enabled property if present, default is true. - /// - [JsonIgnore] - public bool IsMcpEnabled => - Runtime is null || - Runtime.Mcp is null || - Runtime.Mcp.Enabled; - - [JsonIgnore] - public bool IsHealthEnabled => - Runtime is null || - Runtime.Health is null || - Runtime.Health.Enabled; - - /// - /// A shorthand method to determine whether Static Web Apps is configured for the current authentication provider. - /// - /// True if the authentication provider is enabled for Static Web Apps, otherwise false. - [JsonIgnore] - public bool IsStaticWebAppsIdentityProvider => - Runtime?.Host?.Authentication is not null && - EasyAuthType.StaticWebApps.ToString().Equals(Runtime.Host.Authentication.Provider, StringComparison.OrdinalIgnoreCase); - - /// - /// A shorthand method to determine whether App Service is configured for the current authentication provider. - /// - /// True if the authentication provider is enabled for App Service, otherwise false. - [JsonIgnore] - public bool IsAppServiceIdentityProvider => - Runtime?.Host?.Authentication is not null && - EasyAuthType.AppService.ToString().Equals(Runtime.Host.Authentication.Provider, StringComparison.OrdinalIgnoreCase); - - /// - /// A shorthand method to determine whether Unauthenticated is configured for the current authentication provider. - /// - /// True if the authentication provider is Unauthenticated (the default), otherwise false. - [JsonIgnore] - public bool IsUnauthenticatedIdentityProvider => - Runtime is null || - Runtime.Host is null || - Runtime.Host.Authentication is null || - AuthenticationOptions.UNAUTHENTICATED_AUTHENTICATION.Equals(Runtime.Host.Authentication.Provider, StringComparison.OrdinalIgnoreCase); - - /// - /// The path at which Rest APIs are available - /// - [JsonIgnore] - public string RestPath - { - get - { - if (Runtime is null || Runtime.Rest is null) - { - return RestRuntimeOptions.DEFAULT_PATH; - } - else - { - return Runtime.Rest.Path; - } - } - } - - /// - /// The path at which GraphQL API is available - /// - [JsonIgnore] - public string GraphQLPath - { - get - { - if (Runtime is null || Runtime.GraphQL is null) - { - return GraphQLRuntimeOptions.DEFAULT_PATH; - } - else - { - return Runtime.GraphQL.Path; - } - } - } - - /// - /// The path at which MCP API is available - /// - [JsonIgnore] - public string McpPath - { - get - { - if (Runtime is null || Runtime.Mcp is null || Runtime.Mcp.Path is null) - { - return McpRuntimeOptions.DEFAULT_PATH; - } - else - { - return Runtime.Mcp.Path; - } - } - } - - /// - /// Indicates whether introspection is allowed or not. - /// - [JsonIgnore] - public bool AllowIntrospection - { - get - { - return Runtime is null || - Runtime.GraphQL is null || - Runtime.GraphQL.AllowIntrospection; - } - } - - [JsonIgnore] - public string DefaultDataSourceName { get; set; } - - /// - /// Retrieves the value of runtime.graphql.aggregation.enabled property if present, default is true. - /// - [JsonIgnore] - public bool EnableAggregation => - Runtime is not null && - Runtime.GraphQL is not null && - Runtime.GraphQL.EnableAggregation; - - [JsonIgnore] - public HashSet AllowedRolesForHealth => - Runtime?.Health?.Roles ?? new HashSet(); - - [JsonIgnore] - public int CacheTtlSecondsForHealthReport => - Runtime?.Health?.CacheTtlSeconds ?? EntityCacheOptions.DEFAULT_TTL_SECONDS; - - /// - /// Retrieves the value of runtime.graphql.dwnto1joinopt.enabled property if present, default is false. - /// - [JsonIgnore] - public bool EnableDwNto1JoinOpt => - Runtime is not null && - Runtime.GraphQL is not null && - Runtime.GraphQL.FeatureFlags is not null && - Runtime.GraphQL.FeatureFlags.EnableDwNto1JoinQueryOptimization; - - private Dictionary _dataSourceNameToDataSource; - - private Dictionary _entityNameToDataSourceName = new(); - - private Dictionary _autoentityNameToDataSourceName = new(); - - private Dictionary _entityPathNameToEntityName = new(); - - /// - /// List of all datasources. - /// - /// List of datasources - public IEnumerable ListAllDataSources() - { - return _dataSourceNameToDataSource.Values; - } - - /// - /// Get Iterator to iterate over dictionary. - /// - public IEnumerable> GetDataSourceNamesToDataSourcesIterator() - { - return _dataSourceNameToDataSource.AsEnumerable(); - } - - public bool TryAddEntityPathNameToEntityName(string entityPathName, string entityName) - { - return _entityPathNameToEntityName.TryAdd(entityPathName, entityName); - } - - public bool TryGetEntityNameFromPath(string entityPathName, [NotNullWhen(true)] out string? entityName) - { - return _entityPathNameToEntityName.TryGetValue(entityPathName, out entityName); - } - - public bool TryAddEntityNameToDataSourceName(string entityName) - { - return _entityNameToDataSourceName.TryAdd(entityName, this.DefaultDataSourceName); - } - - public bool TryAddGeneratedAutoentityNameToDataSourceName(string entityName, string autoEntityDefinition) - { - if (_autoentityNameToDataSourceName.TryGetValue(autoEntityDefinition, out string? dataSourceName)) - { - return _entityNameToDataSourceName.TryAdd(entityName, dataSourceName); - } - - return false; - } - - public bool RemoveGeneratedAutoentityNameFromDataSourceName(string entityName) - { - return _entityNameToDataSourceName.Remove(entityName); - } - - /// - /// Constructor for runtimeConfig. - /// To be used when setting up from cli json scenario. - /// - /// schema for config. - /// Default datasource. - /// Entities - /// Runtime settings. - /// List of datasource files for multiple db scenario. Null for single db scenario. - [JsonConstructor] - public RuntimeConfig( - string? Schema, - DataSource DataSource, - RuntimeEntities Entities, - RuntimeAutoentities? Autoentities = null, - RuntimeOptions? Runtime = null, - DataSourceFiles? DataSourceFiles = null, - AzureKeyVaultOptions? AzureKeyVault = null) - { - this.Schema = Schema ?? DEFAULT_CONFIG_SCHEMA_LINK; - this.DataSource = DataSource; - this.Runtime = Runtime; - this.AzureKeyVault = AzureKeyVault; - this.Entities = Entities ?? new RuntimeEntities(new Dictionary()); - this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); - this.DefaultDataSourceName = Guid.NewGuid().ToString(); - - if (this.DataSource is null) - { - throw new DataApiBuilderException( - message: "data-source is a mandatory property in DAB Config", - statusCode: HttpStatusCode.UnprocessableEntity, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); - } - - // 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) - { - foreach (KeyValuePair entity in Entities) - { - _entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName); - } - } - - if (Autoentities is not null) - { - foreach (KeyValuePair autoentity in Autoentities) - { - _autoentityNameToDataSourceName.TryAdd(autoentity.Key, this.DefaultDataSourceName); - } - } - - // Process data source and entities information for each database in multiple database scenario. - this.DataSourceFiles = DataSourceFiles; - - if (DataSourceFiles is not null && DataSourceFiles.SourceFiles is not null) - { - IEnumerable>? allEntities = Entities?.AsEnumerable(); - IEnumerable>? allAutoentities = Autoentities?.AsEnumerable(); - // Iterate through all the datasource files and load the config. - IFileSystem fileSystem = new FileSystem(); - // This loader is not used as a part of hot reload and therefore does not need a handler. - FileSystemRuntimeConfigLoader loader = new(fileSystem, handler: null); - - foreach (string dataSourceFile in DataSourceFiles.SourceFiles) - { - // Use Ignore mode so missing env vars are left as literal @env() strings, - // consistent with how the parent config is loaded in TryLoadKnownConfig. - DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true, envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore); - - if (loader.TryLoadConfig(dataSourceFile, out RuntimeConfig? config, replacementSettings: replacementSettings)) - { - try - { - _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); - allEntities = allEntities?.Concat(config.Entities.AsEnumerable()); - allAutoentities = allAutoentities?.Concat(config.Autoentities.AsEnumerable()); - } - catch (Exception e) - { - // Errors could include duplicate datasource names, duplicate entity names, etc. - throw new DataApiBuilderException( - $"Error while loading datasource file {dataSourceFile} with exception {e.Message}", - HttpStatusCode.ServiceUnavailable, - DataApiBuilderException.SubStatusCodes.ConfigValidationError, - 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()); - this.Autoentities = new RuntimeAutoentities(allAutoentities != null ? allAutoentities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary()); - } - - SetupDataSourcesUsed(); - } - - /// - /// Constructor for runtimeConfig. - /// This constructor is to be used when dynamically setting up the config as opposed to using a cli json file. - /// - /// schema for config. - /// Default datasource. - /// Runtime settings. - /// Entities - /// Autoentities - /// List of datasource files for multiple db scenario.Null for single db scenario. - /// DefaultDataSourceName to maintain backward compatibility. - /// Dictionary mapping datasourceName to datasource object. - /// Dictionary mapping entityName to datasourceName. - /// Datasource files which represent list of child runtimeconfigs for multi-db scenario. - public RuntimeConfig(string Schema, DataSource DataSource, RuntimeOptions Runtime, RuntimeEntities Entities, string DefaultDataSourceName, Dictionary DataSourceNameToDataSource, Dictionary EntityNameToDataSourceName, DataSourceFiles? DataSourceFiles = null, AzureKeyVaultOptions? AzureKeyVault = null, RuntimeAutoentities? Autoentities = null) - { - this.Schema = Schema; - this.DataSource = DataSource; - this.Runtime = Runtime; - this.Entities = Entities; - this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); - this.DefaultDataSourceName = DefaultDataSourceName; - _dataSourceNameToDataSource = DataSourceNameToDataSource; - _entityNameToDataSourceName = EntityNameToDataSourceName; - this.DataSourceFiles = DataSourceFiles; - this.AzureKeyVault = AzureKeyVault; - - SetupDataSourcesUsed(); - } - - /// - /// Gets the DataSource corresponding to the datasourceName. - /// - /// Name of datasource. - /// DataSource object. - /// Not found exception if key is not found. - public virtual DataSource GetDataSourceFromDataSourceName(string dataSourceName) - { - CheckDataSourceNamePresent(dataSourceName); - return _dataSourceNameToDataSource[dataSourceName]; - } - - /// - /// Updates the DataSourceNameToDataSource dictionary with the new datasource. - /// - /// Name of datasource - /// Updated datasource value. - /// Not found exception if key is not found. - public void UpdateDataSourceNameToDataSource(string dataSourceName, DataSource dataSource) - { - CheckDataSourceNamePresent(dataSourceName); - _dataSourceNameToDataSource[dataSourceName] = dataSource; - } - - /// - /// In a Hot Reload scenario we should maintain the same default data source - /// name before the hot reload as after the hot reload. This is because we hold - /// references to the Data Source itself which depend on this data source name - /// for lookups. To correctly retrieve this information after a hot reload - /// we need the data source name to stay the same after hot reloading. This method takes - /// a default data source name, such as the one from before hot reload, and - /// replaces the current dictionary entries of this RuntimeConfig that were - /// built using a new, unique guid during the construction of this RuntimeConfig - /// with entries using the provided default data source name. We then update the DefaultDataSourceName. - /// - /// The name used to update the dictionaries. - public void UpdateDefaultDataSourceName(string initialDefaultDataSourceName) - { - _dataSourceNameToDataSource.Remove(DefaultDataSourceName); - 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 - // to be extra safe. - throw new DataApiBuilderException( - message: $"Duplicate data source name: {initialDefaultDataSourceName}.", - statusCode: HttpStatusCode.InternalServerError, - subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError); - } - - foreach (KeyValuePair entity in Entities) - { - _entityNameToDataSourceName[entity.Key] = initialDefaultDataSourceName; - } - - DefaultDataSourceName = initialDefaultDataSourceName; - } - - /// - /// Gets datasourceName from EntityNameToDatasourceName dictionary. - /// - /// entityName - /// DataSourceName - public string GetDataSourceNameFromEntityName(string entityName) - { - CheckEntityNamePresent(entityName); - return _entityNameToDataSourceName[entityName]; - } - - /// - /// Gets datasource using entityName. - /// - /// entityName. - /// DataSource using EntityName. - public DataSource GetDataSourceFromEntityName(string entityName) - { - CheckEntityNamePresent(entityName); - return _dataSourceNameToDataSource[_entityNameToDataSourceName[entityName]]; - } - - /// - /// Gets datasourceName from AutoentityNameToDatasourceName dictionary. - /// - /// autoentityName - /// DataSourceName - public string GetDataSourceNameFromAutoentityName(string autoentityName) - { - if (!_autoentityNameToDataSourceName.TryGetValue(autoentityName, out string? autoentityDataSource)) - { - throw new DataApiBuilderException( - message: $"'{autoentityName}' is not a valid autoentities definition.", - statusCode: HttpStatusCode.NotFound, - subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); - } - - return autoentityDataSource; - } - - /// - /// Validates if datasource is present in runtimeConfig. - /// - public bool CheckDataSourceExists(string dataSourceName) - { - return _dataSourceNameToDataSource.ContainsKey(dataSourceName); - } - - /// - /// Serializes the RuntimeConfig object to JSON for writing to file. - /// - /// - public string ToJson(JsonSerializerOptions? jsonSerializerOptions = null) - { - // get default serializer options if none provided. - jsonSerializerOptions = jsonSerializerOptions ?? RuntimeConfigLoader.GetSerializationOptions(replacementSettings: null); - return JsonSerializer.Serialize(this, jsonSerializerOptions); - } - - public bool IsDevelopmentMode() => - Runtime is not null && Runtime.Host is not null - && Runtime.Host.Mode is HostMode.Development; - - /// - /// Returns the ttl-seconds value for a given entity. - /// If the entity explicitly sets ttl-seconds, that value is used. - /// Otherwise, falls back to the global cache TTL setting. - /// Callers are responsible for checking whether caching is enabled before using the result. - /// - /// Name of the entity to check cache configuration. - /// Number of seconds (ttl) that a cache entry should be valid before cache eviction. - /// Raised when an invalid entity name is provided. - public virtual int GetEntityCacheEntryTtl(string entityName) - { - if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) - { - throw new DataApiBuilderException( - message: $"{entityName} is not a valid entity.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); - } - - if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedTtlOptions) - { - return entityConfig.Cache.TtlSeconds.Value; - } - - return GlobalCacheEntryTtl(); - } - - /// - /// Returns the cache level value for a given entity. - /// If the entity explicitly sets level, that value is used. - /// Otherwise, falls back to the global cache level or the default. - /// Callers are responsible for checking whether caching is enabled before using the result. - /// - /// Name of the entity to check cache configuration. - /// Cache level that a cache entry should be stored in. - /// Raised when an invalid entity name is provided. - public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) - { - if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) - { - throw new DataApiBuilderException( - message: $"{entityName} is not a valid entity.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); - } - - if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedLevelOptions) - { - return entityConfig.Cache.Level.Value; - } - - // GlobalCacheEntryLevel() returns null when runtime cache is not configured. - // Default to L1 to match EntityCacheOptions.DEFAULT_LEVEL. - return GlobalCacheEntryLevel() ?? EntityCacheOptions.DEFAULT_LEVEL; - } - - /// - /// Returns the ttl-seconds value for the global cache entry. - /// If no value is explicitly set, returns the global default value. - /// - /// Number of seconds a cache entry should be valid before cache eviction. - public virtual int GlobalCacheEntryTtl() - { - return Runtime is not null && Runtime.IsCachingEnabled && Runtime.Cache.UserProvidedTtlOptions - ? Runtime.Cache.TtlSeconds.Value - : EntityCacheOptions.DEFAULT_TTL_SECONDS; - } - - /// - /// Returns the cache level value for the global cache entry. - /// The level is inferred from the runtime cache Level2 configuration: - /// if Level2 is enabled, the level is L1L2; otherwise L1. - /// Returns null when runtime cache is not configured. - /// - /// Cache level for a cache entry, or null if runtime cache is not configured. - public virtual EntityCacheLevel? GlobalCacheEntryLevel() - { - return Runtime?.Cache?.InferredLevel; - } - - /// - /// Whether the caching service should be used for a given operation. This is determined by - /// - whether caching is enabled globally - /// - whether the datasource is SQL and session context is disabled. - /// - /// Whether cache operations should proceed. - public virtual bool CanUseCache() - { - bool setSessionContextEnabled = DataSource.GetTypedOptions()?.SetSessionContext ?? true; - return IsCachingEnabled && !setSessionContextEnabled; - } - - private void CheckDataSourceNamePresent(string dataSourceName) - { - if (!_dataSourceNameToDataSource.ContainsKey(dataSourceName)) - { - throw new DataApiBuilderException($"{nameof(dataSourceName)}:{dataSourceName} could not be found within the config", HttpStatusCode.BadRequest, DataApiBuilderException.SubStatusCodes.DataSourceNotFound); - } - } - - private void CheckEntityNamePresent(string entityName) - { - if (!_entityNameToDataSourceName.ContainsKey(entityName)) - { - throw new DataApiBuilderException( - message: $"{entityName} is not a valid entity.", - statusCode: HttpStatusCode.NotFound, - subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); - } - } - - private void SetupDataSourcesUsed() - { - SqlDataSourceUsed = _dataSourceNameToDataSource.Values.Any - (x => x.DatabaseType is DatabaseType.MSSQL || x.DatabaseType is DatabaseType.PostgreSQL || x.DatabaseType is DatabaseType.MySQL || x.DatabaseType is DatabaseType.DWSQL); - - CosmosDataSourceUsed = _dataSourceNameToDataSource.Values.Any - (x => x.DatabaseType is DatabaseType.CosmosDB_NoSQL); - } - - /// - /// Handles the logic for determining if we are in a scenario where hot reload is possible. - /// Hot reload is currently not available, and so this will always return false. When hot reload - /// becomes an available feature this logic will change to reflect the correct state based on - /// the state of the runtime config and any other relevant factors. - /// - /// True in a scenario that support hot reload, false otherwise. - public static bool IsHotReloadable() - { - // always return false while hot reload is not an available feature. - return false; - } - - /// - /// Helper method to check if multiple create option is supported and enabled. - /// - /// Returns true when - /// 1. Multiple create operation is supported by the database type and - /// 2. Multiple create operation is enabled in the runtime config. - /// - /// - public bool IsMultipleCreateOperationEnabled() - { - 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 && - Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions is not null && - Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions.Enabled); - } - - public uint DefaultPageSize() - { - return (uint?)Runtime?.Pagination?.DefaultPageSize ?? PaginationOptions.DEFAULT_PAGE_SIZE; - } - - public uint MaxPageSize() - { - return (uint?)Runtime?.Pagination?.MaxPageSize ?? PaginationOptions.MAX_PAGE_SIZE; - } - - public bool NextLinkRelative() - { - return Runtime?.Pagination?.NextLinkRelative ?? false; - } - - public int MaxResponseSizeMB() - { - return Runtime?.Host?.MaxResponseSizeMB ?? HostOptions.MAX_RESPONSE_LENGTH_DAB_ENGINE_MB; - } - - public bool MaxResponseSizeLogicEnabled() - { - // If the user has provided a max response size, we should use new logic to enforce it. - return Runtime?.Host?.UserProvidedMaxResponseSizeMB ?? false; - } - - /// - /// Get the pagination limit from the runtime configuration. - /// - /// The pagination input from the user. Example: $first=10 - /// - /// - public uint GetPaginationLimit(int? first) - { - uint defaultPageSize = this.DefaultPageSize(); - uint maxPageSize = this.MaxPageSize(); - - if (first is not null) - { - if (first < -1 || first == 0 || first > maxPageSize) - { - throw new DataApiBuilderException( - message: $"Invalid number of items requested, {nameof(first)} argument must be either -1 or a positive number within the max page size limit of {maxPageSize}. Actual value: {first}", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); - } - else - { - return (first == -1 ? maxPageSize : (uint)first); - } - } - else - { - return defaultPageSize; - } - } - - /// - /// Checks if the property log-level or its value are null - /// - public bool IsLogLevelNull() - { - if (Runtime is null || - Runtime.Telemetry is null || - Runtime.Telemetry.LoggerLevel is null || - Runtime.Telemetry.LoggerLevel.Count == 0) - { - return true; - } - - foreach (KeyValuePair logger in Runtime!.Telemetry.LoggerLevel) - { - if (logger.Key == null) - { - return true; - } - } - - return false; - } - - /// - /// Takes in the RuntimeConfig object and checks the LogLevel. - /// If LogLevel is not null, it will return the current value as a LogLevel, - /// else it will take the default option by checking host mode. - /// If host mode is Development, return `LogLevel.Debug`, else - /// for production returns `LogLevel.Error`. - /// - public LogLevel GetConfiguredLogLevel(string loggerFilter = "") - { - if (!IsLogLevelNull()) - { - int max = 0; - string currentFilter = string.Empty; - foreach (KeyValuePair logger in Runtime!.Telemetry!.LoggerLevel!) - { - // Checks if the new key that is valid has more priority than the current key - if (logger.Key.Length > max && loggerFilter.StartsWith(logger.Key)) - { - max = logger.Key.Length; - currentFilter = logger.Key; - } - } - - Runtime!.Telemetry!.LoggerLevel!.TryGetValue(currentFilter, out LogLevel? value); - if (value is not null) - { - return (LogLevel)value; - } - - value = Runtime!.Telemetry!.LoggerLevel! - .SingleOrDefault(kvp => kvp.Key.Equals("default", StringComparison.OrdinalIgnoreCase)).Value; - if (value is not null) - { - return (LogLevel)value; - } - } - - if (IsDevelopmentMode()) - { - return LogLevel.Debug; - } - - return LogLevel.Error; - } - - /// - /// Gets the MCP DML tools configuration - /// - [JsonIgnore] - public DmlToolsConfig? McpDmlTools => Runtime?.Mcp?.DmlTools; - - /// - /// Determines whether caching is enabled for a given entity, resolving inheritance lazily. - /// If the entity explicitly sets cache enabled/disabled, that value wins. - /// If the entity has a cache object but did not explicitly set enabled (UserProvidedEnabledOptions is false), - /// the global runtime cache enabled setting is inherited. - /// If the entity has no cache config at all, the global runtime cache enabled setting is inherited. - /// - /// Name of the entity to check cache configuration. - /// Whether caching is enabled for the entity. - /// Raised when an invalid entity name is provided. - public virtual bool IsEntityCachingEnabled(string entityName) - { - if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) - { - throw new DataApiBuilderException( - message: $"{entityName} is not a valid entity.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); - } - - return IsEntityCachingEnabled(entityConfig); - } - - /// - /// Determines whether caching is enabled for a given entity, resolving inheritance lazily. - /// If the entity explicitly sets cache enabled/disabled (UserProvidedEnabledOptions is true), that value wins. - /// Otherwise, inherits the global runtime cache enabled setting. - /// - /// The entity to check cache configuration. - /// Whether caching is enabled for the entity. - private bool IsEntityCachingEnabled(Entity entity) - { - // If entity has an explicit cache config with user-provided enabled value, use it. - if (entity.Cache is not null && entity.Cache.UserProvidedEnabledOptions) - { - return entity.IsCachingEnabled; - } - - // Otherwise, inherit from the global runtime cache setting. - return IsCachingEnabled; - } -} +// Copyright (c) Microsoft Corporation.// Licensed under the MIT License.using System.Diagnostics.CodeAnalysis;using System.IO.Abstractions;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;namespace Azure.DataApiBuilder.Config.ObjectModel;public record RuntimeConfig{ [JsonPropertyName("$schema")] public string Schema { get; init; } 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 RuntimeOptions? Runtime { get; init; } [JsonPropertyName("azure-key-vault")] public AzureKeyVaultOptions? AzureKeyVault { get; init; } public RuntimeAutoentities Autoentities { get; init; } public virtual RuntimeEntities Entities { get; init; } public DataSourceFiles? DataSourceFiles { get; init; } [JsonIgnore(Condition = JsonIgnoreCondition.Always)] public bool CosmosDataSourceUsed { get; private set; } [JsonIgnore(Condition = JsonIgnoreCondition.Always)] public bool SqlDataSourceUsed { get; private set; } /// /// Retrieves the value of runtime.CacheEnabled property if present, default is false. /// Caching is enabled only when explicitly set to true. /// /// Whether caching is globally enabled. [JsonIgnore] public bool IsCachingEnabled => Runtime is not null && Runtime.IsCachingEnabled; /// /// Retrieves the value of runtime.rest.request-body-strict property if present, default is true. /// [JsonIgnore] public bool IsRequestBodyStrict => Runtime is null || Runtime.Rest is null || Runtime.Rest.RequestBodyStrict; /// /// 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; /// /// 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; /// /// Retrieves the value of runtime.mcp.enabled property if present, default is true. /// [JsonIgnore] public bool IsMcpEnabled => Runtime is null || Runtime.Mcp is null || Runtime.Mcp.Enabled; [JsonIgnore] public bool IsHealthEnabled => Runtime is null || Runtime.Health is null || Runtime.Health.Enabled; /// /// A shorthand method to determine whether Static Web Apps is configured for the current authentication provider. /// /// True if the authentication provider is enabled for Static Web Apps, otherwise false. [JsonIgnore] public bool IsStaticWebAppsIdentityProvider => Runtime?.Host?.Authentication is not null && EasyAuthType.StaticWebApps.ToString().Equals(Runtime.Host.Authentication.Provider, StringComparison.OrdinalIgnoreCase); /// /// A shorthand method to determine whether App Service is configured for the current authentication provider. /// /// True if the authentication provider is enabled for App Service, otherwise false. [JsonIgnore] public bool IsAppServiceIdentityProvider => Runtime?.Host?.Authentication is not null && EasyAuthType.AppService.ToString().Equals(Runtime.Host.Authentication.Provider, StringComparison.OrdinalIgnoreCase); /// /// A shorthand method to determine whether Unauthenticated is configured for the current authentication provider. /// /// True if the authentication provider is Unauthenticated (the default), otherwise false. [JsonIgnore] public bool IsUnauthenticatedIdentityProvider => Runtime is null || Runtime.Host is null || Runtime.Host.Authentication is null || AuthenticationOptions.UNAUTHENTICATED_AUTHENTICATION.Equals(Runtime.Host.Authentication.Provider, StringComparison.OrdinalIgnoreCase); /// /// The path at which Rest APIs are available /// [JsonIgnore] public string RestPath { get { if (Runtime is null || Runtime.Rest is null) { return RestRuntimeOptions.DEFAULT_PATH; } else { return Runtime.Rest.Path; } } } /// /// The path at which GraphQL API is available /// [JsonIgnore] public string GraphQLPath { get { if (Runtime is null || Runtime.GraphQL is null) { return GraphQLRuntimeOptions.DEFAULT_PATH; } else { return Runtime.GraphQL.Path; } } } /// /// The path at which MCP API is available /// [JsonIgnore] public string McpPath { get { if (Runtime is null || Runtime.Mcp is null || Runtime.Mcp.Path is null) { return McpRuntimeOptions.DEFAULT_PATH; } else { return Runtime.Mcp.Path; } } } /// /// Indicates whether introspection is allowed or not. /// [JsonIgnore] public bool AllowIntrospection { get { return Runtime is null || Runtime.GraphQL is null || Runtime.GraphQL.AllowIntrospection; } } [JsonIgnore] public string DefaultDataSourceName { get; set; } /// /// Retrieves the value of runtime.graphql.aggregation.enabled property if present, default is true. /// [JsonIgnore] public bool EnableAggregation => Runtime is not null && Runtime.GraphQL is not null && Runtime.GraphQL.EnableAggregation; [JsonIgnore] public HashSet AllowedRolesForHealth => Runtime?.Health?.Roles ?? new HashSet(); [JsonIgnore] public int CacheTtlSecondsForHealthReport => Runtime?.Health?.CacheTtlSeconds ?? EntityCacheOptions.DEFAULT_TTL_SECONDS; /// /// Retrieves the value of runtime.graphql.dwnto1joinopt.enabled property if present, default is false. /// [JsonIgnore] public bool EnableDwNto1JoinOpt => Runtime is not null && Runtime.GraphQL is not null && Runtime.GraphQL.FeatureFlags is not null && Runtime.GraphQL.FeatureFlags.EnableDwNto1JoinQueryOptimization; private Dictionary _dataSourceNameToDataSource; private Dictionary _entityNameToDataSourceName = new(); private Dictionary _autoentityNameToDataSourceName = new(); private Dictionary _entityPathNameToEntityName = new(); /// /// List of all datasources. /// /// List of datasources public IEnumerable ListAllDataSources() { return _dataSourceNameToDataSource.Values; } /// /// Get Iterator to iterate over dictionary. /// public IEnumerable> GetDataSourceNamesToDataSourcesIterator() { return _dataSourceNameToDataSource.AsEnumerable(); } public bool TryAddEntityPathNameToEntityName(string entityPathName, string entityName) { return _entityPathNameToEntityName.TryAdd(entityPathName, entityName); } public bool TryGetEntityNameFromPath(string entityPathName, [NotNullWhen(true)] out string? entityName) { return _entityPathNameToEntityName.TryGetValue(entityPathName, out entityName); } public bool TryAddEntityNameToDataSourceName(string entityName) { return _entityNameToDataSourceName.TryAdd(entityName, this.DefaultDataSourceName); } public bool TryAddGeneratedAutoentityNameToDataSourceName(string entityName, string autoEntityDefinition) { if (_autoentityNameToDataSourceName.TryGetValue(autoEntityDefinition, out string? dataSourceName)) { return _entityNameToDataSourceName.TryAdd(entityName, dataSourceName); } return false; } public bool RemoveGeneratedAutoentityNameFromDataSourceName(string entityName) { return _entityNameToDataSourceName.Remove(entityName); } /// /// Constructor for runtimeConfig. /// To be used when setting up from cli json scenario. /// /// schema for config. /// Default datasource. /// Entities /// Runtime settings. /// List of datasource files for multiple db scenario. Null for single db scenario. [JsonConstructor] public RuntimeConfig( string? Schema, DataSource DataSource, RuntimeEntities Entities, RuntimeAutoentities? Autoentities = null, RuntimeOptions? Runtime = null, DataSourceFiles? DataSourceFiles = null, AzureKeyVaultOptions? AzureKeyVault = null) { this.Schema = Schema ?? DEFAULT_CONFIG_SCHEMA_LINK; this.DataSource = DataSource; this.Runtime = Runtime; this.AzureKeyVault = AzureKeyVault; this.Entities = Entities ?? new RuntimeEntities(new Dictionary()); this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); this.DefaultDataSourceName = Guid.NewGuid().ToString(); if (this.DataSource is null) { throw new DataApiBuilderException( message: "data-source is a mandatory property in DAB Config", statusCode: HttpStatusCode.UnprocessableEntity, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); } // 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) { foreach (KeyValuePair entity in Entities) { _entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName); } } if (Autoentities is not null) { foreach (KeyValuePair autoentity in Autoentities) { _autoentityNameToDataSourceName.TryAdd(autoentity.Key, this.DefaultDataSourceName); } } // Process data source and entities information for each database in multiple database scenario. this.DataSourceFiles = DataSourceFiles; if (DataSourceFiles is not null && DataSourceFiles.SourceFiles is not null) { IEnumerable>? allEntities = Entities?.AsEnumerable(); IEnumerable>? allAutoentities = Autoentities?.AsEnumerable(); // Iterate through all the datasource files and load the config. IFileSystem fileSystem = new FileSystem(); // This loader is not used as a part of hot reload and therefore does not need a handler. FileSystemRuntimeConfigLoader loader = new(fileSystem, handler: null); // Pass the parent's AKV options so @akv() references in child configs can // be resolved using the parent's Key Vault configuration. // If a child config defines its own azure-key-vault section, TryParseConfig's // ExtractAzureKeyVaultOptions will detect it and override these parent options. DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: this.AzureKeyVault, doReplaceEnvVar: true, doReplaceAkvVar: true, envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore); foreach (string dataSourceFile in DataSourceFiles.SourceFiles) { if (loader.TryLoadConfig(dataSourceFile, out RuntimeConfig? config, replacementSettings: replacementSettings)) { try { _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); allEntities = allEntities?.Concat(config.Entities.AsEnumerable()); allAutoentities = allAutoentities?.Concat(config.Autoentities.AsEnumerable()); } catch (Exception e) { // Errors could include duplicate datasource names, duplicate entity names, etc. throw new DataApiBuilderException( $"Error while loading datasource file {dataSourceFile} with exception {e.Message}", HttpStatusCode.ServiceUnavailable, DataApiBuilderException.SubStatusCodes.ConfigValidationError, e.InnerException); } } } this.Entities = new RuntimeEntities(allEntities != null ? allEntities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary()); this.Autoentities = new RuntimeAutoentities(allAutoentities != null ? allAutoentities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary()); } SetupDataSourcesUsed(); } /// /// Constructor for runtimeConfig. /// This constructor is to be used when dynamically setting up the config as opposed to using a cli json file. /// /// schema for config. /// Default datasource. /// Runtime settings. /// Entities /// Autoentities /// List of datasource files for multiple db scenario.Null for single db scenario. /// DefaultDataSourceName to maintain backward compatibility. /// Dictionary mapping datasourceName to datasource object. /// Dictionary mapping entityName to datasourceName. /// Datasource files which represent list of child runtimeconfigs for multi-db scenario. public RuntimeConfig(string Schema, DataSource DataSource, RuntimeOptions Runtime, RuntimeEntities Entities, string DefaultDataSourceName, Dictionary DataSourceNameToDataSource, Dictionary EntityNameToDataSourceName, DataSourceFiles? DataSourceFiles = null, AzureKeyVaultOptions? AzureKeyVault = null, RuntimeAutoentities? Autoentities = null) { this.Schema = Schema; this.DataSource = DataSource; this.Runtime = Runtime; this.Entities = Entities; this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); this.DefaultDataSourceName = DefaultDataSourceName; _dataSourceNameToDataSource = DataSourceNameToDataSource; _entityNameToDataSourceName = EntityNameToDataSourceName; this.DataSourceFiles = DataSourceFiles; this.AzureKeyVault = AzureKeyVault; SetupDataSourcesUsed(); } /// /// Gets the DataSource corresponding to the datasourceName. /// /// Name of datasource. /// DataSource object. /// Not found exception if key is not found. public virtual DataSource GetDataSourceFromDataSourceName(string dataSourceName) { CheckDataSourceNamePresent(dataSourceName); return _dataSourceNameToDataSource[dataSourceName]; } /// /// Updates the DataSourceNameToDataSource dictionary with the new datasource. /// /// Name of datasource /// Updated datasource value. /// Not found exception if key is not found. public void UpdateDataSourceNameToDataSource(string dataSourceName, DataSource dataSource) { CheckDataSourceNamePresent(dataSourceName); _dataSourceNameToDataSource[dataSourceName] = dataSource; } /// /// In a Hot Reload scenario we should maintain the same default data source /// name before the hot reload as after the hot reload. This is because we hold /// references to the Data Source itself which depend on this data source name /// for lookups. To correctly retrieve this information after a hot reload /// we need the data source name to stay the same after hot reloading. This method takes /// a default data source name, such as the one from before hot reload, and /// replaces the current dictionary entries of this RuntimeConfig that were /// built using a new, unique guid during the construction of this RuntimeConfig /// with entries using the provided default data source name. We then update the DefaultDataSourceName. /// /// The name used to update the dictionaries. public void UpdateDefaultDataSourceName(string initialDefaultDataSourceName) { _dataSourceNameToDataSource.Remove(DefaultDataSourceName); 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 // to be extra safe. throw new DataApiBuilderException( message: $"Duplicate data source name: {initialDefaultDataSourceName}.", statusCode: HttpStatusCode.InternalServerError, subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError); } foreach (KeyValuePair entity in Entities) { _entityNameToDataSourceName[entity.Key] = initialDefaultDataSourceName; } DefaultDataSourceName = initialDefaultDataSourceName; } /// /// Gets datasourceName from EntityNameToDatasourceName dictionary. /// /// entityName /// DataSourceName public string GetDataSourceNameFromEntityName(string entityName) { CheckEntityNamePresent(entityName); return _entityNameToDataSourceName[entityName]; } /// /// Gets datasource using entityName. /// /// entityName. /// DataSource using EntityName. public DataSource GetDataSourceFromEntityName(string entityName) { CheckEntityNamePresent(entityName); return _dataSourceNameToDataSource[_entityNameToDataSourceName[entityName]]; } /// /// Gets datasourceName from AutoentityNameToDatasourceName dictionary. /// /// autoentityName /// DataSourceName public string GetDataSourceNameFromAutoentityName(string autoentityName) { if (!_autoentityNameToDataSourceName.TryGetValue(autoentityName, out string? autoentityDataSource)) { throw new DataApiBuilderException( message: $"{autoentityName} is not a valid autoentity.", statusCode: HttpStatusCode.NotFound, subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } return autoentityDataSource; } /// /// Validates if datasource is present in runtimeConfig. /// public bool CheckDataSourceExists(string dataSourceName) { return _dataSourceNameToDataSource.ContainsKey(dataSourceName); } /// /// Serializes the RuntimeConfig object to JSON for writing to file. /// /// public string ToJson(JsonSerializerOptions? jsonSerializerOptions = null) { // get default serializer options if none provided. jsonSerializerOptions = jsonSerializerOptions ?? RuntimeConfigLoader.GetSerializationOptions(replacementSettings: null); return JsonSerializer.Serialize(this, jsonSerializerOptions); } public bool IsDevelopmentMode() => Runtime is not null && Runtime.Host is not null && Runtime.Host.Mode is HostMode.Development; /// /// Returns the ttl-seconds value for a given entity. /// If the entity explicitly sets ttl-seconds, that value is used. /// Otherwise, falls back to the global cache TTL setting. /// Callers are responsible for checking whether caching is enabled before using the result. /// /// Name of the entity to check cache configuration. /// Number of seconds (ttl) that a cache entry should be valid before cache eviction. /// Raised when an invalid entity name is provided. public virtual int GetEntityCacheEntryTtl(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) { throw new DataApiBuilderException( message: $"{entityName} is not a valid entity.", statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedTtlOptions) { return entityConfig.Cache.TtlSeconds.Value; } return GlobalCacheEntryTtl(); } /// /// Returns the cache level value for a given entity. /// If the entity explicitly sets level, that value is used. /// Otherwise, falls back to the global cache level or the default. /// Callers are responsible for checking whether caching is enabled before using the result. /// /// Name of the entity to check cache configuration. /// Cache level that a cache entry should be stored in. /// Raised when an invalid entity name is provided. public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) { throw new DataApiBuilderException( message: $"{entityName} is not a valid entity.", statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedLevelOptions) { return entityConfig.Cache.Level.Value; } // GlobalCacheEntryLevel() returns null when runtime cache is not configured. // Default to L1 to match EntityCacheOptions.DEFAULT_LEVEL. return GlobalCacheEntryLevel() ?? EntityCacheOptions.DEFAULT_LEVEL; } /// /// Returns the ttl-seconds value for the global cache entry. /// If no value is explicitly set, returns the global default value. /// /// Number of seconds a cache entry should be valid before cache eviction. public virtual int GlobalCacheEntryTtl() { return Runtime is not null && Runtime.IsCachingEnabled && Runtime.Cache.UserProvidedTtlOptions ? Runtime.Cache.TtlSeconds.Value : EntityCacheOptions.DEFAULT_TTL_SECONDS; } /// /// Returns the cache level value for the global cache entry. /// The level is inferred from the runtime cache Level2 configuration: /// if Level2 is enabled, the level is L1L2; otherwise L1. /// Returns null when runtime cache is not configured. /// /// Cache level for a cache entry, or null if runtime cache is not configured. public virtual EntityCacheLevel? GlobalCacheEntryLevel() { return Runtime?.Cache?.InferredLevel; } /// /// Whether the caching service should be used for a given operation. This is determined by /// - whether caching is enabled globally /// - whether the datasource is SQL and session context is disabled. /// /// Whether cache operations should proceed. public virtual bool CanUseCache() { bool setSessionContextEnabled = DataSource.GetTypedOptions()?.SetSessionContext ?? true; return IsCachingEnabled && !setSessionContextEnabled; } private void CheckDataSourceNamePresent(string dataSourceName) { if (!_dataSourceNameToDataSource.ContainsKey(dataSourceName)) { throw new DataApiBuilderException($"{nameof(dataSourceName)}:{dataSourceName} could not be found within the config", HttpStatusCode.BadRequest, DataApiBuilderException.SubStatusCodes.DataSourceNotFound); } } private void CheckEntityNamePresent(string entityName) { if (!_entityNameToDataSourceName.ContainsKey(entityName)) { throw new DataApiBuilderException( message: $"{entityName} is not a valid entity.", statusCode: HttpStatusCode.NotFound, subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } } private void SetupDataSourcesUsed() { SqlDataSourceUsed = _dataSourceNameToDataSource.Values.Any (x => x.DatabaseType is DatabaseType.MSSQL || x.DatabaseType is DatabaseType.PostgreSQL || x.DatabaseType is DatabaseType.MySQL || x.DatabaseType is DatabaseType.DWSQL); CosmosDataSourceUsed = _dataSourceNameToDataSource.Values.Any (x => x.DatabaseType is DatabaseType.CosmosDB_NoSQL); } /// /// Handles the logic for determining if we are in a scenario where hot reload is possible. /// Hot reload is currently not available, and so this will always return false. When hot reload /// becomes an available feature this logic will change to reflect the correct state based on /// the state of the runtime config and any other relevant factors. /// /// True in a scenario that support hot reload, false otherwise. public static bool IsHotReloadable() { // always return false while hot reload is not an available feature. return false; } /// /// Helper method to check if multiple create option is supported and enabled. /// /// Returns true when /// 1. Multiple create operation is supported by the database type and /// 2. Multiple create operation is enabled in the runtime config. /// /// public bool IsMultipleCreateOperationEnabled() { 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 && Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions is not null && Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions.Enabled); } public uint DefaultPageSize() { return (uint?)Runtime?.Pagination?.DefaultPageSize ?? PaginationOptions.DEFAULT_PAGE_SIZE; } public uint MaxPageSize() { return (uint?)Runtime?.Pagination?.MaxPageSize ?? PaginationOptions.MAX_PAGE_SIZE; } public bool NextLinkRelative() { return Runtime?.Pagination?.NextLinkRelative ?? false; } public int MaxResponseSizeMB() { return Runtime?.Host?.MaxResponseSizeMB ?? HostOptions.MAX_RESPONSE_LENGTH_DAB_ENGINE_MB; } public bool MaxResponseSizeLogicEnabled() { // If the user has provided a max response size, we should use new logic to enforce it. return Runtime?.Host?.UserProvidedMaxResponseSizeMB ?? false; } /// /// Get the pagination limit from the runtime configuration. /// /// The pagination input from the user. Example: $first=10 /// /// public uint GetPaginationLimit(int? first) { uint defaultPageSize = this.DefaultPageSize(); uint maxPageSize = this.MaxPageSize(); if (first is not null) { if (first < -1 || first == 0 || first > maxPageSize) { throw new DataApiBuilderException( message: $"Invalid number of items requested, {nameof(first)} argument must be either -1 or a positive number within the max page size limit of {maxPageSize}. Actual value: {first}", statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } else { return (first == -1 ? maxPageSize : (uint)first); } } else { return defaultPageSize; } } /// /// Checks if the property log-level or its value are null /// public bool IsLogLevelNull() { if (Runtime is null || Runtime.Telemetry is null || Runtime.Telemetry.LoggerLevel is null || Runtime.Telemetry.LoggerLevel.Count == 0) { return true; } foreach (KeyValuePair logger in Runtime!.Telemetry.LoggerLevel) { if (logger.Key == null) { return true; } } return false; } /// /// Takes in the RuntimeConfig object and checks the LogLevel. /// If LogLevel is not null, it will return the current value as a LogLevel, /// else it will take the default option by checking host mode. /// If host mode is Development, return `LogLevel.Debug`, else /// for production returns `LogLevel.Error`. /// public LogLevel GetConfiguredLogLevel(string loggerFilter = "") { if (!IsLogLevelNull()) { int max = 0; string currentFilter = string.Empty; foreach (KeyValuePair logger in Runtime!.Telemetry!.LoggerLevel!) { // Checks if the new key that is valid has more priority than the current key if (logger.Key.Length > max && loggerFilter.StartsWith(logger.Key)) { max = logger.Key.Length; currentFilter = logger.Key; } } Runtime!.Telemetry!.LoggerLevel!.TryGetValue(currentFilter, out LogLevel? value); if (value is not null) { return (LogLevel)value; } value = Runtime!.Telemetry!.LoggerLevel! .SingleOrDefault(kvp => kvp.Key.Equals("default", StringComparison.OrdinalIgnoreCase)).Value; if (value is not null) { return (LogLevel)value; } } if (IsDevelopmentMode()) { return LogLevel.Debug; } return LogLevel.Error; } /// /// Gets the MCP DML tools configuration /// [JsonIgnore] public DmlToolsConfig? McpDmlTools => Runtime?.Mcp?.DmlTools; /// /// Determines whether caching is enabled for a given entity, resolving inheritance lazily. /// If the entity explicitly sets cache enabled/disabled, that value wins. /// If the entity has a cache object but did not explicitly set enabled (UserProvidedEnabledOptions is false), /// the global runtime cache enabled setting is inherited. /// If the entity has no cache config at all, the global runtime cache enabled setting is inherited. /// /// Name of the entity to check cache configuration. /// Whether caching is enabled for the entity. /// Raised when an invalid entity name is provided. public virtual bool IsEntityCachingEnabled(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) { throw new DataApiBuilderException( message: $"{entityName} is not a valid entity.", statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } return IsEntityCachingEnabled(entityConfig); } /// /// Determines whether caching is enabled for a given entity, resolving inheritance lazily. /// If the entity explicitly sets cache enabled/disabled (UserProvidedEnabledOptions is true), that value wins. /// Otherwise, inherits the global runtime cache enabled setting. /// /// The entity to check cache configuration. /// Whether caching is enabled for the entity. private bool IsEntityCachingEnabled(Entity entity) { // If entity has an explicit cache config with user-provided enabled value, use it. if (entity.Cache is not null && entity.Cache.UserProvidedEnabledOptions) { return entity.IsCachingEnabled; } // Otherwise, inherit from the global runtime cache setting. return IsCachingEnabled; }} \ No newline at end of file diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index 28b6fbb88d..a08bf1b8a1 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; @@ -133,6 +133,226 @@ public async Task CanLoadValidMultiSourceConfigWithAutoentities(string configPat Assert.AreEqual(expectedEntities, runtimeConfig.Entities.Entities.Count, "Number of entities is not what is expected."); } + /// + /// Validates that when a parent config has azure-key-vault options configured, + /// child configs can resolve @akv('...') references using the parent's AKV configuration. + /// Uses a local .akv file to simulate Azure Key Vault without requiring a real vault. + /// Regression test for https://github.com/Azure/data-api-builder/issues/3322 + /// + [TestMethod] + public async Task ChildConfigResolvesAkvReferencesFromParentAkvOptions() + { + string akvFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".akv"); + string childFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json"); + + try + { + // Create a local .akv secrets file with test secrets. + await File.WriteAllTextAsync(akvFilePath, "my-connection-secret=Server=tcp:127.0.0.1,1433;Trusted_Connection=True;\n"); + + // Parent config with azure-key-vault pointing to the local .akv file. + string parentConfig = $@"{{ + ""$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;"" + }}, + ""azure-key-vault"": {{ + ""endpoint"": ""{akvFilePath.Replace("\\", "\\\\")}"" + }}, + ""data-source-files"": [""{childFilePath.Replace("\\", "\\\\")}""], + ""runtime"": {{ + ""rest"": {{ ""enabled"": true }}, + ""graphql"": {{ ""enabled"": true }}, + ""host"": {{ + ""cors"": {{ ""origins"": [] }}, + ""authentication"": {{ ""provider"": ""StaticWebApps"" }} + }} + }}, + ""entities"": {{}} + }}"; + + // Child config with @akv('...') reference in its connection string. + 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"": ""@akv('my-connection-secret')"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true }, + ""graphql"": { ""enabled"": true }, + ""host"": { + ""cors"": { ""origins"": [] }, + ""authentication"": { ""provider"": ""StaticWebApps"" } + } + }, + ""entities"": { + ""AkvChildEntity"": { + ""source"": ""dbo.AkvTable"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""read""] }] + } + } + }"; + + await File.WriteAllTextAsync(childFilePath, childConfig); + + MockFileSystem fs = new(new Dictionary() + { + { "dab-config.json", new MockFileData(parentConfig) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: new AzureKeyVaultOptions() { Endpoint = akvFilePath, UserProvidedEndpoint = true }, + doReplaceEnvVar: true, + doReplaceAkvVar: true, + envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore); + + Assert.IsTrue( + loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig, replacementSettings: replacementSettings), + "Config should load successfully when child config has @akv() references resolvable via parent AKV options."); + + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("AkvChildEntity"), "Child config entity should be merged into the parent config."); + + // Verify the child's connection string was resolved from the .akv file. + string childDataSourceName = runtimeConfig.GetDataSourceNameFromEntityName("AkvChildEntity"); + DataSource childDataSource = runtimeConfig.GetDataSourceFromDataSourceName(childDataSourceName); + Assert.IsTrue( + childDataSource.ConnectionString.Contains("127.0.0.1"), + "Child config connection string should have the AKV secret resolved."); + } + finally + { + if (File.Exists(akvFilePath)) + { + File.Delete(akvFilePath); + } + + if (File.Exists(childFilePath)) + { + File.Delete(childFilePath); + } + } + } + + /// + /// Validates that when both the parent and child configs define azure-key-vault options, + /// the child's AKV settings take precedence over the parent's. + /// The child config references a secret that only exists in the child's .akv file, + /// proving the child's AKV endpoint was used instead of the parent's. + /// + [TestMethod] + public async Task ChildAkvOptionsOverrideParentAkvOptions() + { + string parentAkvFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".akv"); + string childAkvFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".akv"); + string childFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json"); + + try + { + // Parent's .akv file does NOT contain the secret the child references. + await File.WriteAllTextAsync(parentAkvFilePath, "parent-only-secret=ParentValue\n"); + + // Child's .akv file contains the secret the child references. + await File.WriteAllTextAsync(childAkvFilePath, "child-connection-secret=Server=tcp:10.0.0.1,1433;Trusted_Connection=True;\n"); + + // Parent config with azure-key-vault pointing to the parent's .akv file. + string parentConfig = $@"{{ + ""$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;"" + }}, + ""azure-key-vault"": {{ + ""endpoint"": ""{parentAkvFilePath.Replace("\\", "\\\\")}"" + }}, + ""data-source-files"": [""{childFilePath.Replace("\\", "\\\\")}""], + ""runtime"": {{ + ""rest"": {{ ""enabled"": true }}, + ""graphql"": {{ ""enabled"": true }}, + ""host"": {{ + ""cors"": {{ ""origins"": [] }}, + ""authentication"": {{ ""provider"": ""StaticWebApps"" }} + }} + }}, + ""entities"": {{}} + }}"; + + // Child config with its own azure-key-vault pointing to the child's .akv file, + // and a connection string referencing a secret only in the child's vault. + 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"": ""@akv('child-connection-secret')"" + }}, + ""azure-key-vault"": {{ + ""endpoint"": ""{childAkvFilePath.Replace("\\", "\\\\")}"" + }}, + ""runtime"": {{ + ""rest"": {{ ""enabled"": true }}, + ""graphql"": {{ ""enabled"": true }}, + ""host"": {{ + ""cors"": {{ ""origins"": [] }}, + ""authentication"": {{ ""provider"": ""StaticWebApps"" }} + }} + }}, + ""entities"": {{ + ""ChildOverrideEntity"": {{ + ""source"": ""dbo.ChildTable"", + ""permissions"": [{{ ""role"": ""anonymous"", ""actions"": [""read""] }}] + }} + }} + }}"; + + await File.WriteAllTextAsync(childFilePath, childConfig); + + MockFileSystem fs = new(new Dictionary() + { + { "dab-config.json", new MockFileData(parentConfig) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: new AzureKeyVaultOptions() { Endpoint = parentAkvFilePath, UserProvidedEndpoint = true }, + doReplaceEnvVar: true, + doReplaceAkvVar: true, + envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore); + + Assert.IsTrue( + loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig, replacementSettings: replacementSettings), + "Config should load successfully when child config has its own AKV options."); + + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("ChildOverrideEntity"), "Child config entity should be merged into the parent config."); + + // Verify the child's connection string was resolved using the child's AKV file, not the parent's. + string childDataSourceName = runtimeConfig.GetDataSourceNameFromEntityName("ChildOverrideEntity"); + DataSource childDataSource = runtimeConfig.GetDataSourceFromDataSourceName(childDataSourceName); + Assert.IsTrue( + childDataSource.ConnectionString.Contains("10.0.0.1"), + "Child config connection string should be resolved from the child's own AKV file, not the parent's."); + } + finally + { + if (File.Exists(parentAkvFilePath)) + { + File.Delete(parentAkvFilePath); + } + + if (File.Exists(childAkvFilePath)) + { + File.Delete(childAkvFilePath); + } + + if (File.Exists(childFilePath)) + { + File.Delete(childFilePath); + } + } + } /// /// 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