Skip to content

Commit

Permalink
Merge pull request #25 from neozhu/feature/csvexportimport
Browse files Browse the repository at this point in the history
Import and Export Features for Products
  • Loading branch information
neozhu authored Dec 25, 2024
2 parents 865e08c + 878af33 commit 87b8b0a
Show file tree
Hide file tree
Showing 23 changed files with 508 additions and 44 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ By incorporating robust offline capabilities, CleanAspire empowers developers to
version: '3.8'
services:
apiservice:
image: blazordevlab/cleanaspire-api:0.0.55
image: blazordevlab/cleanaspire-api:0.0.56
environment:
- ASPNETCORE_ENVIRONMENT=Development
- AllowedHosts=*
Expand All @@ -110,7 +110,7 @@ services:


blazorweb:
image: blazordevlab/cleanaspire-webapp:0.0.55
image: blazordevlab/cleanaspire-webapp:0.0.56
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AllowedHosts=*
Expand Down
4 changes: 2 additions & 2 deletions src/CleanAspire.Api/Endpoints/FileUploadEndpointRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ public void RegisterRoutes(IEndpointRouteBuilder routes)
.WithSummary("Upload files to the server")
.WithDescription("Allows uploading multiple files to a specific folder on the server.");

group.MapPost("/image", async ([FromForm] ImageUploadRequest request, HttpContext context, [FromServices] IServiceProvider sp) =>
group.MapPost("/image", async ([FromForm] ImageUploadRequest request, HttpContext context) =>
{
var response = new List<FileUploadResponse>();
var uploadService = sp.GetRequiredService<IUploadService>();
var uploadService = context.RequestServices.GetRequiredService<IUploadService>();
// Construct the URL to access the file
var requestScheme = context.Request.Scheme; // 'http' or 'https'
var requestHost = context.Request.Host.Value; // 'host:port'
Expand Down
61 changes: 60 additions & 1 deletion src/CleanAspire.Api/Endpoints/ProductEndpointRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
using CleanAspire.Application.Features.Products.Queries;
using Mediator;
using Microsoft.AspNetCore.Mvc;
using static CleanAspire.Api.Endpoints.FileUploadEndpointRegistrar;

namespace CleanAspire.Api.Endpoints;

public class ProductEndpointRegistrar : IEndpointRegistrar
public class ProductEndpointRegistrar(ILogger<ProductEndpointRegistrar> logger) : IEndpointRegistrar
{
public void RegisterRoutes(IEndpointRouteBuilder routes)
{
Expand Down Expand Up @@ -74,6 +75,64 @@ public void RegisterRoutes(IEndpointRouteBuilder routes)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.WithSummary("Get products with pagination")
.WithDescription("Returns a paginated list of products based on search keywords, page size, and sorting options.");

// Export products to CSV
group.MapGet("/export", async ([FromQuery] string keywords, [FromServices] IMediator mediator) =>
{
var result = await mediator.Send(new ExportProductsQuery(keywords));
result.Position = 0;
return Results.File(result, "text/csv", "exported-products.csv");
})
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.WithSummary("Export Products to CSV")
.WithDescription("Exports the product data to a CSV file based on the provided keywords. The CSV file includes product details such as ID, name, description, price, SKU, and category.");

// Import products from CSV
group.MapPost("/import", async ([FromForm] FileUploadRequest request, HttpContext context, [FromServices] IMediator mediator) =>
{
var response = new List<FileUploadResponse>();
foreach (var file in request.Files)
{
// Validate file type
if (Path.GetExtension(file.FileName).ToLower() != ".csv")
{
logger.LogWarning($"Invalid file type: {file.FileName}");
return Results.BadRequest("Only CSV files are supported.");
}
var fileName = file.FileName;
// Copy file to memory stream
var filestream = file.OpenReadStream();
var stream = new MemoryStream();
await filestream.CopyToAsync(stream);
stream.Position = 0;
var fileSize = stream.Length;
// Send the file stream to ImportProductsCommand
var importCommand = new ImportProductsCommand(stream);
await mediator.Send(importCommand);

response.Add(new FileUploadResponse
{
Path = file.FileName,
Url = $"Imported {fileName}",
Size = fileSize
});
}

return TypedResults.Ok(response);

}).DisableAntiforgery()
.Accepts<FileUploadRequest>("multipart/form-data")
.Produces<IEnumerable<FileUploadResponse>>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.WithMetadata(new ConsumesAttribute("multipart/form-data"))
.WithSummary("Import Products from CSV")
.WithDescription("Imports product data from one or more CSV files. The CSV files should contain product details in the required format.");

}


}

Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception e
{
Status = StatusCodes.Status400BadRequest,
Title = "Unique Constraint Violation",
Detail = $"Unique constraint {e.ConstraintName} violated. Duplicate value for {e.ConstraintProperties[0]}",
Detail = e.ConstraintName != null && e.ConstraintProperties != null && e.ConstraintProperties.Any()
? $"Unique constraint {e.ConstraintName} violated. Duplicate value for {e.ConstraintProperties[0]}."
: e.ConstraintName != null
? $"Unique constraint {e.ConstraintName} violated."
: "A unique constraint violation occurred.",
Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}",
},
CannotInsertNullException e => new ProblemDetails
Expand Down
1 change: 1 addition & 0 deletions src/CleanAspire.Application/CleanAspire.Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Mediator.Abstractions" Version="3.0.0-preview.27" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Globalization;
using CleanAspire.Application.Features.Products.DTOs;
using CleanAspire.Application.Features.Products.EventHandlers;
using CleanAspire.Application.Pipeline;
using CsvHelper;

namespace CleanAspire.Application.Features.Products.Commands;
public record ImportProductsCommand(Stream Stream
) : IFusionCacheRefreshRequest<Unit>, IRequiresValidation
{
public IEnumerable<string>? Tags => new[] { "products" };
}

public class ImportProductsCommandHandler : IRequestHandler<ImportProductsCommand, Unit>
{
private readonly IApplicationDbContext _context;

public ImportProductsCommandHandler(IApplicationDbContext context)
{
_context = context;
}

public async ValueTask<Unit> Handle(ImportProductsCommand request, CancellationToken cancellationToken)
{
request.Stream.Position = 0;
using (var reader = new StreamReader(request.Stream))
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
{
var records = csv.GetRecords<ProductDto>();
foreach (var product in records.Select(x=>new Product()
{
SKU = x.SKU,
Name = x.Name,
Category = (ProductCategory)x.Category,

Check warning on line 34 in src/CleanAspire.Application/Features/Products/Commands/ImportProductsCommand.cs

View workflow job for this annotation

GitHub Actions / build

Nullable value type may be null.
Description = x.Description,
Price = x.Price,
Currency = x.Currency,
UOM = x.UOM
}))
{
product.AddDomainEvent(new ProductCreatedEvent(product));
_context.Products.Add(product);
}
await _context.SaveChangesAsync(cancellationToken);
}
return Unit.Value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Globalization;
using CleanAspire.Application.Features.Products.DTOs;
using CsvHelper;

namespace CleanAspire.Application.Features.Products.Queries;
public record ExportProductsQuery(string Keywords) : IRequest<Stream>;


public class ExportProductsQueryHandler : IRequestHandler<ExportProductsQuery, Stream>
{
private readonly IApplicationDbContext _context;

public ExportProductsQueryHandler(IApplicationDbContext context)
{
_context = context;
}

public async ValueTask<Stream> Handle(ExportProductsQuery request, CancellationToken cancellationToken)
{
var data = await _context.Products.Where(x => x.SKU.Contains(request.Keywords) || x.Name.Contains(request.Keywords) || x.Description.Contains(request.Keywords))

Check warning on line 20 in src/CleanAspire.Application/Features/Products/Queries/ExportProductsQuery.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
.Select(t => new ProductDto
{
Id = t.Id,
Name = t.Name,
Description = t.Description,
Price = t.Price,
SKU = t.SKU,
UOM = t.UOM,
Currency = t.Currency,
Category = (ProductCategoryDto?)t.Category
}).ToListAsync(cancellationToken);
var steam = new MemoryStream();
using (var writer = new StreamWriter(steam, leaveOpen: true))
using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
csv.WriteRecords(data);
await writer.FlushAsync();
}
return steam;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CleanAspire.Application.Features.Products.Commands;


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CleanAspire.Application.Features.Products.Commands;

namespace CleanAspire.Application.Features.Products.Validators;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CleanAspire.Application.Features.Products.Commands;

namespace CleanAspire.Application.Features.Products.Validators;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// <auto-generated/>
#pragma warning disable CS0618
using CleanAspire.Api.Client.Models;
using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.Kiota.Abstractions.Serialization;
using Microsoft.Kiota.Abstractions;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System.Threading;
using System;
namespace CleanAspire.Api.Client.Products.Export
{
/// <summary>
/// Builds and executes requests for operations under \products\export
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class ExportRequestBuilder : BaseRequestBuilder
{
/// <summary>
/// Instantiates a new <see cref="global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public ExportRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/products/export?keywords={keywords}", pathParameters)
{
}
/// <summary>
/// Instantiates a new <see cref="global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public ExportRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/products/export?keywords={keywords}", rawUrl)
{
}
/// <summary>
/// Exports the product data to a CSV file based on the provided keywords. The CSV file includes product details such as ID, name, description, price, SKU, and category.
/// </summary>
/// <returns>A <see cref="Stream"/></returns>
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::CleanAspire.Api.Client.Models.ProblemDetails">When receiving a 500 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task<Stream?> GetAsync(Action<RequestConfiguration<global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder.ExportRequestBuilderGetQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<Stream> GetAsync(Action<RequestConfiguration<global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder.ExportRequestBuilderGetQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
var requestInfo = ToGetRequestInformation(requestConfiguration);
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
{
{ "500", global::CleanAspire.Api.Client.Models.ProblemDetails.CreateFromDiscriminatorValue },
};
return await RequestAdapter.SendPrimitiveAsync<Stream>(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Exports the product data to a CSV file based on the provided keywords. The CSV file includes product details such as ID, name, description, price, SKU, and category.
/// </summary>
/// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder.ExportRequestBuilderGetQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder.ExportRequestBuilderGetQueryParameters>> requestConfiguration = default)
{
#endif
var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters);
requestInfo.Configure(requestConfiguration);
requestInfo.Headers.TryAdd("Accept", "application/problem+json");
return requestInfo;
}
/// <summary>
/// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
/// </summary>
/// <returns>A <see cref="global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder"/></returns>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
public global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder WithUrl(string rawUrl)
{
return new global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder(rawUrl, RequestAdapter);
}
/// <summary>
/// Exports the product data to a CSV file based on the provided keywords. The CSV file includes product details such as ID, name, description, price, SKU, and category.
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class ExportRequestBuilderGetQueryParameters
{
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
[QueryParameter("keywords")]
public string? Keywords { get; set; }
#nullable restore
#else
[QueryParameter("keywords")]
public string Keywords { get; set; }
#endif
}
/// <summary>
/// Configuration for the request such as headers, query parameters, and middleware options.
/// </summary>
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class ExportRequestBuilderGetRequestConfiguration : RequestConfiguration<global::CleanAspire.Api.Client.Products.Export.ExportRequestBuilder.ExportRequestBuilderGetQueryParameters>
{
}
}
}
#pragma warning restore CS0618
Loading

0 comments on commit 87b8b0a

Please sign in to comment.