From b4b47fd649a439f12ea2aaeece1a1391d98fbb7b Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Wed, 25 Feb 2026 21:36:29 -0700 Subject: [PATCH 1/5] Refactor DataApiBuilder Tests and Update Configuration Files - Updated ContainerResourceCreationTests to improve exception handling and validation. - Removed outdated configuration files (dab-config.json, dab-config-2.json). - Added new configuration files with updated schemas and entities (dab-config-anonymous.json, dab-config-authenticated.json, dab-config-anonymous-2.json). - Introduced SQL script for creating a Star Trek database with relevant tables and data. - Enhanced test coverage for configuration file handling and mount behavior. - Added GlobalSuppressions.cs for code analysis suppression in BlazorApp. --- ...osting.Azure.DataApiBuilder.AppHost.csproj | 6 +- .../Program.cs | 34 +- .../dab-config.json | 5 +- .../sql-server.sql} | 0 .../sql-server/configure-db.sh | 48 -- .../sql-server/entrypoint.sh | 8 - .../Components/Pages/Home.razor | 6 +- .../GlobalSuppressions.cs | 8 + .../TrekApiClient.cs | 61 +- ...Aspire.Hosting.Azure.DataApiBuilder.csproj | 1 - .../DataApiBuilderContainerImageTags.cs | 10 +- .../DataApiBuilderContainerResource.cs | 14 +- .../DataApiBuilderHostingExtension.cs | 125 +++- .../README.md | 134 +++-- .../AppHostTests.cs | 2 +- ....Hosting.Azure.DataApiBuilder.Tests.csproj | 13 +- .../ContainerResourceCreationTests.cs | 556 +++++++++++++++++- .../dab-folder-config-anonymous-2.json | 26 + .../dab-folder-config-anonymous.json | 26 + ...fig-2.json => dab-config-anonymous-2.json} | 2 +- ...-config.json => dab-config-anonymous.json} | 2 +- .../dab-config-authenticated.json | 29 + 22 files changed, 923 insertions(+), 193 deletions(-) rename examples/data-api-builder/{database/create_database.sql => CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server.sql} (100%) delete mode 100755 examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server/configure-db.sh delete mode 100755 examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server/entrypoint.sh create mode 100644 examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/GlobalSuppressions.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/config-folder/dab-folder-config-anonymous-2.json create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/config-folder/dab-folder-config-anonymous.json rename tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/{dab-config-2.json => dab-config-anonymous-2.json} (91%) rename tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/{dab-config.json => dab-config-anonymous.json} (91%) create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-authenticated.json diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost.csproj b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost.csproj index 957385921..2e7412939 100644 --- a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost.csproj +++ b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,6 +15,7 @@ + @@ -22,6 +23,9 @@ Always + + Always + diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/Program.cs b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/Program.cs index 09c57225e..68b4418ed 100644 --- a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/Program.cs +++ b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/Program.cs @@ -1,23 +1,33 @@ var builder = DistributedApplication.CreateBuilder(args); -// Add a SQL Server container -var sqlServer = builder - .AddSqlServer("sql"); +var sqlScript = File.ReadAllText("./sql-server.sql"); -var sqlDatabase = sqlServer.AddDatabase("trek"); +var sqlDatabase = builder + .AddSqlServer("sql") + .WithDataVolume("trek-sql-data") + .AddDatabase("trek") + .WithCreationScript(sqlScript); -// Populate the database with the schema and data -sqlServer - .WithBindMount("./sql-server", target: "/usr/config") - .WithBindMount("../database", target: "/docker-entrypoint-initdb.d") - .WithEntrypoint("/usr/config/entrypoint.sh"); +var dabConfig = new FileInfo("./dab-config.json"); -// Add Data API Builder using dab-config.json var dab = builder.AddDataAPIBuilder("dab") - .WaitFor(sqlServer) + .WithImageTag("1.7.86-rc") + .WithConfigFile(dabConfig) + .WaitFor(sqlDatabase) .WithReference(sqlDatabase); +var mcp = builder + .AddMcpInspector("mcp-inspector", options => + { + options.InspectorVersion = "0.20.0"; + }) + .WithMcpServer(dab, transportType: McpTransportType.StreamableHttp) + .WithParentRelationship(dab) + .WithEnvironment("DANGEROUSLY_OMIT_AUTH", "true") + .WaitFor(dab); + builder.AddProject("blazorApp") - .WithReference(dab); + .WithReference(dab) + .WaitFor(dab); builder.Build().Run(); diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/dab-config.json b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/dab-config.json index 69d8b687c..95b57589b 100644 --- a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/dab-config.json +++ b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/dab-config.json @@ -1,5 +1,5 @@ { - "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.3.19/dab.draft.schema.json", + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.7.86-rc/dab.draft.schema.json", "data-source": { "database-type": "mssql", "connection-string": "@env('ConnectionStrings__trek')", @@ -16,6 +16,9 @@ "path": "/graphql", "allow-introspection": true }, + "mcp": { + "enabled": true + }, "host": { "cors": { "origins": [ diff --git a/examples/data-api-builder/database/create_database.sql b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server.sql similarity index 100% rename from examples/data-api-builder/database/create_database.sql rename to examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server.sql diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server/configure-db.sh b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server/configure-db.sh deleted file mode 100755 index ce7723793..000000000 --- a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server/configure-db.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash - -# set -x - -# Adapted from: https://github.com/microsoft/mssql-docker/blob/80e2a51d0eb1693f2de014fb26d4a414f5a5add5/linux/preview/examples/mssql-customize/configure-db.sh - -# Wait 60 seconds for SQL Server to start up by ensuring that -# calling SQLCMD does not return an error code, which will ensure that sqlcmd is accessible -# and that system and user databases return "0" which means all databases are in an "online" state -# https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-databases-transact-sql?view=sql-server-2017 - -dbstatus=1 -errcode=1 -start_time=$SECONDS -end_by=$((start_time + 60)) - -echo "Starting check for SQL Server start-up at $start_time, will end at $end_by" - -while [[ $SECONDS -lt $end_by && ( $errcode -ne 0 || ( -z "$dbstatus" || $dbstatus -ne 0 ) ) ]]; do - dbstatus="$(/opt/mssql-tools18/bin/sqlcmd -h -1 -t 1 -U sa -P "$MSSQL_SA_PASSWORD" -C -Q "SET NOCOUNT ON; Select SUM(state) from sys.databases")" - errcode=$? - sleep 1 -done - -elapsed_time=$((SECONDS - start_time)) -echo "Stopped checking for SQL Server start-up after $elapsed_time seconds (dbstatus=$dbstatus,errcode=$errcode,seconds=$SECONDS)" - -if [[ $dbstatus -ne 0 ]] || [[ $errcode -ne 0 ]]; then - echo "SQL Server took more than 60 seconds to start up or one or more databases are not in an ONLINE state" - echo "dbstatus = $dbstatus" - echo "errcode = $errcode" - exit 1 -fi - -# Loop through the .sql files in the root of /docker-entrypoint-initdb.d and execute them with sqlcmd -for f in $(find /docker-entrypoint-initdb.d -maxdepth 1 -type f -name "*.sql" | sort); do - echo "- A -=- Processing $f file..." - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -C -d master -i "$f" -done - -# Loop through each subdirectory in /docker-entrypoint-initdb.d -for dir in $(find /docker-entrypoint-initdb.d -mindepth 1 -maxdepth 1 -type d | sort); do - # Loop through the .sql files in each subdirectory and execute them with sqlcmd - for f in $(find "$dir" -maxdepth 1 -type f -name "*.sql" | sort); do - echo "- B -=- Processing $f file in directory $dir..." - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -C -d master -i "$f" - done -done \ No newline at end of file diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server/entrypoint.sh b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server/entrypoint.sh deleted file mode 100755 index fda39d142..000000000 --- a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.AppHost/sql-server/entrypoint.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -# Start the script to create the DB and user -/usr/config/configure-db.sh & - -# Start SQL Server -/opt/mssql/bin/sqlservr - diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/Components/Pages/Home.razor b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/Components/Pages/Home.razor index 292839671..60860905a 100644 --- a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/Components/Pages/Home.razor +++ b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/Components/Pages/Home.razor @@ -5,9 +5,7 @@ Home -

Aspire Data API Builder Integration

- -

Welcome to your demo app 🖖

+

Data API Builder

@if (series is null) { @@ -15,7 +13,7 @@ } else { -
Here some StarTrek series
+
Here are some StarTrek series 🖖
    @foreach (var s in series) { diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/GlobalSuppressions.cs b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/GlobalSuppressions.cs new file mode 100644 index 000000000..590a95472 --- /dev/null +++ b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Performance", "CA1873:Avoid potentially expensive logging", Justification = "", Scope = "member", Target = "~M:CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp.TrekApiClient.GetSeriesAsync~System.Threading.Tasks.Task{System.Collections.Generic.List{CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp.Series}}")] diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/TrekApiClient.cs b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/TrekApiClient.cs index 6d75dabd4..cec3462c4 100644 --- a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/TrekApiClient.cs +++ b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/TrekApiClient.cs @@ -1,45 +1,56 @@ -using System.Net.Http; -using System.Text.Json.Serialization; -using System.Text.Json; - +using System.Text.Json.Serialization; namespace CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp; -public class TrekApiClient +public class TrekApiClient(HttpClient httpClient, ILogger logger) { - private readonly HttpClient httpClient; - private readonly ILogger logger; - - public TrekApiClient(HttpClient httpClient, ILogger logger) - { - this.httpClient = httpClient; - this.logger = logger; - } - public async Task> GetSeriesAsync() { - try + var response = await httpClient.GetAsync($"api/series"); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync>(); + + if(result is null) { - var result = await httpClient.GetFromJsonAsync($"api/series"); - return result?.value ?? new List(); + logger.LogError("Failed to deserialize response from Data API Builder."); + throw new Exception("Failed to deserialize response from Data API Builder."); } - catch (Exception ex) + + if (result.Error is not null) { - logger.LogError(ex, "An error occurred while fetching series."); - return new List(); + logger.LogError("API error: {Code} - {Message}", result.Error.Code, result.Error.Message); + throw new Exception($"{result.Error.Code}: {result.Error.Message}"); } + + return result.Value ?? []; } } -public class SeriesList +public class DabResponse { - public List value { get; set; } = new List(); + [JsonPropertyName("value")] + public List? Value { get; set; } + + [JsonPropertyName("nextLink")] + public string? NextLink { get; set; } + + [JsonPropertyName("error")] + public DabError? Error { get; set; } +} + +public class DabError +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public int Status { get; set; } } public class Series { - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public int Id { get; set; } public required string Name { get; set; } - } diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.csproj b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.csproj index f96e06307..cfb0ffbc8 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.csproj +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.csproj @@ -10,7 +10,6 @@ - \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderContainerImageTags.cs index 5f166f95d..a9fe443f0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderContainerImageTags.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderContainerImageTags.cs @@ -1,8 +1,16 @@ namespace CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder; +/// +/// Container image tags for the Data API Builder container. +/// internal static class DataApiBuilderContainerImageTags { + /// The container registry. public const string Registry = "mcr.microsoft.com"; + + /// The container image name. public const string Image = "azure-databases/data-api-builder"; - public const string Tag = "1.6.77"; + + /// The default container image tag. + public const string Tag = "1.6.87"; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderContainerResource.cs index 14c912e15..f05f54852 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderContainerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderContainerResource.cs @@ -1,17 +1,21 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// A resource that represents Data Api Builder. +/// A resource that represents Data API Builder. /// /// The name of the resource. /// An optional container entrypoint. - -public class DataApiBuilderContainerResource(string name, string? entrypoint = null) +public sealed class DataApiBuilderContainerResource(string name, string? entrypoint = null) : ContainerResource(name, entrypoint), IResourceWithServiceDiscovery { internal const string HttpEndpointName = "http"; - internal const string HttpsEndpointName = "https"; internal const int HttpEndpointPort = 5000; - internal const int HttpsEndpointPort = 5001; + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the primary HTTP endpoint for the Data API Builder container. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, HttpEndpointName); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs index 461fea0b4..657d8e722 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs @@ -1,25 +1,19 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; using Aspire.Hosting.ApplicationModel; using CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder; namespace Aspire.Hosting; /// -/// Provides extension methods for adding DataApiBuilder api to an . +/// Provides extension methods for adding Data API Builder to an . /// public static class DataApiBuilderHostingExtension { /// - /// Adds a DataAPIBuilder application to the application model. Executes the pre-built containerized DataAPIBuilder engine. + /// Adds a Data API Builder application to the application model. Executes the pre-built containerized Data API Builder engine. /// /// The to add the resource to. /// The name of the resource. - /// The path to the config or schema file(s) for Data API Builder." - /// - /// At this time, this Aspire DAB integration only supports HTTPS ports. - /// You can deploy DAB with HTTPS and custom certs in production. - /// + /// The path to the config or schema file(s) for Data API Builder. /// A reference to the . public static IResourceBuilder AddDataAPIBuilder(this IDistributedApplicationBuilder builder, [ResourceName] string name, @@ -29,23 +23,20 @@ public static IResourceBuilder AddDataAPIBuilde } /// - /// Adds a DataAPIBuilder application to the application model. Executes the pre-built containerized DataAPIBuilder engine. + /// Adds a Data API Builder application to the application model. Executes the pre-built containerized Data API Builder engine. /// /// The to add the resource to. /// The name of the resource. - /// The HTTP port number for the Data API Builder container." - /// The path to the config or schema file(s) for Data API Builder." - /// - /// At this time, this Aspire DAB integration only supports HTTPS ports. - /// You can deploy DAB with HTTPS and custom certs in production. - /// + /// The HTTP port number for the Data API Builder container. + /// The path to the config or schema file(s) for Data API Builder. /// A reference to the . public static IResourceBuilder AddDataAPIBuilder(this IDistributedApplicationBuilder builder, [ResourceName] string name, int? httpPort = null, params string[] configFilePaths) { - ArgumentNullException.ThrowIfNull("Service name must be specified.", nameof(name)); + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); var resource = new DataApiBuilderContainerResource(name); @@ -58,12 +49,6 @@ public static IResourceBuilder AddDataAPIBuilde name: DataApiBuilderContainerResource.HttpEndpointName) .WithDataApiBuilderDefaults(); - // Use default config file path if no paths are provided - if (configFilePaths is []) - { - configFilePaths = ["./dab-config.json"]; - } - foreach (var configFilePath in configFilePaths) { var configFileName = File.Exists(configFilePath) @@ -76,6 +61,100 @@ public static IResourceBuilder AddDataAPIBuilde return rb; } + /// + /// Adds one or more Data API Builder configuration files to the container as read-only bind mounts. + /// This method can be called multiple times to add additional files. + /// + /// The to configure. + /// One or more objects pointing to config or schema files. + /// A reference to the for chaining. + /// Thrown when or is . + /// Thrown when a specified file does not exist. + /// Thrown when a file with the same name is already mounted. + public static IResourceBuilder WithConfigFile( + this IResourceBuilder builder, + params FileInfo[] configFiles) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configFiles); + + foreach (var file in configFiles) + { + ArgumentNullException.ThrowIfNull(file); + + if (!file.Exists) + { + throw new FileNotFoundException($"Config file not found: {file.FullName}"); + } + + string targetPath = $"/App/{file.Name}"; + ThrowIfMountTargetExists(builder, targetPath, file.FullName); + + builder.WithBindMount(file.FullName, targetPath, isReadOnly: true); + } + + return builder; + } + + /// + /// Adds all files from one or more directories to the container as individual read-only bind mounts. + /// Each file in the directory is mounted individually (not as a folder mount). Only top-level files are included. + /// This method can be called multiple times to add files from additional directories. + /// + /// The to configure. + /// One or more objects pointing to directories containing config files. + /// A reference to the for chaining. + /// Thrown when or is . + /// Thrown when a specified directory does not exist. + /// Thrown when a file with the same name is already mounted. + public static IResourceBuilder WithConfigFolder( + this IResourceBuilder builder, + params DirectoryInfo[] configFolders) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configFolders); + + foreach (var folder in configFolders) + { + ArgumentNullException.ThrowIfNull(folder); + + if (!folder.Exists) + { + throw new DirectoryNotFoundException($"Config directory not found: {folder.FullName}"); + } + + foreach (var file in folder.GetFiles()) + { + string targetPath = $"/App/{file.Name}"; + ThrowIfMountTargetExists(builder, targetPath, file.FullName); + + builder.WithBindMount(file.FullName, targetPath, isReadOnly: true); + } + } + + return builder; + } + + private static void ThrowIfMountTargetExists( + IResourceBuilder builder, + string targetPath, + string sourceDescription) + { + if (builder.Resource.TryGetAnnotationsOfType(out var existingMounts)) + { + foreach (var mount in existingMounts) + { + if (string.Equals(mount.Target, targetPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"A config file is already mounted to '{targetPath}'. " + + $"The file '{sourceDescription}' conflicts with an existing mount. " + + $"Each config file must have a unique filename."); + } + } + } + } + private static IResourceBuilder WithDataApiBuilderDefaults( this IResourceBuilder builder) => builder.WithOtlpExporter() diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md index 9df60a084..33455ade1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md @@ -1,13 +1,15 @@ -# CommunityToolkit.Hosting.Azure.DataApiBuilder +# CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder ## Overview -This Aspire Integration runs [Data API builder](https://aka.ms/dab/docs) in a container. Data API builder generates secure, feature-rich REST and GraphQL endpoints for Tables, Views and Stored Procedures performing CRUD (Create, Read, Update, Delete, Execute) operations against Azure SQL Database, SQL Server, PostgreSQL, MySQL and Azure CosmosDB. +This Aspire Integration runs [Data API builder](https://aka.ms/dab/docs) in a container. Data API builder generates secure, feature-rich REST and GraphQL endpoints for Tables, Views and Stored Procedures performing CRUD (Create, Read, Update, Delete, Execute) operations against Azure SQL Database, SQL Server, PostgreSQL, MySQL and Azure CosmosDB. ## Usage ### Example 1: Single data source +The docs for a basic configuration file are at [MS Learn](https://learn.microsoft.com/en-us/azure/data-api-builder/configuration/). + ```csharp var builder = DistributedApplication.CreateBuilder(args); @@ -15,7 +17,10 @@ var sqlDatabase = builder .AddSqlServer("your-server-name") .AddDatabase("your-database-name"); +var dabConfig = new FileInfo("./dab-config.json"); + var dab = builder.AddDataAPIBuilder("dab") + .WithConfigFile(dabConfig) .WithReference(sqlDatabase) .WaitFor(sqlDatabase); @@ -28,6 +33,8 @@ builder.Build().Run(); ### Example 2: Multiple data sources +The docs for multi-source configuration are at [MS Learn](https://learn.microsoft.com/en-us/azure/data-api-builder/concept/config/multi-data-source). + ```csharp var builder = DistributedApplication.CreateBuilder(args); @@ -39,9 +46,11 @@ var sqlDatabase2 = builder .AddSqlServer("your-server-name") .AddDatabase("your-database-name"); -var dab = builder.AddDataAPIBuilder("dab", - "./dab-config-1.json", - "./dab-config-2.json") +var dabConfig1 = new FileInfo("./dab-config-1.json"); +var dabConfig2 = new FileInfo("./dab-config-2.json"); + +var dab = builder.AddDataAPIBuilder("dab") + .WithConfigFile(dabConfig1, dabConfig2) .WithReference(sqlDatabase1) .WithReference(sqlDatabase2) .WaitFor(sqlDatabase1) @@ -54,7 +63,7 @@ var app = builder builder.Build().Run(); ``` -> Note: All files are mounted/copied to the same `/App` folder. +> Note: All files are mounted/copied to the same `/App` folder. Each config file must have a unique filename. If a duplicate filename is detected, a friendly `InvalidOperationException` is thrown. ### Example 3: Cosmos DB and a schema file @@ -65,9 +74,11 @@ var cosmosdb = builder .AddAzureCosmosDB("myNewCosmosAccountName") .AddDatabase("myCosmosDatabaseName"); -var dab = builder.AddDataAPIBuilder("dab", - "./dab-config.json", - "./schema.graphql") +var dabConfig = new FileInfo("./dab-config.json"); +var dabSchema = new FileInfo("./schema.graphql"); + +var dab = builder.AddDataAPIBuilder("dab") + .WithConfigFile(dabConfig, dabSchema) .WithReference(cosmosdb) .WaitFor(cosmosdb); @@ -80,6 +91,8 @@ builder.Build().Run(); ### Example 4: Connection string-only +Sometimes your SQL Server is installed locally or part of your development environment and doesn't need to be created by Aspire. In these cases, you can use the `AddConnectionString` method. This also works for any data source type supported. + ```csharp var builder = DistributedApplication.CreateBuilder(args); @@ -87,6 +100,7 @@ var sqlDatabase = builder .AddConnectionString("your-cs-name"); var dab = builder.AddDataAPIBuilder("dab") + .WithConfigFile(new FileInfo("./dab-config.json")) .WithReference(sqlDatabase); var app = builder @@ -96,26 +110,91 @@ var app = builder builder.Build().Run(); ``` -### Configuration +### Example 5: Custom image tag + +In some cases, including those times when you want to use a release candidate (RC) or a private build of the Data API builder container image, you may want to specify a custom image or image tag. For a custom image tag, you can do this with the `WithImageTag` method, a standard method in Aspire. + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var sqlDatabase = builder + .AddConnectionString("your-cs-name"); + +var dab = builder.AddDataAPIBuilder("dab") + .WithConfigFile(new FileInfo("./dab-config.json")) + .WithReference(sqlDatabase) + .WithImageTag("1.7.86-rc"); // specify a custom image tag + +var app = builder + .AddProject() + .WithReference(dab); + +builder.Build().Run(); +``` -- `name` - The name of the resource. -- `port` - The optional port number for the Data API builder container. Defaults to `random`. -- `configFilePaths` - Opiotnal paths to the config/schema file(s) for Data API builder. Default is `./dab-config.json`. -### Data API builder Container Image Configuration +### Example 6: Testing with MCP Inspector + +Because version 1.7 and later of Data API builder has MCP capability for agentic applications, you can test and debug your instance with the MCP Inspector which is available through Aspire's CommunityToolkit.Aspire.Hosting.McpInspector version 13.1.1 or later. -You can specify custom registry/image/tag values by using the `WithImageRegistry`/`WithImage`/`WithImageTag` methods: ```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var sqlDatabase = builder + .AddConnectionString("your-cs-name"); + var dab = builder.AddDataAPIBuilder("dab") - .WithImageRegistry("mcr.microsoft.com") - .WithImage("azure-databases/data-api-builder") - .WithImageTag("latest"); + .WithConfigFile(new FileInfo("./dab-config.json")) + .WithReference(sqlDatabase) + .WithImageTag("1.7.86-rc"); // specify a custom image tag + +var mcp = builder + .AddMcpInspector("mcp-inspector", options => + { + options.InspectorVersion = "0.20.0"; + }) + .WithMcpServer(dab, transportType: McpTransportType.StreamableHttp) + .WithParentRelationship(dab) + .WithEnvironment("DANGEROUSLY_OMIT_AUTH", "true") + .WaitFor(dab); + +var app = builder + .AddProject() + .WithReference(dab); + +builder.Build().Run(); ``` -### OpenTelemetry Instrumentation +## Toolkit Documentation + +The following methods are available for configuring the Data API builder container in Aspire: + +| Method | Parameter | Description | +|-|-|-| +|AddDataAPIBuilder() || Adds a Data API builder container to the application.| +|| string name | The name of the resource. | +|| int httpPort | Optional HTTP port number for the Data API builder container. Defaults to a random port. | +| WithConfigFile() || Adds one or more config or schema files to the container. +|| FileInfo[] files | The config or schema file(s) to add. +| WithConfigFolder() || Adds all files from the specified folder(s) to the container. +|| DirectoryInfo[] folders | The folder(s) from which to add all top-level config or schema files. + +### Health Checks + +If your Data API builder configuration requires authentication (e.g., EasyAuth, JWT, or any provider other than `Simulator`), the `/health` endpoint may return a non-200 status even when the service is otherwise healthy. In development, consider using the `Simulator` authentication provider in your `dab-config.json` to avoid health check failures: +> +> ```json +> "authentication": { +> "provider": "Simulator" +> } +> ``` -The Data API builder integration automatically configures OpenTelemetry (OTEL) instrumentation for distributed tracing and metrics. The integration uses the standard `.WithOtlpExporter()` method which sets up the necessary OTEL environment variables that Data API builder automatically recognizes. +For more information about Data API builder health checks, see the [official documentation](https://learn.microsoft.com/azure/data-api-builder/concept/monitor/health-checks). + +For more information about Data API builder's Simulator authentication provider, see the [official documentation](https://learn.microsoft.com/en-us/azure/data-api-builder/concept/security/how-to-authenticate-simulator). + +### OpenTelemetry Instrumentation To enable OTEL telemetry in Data API builder, add the following configuration to your `dab-config.json` file: @@ -135,20 +214,5 @@ To enable OTEL telemetry in Data API builder, add the following configuration to } ``` -The configuration includes the following settings: -- `enabled`: Enables/disables OTEL telemetry (default: `false`) -- `service-name`: Logical name for the service in traces. Uses the `@env('OTEL_SERVICE_NAME')` syntax to reference the environment variable automatically set by Aspire -- `endpoint`: OTEL collector endpoint URL. Uses `@env('OTEL_EXPORTER_OTLP_ENDPOINT')` to reference the Aspire-provided endpoint -- `exporter-protocol`: Protocol for exporting telemetry. Set to `grpc` for efficient binary transport -- `headers`: Custom headers for OTEL export. Uses `@env('OTEL_EXPORTER_OTLP_HEADERS')` to reference Aspire-provided headers - -With this configuration, Data API builder will: -- Export traces and metrics to the Aspire dashboard via OTLP (OpenTelemetry Protocol) -- Automatically use the OTEL endpoint provided by the Aspire app host -- Include telemetry for REST and GraphQL operations, database queries, and system metrics - For more information about Data API builder telemetry, see the [official documentation](https://learn.microsoft.com/azure/data-api-builder/concept/monitor/open-telemetry). -## Known Issues - -The current implementation of the Data API builder Aspire integration does not support HTTPS endpoints. However, this is only a dev-time consideration. Service discovery when published can use HTTPS without any problems. diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/AppHostTests.cs index 8b1b0d52a..ab71bacb1 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/AppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/AppHostTests.cs @@ -31,6 +31,6 @@ public async Task CanGetSeries() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var series = await response.Content.ReadFromJsonAsync(); Assert.NotNull(series); - Assert.Equal(5, series.value.Count); + Assert.Equal(5, series.Value.Count); } } \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests.csproj index 04dde9f22..c03d30f4c 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests.csproj +++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests.csproj @@ -12,10 +12,19 @@ - + Always - + + Always + + + Always + + + Always + + Always diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/ContainerResourceCreationTests.cs index 61c57534f..71c8ba1f2 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/ContainerResourceCreationTests.cs @@ -8,7 +8,7 @@ public void AddDataAPIBuilderBuilderShouldNotBeNull() { IDistributedApplicationBuilder builder = null!; - Assert.Throws(() => builder.AddDataAPIBuilder("dab")); + Assert.Throws(() => builder.AddDataAPIBuilder("dab")); } [Fact] @@ -35,7 +35,10 @@ public void AddDataAPIBuilderContainerDetailsSetOnResource() Assert.NotNull(resource); Assert.Equal("dab", resource.Name); - Assert.True(resource.TryGetLastAnnotation(out ContainerImageAnnotation? imageAnnotations)); + Assert.True(resource.TryGetLastAnnotation(out ContainerImageAnnotation? imageAnnotation)); + Assert.Equal(DataApiBuilderContainerImageTags.Registry, imageAnnotation.Registry); + Assert.Equal(DataApiBuilderContainerImageTags.Image, imageAnnotation.Image); + Assert.Equal(DataApiBuilderContainerImageTags.Tag, imageAnnotation.Tag); // verify ports @@ -43,17 +46,14 @@ public void AddDataAPIBuilderContainerDetailsSetOnResource() var http = endpoints.Where(x => x.Name == DataApiBuilderContainerResource.HttpEndpointName).Single(); Assert.Equal(DataApiBuilderContainerResource.HttpEndpointPort, http.TargetPort); - - // var https = endpoints.Where(x => x.Name == DataApiBuilderContainerResource.HttpsEndpointName).Single(); - // Assert.Equal(DataApiBuilderContainerResource.HttpsEndpointPort, https.TargetPort); } [Fact] - public void AddDataAPIBuilderContainer_DefaultFile_NoEx() + public void AddDataAPIBuilderContainer_NoConfigPaths_NoMounts() { IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - // defaults to ./dab-config.json which exists in this test project root + // no config paths specified, no auto-default mount builder.AddDataAPIBuilder("dab"); using var app = builder.Build(); @@ -62,11 +62,7 @@ public void AddDataAPIBuilderContainer_DefaultFile_NoEx() var resource = Assert.Single(appModel.Resources.OfType()); - Assert.True(resource.TryGetAnnotationsOfType(out var configFileAnnotations)); - - var annotation = Assert.Single(configFileAnnotations); - Assert.EndsWith("dab-config.json", annotation.Source); - Assert.Equal("/App/dab-config.json", annotation.Target); + Assert.False(resource.TryGetAnnotationsOfType(out _)); } [Fact] @@ -95,7 +91,7 @@ public void AddDataAPIBuilderContainer_ValidFile_NoEx() IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); // file exists in test project root - builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config.json"); + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json"); using var app = builder.Build(); @@ -106,8 +102,8 @@ public void AddDataAPIBuilderContainer_ValidFile_NoEx() Assert.True(resource.TryGetAnnotationsOfType(out var configFileAnnotations)); var annotation = Assert.Single(configFileAnnotations); - Assert.EndsWith("dab-config.json", annotation.Source); - Assert.Equal("/App/dab-config.json", annotation.Target); + Assert.EndsWith("dab-config-anonymous.json", annotation.Source); + Assert.Equal("/App/dab-config-anonymous.json", annotation.Target); } [Fact] @@ -116,7 +112,7 @@ public void AddDataAPIBuilderContainer_ValidFileWithPort_NoEx() IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); // file exists in test project root - builder.AddDataAPIBuilder("dab", httpPort: 1234, configFilePaths: "./dab-config.json"); + builder.AddDataAPIBuilder("dab", httpPort: 1234, configFilePaths: "./dab-config-anonymous.json"); using var app = builder.Build(); @@ -133,8 +129,8 @@ public void AddDataAPIBuilderContainer_ValidFileWithPort_NoEx() Assert.True(resource.TryGetAnnotationsOfType(out var configFileAnnotations)); var configAnnotation = Assert.Single(configFileAnnotations); - Assert.EndsWith("dab-config.json", configAnnotation.Source); - Assert.Equal("/App/dab-config.json", configAnnotation.Target); + Assert.EndsWith("dab-config-anonymous.json", configAnnotation.Source); + Assert.Equal("/App/dab-config-anonymous.json", configAnnotation.Target); } [Fact] @@ -152,7 +148,7 @@ public void AddDataAPIBuilderContainer_ValidFiles_NoEx() IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); // both files exist in test project root - builder.AddDataAPIBuilder("dab", "./dab-config.json", "./dab-config-2.json"); + builder.AddDataAPIBuilder("dab", "./dab-config-anonymous.json", "./dab-config-anonymous-2.json"); using var app = builder.Build(); @@ -167,13 +163,13 @@ public void AddDataAPIBuilderContainer_ValidFiles_NoEx() configFileAnnotations, a => { - Assert.EndsWith("dab-config.json", a.Source); - Assert.Equal("/App/dab-config.json", a.Target); + Assert.EndsWith("dab-config-anonymous.json", a.Source); + Assert.Equal("/App/dab-config-anonymous.json", a.Target); }, a => { - Assert.EndsWith("dab-config-2.json", a.Source); - Assert.Equal("/App/dab-config-2.json", a.Target); + Assert.EndsWith("dab-config-anonymous-2.json", a.Source); + Assert.Equal("/App/dab-config-anonymous-2.json", a.Target); }); } @@ -183,6 +179,518 @@ public void AddDataAPIBuilderContainer_InvalidFiles_NoEx() IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); // some (not all) files exist in test project root - Assert.Throws(() => builder.AddDataAPIBuilder("dab", "./dab-config.json", "./dab-config-2.json", Guid.NewGuid().ToString())); + Assert.Throws(() => builder.AddDataAPIBuilder("dab", "./dab-config-anonymous.json", "./dab-config-anonymous-2.json", Guid.NewGuid().ToString())); + } + + [Fact] + public void AddDataAPIBuilderContainer_HasHealthCheckAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var healthCheckAnnotations)); + Assert.NotEmpty(healthCheckAnnotations); + } + + [Fact] + public void AddDataAPIBuilderContainer_PrimaryEndpointIsAccessible() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var dab = builder.AddDataAPIBuilder("dab"); + + Assert.NotNull(dab.Resource.PrimaryEndpoint); + Assert.Equal(DataApiBuilderContainerResource.HttpEndpointName, dab.Resource.PrimaryEndpoint.EndpointName); + } + + [Fact] + public void AddDataAPIBuilderContainer_WithImageTag_OverridesDefault() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab") + .WithImageTag("custom-tag"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var imageAnnotation = resource.Annotations.OfType().Single(); + Assert.Equal("custom-tag", imageAnnotation.Tag); + Assert.Equal(DataApiBuilderContainerImageTags.Image, imageAnnotation.Image); + Assert.Equal(DataApiBuilderContainerImageTags.Registry, imageAnnotation.Registry); + } + + [Fact] + public void AddDataAPIBuilderContainer_WithImage_OverridesDefault() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab") + .WithImage("custom-image"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var imageAnnotation = resource.Annotations.OfType().Single(); + Assert.Equal("custom-image", imageAnnotation.Image); + } + + [Fact] + public void AddDataAPIBuilderContainer_WithImageRegistry_OverridesDefault() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab") + .WithImageRegistry("custom.registry.io"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var imageAnnotation = resource.Annotations.OfType().Single(); + Assert.Equal("custom.registry.io", imageAnnotation.Registry); + Assert.Equal(DataApiBuilderContainerImageTags.Image, imageAnnotation.Image); + Assert.Equal(DataApiBuilderContainerImageTags.Tag, imageAnnotation.Tag); + } + + [Fact] + public void AddDataAPIBuilderContainer_WithAllImageOverrides() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab") + .WithImageRegistry("custom.registry.io") + .WithImage("custom-image") + .WithImageTag("custom-tag"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var imageAnnotation = resource.Annotations.OfType().Single(); + Assert.Equal("custom.registry.io", imageAnnotation.Registry); + Assert.Equal("custom-image", imageAnnotation.Image); + Assert.Equal("custom-tag", imageAnnotation.Tag); + } + + [Fact] + public void AddDataAPIBuilderContainer_BindMountsAreReadOnly() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mountAnnotations)); + var mount = Assert.Single(mountAnnotations); + Assert.True(mount.IsReadOnly); + } + + [Fact] + public void AddDataAPIBuilderContainer_DefaultPortIsNotFixed() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var endpoints)); + var http = endpoints.Single(x => x.Name == DataApiBuilderContainerResource.HttpEndpointName); + Assert.Null(http.Port); + Assert.Equal(DataApiBuilderContainerResource.HttpEndpointPort, http.TargetPort); + } + + [Fact] + public void AddDataAPIBuilderContainer_ImplementsServiceDiscovery() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var dab = builder.AddDataAPIBuilder("dab"); + + Assert.IsAssignableFrom(dab.Resource); + } + + [Fact] + public void AddDataAPIBuilderContainer_AuthenticatedConfig_NoEx() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-authenticated.json"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var configFileAnnotations)); + + var annotation = Assert.Single(configFileAnnotations); + Assert.EndsWith("dab-config-authenticated.json", annotation.Source); + Assert.Equal("/App/dab-config-authenticated.json", annotation.Target); + } + + [Fact] + public void AddDataAPIBuilderContainer_AuthenticatedAndAnonymousConfigs_NoEx() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", "./dab-config-anonymous.json", "./dab-config-authenticated.json"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var configFileAnnotations)); + + Assert.Equal(2, configFileAnnotations.Count()); + Assert.Collection( + configFileAnnotations, + a => + { + Assert.EndsWith("dab-config-anonymous.json", a.Source); + Assert.Equal("/App/dab-config-anonymous.json", a.Target); + }, + a => + { + Assert.EndsWith("dab-config-authenticated.json", a.Source); + Assert.Equal("/App/dab-config-authenticated.json", a.Target); + }); + } + + [Fact] + public void WithConfigFile_SingleFile_NoDefault_MountsCorrectly() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + // Use configFilePaths to skip the default, then use WithConfigFile + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous-2.json") + .WithConfigFile(new FileInfo("./dab-config-authenticated.json")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + Assert.Equal(2, mounts.Count()); + Assert.Contains(mounts, m => m.Target == "/App/dab-config-anonymous-2.json"); + Assert.Contains(mounts, m => m.Target == "/App/dab-config-authenticated.json"); + } + + [Fact] + public void WithConfigFile_MultipleFiles_MountsAll() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFile( + new FileInfo("./dab-config-anonymous-2.json"), + new FileInfo("./dab-config-authenticated.json")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + Assert.Equal(3, mounts.Count()); + Assert.Contains(mounts, m => m.Target == "/App/dab-config-anonymous.json"); + Assert.Contains(mounts, m => m.Target == "/App/dab-config-anonymous-2.json"); + Assert.Contains(mounts, m => m.Target == "/App/dab-config-authenticated.json"); + } + + [Fact] + public void WithConfigFile_CalledMultipleTimes_IsAdditive() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFile(new FileInfo("./dab-config-anonymous-2.json")) + .WithConfigFile(new FileInfo("./dab-config-authenticated.json")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + Assert.Equal(3, mounts.Count()); + } + + [Fact] + public void WithConfigFile_MountsAreReadOnly() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFile(new FileInfo("./dab-config-anonymous-2.json")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + Assert.All(mounts, m => Assert.True(m.IsReadOnly)); + } + + [Fact] + public void WithConfigFile_NonExistentFile_ThrowsFileNotFoundException() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var nonExistent = new FileInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".json")); + + Assert.Throws(() => + builder.AddDataAPIBuilder("dab") + .WithConfigFile(nonExistent)); + } + + [Fact] + public void WithConfigFile_DuplicateFile_ThrowsInvalidOperationException() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var ex = Assert.Throws(() => + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFile(new FileInfo("./dab-config-anonymous.json"))); + + Assert.Contains("/App/dab-config-anonymous.json", ex.Message); + Assert.Contains("already mounted", ex.Message); + } + + [Fact] + public void WithConfigFile_DuplicateAcrossCalls_ThrowsInvalidOperationException() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var ex = Assert.Throws(() => + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFile(new FileInfo("./dab-config-anonymous-2.json")) + .WithConfigFile(new FileInfo("./dab-config-anonymous-2.json"))); + + Assert.Contains("/App/dab-config-anonymous-2.json", ex.Message); + } + + [Fact] + public void WithConfigFile_NullBuilder_ThrowsArgumentNullException() + { + IResourceBuilder builder = null!; + + Assert.Throws(() => + builder.WithConfigFile(new FileInfo("./dab-config-anonymous.json"))); + } + + + [Fact] + public void WithConfigFolder_MountsAllFilesInDirectory() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFolder(new DirectoryInfo("./config-folder")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + + // 1 from AddDataAPIBuilder + 2 from config-folder + Assert.Equal(3, mounts.Count()); + Assert.Contains(mounts, m => m.Target == "/App/dab-config-anonymous.json"); + Assert.Contains(mounts, m => m.Target == "/App/dab-folder-config-anonymous.json"); + Assert.Contains(mounts, m => m.Target == "/App/dab-folder-config-anonymous-2.json"); + } + + [Fact] + public void WithConfigFolder_MountsAreReadOnly() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFolder(new DirectoryInfo("./config-folder")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + Assert.All(mounts, m => Assert.True(m.IsReadOnly)); + } + + [Fact] + public void WithConfigFolder_MountsIndividualFilesNotFolders() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFolder(new DirectoryInfo("./config-folder")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + + // Every mount target should be a file path (not a directory) + Assert.All(mounts, m => + { + Assert.StartsWith("/App/", m.Target); + Assert.Contains(".", m.Target); // has a file extension + Assert.Equal(ContainerMountType.BindMount, m.Type); + }); + } + + [Fact] + public void WithConfigFolder_CalledMultipleTimes_IsAdditive() + { + // Create a temp directory with a unique file to avoid collisions + string tempDir = Path.Combine(Path.GetTempPath(), "dab-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + string tempFile = Path.Combine(tempDir, "dab-temp-config.json"); + File.WriteAllText(tempFile, "{}"); + + try + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFolder(new DirectoryInfo("./config-folder")) + .WithConfigFolder(new DirectoryInfo(tempDir)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + + // 1 default + 2 from config-folder + 1 from tempDir + Assert.Equal(4, mounts.Count()); + Assert.Contains(mounts, m => m.Target == "/App/dab-temp-config.json"); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void WithConfigFolder_NonExistentDirectory_ThrowsDirectoryNotFoundException() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var nonExistent = new DirectoryInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + + Assert.Throws(() => + builder.AddDataAPIBuilder("dab") + .WithConfigFolder(nonExistent)); + } + + [Fact] + public void WithConfigFolder_EmptyDirectory_IsNoOp() + { + string emptyDir = Path.Combine(Path.GetTempPath(), "dab-empty-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(emptyDir); + + try + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFolder(new DirectoryInfo(emptyDir)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + + // Only the default config file mount + Assert.Single(mounts); + } + finally + { + Directory.Delete(emptyDir, true); + } + } + + [Fact] + public void WithConfigFolder_DuplicateWithExistingMount_ThrowsInvalidOperationException() + { + // config-folder contains dab-folder-config-anonymous.json - use WithConfigFile to mount it first, then folder + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var folderDir = new DirectoryInfo("./config-folder"); + var fileInFolder = folderDir.GetFiles().First(); + + var ex = Assert.Throws(() => + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFile(new FileInfo(fileInFolder.FullName)) + .WithConfigFolder(folderDir)); + + Assert.Contains("already mounted", ex.Message); + } + + [Fact] + public void WithConfigFolder_NullBuilder_ThrowsArgumentNullException() + { + IResourceBuilder builder = null!; + + Assert.Throws(() => + builder.WithConfigFolder(new DirectoryInfo("./config-folder"))); + } + + [Fact] + public void WithConfigFile_And_WithConfigFolder_Combined() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddDataAPIBuilder("dab", configFilePaths: "./dab-config-anonymous.json") + .WithConfigFile(new FileInfo("./dab-config-authenticated.json")) + .WithConfigFolder(new DirectoryInfo("./config-folder")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var mounts)); + + // 1 from AddDataAPIBuilder + 1 from WithConfigFile + 2 from WithConfigFolder + Assert.Equal(4, mounts.Count()); + Assert.Contains(mounts, m => m.Target == "/App/dab-config-anonymous.json"); + Assert.Contains(mounts, m => m.Target == "/App/dab-config-authenticated.json"); + Assert.Contains(mounts, m => m.Target == "/App/dab-folder-config-anonymous.json"); + Assert.Contains(mounts, m => m.Target == "/App/dab-folder-config-anonymous-2.json"); } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/config-folder/dab-folder-config-anonymous-2.json b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/config-folder/dab-folder-config-anonymous-2.json new file mode 100644 index 000000000..fcaa327c5 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/config-folder/dab-folder-config-anonymous-2.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.6.87/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "missing-on-purpose" + }, + "runtime": { + "host": { + "mode": "development" + } + }, + "entities": { + "Genre": { + "source": { + "object": "[dbo].[Genre]", + "type": "table" + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ "*" ] + } + ] + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/config-folder/dab-folder-config-anonymous.json b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/config-folder/dab-folder-config-anonymous.json new file mode 100644 index 000000000..a094020a6 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/config-folder/dab-folder-config-anonymous.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.6.87/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "missing-on-purpose" + }, + "runtime": { + "host": { + "mode": "development" + } + }, + "entities": { + "Director": { + "source": { + "object": "[dbo].[Director]", + "type": "table" + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ "*" ] + } + ] + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-2.json b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-anonymous-2.json similarity index 91% rename from tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-2.json rename to tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-anonymous-2.json index 924bde438..008003aa4 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-2.json +++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-anonymous-2.json @@ -1,5 +1,5 @@ { - "$schema": "https://github.com/Azure/data-api-builder/releases/download/v0.9.7/dab.draft.schema.json", + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.6.87/dab.draft.schema.json", "data-source": { "database-type": "mssql", "connection-string": "missing-on-purpose" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config.json b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-anonymous.json similarity index 91% rename from tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config.json rename to tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-anonymous.json index 924bde438..008003aa4 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config.json +++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-anonymous.json @@ -1,5 +1,5 @@ { - "$schema": "https://github.com/Azure/data-api-builder/releases/download/v0.9.7/dab.draft.schema.json", + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.6.87/dab.draft.schema.json", "data-source": { "database-type": "mssql", "connection-string": "missing-on-purpose" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-authenticated.json b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-authenticated.json new file mode 100644 index 000000000..38ea95aba --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/dab-config-authenticated.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.6.87/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "missing-on-purpose" + }, + "runtime": { + "host": { + "mode": "development", + "authentication": { + "provider": "Simulator" + } + } + }, + "entities": { + "Actor": { + "source": { + "object": "[dbo].[Actor]", + "type": "table" + }, + "permissions": [ + { + "role": "authenticated", + "actions": [ "*" ] + } + ] + } + } +} From b0958c76321596d7c748bfce851454250f324359 Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Wed, 25 Feb 2026 22:14:14 -0700 Subject: [PATCH 2/5] Enhance DataApiBuilderDefaults with additional URL endpoints and update test to reflect response type change --- .../DataApiBuilderHostingExtension.cs | 24 ++++++++++++++++++- .../AppHostTests.cs | 4 ++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs index 657d8e722..9e63fe371 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs @@ -158,5 +158,27 @@ private static void ThrowIfMountTargetExists( private static IResourceBuilder WithDataApiBuilderDefaults( this IResourceBuilder builder) => builder.WithOtlpExporter() - .WithHttpHealthCheck("/health"); + .WithHttpHealthCheck("/health") + .WithUrls(context => + { + context.Urls.Clear(); + context.Urls.Add(new() + { + Url = "/swagger", + DisplayText = "Swagger", + Endpoint = context.GetEndpoint(DataApiBuilderContainerResource.HttpEndpointName) + }); + context.Urls.Add(new() + { + Url = "/graphql", + DisplayText = "GraphQL", + Endpoint = context.GetEndpoint(DataApiBuilderContainerResource.HttpEndpointName) + }); + context.Urls.Add(new() + { + Url = "/health", + DisplayText = "Health", + Endpoint = context.GetEndpoint(DataApiBuilderContainerResource.HttpEndpointName) + }); + }); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/AppHostTests.cs index ab71bacb1..d0b5b427a 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/AppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.Tests/AppHostTests.cs @@ -29,8 +29,8 @@ public async Task CanGetSeries() var response = await httpClient.GetAsync("/api/series"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var series = await response.Content.ReadFromJsonAsync(); + var series = await response.Content.ReadFromJsonAsync>(); Assert.NotNull(series); - Assert.Equal(5, series.Value.Count); + Assert.Equal(5, series.Value!.Count); } } \ No newline at end of file From ea315d896ca07d48c036287df7792fe667da68a3 Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 26 Feb 2026 00:03:00 -0700 Subject: [PATCH 3/5] Repairing an OTEL error in DAB readme. --- .../README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md index 33455ade1..61197cd0c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md @@ -196,7 +196,7 @@ For more information about Data API builder's Simulator authentication provider, ### OpenTelemetry Instrumentation -To enable OTEL telemetry in Data API builder, add the following configuration to your `dab-config.json` file: +Aspire automatically injects `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_SERVICE_NAME` into the Data API builder container via `.WithOtlpExporter()`. To enable OTEL telemetry in Data API builder, add the following configuration to your `dab-config.json` file: ```json { @@ -206,13 +206,19 @@ To enable OTEL telemetry in Data API builder, add the following configuration to "enabled": true, "service-name": "@env('OTEL_SERVICE_NAME')", "endpoint": "@env('OTEL_EXPORTER_OTLP_ENDPOINT')", - "exporter-protocol": "grpc", - "headers": "@env('OTEL_EXPORTER_OTLP_HEADERS')" + "exporter-protocol": "grpc" } } } } ``` +> **Warning:** Do **not** add `"headers": "@env('OTEL_EXPORTER_OTLP_HEADERS')"` unless your OTLP endpoint requires authentication (e.g., a cloud APM service). Aspire does not inject `OTEL_EXPORTER_OTLP_HEADERS`, and DAB requires all `@env()` references to resolve — an unset variable causes a fatal deserialization crash loop. If you need headers, set the environment variable explicitly on the container: +> +> ```csharp +> builder.AddDataAPIBuilder("dab") +> .WithEnvironment("OTEL_EXPORTER_OTLP_HEADERS", "api-key=your-key"); +> ``` + For more information about Data API builder telemetry, see the [official documentation](https://learn.microsoft.com/azure/data-api-builder/concept/monitor/open-telemetry). From a8ade1b954bf5673c8866bd837450d7f8020e4ee Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 26 Feb 2026 12:25:00 -0700 Subject: [PATCH 4/5] Added unique icon for DAB in Graph --- .../DataApiBuilderHostingExtension.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs index 9e63fe371..6b015e897 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs @@ -41,6 +41,7 @@ public static IResourceBuilder AddDataAPIBuilde var resource = new DataApiBuilderContainerResource(name); var rb = builder.AddResource(resource) + .WithIconName("Drag") .WithImage(DataApiBuilderContainerImageTags.Image) .WithImageTag(DataApiBuilderContainerImageTags.Tag) .WithImageRegistry(DataApiBuilderContainerImageTags.Registry) From db4956535f294647d0610dfcbbd3c29d9e70a694 Mon Sep 17 00:00:00 2001 From: Jerry Nixon <1749983+JerryNixon@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:11:41 -0700 Subject: [PATCH 5/5] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Components/Pages/Home.razor | 2 +- .../GlobalSuppressions.cs | 2 +- .../DataApiBuilderHostingExtension.cs | 2 +- .../README.md | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/Components/Pages/Home.razor b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/Components/Pages/Home.razor index 60860905a..50235678c 100644 --- a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/Components/Pages/Home.razor +++ b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/Components/Pages/Home.razor @@ -13,7 +13,7 @@ } else { -
    Here are some StarTrek series 🖖
    +
    Here are some Star Trek series 🖖
      @foreach (var s in series) { diff --git a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/GlobalSuppressions.cs b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/GlobalSuppressions.cs index 590a95472..a59ecde2b 100644 --- a/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/GlobalSuppressions.cs +++ b/examples/data-api-builder/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp/GlobalSuppressions.cs @@ -5,4 +5,4 @@ using System.Diagnostics.CodeAnalysis; -[assembly: SuppressMessage("Performance", "CA1873:Avoid potentially expensive logging", Justification = "", Scope = "member", Target = "~M:CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp.TrekApiClient.GetSeriesAsync~System.Threading.Tasks.Task{System.Collections.Generic.List{CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp.Series}}")] +[assembly: SuppressMessage("Performance", "CA1873:Avoid potentially expensive logging", Justification = "The additional allocation cost from logging in TrekApiClient.GetSeriesAsync is acceptable for this sample Blazor application and simplifies the example code.", Scope = "member", Target = "~M:CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp.TrekApiClient.GetSeriesAsync~System.Threading.Tasks.Task{System.Collections.Generic.List{CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder.BlazorApp.Series}}")] diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs index 6b015e897..8fa33b308 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/DataApiBuilderHostingExtension.cs @@ -36,7 +36,7 @@ public static IResourceBuilder AddDataAPIBuilde params string[] configFilePaths) { ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(name); + ArgumentException.ThrowIfNullOrEmpty(name); var resource = new DataApiBuilderContainerResource(name); diff --git a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md index 61197cd0c..45c69d75b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder/README.md @@ -175,10 +175,10 @@ The following methods are available for configuring the Data API builder contain |AddDataAPIBuilder() || Adds a Data API builder container to the application.| || string name | The name of the resource. | || int httpPort | Optional HTTP port number for the Data API builder container. Defaults to a random port. | -| WithConfigFile() || Adds one or more config or schema files to the container. -|| FileInfo[] files | The config or schema file(s) to add. -| WithConfigFolder() || Adds all files from the specified folder(s) to the container. -|| DirectoryInfo[] folders | The folder(s) from which to add all top-level config or schema files. +| WithConfigFile() || Adds one or more config or schema files to the container. | +|| FileInfo[] files | The config or schema file(s) to add. | +| WithConfigFolder() || Adds all files from the specified folder(s) to the container. | +|| DirectoryInfo[] folders | The folder(s) from which to add all top-level config or schema files. | ### Health Checks