diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 61ab7474b7..b6a7f66268 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -693,6 +693,166 @@ } } }, + "autoentities": { + "type": "object", + "description": "Defines automatic entity generation rules for MSSQL tables based on include/exclude patterns and defaults.", + "patternProperties": { + "^.*$": { + "type": "object", + "additionalProperties": false, + "properties": { + "patterns": { + "type": "object", + "description": "Pattern matching rules for including/excluding database objects", + "additionalProperties": false, + "properties": { + "include": { + "type": "array", + "description": "MSSQL LIKE pattern for objects to include (e.g., '%.%'). Null includes all.", + "items": { + "type": "string" + }, + "default": [ "%.%" ] + }, + "exclude": { + "type": "array", + "description": "MSSQL LIKE pattern for objects to exclude (e.g., 'sales.%'). Null excludes none.", + "items": { + "type": "string" + }, + "default": null + }, + "name": { + "type": "string", + "description": "Entity name interpolation pattern using {schema} and {object}. Null defaults to {object}. Must be unique for every entity inside the pattern", + "default": "{object}" + } + } + }, + "template": { + "type": "object", + "description": "Template configuration for generated entities", + "additionalProperties": false, + "properties": { + "mcp": { + "type": "object", + "description": "MCP endpoint configuration", + "additionalProperties": false, + "properties": { + "dml-tools": { + "type": "boolean", + "description": "Enable/disable all DML tools with default settings." + } + } + }, + "rest": { + "type": "object", + "description": "REST endpoint configuration", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable REST endpoint", + "default": true + } + } + }, + "graphql": { + "type": "object", + "description": "GraphQL endpoint configuration", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable GraphQL endpoint", + "default": true + } + } + }, + "health": { + "type": "object", + "description": "Health check configuration", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable health check endpoint", + "default": true + } + } + }, + "cache": { + "type": "object", + "description": "Cache configuration", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable caching", + "default": false + }, + "ttl-seconds": { + "type": [ "integer", "null" ], + "description": "Time-to-live for cached responses in seconds", + "default": null, + "minimum": 1 + }, + "level": { + "type": "string", + "description": "Cache level (L1 or L1L2)", + "enum": [ "L1", "L1L2", null ], + "default": "L1L2" + } + } + } + } + }, + "permissions": { + "type": "array", + "description": "Permissions assigned to this object", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "role": { + "type": "string" + }, + "actions": { + "oneOf": [ + { + "type": "string", + "pattern": "[*]" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/action" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/$defs/action" + } + } + } + ] + }, + "uniqueItems": true + } + ] + } + } + }, + "required": [ "role", "actions" ] + } + } + } + } + }, "entities": { "type": "object", "description": "Entities that will be exposed via REST, GraphQL and/or MCP", diff --git a/src/Config/Converters/AutoentityConverter.cs b/src/Config/Converters/AutoentityConverter.cs new file mode 100644 index 0000000000..5c09ed8e7b --- /dev/null +++ b/src/Config/Converters/AutoentityConverter.cs @@ -0,0 +1,110 @@ +// 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; + +internal class AutoentityConverter : JsonConverter +{ + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; + + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public AutoentityConverter(DeserializationVariableReplacementSettings? replacementSettings = null) + { + _replacementSettings = replacementSettings; + } + + /// + public override Autoentity? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.StartObject) + { + // Initialize all sub-properties to null. + AutoentityPatterns? patterns = null; + AutoentityTemplate? template = null; + EntityPermission[]? permissions = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new Autoentity(patterns, template, permissions); + } + + string? propertyName = reader.GetString(); + + reader.Read(); + switch (propertyName) + { + case "patterns": + AutoentityPatternsConverter patternsConverter = new(_replacementSettings); + patterns = patternsConverter.Read(ref reader, typeof(AutoentityPatterns), options); + break; + + case "template": + AutoentityTemplateConverter templateConverter = new(_replacementSettings); + template = templateConverter.Read(ref reader, typeof(AutoentityTemplate), options); + break; + + case "permissions": + permissions = JsonSerializer.Deserialize(ref reader, options) + ?? throw new JsonException("The 'permissions' property must contain at least one permission."); + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + throw new JsonException("Failed to read the Autoentities"); + } + + /// + /// When writing the autoentities back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + /// + public override void Write(Utf8JsonWriter writer, Autoentity value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + AutoentityPatterns? patterns = value?.Patterns; + if (patterns?.UserProvidedIncludeOptions is true + || patterns?.UserProvidedExcludeOptions is true + || patterns?.UserProvidedNameOptions is true) + { + AutoentityPatternsConverter autoentityPatternsConverter = options.GetConverter(typeof(AutoentityPatterns)) as AutoentityPatternsConverter ?? + throw new JsonException("Failed to get autoentities.patterns options converter"); + writer.WritePropertyName("patterns"); + autoentityPatternsConverter.Write(writer, patterns, options); + } + + AutoentityTemplate? template = value?.Template; + if (template?.UserProvidedRestOptions is true + || template?.UserProvidedGraphQLOptions is true + || template?.UserProvidedHealthOptions is true + || template?.UserProvidedCacheOptions is true) + { + AutoentityTemplateConverter autoentityTemplateConverter = options.GetConverter(typeof(AutoentityTemplate)) as AutoentityTemplateConverter ?? + throw new JsonException("Failed to get autoentities.template options converter"); + writer.WritePropertyName("template"); + autoentityTemplateConverter.Write(writer, template, options); + } + + if (value?.Permissions is not null) + { + writer.WritePropertyName("permissions"); + JsonSerializer.Serialize(writer, value.Permissions, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Config/Converters/AutoentityPatternsConverter.cs b/src/Config/Converters/AutoentityPatternsConverter.cs new file mode 100644 index 0000000000..d8029ff033 --- /dev/null +++ b/src/Config/Converters/AutoentityPatternsConverter.cs @@ -0,0 +1,151 @@ +// 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; + +internal class AutoentityPatternsConverter : JsonConverter +{ + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; + + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public AutoentityPatternsConverter(DeserializationVariableReplacementSettings? replacementSettings = null) + { + _replacementSettings = replacementSettings; + } + + /// + public override AutoentityPatterns? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.StartObject) + { + string[]? include = null; + string[]? exclude = null; + string? name = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new AutoentityPatterns(include, exclude, name); + } + + string? propertyName = reader.GetString(); + + reader.Read(); + switch (propertyName) + { + case "include": + if (reader.TokenType is not JsonTokenType.Null) + { + List includeList = new(); + + if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + string? value = reader.DeserializeString(_replacementSettings); + if (value is not null) + { + includeList.Add(value); + } + } + + include = includeList.ToArray(); + } + else + { + throw new JsonException("Expected array for 'include' property."); + } + } + + break; + + case "exclude": + if (reader.TokenType is not JsonTokenType.Null) + { + List excludeList = new(); + + if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + string? value = reader.DeserializeString(_replacementSettings); + if (value is not null) + { + excludeList.Add(value); + } + } + + exclude = excludeList.ToArray(); + } + else + { + throw new JsonException("Expected array for 'exclude' property."); + } + } + + break; + + case "name": + name = reader.DeserializeString(_replacementSettings); + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + throw new JsonException("Failed to read the Autoentities Pattern Options"); + } + + /// + /// When writing the autoentities.patterns back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + /// + public override void Write(Utf8JsonWriter writer, AutoentityPatterns value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value?.UserProvidedIncludeOptions is true) + { + writer.WritePropertyName("include"); + writer.WriteStartArray(); + foreach (string? include in value.Include) + { + JsonSerializer.Serialize(writer, include, options); + } + + writer.WriteEndArray(); + } + + if (value?.UserProvidedExcludeOptions is true) + { + writer.WritePropertyName("exclude"); + writer.WriteStartArray(); + foreach (string? exclude in value.Exclude) + { + JsonSerializer.Serialize(writer, exclude, options); + } + + writer.WriteEndArray(); + } + + if (value?.UserProvidedNameOptions is true) + { + writer.WritePropertyName("name"); + JsonSerializer.Serialize(writer, value.Name, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Config/Converters/AutoentityTemplateConverter.cs b/src/Config/Converters/AutoentityTemplateConverter.cs new file mode 100644 index 0000000000..275cfc4314 --- /dev/null +++ b/src/Config/Converters/AutoentityTemplateConverter.cs @@ -0,0 +1,133 @@ +// 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; + +internal class AutoentityTemplateConverter : JsonConverter +{ + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; + + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public AutoentityTemplateConverter(DeserializationVariableReplacementSettings? replacementSettings = null) + { + _replacementSettings = replacementSettings; + } + + /// + public override AutoentityTemplate? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.StartObject) + { + // Create converters for each of the sub-properties. + EntityRestOptionsConverterFactory restOptionsConverterFactory = new(_replacementSettings); + JsonConverter restOptionsConverter = (JsonConverter)(restOptionsConverterFactory.CreateConverter(typeof(EntityRestOptions), options) + ?? throw new JsonException("Unable to create converter for EntityRestOptions")); + + EntityGraphQLOptionsConverterFactory graphQLOptionsConverterFactory = new(_replacementSettings); + JsonConverter graphQLOptionsConverter = (JsonConverter)(graphQLOptionsConverterFactory.CreateConverter(typeof(EntityGraphQLOptions), options) + ?? throw new JsonException("Unable to create converter for EntityGraphQLOptions")); + + EntityMcpOptionsConverterFactory mcpOptionsConverterFactory = new(); + JsonConverter mcpOptionsConverter = (JsonConverter)(mcpOptionsConverterFactory.CreateConverter(typeof(EntityMcpOptions), options) + ?? throw new JsonException("Unable to create converter for EntityMcpOptions")); + + EntityHealthOptionsConvertorFactory healthOptionsConverterFactory = new(); + JsonConverter healthOptionsConverter = (JsonConverter)(healthOptionsConverterFactory.CreateConverter(typeof(EntityHealthCheckConfig), options) + ?? throw new JsonException("Unable to create converter for EntityHealthCheckConfig")); + + EntityCacheOptionsConverterFactory cacheOptionsConverterFactory = new(_replacementSettings); + JsonConverter cacheOptionsConverter = (JsonConverter)(cacheOptionsConverterFactory.CreateConverter(typeof(EntityCacheOptions), options) + ?? throw new JsonException("Unable to create converter for EntityCacheOptions")); + + // Initialize all sub-properties to null. + EntityRestOptions? rest = null; + EntityGraphQLOptions? graphQL = null; + EntityMcpOptions? mcp = null; + EntityHealthCheckConfig? health = null; + EntityCacheOptions? cache = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new AutoentityTemplate(rest, graphQL, mcp, health, cache); + } + + string? propertyName = reader.GetString(); + + reader.Read(); + switch (propertyName) + { + case "rest": + rest = restOptionsConverter.Read(ref reader, typeof(EntityRestOptions), options); + break; + + case "graphql": + graphQL = graphQLOptionsConverter.Read(ref reader, typeof(EntityGraphQLOptions), options); + break; + + case "mcp": + mcp = mcpOptionsConverter.Read(ref reader, typeof(EntityMcpOptions), options); + break; + + case "health": + health = healthOptionsConverter.Read(ref reader, typeof(EntityHealthCheckConfig), options); + break; + + case "cache": + cache = cacheOptionsConverter.Read(ref reader, typeof(EntityCacheOptions), options); + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + throw new JsonException("Failed to read the Autoentities Template Options"); + } + + /// + /// When writing the autoentities.template back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + /// + public override void Write(Utf8JsonWriter writer, AutoentityTemplate value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value?.UserProvidedRestOptions is true) + { + writer.WritePropertyName("rest"); + JsonSerializer.Serialize(writer, value.Rest, options); + } + + if (value?.UserProvidedGraphQLOptions is true) + { + writer.WritePropertyName("graphql"); + JsonSerializer.Serialize(writer, value.GraphQL, options); + } + + if (value?.UserProvidedHealthOptions is true) + { + writer.WritePropertyName("health"); + JsonSerializer.Serialize(writer, value.Health, options); + } + + if (value?.UserProvidedCacheOptions is true) + { + writer.WritePropertyName("cache"); + JsonSerializer.Serialize(writer, value.Cache, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Config/Converters/AzureLogAnalyticsOptionsConverterFactory.cs b/src/Config/Converters/AzureLogAnalyticsOptionsConverterFactory.cs index fc7c72d655..608c8e79e3 100644 --- a/src/Config/Converters/AzureLogAnalyticsOptionsConverterFactory.cs +++ b/src/Config/Converters/AzureLogAnalyticsOptionsConverterFactory.cs @@ -142,7 +142,7 @@ public override void Write(Utf8JsonWriter writer, AzureLogAnalyticsOptions value if (value?.Auth is not null && (value.Auth.UserProvidedCustomTableName || value.Auth.UserProvidedDcrImmutableId || value.Auth.UserProvidedDceEndpoint)) { AzureLogAnalyticsAuthOptionsConverter authOptionsConverter = options.GetConverter(typeof(AzureLogAnalyticsAuthOptions)) as AzureLogAnalyticsAuthOptionsConverter ?? - throw new JsonException("Failed to get azure-log-analytics.auth options converter"); + throw new JsonException("Failed to get azure-log-analytics.auth options converter"); writer.WritePropertyName("auth"); authOptionsConverter.Write(writer, value.Auth, options); diff --git a/src/Config/Converters/EntityRestOptionsConverterFactory.cs b/src/Config/Converters/EntityRestOptionsConverterFactory.cs index f8c9096673..7f8fc87a05 100644 --- a/src/Config/Converters/EntityRestOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityRestOptionsConverterFactory.cs @@ -90,6 +90,7 @@ public EntityRestOptionsConverter(DeserializationVariableReplacementSettings? re restOptions = restOptions with { Methods = methods.ToArray() }; break; + case "enabled": reader.Read(); restOptions = restOptions with { Enabled = reader.GetBoolean() }; diff --git a/src/Config/Converters/RuntimeAutoentitiesConverter.cs b/src/Config/Converters/RuntimeAutoentitiesConverter.cs new file mode 100644 index 0000000000..b65bcb9989 --- /dev/null +++ b/src/Config/Converters/RuntimeAutoentitiesConverter.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// This converter is used to convert all the autoentities defined in the configuration file +/// each into a object. The resulting collection is then wrapped in the +/// object. +/// +class RuntimeAutoentitiesConverter : JsonConverter +{ + /// + public override RuntimeAutoentities? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + Dictionary autoEntities = + JsonSerializer.Deserialize>(ref reader, options) ?? + throw new JsonException("Failed to read autoentities"); + + return new RuntimeAutoentities(new ReadOnlyDictionary(autoEntities)); + } + + /// + public override void Write(Utf8JsonWriter writer, RuntimeAutoentities value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach ((string key, Autoentity autoEntity) in value.AutoEntities) + { + writer.WritePropertyName(key); + JsonSerializer.Serialize(writer, autoEntity, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Config/ObjectModel/Autoentity.cs b/src/Config/ObjectModel/Autoentity.cs new file mode 100644 index 0000000000..45fca68642 --- /dev/null +++ b/src/Config/ObjectModel/Autoentity.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Defines an individual auto-entity definition with patterns, template, and permissions. +/// +/// Pattern matching rules for including/excluding database objects +/// Template configuration for generated entities +/// Permissions configuration for generated entities (at least one required) +public record Autoentity +{ + public AutoentityPatterns Patterns { get; init; } + public AutoentityTemplate Template { get; init; } + public EntityPermission[] Permissions { get; init; } + + [JsonConstructor] + public Autoentity( + AutoentityPatterns? Patterns, + AutoentityTemplate? Template, + EntityPermission[]? Permissions) + { + this.Patterns = Patterns ?? new AutoentityPatterns(); + + this.Template = Template ?? new AutoentityTemplate(); + + this.Permissions = Permissions ?? Array.Empty(); + } +} diff --git a/src/Config/ObjectModel/AutoentityPatterns.cs b/src/Config/ObjectModel/AutoentityPatterns.cs new file mode 100644 index 0000000000..d287c56109 --- /dev/null +++ b/src/Config/ObjectModel/AutoentityPatterns.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Defines the pattern matching rules for auto-entities. +/// +/// T-SQL LIKE pattern to include database objects +/// T-SQL LIKE pattern to exclude database objects +/// Interpolation syntax for entity naming (must be unique for each generated entity) +public record AutoentityPatterns +{ + public string[] Include { get; init; } + public string[] Exclude { get; init; } + public string Name { get; init; } + + [JsonConstructor] + public AutoentityPatterns( + string[]? Include = null, + string[]? Exclude = null, + string? Name = null) + { + if (Include is not null) + { + this.Include = Include; + UserProvidedIncludeOptions = true; + } + else + { + this.Include = ["%.%"]; + } + + if (Exclude is not null) + { + this.Exclude = Exclude; + UserProvidedExcludeOptions = true; + } + else + { + this.Exclude = []; + } + + if (!string.IsNullOrWhiteSpace(Name)) + { + this.Name = Name; + UserProvidedNameOptions = true; + } + else + { + this.Name = "{object}"; + } + } + + /// + /// Flag which informs CLI and JSON serializer whether to write include + /// property and value to the runtime config file. + /// When user doesn't provide the include property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a include + /// property/value specified would be interpreted by DAB as "user explicitly set include." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Include))] + public bool UserProvidedIncludeOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write exclude + /// property and value to the runtime config file. + /// When user doesn't provide the exclude property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a exclude + /// property/value specified would be interpreted by DAB as "user explicitly set exclude." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Exclude))] + public bool UserProvidedExcludeOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write name + /// property and value to the runtime config file. + /// When user doesn't provide the name property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a name + /// property/value specified would be interpreted by DAB as "user explicitly set name." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Name))] + public bool UserProvidedNameOptions { get; init; } = false; +} diff --git a/src/Config/ObjectModel/AutoentityTemplate.cs b/src/Config/ObjectModel/AutoentityTemplate.cs new file mode 100644 index 0000000000..78a804d0a4 --- /dev/null +++ b/src/Config/ObjectModel/AutoentityTemplate.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Template used by auto-entities to configure all entities it generates. +/// +/// MCP endpoint configuration +/// REST endpoint configuration +/// GraphQL endpoint configuration +/// Health check configuration +/// Cache configuration +public record AutoentityTemplate +{ + public EntityMcpOptions? Mcp { get; init; } + public EntityRestOptions Rest { get; init; } + public EntityGraphQLOptions GraphQL { get; init; } + public EntityHealthCheckConfig Health { get; init; } + public EntityCacheOptions Cache { get; init; } + + [JsonConstructor] + public AutoentityTemplate( + EntityRestOptions? Rest = null, + EntityGraphQLOptions? GraphQL = null, + EntityMcpOptions? Mcp = null, + EntityHealthCheckConfig? Health = null, + EntityCacheOptions? Cache = null) + { + if (Rest is not null) + { + this.Rest = Rest; + UserProvidedRestOptions = true; + } + else + { + this.Rest = new EntityRestOptions(); + } + + if (GraphQL is not null) + { + this.GraphQL = GraphQL; + UserProvidedGraphQLOptions = true; + } + else + { + this.GraphQL = new EntityGraphQLOptions(string.Empty, string.Empty); + } + + if (Mcp is not null) + { + this.Mcp = Mcp; + UserProvidedMcpOptions = true; + } + else + { + this.Mcp = new EntityMcpOptions(null, null); + } + + if (Health is not null) + { + this.Health = Health; + UserProvidedHealthOptions = true; + } + else + { + this.Health = new EntityHealthCheckConfig(); + } + + if (Cache is not null) + { + this.Cache = Cache; + UserProvidedCacheOptions = true; + } + else + { + this.Cache = new EntityCacheOptions(); + } + } + + /// + /// Flag which informs CLI and JSON serializer whether to write rest + /// property and value to the runtime config file. + /// When user doesn't provide the rest property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a rest + /// property/value specified would be interpreted by DAB as "user explicitly set rest." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Rest))] + public bool UserProvidedRestOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write graphql + /// property and value to the runtime config file. + /// When user doesn't provide the graphql property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a graphql + /// property/value specified would be interpreted by DAB as "user explicitly set graphql." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(GraphQL))] + public bool UserProvidedGraphQLOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write mcp + /// property and value to the runtime config file. + /// When user doesn't provide the mcp property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a mcp + /// property/value specified would be interpreted by DAB as "user explicitly set mcp." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Mcp))] + public bool UserProvidedMcpOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write health + /// property and value to the runtime config file. + /// When user doesn't provide the health property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a health + /// property/value specified would be interpreted by DAB as "user explicitly set health." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Health))] + public bool UserProvidedHealthOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write cache + /// property and value to the runtime config file. + /// When user doesn't provide the cache property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a cache + /// property/value specified would be interpreted by DAB as "user explicitly set cache." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Cache))] + public bool UserProvidedCacheOptions { get; init; } = false; +} diff --git a/src/Config/ObjectModel/RuntimeAutoentities.cs b/src/Config/ObjectModel/RuntimeAutoentities.cs new file mode 100644 index 0000000000..0fec45f5a1 --- /dev/null +++ b/src/Config/ObjectModel/RuntimeAutoentities.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.Converters; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Represents a collection of available from the RuntimeConfig. +/// +[JsonConverter(typeof(RuntimeAutoentitiesConverter))] +public record RuntimeAutoentities +{ + /// + /// The collection of available from the RuntimeConfig. + /// + public IReadOnlyDictionary AutoEntities { get; init; } + + /// + /// Creates a new instance of the class using a collection of entities. + /// + /// The collection of auto-entities to map to RuntimeAutoentities. + public RuntimeAutoentities(IReadOnlyDictionary autoEntities) + { + AutoEntities = autoEntities; + } +} diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 6896d82161..b5957b5e46 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -25,6 +25,8 @@ public record RuntimeConfig [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; } @@ -246,6 +248,7 @@ public RuntimeConfig( string? Schema, DataSource DataSource, RuntimeEntities Entities, + RuntimeAutoentities? Autoentities = null, RuntimeOptions? Runtime = null, DataSourceFiles? DataSourceFiles = null, AzureKeyVaultOptions? AzureKeyVault = null) @@ -255,6 +258,7 @@ public RuntimeConfig( this.Runtime = Runtime; this.AzureKeyVault = AzureKeyVault; this.Entities = Entities; + this.Autoentities = Autoentities; this.DefaultDataSourceName = Guid.NewGuid().ToString(); if (this.DataSource is null) diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index 6d6cf6d51b..5996a902ed 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -314,6 +314,9 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityActionConverterFactory()); options.Converters.Add(new DataSourceFilesConverter()); options.Converters.Add(new EntityCacheOptionsConverterFactory(replacementSettings)); + options.Converters.Add(new AutoentityConverter(replacementSettings)); + options.Converters.Add(new AutoentityPatternsConverter(replacementSettings)); + options.Converters.Add(new AutoentityTemplateConverter(replacementSettings)); options.Converters.Add(new EntityMcpOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheLevel2OptionsConverterFactory()); diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs index 02b7ca6492..68c9225b96 100644 --- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs +++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs @@ -760,12 +760,13 @@ private static Mock CreateMockRuntimeConfigProvider(strin }); Mock mockRuntimeConfig = new( - string.Empty, - dataSource, - entities, - null, - null, - null + string.Empty, // Schema + dataSource, // DataSource + entities, // Entities + null, // Autoentities + null, // Runtime + null, // DataSourceFiles + null // AzureKeyVault ); mockRuntimeConfig .Setup(c => c.GetDataSourceFromDataSourceName(It.IsAny())) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 9728c27ee7..6078d8f364 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -4304,6 +4304,176 @@ public void FileSinkSerialization( } } + /// + /// Test validates that autoentities section can be deserialized and serialized correctly. + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(null, null, null, null, null, null, null, null, null, "anonymous", EntityActionOperation.Read)] + [DataRow(new[] { "%.%" }, new[] { "%.%" }, "{object}", true, true, true, false, 5, EntityCacheLevel.L1L2, "anonymous", EntityActionOperation.Read)] + [DataRow(new[] { "books.%" }, new[] { "books.pages.%" }, "books_{object}", false, false, false, true, 2147483647, EntityCacheLevel.L1, "test-user", EntityActionOperation.Delete)] + [DataRow(new[] { "books.%" }, null, "books_{object}", false, null, false, null, 2147483647, null, "test-user", EntityActionOperation.Delete)] + [DataRow(null, new[] { "books.pages.%" }, null, null, false, null, true, null, EntityCacheLevel.L1, "test-user", EntityActionOperation.Delete)] + [DataRow(new[] { "title.%", "books.%", "names.%" }, new[] { "names.%", "%.%" }, "{schema}.{object}", true, false, false, true, 1, null, "second-test-user", EntityActionOperation.Create)] + public void TestAutoEntitiesSerializationDeserialization( + string[]? include, + string[]? exclude, + string? name, + bool? restEnabled, + bool? graphqlEnabled, + bool? healthCheckEnabled, + bool? cacheEnabled, + int? cacheTTL, + EntityCacheLevel? cacheLevel, + string role, + EntityActionOperation entityActionOp) + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + Dictionary createdAutoentity = new(); + createdAutoentity.Add("test-entity", + new Autoentity( + Patterns: new AutoentityPatterns(include, exclude, name), + Template: new AutoentityTemplate( + Rest: restEnabled == null ? null : new EntityRestOptions(Enabled: (bool)restEnabled), + GraphQL: graphqlEnabled == null ? null : new EntityGraphQLOptions(Singular: string.Empty, Plural: string.Empty, Enabled: (bool)graphqlEnabled), + Health: healthCheckEnabled == null ? null : new EntityHealthCheckConfig(healthCheckEnabled), + Cache: (cacheEnabled == null && cacheTTL == null && cacheLevel == null) ? null : new EntityCacheOptions(Enabled: cacheEnabled, TtlSeconds: cacheTTL, Level: cacheLevel) + ), + Permissions: new EntityPermission[1])); + + EntityAction[] entityActions = new EntityAction[] { new(entityActionOp, null, null) }; + createdAutoentity["test-entity"].Permissions[0] = new EntityPermission(role, entityActions); + RuntimeAutoentities autoentities = new(createdAutoentity); + + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + baseLoader.TryLoadKnownConfig(out RuntimeConfig? baseConfig); + + RuntimeConfig config = new( + Schema: baseConfig!.Schema, + DataSource: baseConfig.DataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null), + Telemetry: new() + ), + Entities: baseConfig.Entities, + Autoentities: autoentities + ); + + string configWithCustomJson = config.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomJson, out RuntimeConfig? deserializedRuntimeConfig)); + + string serializedConfig = deserializedRuntimeConfig.ToJson(); + + using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) + { + JsonElement root = parsedDocument.RootElement; + JsonElement autoentitiesElement = root.GetProperty("autoentities"); + + bool entityExists = autoentitiesElement.TryGetProperty("test-entity", out JsonElement entityElement); + Assert.AreEqual(expected: true, actual: entityExists); + + // Validate patterns properties and their values exists in autoentities + bool expectedPatternsExist = include != null || exclude != null || name != null; + bool patternsExists = entityElement.TryGetProperty("patterns", out JsonElement patternsElement); + Assert.AreEqual(expected: expectedPatternsExist, actual: patternsExists); + + if (patternsExists) + { + bool includeExists = patternsElement.TryGetProperty("include", out JsonElement includeElement); + Assert.AreEqual(expected: (include != null), actual: includeExists); + if (includeExists) + { + CollectionAssert.AreEqual(expected: include, actual: includeElement.EnumerateArray().Select(e => e.GetString()).ToArray()); + } + + bool excludeExists = patternsElement.TryGetProperty("exclude", out JsonElement excludeElement); + Assert.AreEqual(expected: (exclude != null), actual: excludeExists); + if (excludeExists) + { + CollectionAssert.AreEqual(expected: exclude, actual: excludeElement.EnumerateArray().Select(e => e.GetString()).ToArray()); + } + + bool nameExists = patternsElement.TryGetProperty("name", out JsonElement nameElement); + Assert.AreEqual(expected: (name != null), actual: nameExists); + if (nameExists) + { + Assert.AreEqual(expected: name, actual: nameElement.GetString()); + } + } + + // Validate template properties and their values exists in autoentities + bool expectedTemplateExist = restEnabled != null || graphqlEnabled != null || healthCheckEnabled != null + || cacheEnabled != null || cacheLevel != null || cacheTTL != null; + bool templateExists = entityElement.TryGetProperty("template", out JsonElement templateElement); + Assert.AreEqual(expected: expectedTemplateExist, actual: templateExists); + + if (templateExists) + { + bool restPropertyExists = templateElement.TryGetProperty("rest", out JsonElement restElement); + Assert.AreEqual(expected: (restEnabled != null), actual: restPropertyExists); + if (restPropertyExists) + { + Assert.IsTrue(restElement.TryGetProperty("enabled", out JsonElement restEnabledElement)); + Assert.AreEqual(expected: restEnabled, actual: restEnabledElement.GetBoolean()); + } + + bool graphqlPropertyExists = templateElement.TryGetProperty("graphql", out JsonElement graphqlElement); + Assert.AreEqual(expected: (graphqlEnabled != null), actual: graphqlPropertyExists); + if (graphqlPropertyExists) + { + Assert.IsTrue(graphqlElement.TryGetProperty("enabled", out JsonElement graphqlEnabledElement)); + Assert.AreEqual(expected: graphqlEnabled, actual: graphqlEnabledElement.GetBoolean()); + } + + bool healthPropertyExists = templateElement.TryGetProperty("health", out JsonElement healthElement); + Assert.AreEqual(expected: (healthCheckEnabled != null), actual: healthPropertyExists); + if (healthPropertyExists) + { + Assert.IsTrue(healthElement.TryGetProperty("enabled", out JsonElement healthEnabledElement)); + Assert.AreEqual(expected: healthCheckEnabled, actual: healthEnabledElement.GetBoolean()); + } + + bool expectedCacheExist = cacheEnabled != null || cacheTTL != null || cacheLevel != null; + bool cachePropertyExists = templateElement.TryGetProperty("cache", out JsonElement cacheElement); + Assert.AreEqual(expected: expectedCacheExist, actual: cachePropertyExists); + if (cacheEnabled != null) + { + Assert.IsTrue(cacheElement.TryGetProperty("enabled", out JsonElement cacheEnabledElement)); + Assert.AreEqual(expected: cacheEnabled, actual: cacheEnabledElement.GetBoolean()); + } + + if (cacheTTL != null) + { + Assert.IsTrue(cacheElement.TryGetProperty("ttl-seconds", out JsonElement cacheTtlElement)); + Assert.AreEqual(expected: cacheTTL, actual: cacheTtlElement.GetInt32()); + } + + if (cacheLevel != null) + { + Assert.IsTrue(cacheElement.TryGetProperty("level", out JsonElement cacheLevelElement)); + Assert.IsTrue(string.Equals(cacheLevel.ToString(), cacheLevelElement.GetString(), StringComparison.OrdinalIgnoreCase)); + } + } + + // Validate permissions properties and their values exists in autoentities + JsonElement permissionsElement = entityElement.GetProperty("permissions"); + + bool roleExists = permissionsElement[0].TryGetProperty("role", out JsonElement roleElement); + Assert.AreEqual(expected: true, actual: roleExists); + Assert.AreEqual(expected: role, actual: roleElement.GetString()); + + bool entityActionsExists = permissionsElement[0].TryGetProperty("actions", out JsonElement entityActionsElement); + Assert.AreEqual(expected: true, actual: entityActionsExists); + bool entityActionOpExists = entityActionsElement[0].TryGetProperty("action", out JsonElement entityActionOpElement); + Assert.AreEqual(expected: true, actual: entityActionOpExists); + Assert.IsTrue(string.Equals(entityActionOp.ToString(), entityActionOpElement.GetString(), StringComparison.OrdinalIgnoreCase)); + } + } + #nullable disable ///