diff --git a/src/Distech.CloudRelay.API/Distech.CloudRelay.API.csproj b/src/Distech.CloudRelay.API/Distech.CloudRelay.API.csproj index ebe3b38..d142e23 100644 --- a/src/Distech.CloudRelay.API/Distech.CloudRelay.API.csproj +++ b/src/Distech.CloudRelay.API/Distech.CloudRelay.API.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1 + net6.0 true Distech.CloudRelay.API @@ -11,20 +11,13 @@ - - - - - - + + + - - - - diff --git a/src/Distech.CloudRelay.API/Model/DeviceRequestHeaders.cs b/src/Distech.CloudRelay.API/Model/DeviceRequestHeaders.cs index 82cae4f..2b6ac86 100644 --- a/src/Distech.CloudRelay.API/Model/DeviceRequestHeaders.cs +++ b/src/Distech.CloudRelay.API/Model/DeviceRequestHeaders.cs @@ -20,19 +20,19 @@ public class DeviceRequestHeaders /// /// Gets or sets the content-type header. /// - [JsonProperty(PropertyName = HeaderNames.ContentType, NullValueHandling = NullValueHandling.Ignore)] + [JsonProperty(PropertyName = "Content-Type", NullValueHandling = NullValueHandling.Ignore)] public string ContentType { get; set; } /// /// Gets or sets the content-disposition header. /// - [JsonProperty(PropertyName = HeaderNames.ContentDisposition, NullValueHandling = NullValueHandling.Ignore)] + [JsonProperty(PropertyName = "Content-Disposition", NullValueHandling = NullValueHandling.Ignore)] public string ContentDisposition { get; set; } /// /// Gets or sets the content-length header. /// - [JsonProperty(PropertyName = HeaderNames.ContentLength, NullValueHandling = NullValueHandling.Ignore)] + [JsonProperty(PropertyName = "Content-Length", NullValueHandling = NullValueHandling.Ignore)] public long? ContentLength { get; set; } #endregion @@ -52,7 +52,10 @@ public DeviceRequestHeaders() /// public DeviceRequestHeaders(HttpRequest request) { - Accept = request.GetTypedHeaders().Accept?.Select(a => a.ToString()).Aggregate((accept1, accept2) => $"{accept1}, {accept2}"); + Accept = request.GetTypedHeaders().Accept + .Select(a => a.ToString()) + .DefaultIfEmpty() //aggregate does not support empty sequence + .Aggregate((accept1, accept2) => $"{accept1}, {accept2}"); ContentType = request.GetTypedHeaders().ContentType?.ToString(); ContentDisposition = request.GetTypedHeaders().ContentDisposition?.ToString(); ContentLength = request.ContentLength; diff --git a/src/Distech.CloudRelay.API/Model/DeviceResponseHeaders.cs b/src/Distech.CloudRelay.API/Model/DeviceResponseHeaders.cs index 9c49811..1307e51 100644 --- a/src/Distech.CloudRelay.API/Model/DeviceResponseHeaders.cs +++ b/src/Distech.CloudRelay.API/Model/DeviceResponseHeaders.cs @@ -16,13 +16,13 @@ public class DeviceResponseHeaders /// /// Gets or sets the content-type header. /// - [JsonProperty(PropertyName = HeaderNames.ContentType)] + [JsonProperty(PropertyName = "Content-Type")] public string ContentType { get; set; } /// /// Gets or sets the content-disposition header. /// - [JsonProperty(PropertyName = HeaderNames.ContentDisposition)] + [JsonProperty(PropertyName = "Content-Disposition")] public string ContentDisposition { get; set; } #endregion diff --git a/src/Distech.CloudRelay.API/Program.cs b/src/Distech.CloudRelay.API/Program.cs index 4ccdaae..811ce27 100644 --- a/src/Distech.CloudRelay.API/Program.cs +++ b/src/Distech.CloudRelay.API/Program.cs @@ -2,21 +2,148 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Threading.Tasks; -using Microsoft.AspNetCore; +using Distech.CloudRelay.API; +using Distech.CloudRelay.API.Middleware; +using Distech.CloudRelay.API.Model; +using Distech.CloudRelay.API.Options; +using Distech.CloudRelay.API.Services; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.AzureAD.UI; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.Azure.Devices; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; -namespace Distech.CloudRelay.API +var builder = WebApplication.CreateBuilder(args); + +//inject authentication related entities and services +#pragma warning disable 0618 //requires breaking changes to replace with Microsoft.Identity.Web +builder.Services.AddAuthentication(AzureADDefaults.BearerAuthenticationScheme) +#pragma warning restore 0618 + .AddAzureADBearer(options => builder.Configuration.Bind(ApiEnvironment.AzureADOptions, options)); + +if (builder.Environment.IsDevelopment()) { - public class Program + //add diagnostics support for the JwtBearer middleware +#pragma warning disable 0618 //requires breaking changes to replace with Microsoft.Identity.Web + builder.Services.PostConfigure(AzureADDefaults.JwtBearerAuthenticationScheme, static options => +#pragma warning restore 0618 { - public static void Main(string[] args) + options.Events = JwtBearerMiddlewareDiagnostics.Subscribe(options.Events); + }); +} + +//inject common services +builder.Services.AddCloudRelayFileService(static (serviceProvider, options) => +{ + var config = serviceProvider.GetRequiredService(); + config.GetSection(ApiEnvironment.FileStorageSectionKey).Bind(options); + + var adapterOptions = serviceProvider.GetService>(); + options.ConnectionString = ApiEnvironment.GetConnectionString(ApiEnvironment.ResourceType.StorageAccount, adapterOptions.Value.EnvironmentId, config); + + // files uploaded by the server are stored in a dedicated sub folder + options.ServerFileUploadSubFolder = adapterOptions.Value.MethodName; +}); + +//inject local services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(static serviceProvider => +{ + var config = serviceProvider.GetRequiredService(); + var options = serviceProvider.GetService>(); + var connectionString = ApiEnvironment.GetConnectionString(ApiEnvironment.ResourceType.IoTHubService, options.Value.EnvironmentId, config); + return ServiceClient.CreateFromConnectionString(connectionString); +}); +builder.Services.ConfigureOptions(); + +//enables Application Insights telemetry (APPINSIGHTS_INSTRUMENTATIONKEY and ApplicationInsights:InstrumentationKey are supported) +builder.Services.AddApplicationInsightsTelemetry(static options => +{ + options.ApplicationVersion = Assembly.GetEntryAssembly().GetCustomAttribute().InformationalVersion; +}); +builder.Services.AddApplicationInsightsTelemetryProcessor(); +builder.Services.AddSingleton(); + +//enables CORS using the default policy +builder.Services.AddCors(static options => +{ + options.AddDefaultPolicy(static corsBuilder => + { + corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().WithExposedHeaders(HeaderNames.ContentDisposition); + }); +}); + +//allow accessing the HttpContext inside a service class +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddControllers(configure => +{ + // 'AzureAD' is currently the only supported authentication provider name and anything else will allow anonymous access + if (IdentityProviders.AzureActiveDirectory.Equals(builder.Configuration.GetSection(ApiEnvironment.AuthenticationProvider).Value, StringComparison.OrdinalIgnoreCase)) + { + AuthorizationPolicy policy; + + string[] roles = builder.Configuration.GetSection(ApiEnvironment.AzureADRoles).GetChildren().Select(role => role.Value).ToArray(); + string[] scopes = builder.Configuration.GetSection(ApiEnvironment.AzureADScopes).GetChildren().Select(role => role.Value).ToArray(); + + if (roles.Length > 0) + { + // authentication + authorization based on application permissions aka roles + policy = new AuthorizationPolicyBuilder().RequireRole(roles).Build(); + } + else if (scopes.Length > 0) + { + // authentication + authorization based on delegated permissions aka scopes + policy = new AuthorizationPolicyBuilder().RequireAssertion((ctx => + { + var scopeClaim = ctx.User.FindFirst(c => c.Type == "http://schemas.microsoft.com/identity/claims/scope")?.Value.Split(' '); + return scopeClaim != null && scopes.All(s => scopeClaim.Contains(s)); + })) + .Build(); + } + else { - CreateWebHostBuilder(args).Build().Run(); + // authentication only + policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); } - public static IWebHostBuilder CreateWebHostBuilder(string[] args) => - WebHost.CreateDefaultBuilder(args) - .UseStartup(); + configure.Filters.Add(new AuthorizeFilter(policy)); } +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); } + +app.UseCors(); +app.UseHttpsRedirection(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseApplicationErrorHandler(); +app.UseMultiPartRequestBuffering(); + +app.MapControllers(); + +app.Run(); + +/// +/// Intended for test projects using WebApplicationFactory. +/// +public partial class Program { } diff --git a/src/Distech.CloudRelay.API/Properties/launchSettings.json b/src/Distech.CloudRelay.API/Properties/launchSettings.json index 95deec9..a35b209 100644 --- a/src/Distech.CloudRelay.API/Properties/launchSettings.json +++ b/src/Distech.CloudRelay.API/Properties/launchSettings.json @@ -9,19 +9,19 @@ } }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, + "Distech.CloudRelay.API": { + "commandName": "Project", + "launchBrowser": false, "launchUrl": "api", + "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, - "Distech.CloudRelay.API": { - "commandName": "Project", - "launchBrowser": true, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, "launchUrl": "api", - "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Distech.CloudRelay.API/Startup.cs b/src/Distech.CloudRelay.API/Startup.cs deleted file mode 100644 index 71dfc5f..0000000 --- a/src/Distech.CloudRelay.API/Startup.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Distech.CloudRelay.API.Middleware; -using Distech.CloudRelay.API.Model; -using Distech.CloudRelay.API.Options; -using Distech.CloudRelay.API.Services; -using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.AzureAD.UI; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Authorization; -using Microsoft.Azure.Devices; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.Net.Http.Headers; -using System; -using System.Linq; -using System.Reflection; - -namespace Distech.CloudRelay.API -{ - public class Startup - { - public IConfiguration Configuration { get; } - - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public void ConfigureDevelopmentServices(IServiceCollection services) - { - //add diagnostics support for the JwtBearer middleware - services.PostConfigure(AzureADDefaults.JwtBearerAuthenticationScheme, options => - { - options.Events = JwtBearerMiddlewareDiagnostics.Subscribe(options.Events); - }); - - ConfigureServices(services); - } - - public void ConfigureServices(IServiceCollection services) - { - //inject authentication related entities and services - services.AddAuthentication(AzureADDefaults.BearerAuthenticationScheme) - .AddAzureADBearer(options => Configuration.Bind(ApiEnvironment.AzureADOptions, options)); - - //inject common services - services.AddCloudRelayFileService((serviceProvider, options) => - { - Configuration.GetSection(ApiEnvironment.FileStorageSectionKey).Bind(options); - - var adapterOptions = serviceProvider.GetService>(); - options.ConnectionString = ApiEnvironment.GetConnectionString(ApiEnvironment.ResourceType.StorageAccount, adapterOptions.Value.EnvironmentId, Configuration); - - // files uploaded by the server are stored in a dedicated sub folder - options.ServerFileUploadSubFolder = adapterOptions.Value.MethodName; - }); - - //inject local services - services.AddScoped(); - services.AddScoped(); - services.AddScoped(serviceProvider => - { - var options = serviceProvider.GetService>(); - var connectionString = ApiEnvironment.GetConnectionString(ApiEnvironment.ResourceType.IoTHubService, options.Value.EnvironmentId, Configuration); - return ServiceClient.CreateFromConnectionString(connectionString); - }); - services.ConfigureOptions(); - - //enables Application Insights telemetry (APPINSIGHTS_INSTRUMENTATIONKEY and ApplicationInsights:InstrumentationKey are supported) - services.AddApplicationInsightsTelemetry(options => - { - options.ApplicationVersion = Assembly.GetEntryAssembly().GetCustomAttribute().InformationalVersion; - }); - services.AddApplicationInsightsTelemetryProcessor(); - services.AddSingleton(); - - //enables CORS using the default policy - services.AddCors((options => - { - options.AddDefaultPolicy(builder => - { - builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().WithExposedHeaders(HeaderNames.ContentDisposition); - }); - })); - - services.AddMvc(opts => - { - // 'AzureAD' is currently the only supported authentication provider name and anything else will allow anonymous access - if (IdentityProviders.AzureActiveDirectory.Equals(Configuration.GetSection(ApiEnvironment.AuthenticationProvider).Value, StringComparison.OrdinalIgnoreCase)) - { - AuthorizationPolicy policy; - - string[] roles = Configuration.GetSection(ApiEnvironment.AzureADRoles).GetChildren().Select(role => role.Value).ToArray(); - string[] scopes = Configuration.GetSection(ApiEnvironment.AzureADScopes).GetChildren().Select(role => role.Value).ToArray(); - - if (roles.Count() > 0) - { - // authentication + authorization based on application permissions aka roles - policy = new AuthorizationPolicyBuilder().RequireRole(roles).Build(); - } - else if (scopes.Count() > 0) - { - // authentication + authorization based on delegated permissions aka scopes - policy = new AuthorizationPolicyBuilder().RequireAssertion((ctx => - { - var scopeClaim = ctx.User.FindFirst(c => c.Type == "http://schemas.microsoft.com/identity/claims/scope")?.Value.Split(' '); - return scopeClaim != null && scopes.All(s => scopeClaim.Contains(s)); - })) - .Build(); - } - else - { - // authentication only - policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); - } - - opts.Filters.Add(new AuthorizeFilter(policy)); - } - }) - .SetCompatibilityVersion(CompatibilityVersion.Version_2_1); - - //allow accessing the HttpContext inside a service class - services.AddHttpContextAccessor(); - } - - public void Configure(IApplicationBuilder app, IHostingEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - else - { - app.UseHsts(); - } - - app.UseCors(); - app.UseHttpsRedirection(); - ConfigureAuthentication(app); - - app.UseApplicationErrorHandler(); - app.UseMultiPartRequestBuffering(); - - app.UseMvc(); - } - - protected virtual void ConfigureAuthentication(IApplicationBuilder app) - { - app.UseAuthentication(); - } - } -} diff --git a/src/Distech.CloudRelay.API/appsettings.Development.json b/src/Distech.CloudRelay.API/appsettings.Development.json index e203e94..53d03ea 100644 --- a/src/Distech.CloudRelay.API/appsettings.Development.json +++ b/src/Distech.CloudRelay.API/appsettings.Development.json @@ -1,4 +1,22 @@ { + "Authentication": { + "AzureAD": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "00000000-0000-0000-0000-000000000000", + "ClientId": "00000000-0000-0000-0000-000000000000" + } + }, + "DeviceCommunication": { + "IoTHub": { + "MethodName": "RestApi", + "MessageSizeThreshold": "16384", + "ResponseTimeout": "30" + } + }, + "FileStorage": { + "DeviceFileUploadFolder": "iothub-fileupload", + "ServerFileUploadFolder": "cloud-relay-fileupload" + }, "Logging": { "LogLevel": { "Default": "Debug", diff --git a/src/Distech.CloudRelay.API/appsettings.json b/src/Distech.CloudRelay.API/appsettings.json index 5be7fcb..ce5c87c 100644 --- a/src/Distech.CloudRelay.API/appsettings.json +++ b/src/Distech.CloudRelay.API/appsettings.json @@ -1,5 +1,5 @@ { - /* Required for the test execution to succeed when using the Production environment */ + //TODO: remove on next breaking change "Authentication": { "AzureAD": { "Instance": "https://login.microsoftonline.com/" diff --git a/src/Distech.CloudRelay.Common/DAL/AzureBlob.cs b/src/Distech.CloudRelay.Common/DAL/AzureBlob.cs index e3be172..c0515cb 100644 --- a/src/Distech.CloudRelay.Common/DAL/AzureBlob.cs +++ b/src/Distech.CloudRelay.Common/DAL/AzureBlob.cs @@ -1,4 +1,4 @@ -using Microsoft.WindowsAzure.Storage.Blob; +using Azure.Storage.Blobs.Models; using System; using System.Collections.Generic; using System.Text; @@ -27,18 +27,38 @@ private AzureBlob() /// /// Create a new from an Azure storage blob properties. /// + /// /// /// - public static AzureBlob FromBlobProperties(string blobPath, BlobProperties blobProperties, IDictionary blobMetadata) + public static AzureBlob FromBlobProperties(string blobPath, BlobProperties blobProperties) { return new AzureBlob { Path = blobPath, - Checksum = blobProperties.ContentMD5, + Checksum = Convert.ToBase64String(blobProperties.ContentHash), ContentType = blobProperties.ContentType, - LastModified = blobProperties.LastModified.Value, - Length = blobProperties.Length, - Metadata = BlobMetadata.FromDictionary(blobMetadata) + LastModified = blobProperties.LastModified, + Length = blobProperties.ContentLength, + Metadata = BlobMetadata.FromDictionary(blobProperties.Metadata) + }; + } + + /// + /// Create a new from a instance. + /// + /// + /// + /// + public static AzureBlob FromBlobItem(string blobPath, BlobItem item) + { + return new AzureBlob + { + Path = blobPath, + Checksum = Convert.ToBase64String(item.Properties.ContentHash), + ContentType = item.Properties.ContentType, + LastModified = item.Properties.LastModified ?? DateTimeOffset.MinValue, + Length = item.Properties.ContentLength ?? -1, + Metadata = BlobMetadata.FromDictionary(item.Metadata) }; } diff --git a/src/Distech.CloudRelay.Common/DAL/AzureBlobStorageBlobRepository.cs b/src/Distech.CloudRelay.Common/DAL/AzureBlobStorageBlobRepository.cs index 2750e76..8ed6bf9 100644 --- a/src/Distech.CloudRelay.Common/DAL/AzureBlobStorageBlobRepository.cs +++ b/src/Distech.CloudRelay.Common/DAL/AzureBlobStorageBlobRepository.cs @@ -1,15 +1,16 @@ -using Distech.CloudRelay.Common.Exceptions; +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Sas; +using Distech.CloudRelay.Common.Exceptions; using Distech.CloudRelay.Common.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.WindowsAzure.Storage; -using Microsoft.WindowsAzure.Storage.Blob; -using Microsoft.WindowsAzure.Storage.Blob.Protocol; using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace Distech.CloudRelay.Common.DAL @@ -22,7 +23,7 @@ internal class AzureStorageBlobRepository { #region Members - private readonly Lazy m_LazyBlobClient; + private readonly Lazy m_LazyBlobServiceClient; private readonly ILogger m_Logger; @@ -37,10 +38,17 @@ internal class AzureStorageBlobRepository /// public AzureStorageBlobRepository(IOptionsSnapshot options, ILogger logger) { - m_LazyBlobClient = new Lazy(() => + //Note: Continues to rely on injecting options and creating client locally instead of relying on Microsoft.Azure.Extension + // to rely on DI instead of BlobServiceClient. + // Why: such clients are registered as Singleton by default, which is not compatible with current IOptions implementation + // reying on HttpContext to resolve proper storage account/connection strings based on environment associated to request/tenant. + // We end-up having a singleton instance (the client) having a depencency on IOptionsShapshot, which needs + // to remain scoped. Even if it worked, only the first connection string will be accounted for... + // In that case we need to discover the supported storage accounts ahead of time on API side to register any possible client + // instances as named instance and rely on some sort of locator/scoped instance instead on the service side. + m_LazyBlobServiceClient = new Lazy(() => { - CloudStorageAccount account = CloudStorageAccount.Parse(options.Value.ConnectionString); - return account.CreateCloudBlobClient(); + return new BlobServiceClient(options.Value.ConnectionString); }); m_Logger = logger; } @@ -57,14 +65,16 @@ public AzureStorageBlobRepository(IOptionsSnapshot o public async Task GetBlobInfoAsync(string blobPath) { BlobInfo result = null; + BlobClient blob = GetBlobClient(blobPath); - CloudBlobClient blobClient = m_LazyBlobClient.Value; - CloudBlob blob = new CloudBlob(GetAbsoluteBlobUri(blobPath), blobClient.Credentials); - - //`ExistsAsync` also refreshes properties and metadata at the same times - if (await blob.ExistsAsync()) + try { - result = AzureBlob.FromBlobProperties(GetBlobPath(blob), blob.Properties, blob.Metadata); + var properties = await blob.GetPropertiesAsync(); + result = AzureBlob.FromBlobProperties(GetBlobPath(blob), properties); + } + catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound || ex.ErrorCode == BlobErrorCode.ContainerNotFound) + { + //swallow does not exist error and return default null instead (as previous behavior before dotnet6 migration) } return result; @@ -84,28 +94,21 @@ public async Task> ListBlobAsync(string blobPathPrefix) throw new ArgumentException(nameof(blobPathPrefix)); } - CloudBlobClient blobClient = m_LazyBlobClient.Value; - BlobContinuationToken continuationToken = null; + BlobContainerClient containerClient = GetContainerClient(blobPathPrefix); + List result = new(); - //pad prefix path for container only with trailing slash '/', otherwise storage SDK list in $root container only - if (!prefix.Contains('/')) - { - prefix += '/'; - } - - var result = new List(); + //resolve any virtual folder prefix remaining without container info + prefix = Regex.Replace(blobPathPrefix, $"^[/]?{containerClient.Name}", string.Empty); try { - do + var segment = containerClient.GetBlobsAsync(prefix: prefix, traits: BlobTraits.Metadata).AsPages(); + await foreach (Page blobPage in segment) { - BlobResultSegment segment = await blobClient.ListBlobsSegmentedAsync(prefix, true, BlobListingDetails.Metadata, null, continuationToken, null, null); - continuationToken = segment.ContinuationToken; - - result.AddRange(segment.Results.Cast().Select(b => AzureBlob.FromBlobProperties(GetBlobPath(b), b.Properties, b.Metadata))); - } while (continuationToken != null); + result.AddRange(blobPage.Values.Select(b => AzureBlob.FromBlobItem(GetBlobPath(containerClient, b), b))); + } } - catch (StorageException ex) when (ex.RequestInformation.ErrorCode == BlobErrorCodeStrings.ContainerNotFound) + catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.ContainerNotFound) { //swallow container not found and let the default return the currently empty list instead } @@ -121,21 +124,21 @@ public async Task> ListBlobAsync(string blobPathPrefix) /// public async Task OpenBlobAsync(string blobPath) { - CloudBlobClient blobClient = m_LazyBlobClient.Value; - CloudBlob blob = new CloudBlob(GetAbsoluteBlobUri(blobPath), blobClient.Credentials); + BlobClient blob = GetBlobClient(blobPath); try { + BlobProperties properties = await blob.GetPropertiesAsync(); //blob properties and metadata are automatically fetched when opening the blob stream Stream blobStream = await blob.OpenReadAsync(); - BlobInfo blobInfo = AzureBlob.FromBlobProperties(GetBlobPath(blob), blob.Properties, blob.Metadata); + BlobInfo blobInfo = AzureBlob.FromBlobProperties(GetBlobPath(blob), properties); return new BlobStreamDecorator(blobStream, blobInfo); } - catch (StorageException ex) when (ex.RequestInformation.ErrorCode == BlobErrorCodeStrings.BlobNotFound || ex.RequestInformation.ErrorCode == BlobErrorCodeStrings.ContainerNotFound) + catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound || ex.ErrorCode == BlobErrorCode.ContainerNotFound) { - if (ex.RequestInformation.ErrorCode == BlobErrorCodeStrings.ContainerNotFound) + if (ex.ErrorCode == BlobErrorCode.ContainerNotFound) { - m_Logger.LogWarning($"Container '{blob.Container.Name}' does not exist"); + m_Logger.LogWarning($"Container '{blob.BlobContainerName}' does not exist"); } throw new IdNotFoundException(ErrorCodes.BlobNotFound, blobPath); @@ -152,25 +155,23 @@ public async Task OpenBlobAsync(string blobPath) /// public async Task WriteBlobAsync(string blobPath, BlobStreamDecorator data, bool overwrite = false) { - CloudBlobClient blobClient = m_LazyBlobClient.Value; - CloudBlockBlob blockBlob = new CloudBlockBlob(GetAbsoluteBlobUri(blobPath), blobClient.Credentials); + BlobClient blob = GetBlobClient(blobPath); try { //associate metadata to the blob, which will be saved by the next upload operation - data.ApplyDecoratorTo(blockBlob); + BlobUploadOptions options = new(); + data.ApplyDecoratorTo(options); - //use IfNotExistsCondition by default to avoid overriding an existing blob without knowing about it //access condition to ensure blob does not exist yet to catch concurrency issues - AccessCondition condition = null; if (!overwrite) { - condition = AccessCondition.GenerateIfNotExistsCondition(); + options.Conditions = new BlobRequestConditions { IfNoneMatch = ETag.All }; } - await blockBlob.UploadFromStreamAsync(data, condition, null, null); + await blob.UploadAsync(data, options); } - catch (StorageException ex) when (ex.RequestInformation.ErrorCode == BlobErrorCodeStrings.BlobAlreadyExists) + catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobAlreadyExists) { //blob already exists, overwriting is currently not implemented => concurrency error on the client side throw new ConflictException(ErrorCodes.BlobAlreadyExists); @@ -184,15 +185,14 @@ public async Task WriteBlobAsync(string blobPath, BlobStreamDecorator data, bool /// Returns true if the blob has been deleted or false if the blob did not exist. public async Task DeleteBlobAsync(string blobPath) { - CloudBlobClient blobClient = m_LazyBlobClient.Value; - var blob = new CloudBlob(GetAbsoluteBlobUri(blobPath), blobClient.Credentials); + BlobClient blob = GetBlobClient(blobPath); try { await blob.DeleteAsync(); return true; } - catch (StorageException ex) when (ex.RequestInformation.ErrorCode == BlobErrorCodeStrings.BlobNotFound) + catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound) { //swallow blob not found to ease delete operations return false; @@ -208,21 +208,53 @@ public async Task DeleteBlobAsync(string blobPath) /// public async Task GetDelegatedReadAccessAsync(string blobPath, int secondsAccessExipiryDelay) { - CloudBlobClient blobClient = m_LazyBlobClient.Value; - CloudBlob blob = new CloudBlob(GetAbsoluteBlobUri(blobPath), blobClient.Credentials); + BlobClient blob = GetBlobClient(blobPath); if (!await blob.ExistsAsync()) { throw new IdNotFoundException(ErrorCodes.BlobNotFound, blobPath); } - string sas = blob.GetSharedAccessSignature(new SharedAccessBlobPolicy() + Uri sasUri = blob.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.UtcNow.AddSeconds(secondsAccessExipiryDelay)); + return sasUri.ToString(); + } + + #endregion + + #region Azure Clients + + /// + /// Returns a client to manage the storage container specified in blob path. + /// + /// + /// + /// + private BlobContainerClient GetContainerClient(string blobRelativePath) + { + var segments = blobRelativePath.TrimStart('/').Split('/', 2); + if (segments.Length < 1) { - Permissions = SharedAccessBlobPermissions.Read, - SharedAccessExpiryTime = DateTimeOffset.Now.AddSeconds(secondsAccessExipiryDelay) - }); + throw new ArgumentException($"Invalid blob path", nameof(blobRelativePath)); + } - return $"{blob.Uri}{sas}"; + return m_LazyBlobServiceClient.Value.GetBlobContainerClient(segments[0]); + } + + /// + /// Returns a client to manage the storage blob specified in blob path. + /// + /// + /// + /// + private BlobClient GetBlobClient(string blobRelativePath) + { + var segments = blobRelativePath.TrimStart('/').Split('/', 2); + if (segments.Length != 2) + { + throw new ArgumentException($"Invalid blob path", nameof(blobRelativePath)); + } + + return m_LazyBlobServiceClient.Value.GetBlobContainerClient(segments[0]).GetBlobClient(segments[1]); } #endregion @@ -230,26 +262,24 @@ public async Task GetDelegatedReadAccessAsync(string blobPath, int secon #region Helpers /// - /// Returns the aboslute URI for the specified relative blob path. + /// Returns the relative path for the specified blob from the blob client base URI. /// - /// + /// /// - private Uri GetAbsoluteBlobUri(string blobRelativePath) + private static string GetBlobPath(BlobClient blob) { - //blob client abosulte uri contains '/' when using a real storage account, but does not for the emulator - // - Azure storage account: "https://{accountName}.blob.core.windows.net/" - // - Emulator: "http://127.0.0.1:10000/devstoreaccount1" - return new Uri($"{m_LazyBlobClient.Value.BaseUri.AbsoluteUri.TrimEnd('/')}/{blobRelativePath.TrimStart('/')}"); + return $"{blob.BlobContainerName}/{blob.Name}"; } /// /// Returns the relative path for the specified blob from the blob client base URI. /// - /// + /// + /// /// - private string GetBlobPath(CloudBlob blob) + private static string GetBlobPath(BlobContainerClient container, BlobItem item) { - return $"{blob.Container.Name}/{blob.Name}"; + return $"{container.Name}/{item.Name}"; } #endregion diff --git a/src/Distech.CloudRelay.Common/DAL/BlobMetadata.cs b/src/Distech.CloudRelay.Common/DAL/BlobMetadata.cs index 4fe882f..ced14b9 100644 --- a/src/Distech.CloudRelay.Common/DAL/BlobMetadata.cs +++ b/src/Distech.CloudRelay.Common/DAL/BlobMetadata.cs @@ -1,4 +1,4 @@ -using Microsoft.WindowsAzure.Storage.Blob; +using Azure.Storage.Blobs.Models; using System; using System.Collections; using System.Collections.Generic; @@ -71,7 +71,7 @@ internal static BlobMetadata FromDictionary(IDictionary metadata /// Applies the metadata information to the specified azure storage blob. /// /// - public void ApplyTo(CloudBlob blob) + public void ApplyTo(BlobUploadOptions blob) { foreach (var item in m_Metadata) { diff --git a/src/Distech.CloudRelay.Common/DAL/BlobStreamDecorator.cs b/src/Distech.CloudRelay.Common/DAL/BlobStreamDecorator.cs index 325f2d7..5cb0c02 100644 --- a/src/Distech.CloudRelay.Common/DAL/BlobStreamDecorator.cs +++ b/src/Distech.CloudRelay.Common/DAL/BlobStreamDecorator.cs @@ -1,8 +1,10 @@ -using Microsoft.WindowsAzure.Storage.Blob; +using Azure.Storage.Blobs.Models; using System; using System.Collections.Generic; using System.IO; using System.Text; +using System.Threading; +using System.Threading.Tasks; namespace Distech.CloudRelay.Common.DAL { @@ -103,16 +105,36 @@ protected override void Dispose(bool disposing) public override long Position { get => m_Stream.Position; set => m_Stream.Position = value; } + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + return m_Stream.CopyToAsync(destination, bufferSize, cancellationToken); + } + public override void Flush() { m_Stream.Flush(); } + public override Task FlushAsync(CancellationToken cancellationToken) + { + return m_Stream.FlushAsync(cancellationToken); + } + public override int Read(byte[] buffer, int offset, int count) { return m_Stream.Read(buffer, offset, count); } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return m_Stream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return m_Stream.ReadAsync(buffer, cancellationToken); + } + public override long Seek(long offset, SeekOrigin origin) { return m_Stream.Seek(offset, origin); @@ -128,6 +150,16 @@ public override void Write(byte[] buffer, int offset, int count) m_Stream.Write(buffer, offset, count); } + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return m_Stream.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + return m_Stream.WriteAsync(buffer, cancellationToken); + } + #endregion #region Azure Blob Support @@ -136,9 +168,10 @@ public override void Write(byte[] buffer, int offset, int count) /// Applies the decorator information to the specified . /// /// - public void ApplyDecoratorTo(CloudBlob blob) + public void ApplyDecoratorTo(BlobUploadOptions blob) { - blob.Properties.ContentType = ContentType; + blob.HttpHeaders ??= new BlobHttpHeaders(); + blob.HttpHeaders.ContentType = ContentType; Metadata?.ApplyTo(blob); } diff --git a/src/Distech.CloudRelay.Common/Distech.CloudRelay.Common.csproj b/src/Distech.CloudRelay.Common/Distech.CloudRelay.Common.csproj index 10f4601..6f113c2 100644 --- a/src/Distech.CloudRelay.Common/Distech.CloudRelay.Common.csproj +++ b/src/Distech.CloudRelay.Common/Distech.CloudRelay.Common.csproj @@ -1,15 +1,15 @@ - + - netstandard2.0 + net6.0 Distech.CloudRelay.Common Common services and options for Distech Relay API assemblies - - + + diff --git a/src/Distech.CloudRelay.Functions/Distech.CloudRelay.Functions.csproj b/src/Distech.CloudRelay.Functions/Distech.CloudRelay.Functions.csproj index fd89193..bb693da 100644 --- a/src/Distech.CloudRelay.Functions/Distech.CloudRelay.Functions.csproj +++ b/src/Distech.CloudRelay.Functions/Distech.CloudRelay.Functions.csproj @@ -1,13 +1,13 @@  - netcoreapp2.1 - v2 + net6.0 + v4 92dc58da-6f07-4cf7-8571-ec5c9f11d03d - - + + diff --git a/src/Distech.CloudRelay.Functions/local.settings.json b/src/Distech.CloudRelay.Functions/local.settings.json index 04f6449..9a35c5c 100644 --- a/src/Distech.CloudRelay.Functions/local.settings.json +++ b/src/Distech.CloudRelay.Functions/local.settings.json @@ -6,6 +6,9 @@ "AzureFunctionsJobHost__Logging__LogLevel__Default": "Debug", "AzureFunctionsJobHost__Logging__LogLevel__System": "Information", - "AzureFunctionsJobHost__Logging__LogLevel__Microsoft": "Information" + "AzureFunctionsJobHost__Logging__LogLevel__Microsoft": "Information", + "FileStorage__DeviceFileUploadFolder": "iothub-fileupload", + "FileStorage__ServerFileUploadFolder": "cloud-relay-fileupload", + "FileStorage__ServerFileUploadSubFolder": "RestApi" } } \ No newline at end of file diff --git a/test/Distech.CloudRelay.API.FunctionalTests/CloudRelayAPIApplicationFactory.cs b/test/Distech.CloudRelay.API.FunctionalTests/CloudRelayAPIApplicationFactory.cs index faf08a5..efd997a 100644 --- a/test/Distech.CloudRelay.API.FunctionalTests/CloudRelayAPIApplicationFactory.cs +++ b/test/Distech.CloudRelay.API.FunctionalTests/CloudRelayAPIApplicationFactory.cs @@ -1,7 +1,11 @@ -using Microsoft.AspNetCore; +using Distech.CloudRelay.API.FunctionalTests.Middleware; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System; using System.IO; namespace Distech.CloudRelay.API.FunctionalTests @@ -23,14 +27,39 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } - protected override IWebHostBuilder CreateWebHostBuilder() + protected override void ConfigureWebHost(IWebHostBuilder builder) { - return WebHost.CreateDefaultBuilder(null); + builder.UseSolutionRelativeContentRoot(Path.Combine("src", "Distech.CloudRelay.API")); + builder.ConfigureServices(services => + { + services.AddTransient(); + }); } - protected override void ConfigureWebHost(IWebHostBuilder builder) + class IdentityMiddlewareFilter + : IStartupFilter { - builder.UseSolutionRelativeContentRoot(Path.Combine("src", "Distech.CloudRelay.API")); + private readonly IConfiguration m_Configuration; + + public IdentityMiddlewareFilter(IConfiguration configuration) + { + m_Configuration = configuration; + } + + public Action Configure(Action next) + { + var configuration = m_Configuration; + return builder => + { + if (configuration.GetValue(TestEnvironment.UseIdentity, false)) + { + builder.UseMiddleware(); + } + + next(builder); + }; + + } } } } diff --git a/test/Distech.CloudRelay.API.FunctionalTests/DevicesTests.cs b/test/Distech.CloudRelay.API.FunctionalTests/DevicesTests.cs index 63b25fd..25d01e8 100644 --- a/test/Distech.CloudRelay.API.FunctionalTests/DevicesTests.cs +++ b/test/Distech.CloudRelay.API.FunctionalTests/DevicesTests.cs @@ -9,23 +9,23 @@ using Newtonsoft.Json; using System.Collections.Generic; using System.Net; -using System.Net.Http; +using System.Net.Http.Json; using System.Threading.Tasks; using Xunit; namespace Distech.CloudRelay.API.FunctionalTests { - public class DevicesControllerTests : IClassFixture> + public class DevicesControllerTests : IClassFixture> { #region Member Variables - private readonly CloudRelayAPIApplicationFactory m_ApiAppFactory; + private readonly CloudRelayAPIApplicationFactory m_ApiAppFactory; #endregion #region Constructors - public DevicesControllerTests(CloudRelayAPIApplicationFactory apiAppFactory) + public DevicesControllerTests(CloudRelayAPIApplicationFactory apiAppFactory) { m_ApiAppFactory = apiAppFactory; } @@ -80,8 +80,7 @@ public async Task GetDeviceRequest_DeviceNotFound_ReturnsNotFound() //Act var response = await client.GetAsync($"api/v1/devices/{deviceId}/request"); - var content = await response.Content.ReadAsStringAsync(); - ApplicationProblemDetails problem = JsonConvert.DeserializeObject(content); + var problem = await response.Content.ReadFromJsonAsync(); //Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -108,8 +107,7 @@ public async Task GetDeviceRequest_EnvironmentNotFound_ReturnsNotFound() //Act var response = await client.GetAsync($"api/v1/devices/{deviceId}/request?environmentId=someId"); - var content = await response.Content.ReadAsStringAsync(); - ApplicationProblemDetails problem = JsonConvert.DeserializeObject(content); + var problem = await response.Content.ReadFromJsonAsync(); //Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -130,6 +128,8 @@ public async Task PostDeviceRequest_HappyPath_ReturnsOk() builder.ConfigureTestServices(services => { var stubAdapter = new Mock(); + stubAdapter.Setup(a => a.GetMaximumMessageSize()) + .Returns(int.MaxValue); stubAdapter.Setup(a => a.InvokeCommandAsync(deviceId, It.IsAny())) .ReturnsAsync(new InvocationResult(StatusCodes.Status200OK, GetMockedResponse(StatusCodes.Status200OK, new object[] { }))); services.AddScoped(_ => stubAdapter.Object); @@ -155,6 +155,8 @@ public async Task PostDeviceRequest_DeviceNotFound_ReturnsNotFound() builder.ConfigureTestServices(services => { var stubAdapter = new Mock(); + stubAdapter.Setup(a => a.GetMaximumMessageSize()) + .Returns(int.MaxValue); stubAdapter.Setup(a => a.InvokeCommandAsync(deviceId, It.IsAny())) .ThrowsAsync(new IdNotFoundException(ErrorCodes.DeviceNotFound, deviceId)); services.AddScoped(_ => stubAdapter.Object); @@ -165,8 +167,7 @@ public async Task PostDeviceRequest_DeviceNotFound_ReturnsNotFound() //Act var response = await client.PostAsJsonAsync($"api/v1/devices/{deviceId}/request", default(string)); - var content = await response.Content.ReadAsStringAsync(); - ApplicationProblemDetails problem = JsonConvert.DeserializeObject(content); + var problem = await response.Content.ReadFromJsonAsync(); //Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -193,8 +194,7 @@ public async Task PostDeviceRequest_EnvironmentNotFound_ReturnsNotFound() //Act var response = await client.PostAsJsonAsync($"api/v1/devices/{deviceId}/request?environmentId=someId", default(string)); - var content = await response.Content.ReadAsStringAsync(); - ApplicationProblemDetails problem = JsonConvert.DeserializeObject(content); + var problem = await response.Content.ReadFromJsonAsync(); //Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -215,6 +215,8 @@ public async Task PuDeviceRequest_HappyPath_ReturnsOk() builder.ConfigureTestServices(services => { var stubAdapter = new Mock(); + stubAdapter.Setup(a => a.GetMaximumMessageSize()) + .Returns(int.MaxValue); stubAdapter.Setup(a => a.InvokeCommandAsync(deviceId, It.IsAny())) .ReturnsAsync(new InvocationResult(StatusCodes.Status200OK, GetMockedLegacyResponse(StatusCodes.Status200OK, default(string)))); services.AddScoped(_ => stubAdapter.Object); @@ -240,6 +242,8 @@ public async Task PutDeviceRequest_DeviceNotFound_ReturnsNotFound() builder.ConfigureTestServices(services => { var stubAdapter = new Mock(); + stubAdapter.Setup(a => a.GetMaximumMessageSize()) + .Returns(int.MaxValue); stubAdapter.Setup(a => a.InvokeCommandAsync(deviceId, It.IsAny())) .ThrowsAsync(new IdNotFoundException(ErrorCodes.DeviceNotFound, deviceId)); services.AddScoped(_ => stubAdapter.Object); @@ -250,8 +254,7 @@ public async Task PutDeviceRequest_DeviceNotFound_ReturnsNotFound() //Act var response = await client.PutAsJsonAsync($"api/v1/devices/{deviceId}/request", default(string)); - var content = await response.Content.ReadAsStringAsync(); - ApplicationProblemDetails problem = JsonConvert.DeserializeObject(content); + var problem = await response.Content.ReadFromJsonAsync(); //Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -278,8 +281,7 @@ public async Task PutDeviceRequest_EnvironmentNotFound_ReturnsNotFound() //Act var response = await client.PutAsJsonAsync($"api/v1/devices/{deviceId}/request?environmentId=someId", default(string)); - var content = await response.Content.ReadAsStringAsync(); - ApplicationProblemDetails problem = JsonConvert.DeserializeObject(content); + var problem = await response.Content.ReadFromJsonAsync(); //Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -335,8 +337,7 @@ public async Task DeleteDeviceRequest_DeviceNotFound_ReturnsNotFound() //Act var response = await client.DeleteAsync($"api/v1/devices/{deviceId}/request"); - var content = await response.Content.ReadAsStringAsync(); - ApplicationProblemDetails problem = JsonConvert.DeserializeObject(content); + var problem = await response.Content.ReadFromJsonAsync(); //Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -363,8 +364,7 @@ public async Task DeleteDeviceRequest_EnvironmentNotFound_ReturnsNotFound() //Act var response = await client.DeleteAsync($"api/v1/devices/{deviceId}/request?environmentId=someId"); - var content = await response.Content.ReadAsStringAsync(); - ApplicationProblemDetails problem = JsonConvert.DeserializeObject(content); + var problem = await response.Content.ReadFromJsonAsync(); //Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); diff --git a/test/Distech.CloudRelay.API.FunctionalTests/Distech.CloudRelay.API.FunctionalTests.csproj b/test/Distech.CloudRelay.API.FunctionalTests/Distech.CloudRelay.API.FunctionalTests.csproj index a57eabd..db45d94 100644 --- a/test/Distech.CloudRelay.API.FunctionalTests/Distech.CloudRelay.API.FunctionalTests.csproj +++ b/test/Distech.CloudRelay.API.FunctionalTests/Distech.CloudRelay.API.FunctionalTests.csproj @@ -1,18 +1,17 @@  - netcoreapp2.1 + net6.0 false - - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/Distech.CloudRelay.API.FunctionalTests/TestStartup.cs b/test/Distech.CloudRelay.API.FunctionalTests/TestStartup.cs deleted file mode 100644 index e30304d..0000000 --- a/test/Distech.CloudRelay.API.FunctionalTests/TestStartup.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Distech.CloudRelay.API.FunctionalTests.Middleware; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Distech.CloudRelay.API.FunctionalTests -{ - public class TestStartup : Startup - { - public TestStartup(IConfiguration configuration) - : base(configuration) - { - } - - protected override void ConfigureAuthentication(IApplicationBuilder app) - { - base.ConfigureAuthentication(app); - - if (Configuration.GetValue(TestEnvironment.UseIdentity, false)) - app.UseMiddleware(); - } - } -} diff --git a/test/Distech.CloudRelay.Common.UnitTests/Distech.CloudRelay.Common.UnitTests.csproj b/test/Distech.CloudRelay.Common.UnitTests/Distech.CloudRelay.Common.UnitTests.csproj index 88b504e..54b2b71 100644 --- a/test/Distech.CloudRelay.Common.UnitTests/Distech.CloudRelay.Common.UnitTests.csproj +++ b/test/Distech.CloudRelay.Common.UnitTests/Distech.CloudRelay.Common.UnitTests.csproj @@ -1,20 +1,23 @@ - + - netcoreapp2.1 + net6.0 false - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive -