diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 80cfd953ad..fb759befb7 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -693,6 +693,207 @@ } } }, + "autoentities": { + "type": "object", + "description": "Auto-entity definitions for wildcard pattern matching", + "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": "T-SQL LIKE pattern to include database objects (e.g., '%.%')", + "items": { + "type": "string" + }, + "default": null + }, + "exclude": { + "type": "array", + "description": "T-SQL LIKE pattern to exclude database objects (e.g., 'sales.%')", + "items": { + "type": "string" + }, + "default": null + }, + "name": { + "type": "string", + "description": "Interpolation syntax for entity naming, must be unique for every entity inside the pattern", + "default": "{schema}{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": { + "oneOf": [ + { + "type": "boolean", + "description": "Enable/disable all DML tools with default settings." + }, + { + "type": "object", + "description": "Individual DML tools configuration", + "additionalProperties": false, + "properties": { + "describe-entities": { + "type": "boolean", + "description": "Enable/disable the describe-entities tool.", + "default": false + }, + "create-record": { + "type": "boolean", + "description": "Enable/disable the create-record tool.", + "default": false + }, + "read-records": { + "type": "boolean", + "description": "Enable/disable the read-records tool.", + "default": false + }, + "update-record": { + "type": "boolean", + "description": "Enable/disable the update-record tool.", + "default": false + }, + "delete-record": { + "type": "boolean", + "description": "Enable/disable the delete-record tool.", + "default": false + }, + "execute-entity": { + "type": "boolean", + "description": "Enable/disable the execute-entity tool.", + "default": false + } + } + } + ] + } + } + }, + "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": null + } + } + } + } + }, + "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 and/or GraphQL", diff --git a/src/Config/Converters/RuntimeAutoEntitiesConverter.cs b/src/Config/Converters/RuntimeAutoEntitiesConverter.cs new file mode 100644 index 0000000000..bc0abf8ff4 --- /dev/null +++ b/src/Config/Converters/RuntimeAutoEntitiesConverter.cs @@ -0,0 +1,36 @@ +// 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; + +/// +/// Custom JSON converter for RuntimeAutoEntities. +/// +class RuntimeAutoEntitiesConverter : JsonConverter +{ + /// + public override RuntimeAutoEntities? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + Dictionary? autoEntities = + JsonSerializer.Deserialize>(ref reader, options); + + return new RuntimeAutoEntities(autoEntities); + } + + /// + public override void Write(Utf8JsonWriter writer, RuntimeAutoEntities value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach ((string key, AutoEntity autoEntity) in value) + { + 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..b580a8f5c5 --- /dev/null +++ b/src/Config/ObjectModel/AutoEntity.cs @@ -0,0 +1,18 @@ +// 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( + [property: JsonPropertyName("patterns")] AutoEntityPatterns Patterns, + [property: JsonPropertyName("template")] AutoEntityTemplate Template, + [property: JsonPropertyName("permissions")] EntityPermission[] Permissions +); diff --git a/src/Config/ObjectModel/AutoEntityPatterns.cs b/src/Config/ObjectModel/AutoEntityPatterns.cs new file mode 100644 index 0000000000..037202a48e --- /dev/null +++ b/src/Config/ObjectModel/AutoEntityPatterns.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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 (default: null) +/// T-SQL LIKE pattern to exclude database objects (default: null) +/// Interpolation syntax for entity naming (must be unique, default: null) +public record AutoEntityPatterns( + [property: JsonPropertyName("include")] string? Include = null, + [property: JsonPropertyName("exclude")] string? Exclude = null, + [property: JsonPropertyName("name")] string? Name = null +); diff --git a/src/Config/ObjectModel/AutoEntityTemplate.cs b/src/Config/ObjectModel/AutoEntityTemplate.cs new file mode 100644 index 0000000000..2baefcf52a --- /dev/null +++ b/src/Config/ObjectModel/AutoEntityTemplate.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Defines the template configuration for auto-entities. +/// +/// MCP endpoint configuration +/// REST endpoint configuration +/// GraphQL endpoint configuration +/// Health check configuration +/// Cache configuration +public record AutoEntityTemplate( + [property: JsonPropertyName("mcp")] AutoEntityMcpTemplate? Mcp = null, + [property: JsonPropertyName("rest")] AutoEntityRestTemplate? Rest = null, + [property: JsonPropertyName("graphql")] AutoEntityGraphQLTemplate? GraphQL = null, + [property: JsonPropertyName("health")] AutoEntityHealthTemplate? Health = null, + [property: JsonPropertyName("cache")] EntityCacheOptions? Cache = null +); + +/// +/// MCP template configuration for auto-entities. +/// +/// Enable/disable DML tool (default: true) +public record AutoEntityMcpTemplate( + [property: JsonPropertyName("dml-tool")] bool DmlTool = true +); + +/// +/// REST template configuration for auto-entities. +/// +/// Enable/disable REST endpoint (default: true) +public record AutoEntityRestTemplate( + [property: JsonPropertyName("enabled")] bool Enabled = true +); + +/// +/// GraphQL template configuration for auto-entities. +/// +/// Enable/disable GraphQL endpoint (default: true) +public record AutoEntityGraphQLTemplate( + [property: JsonPropertyName("enabled")] bool Enabled = true +); + +/// +/// Health check template configuration for auto-entities. +/// +/// Enable/disable health check endpoint (default: true) +public record AutoEntityHealthTemplate( + [property: JsonPropertyName("enabled")] bool Enabled = true +); diff --git a/src/Config/ObjectModel/RuntimeAutoEntities.cs b/src/Config/ObjectModel/RuntimeAutoEntities.cs new file mode 100644 index 0000000000..590984c0a9 --- /dev/null +++ b/src/Config/ObjectModel/RuntimeAutoEntities.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.Converters; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Represents a collection of auto-entity definitions. +/// Each definition is keyed by a unique definition name. +/// +[JsonConverter(typeof(RuntimeAutoEntitiesConverter))] +public class RuntimeAutoEntities : IEnumerable> +{ + private readonly Dictionary _autoEntities; + + /// + /// Creates a new RuntimeAutoEntities collection. + /// + /// Dictionary of auto-entity definitions keyed by definition name. + public RuntimeAutoEntities(Dictionary? autoEntities = null) + { + _autoEntities = autoEntities ?? new Dictionary(); + } + + /// + /// Gets an auto-entity definition by its definition name. + /// + /// The name of the auto-entity definition. + /// The auto-entity definition. + public AutoEntity this[string definitionName] => _autoEntities[definitionName]; + + /// + /// Tries to get an auto-entity definition by its definition name. + /// + /// The name of the auto-entity definition. + /// The auto-entity definition if found. + /// True if the auto-entity definition was found, false otherwise. + public bool TryGetValue(string definitionName, [NotNullWhen(true)] out AutoEntity? autoEntity) + { + return _autoEntities.TryGetValue(definitionName, out autoEntity); + } + + /// + /// Determines whether an auto-entity definition with the specified name exists. + /// + /// The name of the auto-entity definition. + /// True if the auto-entity definition exists, false otherwise. + public bool ContainsKey(string definitionName) + { + return _autoEntities.ContainsKey(definitionName); + } + + /// + /// Gets the number of auto-entity definitions in the collection. + /// + public int Count => _autoEntities.Count; + + /// + /// Gets all the auto-entity definition names. + /// + public IEnumerable Keys => _autoEntities.Keys; + + /// + /// Gets all the auto-entity definitions. + /// + public IEnumerable Values => _autoEntities.Values; + + public IEnumerator> GetEnumerator() + { + return _autoEntities.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index a450e1265c..e2905680ae 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -25,6 +25,9 @@ public record RuntimeConfig [JsonPropertyName("azure-key-vault")] public AzureKeyVaultOptions? AzureKeyVault { get; init; } + [JsonPropertyName("autoentities")] + public RuntimeAutoEntities? AutoEntities { get; init; } + public virtual RuntimeEntities Entities { get; init; } public DataSourceFiles? DataSourceFiles { get; init; } diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index 7dcf837d08..9997eda401 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -101,4 +101,37 @@ public async Task FailLoadMultiDataSourceConfigDuplicateEntities(string configPa Assert.IsTrue(error.StartsWith("Deserialization of the configuration file failed during a post-processing step.")); Assert.IsTrue(error.Contains("An item with the same key has already been added.")); } + + /// + /// Test validates that a config file with autoentities section can be loaded successfully. + /// + [TestMethod] + public async Task CanLoadConfigWithAutoEntities() + { + string configPath = "dab-config.AutoEntities.json"; + string fileContents = await File.ReadAllTextAsync(configPath); + + IFileSystem fs = new MockFileSystem(new Dictionary() { { configPath, new MockFileData(fileContents) } }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + Assert.IsTrue(loader.TryLoadConfig(configPath, out RuntimeConfig runtimeConfig), "Failed to load config with autoentities"); + Assert.IsNotNull(runtimeConfig.AutoEntities, "AutoEntities should not be null"); + Assert.AreEqual(2, runtimeConfig.AutoEntities.Count, "Should have 2 auto-entity definitions"); + + // Verify first auto-entity definition + Assert.IsTrue(runtimeConfig.AutoEntities.ContainsKey("all-tables"), "Should contain 'all-tables' definition"); + AutoEntity allTables = runtimeConfig.AutoEntities["all-tables"]; + Assert.AreEqual("%.%", allTables.Patterns.Include, "Include pattern should match"); + Assert.AreEqual("sys.%", allTables.Patterns.Exclude, "Exclude pattern should match"); + Assert.AreEqual("{schema}_{object}", allTables.Patterns.Name, "Name pattern should match"); + Assert.AreEqual(1, allTables.Permissions.Length, "Should have 1 permission"); + + // Verify second auto-entity definition + Assert.IsTrue(runtimeConfig.AutoEntities.ContainsKey("admin-tables"), "Should contain 'admin-tables' definition"); + AutoEntity adminTables = runtimeConfig.AutoEntities["admin-tables"]; + Assert.AreEqual("admin.%", adminTables.Patterns.Include, "Include pattern should match"); + Assert.IsNull(adminTables.Patterns.Exclude, "Exclude pattern should be null"); + Assert.AreEqual(1, adminTables.Permissions.Length, "Should have 1 permission"); + } } diff --git a/src/Service.Tests/dab-config.AutoEntities.json b/src/Service.Tests/dab-config.AutoEntities.json new file mode 100644 index 0000000000..af9820a7be --- /dev/null +++ b/src/Service.Tests/dab-config.AutoEntities.json @@ -0,0 +1,79 @@ +{ + "$schema": "dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "Server=localhost;Database=TestDB;User ID=sa;Password=PLACEHOLDER;TrustServerCertificate=true" + }, + "runtime": { + "rest": { + "path": "/api", + "enabled": true + }, + "graphql": { + "path": "/graphql", + "enabled": true + } + }, + "autoentities": { + "all-tables": { + "patterns": { + "include": "%.%", + "exclude": "sys.%", + "name": "{schema}_{object}" + }, + "template": { + "mcp": { + "dml-tool": true + }, + "rest": { + "enabled": true + }, + "graphql": { + "enabled": true + }, + "health": { + "enabled": true + }, + "cache": { + "enabled": false + } + }, + "permissions": [ + { + "role": "anonymous", + "actions": ["read"] + } + ] + }, + "admin-tables": { + "patterns": { + "include": "admin.%" + }, + "template": { + "rest": { + "enabled": true + }, + "graphql": { + "enabled": false + } + }, + "permissions": [ + { + "role": "admin", + "actions": ["*"] + } + ] + } + }, + "entities": { + "Book": { + "source": "books", + "permissions": [ + { + "role": "anonymous", + "actions": ["read"] + } + ] + } + } +}