diff --git a/samples/WebApiContrib.Core.Samples/Controllers/CsvTestController.cs b/samples/WebApiContrib.Core.Samples/Controllers/CsvTestController.cs index 2b94380..cd7d0a0 100644 --- a/samples/WebApiContrib.Core.Samples/Controllers/CsvTestController.cs +++ b/samples/WebApiContrib.Core.Samples/Controllers/CsvTestController.cs @@ -19,7 +19,7 @@ public IActionResult Get() [Produces("text/csv")] public IActionResult GetDataAsCsv() { - return Ok( DummyDataList()); + return Ok(DummyDataList()); } [HttpGet] diff --git a/samples/WebApiContrib.Core.Samples/Controllers/FluentCsvTestController.cs b/samples/WebApiContrib.Core.Samples/Controllers/FluentCsvTestController.cs new file mode 100644 index 0000000..664c171 --- /dev/null +++ b/samples/WebApiContrib.Core.Samples/Controllers/FluentCsvTestController.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.AspNetCore.Mvc; +using WebApiContrib.Core.Formatter.Csv; +using WebApiContrib.Core.Samples.Model; + +namespace WebApiContrib.Core.Samples.Controllers +{ + /// + /// + /// Configuration is used for both CSV Output AND Input formatters + /// + /// 1. Only primitive value type properties are allowed for UseProperty (no reference types and no method calls are allowed) + /// 2. Chain parameterless ForHeader() method when: + /// a) Not using headers (UseProperty is always chained after UseHeader) + /// b) Using headers but you want them generated automatically based on property name (or path) + /// 3. Chain method UseCsvDelimiter(string) when you want to override default delimiter (semilocolon) + /// 4. Chain method UseEncoding when you want to override default encoding (ISO-8859-1) + /// 5. Chain method UseFormatProvider when you want to provide custom formatting for your primitive types + /// + /// + public class AuthorModelConfiguration : IFormattingConfiguration + { + public void Configure(IFormattingConfigurationBuilder builder) + { + CultureInfo culture = CultureInfo.CreateSpecificCulture("en-US"); + DateTimeFormatInfo dtfi = culture.DateTimeFormat; + dtfi.DateSeparator = "-"; + builder + .UseHeaders() + .UseFormatProvider(culture) + .ForHeader("Identifier") + .UseProperty(x => x.Id) + .ForHeader("First Name") + .UseProperty(x => x.FirstName) + .ForHeader("Last Name") + .UseProperty(x => x.LastName) + .ForHeader("Date of Birth") + .UseProperty(x => x.DateOfBirth) + .ForHeader("IQ") + .UseProperty(x => x.IQ) + .ForHeader("Street") + .UseProperty(x => x.Address.Street) + .ForHeader("City") + .UseProperty(x => x.Address.City) + // Header name will be inferred from property path 'Address.City' + .ForHeader() + .UseProperty(x => x.Address.Country) + // Header name will be inferred from property name 'Signature' + .ForHeader() + .UseProperty(x => x.Signature); + } + } + + [Route("api/[controller]")] + public class FluentCsvTestController : Controller + { + // GET api/fluentcsvtest + [HttpGet] + public IActionResult Get() + { + return Ok(DummyDataList()); + } + + [HttpGet] + [Route("data.csv")] + [Produces("text/csv")] + public IActionResult GetDataAsCsv() + { + return Ok(DummyDataList()); + } + + [HttpGet] + [Route("dataarray.csv")] + [Produces("text/csv")] + public IActionResult GetArrayDataAsCsv() + { + return Ok(DummyDataArray()); + } + + private static IEnumerable DummyDataList() + { + return new List + { + new AuthorModel + { + Id = 1, + FirstName = "Joanne", + LastName = "Rowling", + DateOfBirth = DateTime.Now, + IQ = 70, + Signature = "signature", + Address = new AuthorAddress + { + Street = null, + City = "London", + Country = "UK" + } + }, + new AuthorModel + { + Id = 1, + FirstName = "Hermann", + LastName = "Hesse", + DateOfBirth = DateTime.Now, + IQ = 180, + Signature = "signature" + } + }; + } + + private static AuthorModel[] DummyDataArray() + { + return new AuthorModel[] + { + new AuthorModel + { + Id = 1, + FirstName = "Joanne", + LastName = "Rowling", + DateOfBirth = DateTime.Now, + IQ = 70, + Signature = "signature", + Address = new AuthorAddress + { + Street = null, + City = "London", + Country = "UK" + } + }, + new AuthorModel + { + Id = 1, + FirstName = "Hermann", + LastName = "Hesse", + DateOfBirth = DateTime.Now, + IQ = 180, + Signature = "signature", + Address = new AuthorAddress + { + Street = null, + City = "Berlin", + Country = "Germany" + } + } + }; + } + + // POST api/fluentcsvtest/import + [HttpPost] + [Route("import")] + public IActionResult Import([FromBody]List value) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + else + { + List data = value; + return Ok(); + } + } + + // POST api/fluentcsvtest/import + [HttpPost] + [Route("importarray")] + public IActionResult ImportArray([FromBody]AuthorModel[] value) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + else + { + var data = value; + return Ok(); + } + } + } +} \ No newline at end of file diff --git a/samples/WebApiContrib.Core.Samples/Model/AuthorModel.cs b/samples/WebApiContrib.Core.Samples/Model/AuthorModel.cs new file mode 100644 index 0000000..a9e7a66 --- /dev/null +++ b/samples/WebApiContrib.Core.Samples/Model/AuthorModel.cs @@ -0,0 +1,23 @@ +using System; + +namespace WebApiContrib.Core.Samples.Model +{ + public class AuthorModel + { + public long Id { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public DateTime DateOfBirth { get; set; } + public int IQ { get; set; } + public object Signature { get; set; } + + public AuthorAddress Address { get; set; } + } + + public class AuthorAddress + { + public string Street { get; set; } + public string City { get; set; } + public string Country { get; set; } + } +} diff --git a/samples/WebApiContrib.Core.Samples/Program.cs b/samples/WebApiContrib.Core.Samples/Program.cs index 036091f..83244de 100644 --- a/samples/WebApiContrib.Core.Samples/Program.cs +++ b/samples/WebApiContrib.Core.Samples/Program.cs @@ -1,12 +1,5 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; namespace WebApiContrib.Core.Samples { diff --git a/samples/WebApiContrib.Core.Samples/Startup.cs b/samples/WebApiContrib.Core.Samples/Startup.cs index 60c450a..46f0d20 100644 --- a/samples/WebApiContrib.Core.Samples/Startup.cs +++ b/samples/WebApiContrib.Core.Samples/Startup.cs @@ -11,7 +11,6 @@ using WebApiContrib.Core.Versioning; using WebApiContrib.Core.Samples.Services; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Server.Kestrel.Core; namespace WebApiContrib.Core.Samples { @@ -37,7 +36,15 @@ public void ConfigureServices(IServiceCollection services) { o.AddJsonpOutputFormatter(); o.UseFromBodyBinding(controllerPredicate: c => c.ControllerType.AsType() == typeof(BindingController)); - }).AddCsvSerializerFormatters() + }) + // Register fluent csv formatters + .AddCsvSerializerFormatters( + builder => + { + builder.RegisterConfiguration(new AuthorModelConfiguration()); + }) + // Register standard csv formatters + .AddCsvSerializerFormatters() .AddPlainTextFormatters() .AddVersionNegotiation(opt => { diff --git a/samples/WebApiContrib.Core.Samples/WebApiContrib.Core.Samples.csproj b/samples/WebApiContrib.Core.Samples/WebApiContrib.Core.Samples.csproj index a5d8253..2d59450 100644 --- a/samples/WebApiContrib.Core.Samples/WebApiContrib.Core.Samples.csproj +++ b/samples/WebApiContrib.Core.Samples/WebApiContrib.Core.Samples.csproj @@ -1,27 +1,27 @@  - - netcoreapp2.0 - WebApiContrib.Core.Samples + + netcoreapp2.0 + WebApiContrib.Core.Samples - - - PreserveNewest - + + + PreserveNewest + - - - - - - - + + + + + + + - - + + diff --git a/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterMvcBuilderExtensions.cs b/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterMvcBuilderExtensions.cs index 9ba1fd3..5699ad9 100644 --- a/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterMvcBuilderExtensions.cs +++ b/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterMvcBuilderExtensions.cs @@ -1,45 +1,66 @@ -using Microsoft.Extensions.DependencyInjection; -using System; +using System; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; namespace WebApiContrib.Core.Formatter.Csv { public static class CsvFormatterMvcBuilderExtensions - { - public static IMvcBuilder AddCsvSerializerFormatters(this IMvcBuilder builder) - { - if (builder == null) - { - throw new ArgumentNullException(nameof(builder)); - } - - return AddCsvSerializerFormatters(builder, csvFormatterOptions: null); - } - - public static IMvcBuilder AddCsvSerializerFormatters( this IMvcBuilder builder, CsvFormatterOptions csvFormatterOptions) - { - if (builder == null) - { - throw new ArgumentNullException(nameof(builder)); - } - - builder.AddFormatterMappings(m => m.SetMediaTypeMappingForFormat("csv", new MediaTypeHeaderValue("text/csv"))); - - if (csvFormatterOptions == null) - { - csvFormatterOptions = new CsvFormatterOptions(); - } - - if (string.IsNullOrWhiteSpace(csvFormatterOptions.CsvDelimiter)) - { - throw new ArgumentException("CsvDelimiter cannot be empty"); - } - - builder.AddMvcOptions(options => options.InputFormatters.Add(new CsvInputFormatter(csvFormatterOptions))); - builder.AddMvcOptions(options => options.OutputFormatters.Add(new CsvOutputFormatter(csvFormatterOptions))); - - - return builder; - } - } -} + { + public static IMvcBuilder AddCsvSerializerFormatters(this IMvcBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return AddCsvSerializerFormatters(builder, csvFormatterOptions: null); + } + + public static IMvcBuilder AddCsvSerializerFormatters(this IMvcBuilder builder, + CsvFormatterOptions csvFormatterOptions) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.AddFormatterMappings(m => m.SetMediaTypeMappingForFormat("csv", new MediaTypeHeaderValue("text/csv"))); + + if (csvFormatterOptions == null) + { + csvFormatterOptions = new CsvFormatterOptions(); + } + + if (string.IsNullOrWhiteSpace(csvFormatterOptions.CsvDelimiter)) + { + throw new ArgumentException("CsvDelimiter cannot be empty"); + } + + builder.AddMvcOptions(options => options.InputFormatters.Add(new StandardCsvInputFormatter(csvFormatterOptions))); + builder.AddMvcOptions(options => options.OutputFormatters.Add(new StandardCsvOutputFormatter(csvFormatterOptions))); + + return builder; + } + + public static IMvcBuilder AddCsvSerializerFormatters(this IMvcBuilder builder, + Action configuration) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.AddFormatterMappings(m => m.SetMediaTypeMappingForFormat("csv", new MediaTypeHeaderValue("text/csv"))); + + // Register provided configurations + var configCollection = new FormattingConfigurationCollection(builder.Services); + configuration.Invoke(configCollection); + var registeredTypes = configCollection.GetRegistredTypes(); + + builder.AddMvcOptions(options => options.InputFormatters.Add(new FluentCsvInputFormatter(registeredTypes))); + builder.AddMvcOptions(options => options.OutputFormatters.Add(new FluentCsvOutputFormatter(registeredTypes))); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterMvcCoreBuilderExtensions.cs b/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterMvcCoreBuilderExtensions.cs index 867564c..52fb9b2 100644 --- a/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterMvcCoreBuilderExtensions.cs +++ b/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterMvcCoreBuilderExtensions.cs @@ -1,6 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; +using System; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; -using System; namespace WebApiContrib.Core.Formatter.Csv { @@ -35,8 +35,29 @@ public static IMvcCoreBuilder AddCsvSerializerFormatters(this IMvcCoreBuilder bu throw new ArgumentException("CsvDelimiter cannot be empty"); } - builder.AddMvcOptions(options => options.InputFormatters.Add(new CsvInputFormatter(csvFormatterOptions))); - builder.AddMvcOptions(options => options.OutputFormatters.Add(new CsvOutputFormatter(csvFormatterOptions))); + builder.AddMvcOptions(options => options.InputFormatters.Add(new StandardCsvInputFormatter(csvFormatterOptions))); + builder.AddMvcOptions(options => options.OutputFormatters.Add(new StandardCsvOutputFormatter(csvFormatterOptions))); + + return builder; + } + + public static IMvcCoreBuilder AddCsvSerializerFormatters(this IMvcCoreBuilder builder, + Action configuration) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.AddFormatterMappings(m => m.SetMediaTypeMappingForFormat("csv", new MediaTypeHeaderValue("text/csv"))); + + // Register provided configurations + var configCollection = new FormattingConfigurationCollection(builder.Services); + configuration.Invoke(configCollection); + var registeredTypes = configCollection.GetRegistredTypes(); + + builder.AddMvcOptions(options => options.InputFormatters.Add(new FluentCsvInputFormatter(registeredTypes))); + builder.AddMvcOptions(options => options.OutputFormatters.Add(new FluentCsvOutputFormatter(registeredTypes))); return builder; } diff --git a/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterOptions.cs b/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterOptions.cs index ee2078a..4ad6b4e 100644 --- a/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterOptions.cs +++ b/src/WebApiContrib.Core.Formatter.Csv/CsvFormatterOptions.cs @@ -8,4 +8,4 @@ public class CsvFormatterOptions public string Encoding { get; set; } = "ISO-8859-1"; } -} +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/CsvInputFormatter.cs b/src/WebApiContrib.Core.Formatter.Csv/CsvInputFormatter.cs deleted file mode 100644 index 8257716..0000000 --- a/src/WebApiContrib.Core.Formatter.Csv/CsvInputFormatter.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Net.Http.Headers; -using System.Text; - -namespace WebApiContrib.Core.Formatter.Csv -{ - /// - /// ContentType: text/csv - /// - public class CsvInputFormatter : InputFormatter - { - private readonly CsvFormatterOptions _options; - - public CsvInputFormatter(CsvFormatterOptions csvFormatterOptions) - { - SupportedMediaTypes.Add(Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse("text/csv")); - - if (csvFormatterOptions == null) - { - throw new ArgumentNullException(nameof(csvFormatterOptions)); - } - - _options = csvFormatterOptions; - } - - public override Task ReadRequestBodyAsync(InputFormatterContext context) - { - var type = context.ModelType; - var request = context.HttpContext.Request; - MediaTypeHeaderValue requestContentType = null; - MediaTypeHeaderValue.TryParse(request.ContentType, out requestContentType); - - - var result = ReadStream(type, request.Body); - return InputFormatterResult.SuccessAsync(result); - } - - public override bool CanRead(InputFormatterContext context) - { - var type = context.ModelType; - if (type == null) - throw new ArgumentNullException("type"); - - return IsTypeOfIEnumerable(type); - } - - private bool IsTypeOfIEnumerable(Type type) - { - - foreach (Type interfaceType in type.GetInterfaces()) - { - - if (interfaceType == typeof(IList)) - return true; - } - - return false; - } - - private object ReadStream(Type type, Stream stream) - { - Type itemType; - var typeIsArray = false; - IList list; - if (type.GetGenericArguments().Length > 0) - { - itemType = type.GetGenericArguments()[0]; - list = (IList)Activator.CreateInstance(type); - } - else - { - typeIsArray = true; - itemType = type.GetElementType(); - - var listType = typeof(List<>); - var constructedListType = listType.MakeGenericType(itemType); - - list = (IList)Activator.CreateInstance(constructedListType); - } - - var reader = new StreamReader(stream, Encoding.GetEncoding(_options.Encoding)); - - bool skipFirstLine = _options.UseSingleLineHeaderInCsv; - while (!reader.EndOfStream) - { - var line = reader.ReadLine(); - var values = line.Split(_options.CsvDelimiter.ToCharArray()); - if(skipFirstLine) - { - skipFirstLine = false; - } - else - { - var itemTypeInGeneric = list.GetType().GetTypeInfo().GenericTypeArguments[0]; - var item = Activator.CreateInstance(itemTypeInGeneric); - var properties = item.GetType().GetProperties(); - for (int i = 0;i - /// Original code taken from - /// http://www.tugberkugurlu.com/archive/creating-custom-csvmediatypeformatter-in-asp-net-web-api-for-comma-separated-values-csv-format - /// Adapted for ASP.NET Core and uses ; instead of , for delimiters - /// - public class CsvOutputFormatter : OutputFormatter - { - private readonly CsvFormatterOptions _options; - - public string ContentType { get; private set; } - - public CsvOutputFormatter(CsvFormatterOptions csvFormatterOptions) - { - ContentType = "text/csv"; - SupportedMediaTypes.Add(Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse("text/csv")); - _options = csvFormatterOptions ?? throw new ArgumentNullException(nameof(csvFormatterOptions)); - } - - protected override bool CanWriteType(Type type) - { - - if (type == null) - throw new ArgumentNullException("type"); - - return IsTypeOfIEnumerable(type); - } - - private bool IsTypeOfIEnumerable(Type type) - { - - foreach (Type interfaceType in type.GetInterfaces()) - { - - if (interfaceType == typeof(IList)) - return true; - } - - return false; - } - - public async override Task WriteResponseBodyAsync(OutputFormatterWriteContext context) - { - var response = context.HttpContext.Response; - - Type type = context.Object.GetType(); - Type itemType; - - if (type.GetGenericArguments().Length > 0) - { - itemType = type.GetGenericArguments()[0]; - } - else - { - itemType = type.GetElementType(); - } - - var streamWriter = new StreamWriter(response.Body, Encoding.GetEncoding(_options.Encoding)); - - if (_options.UseSingleLineHeaderInCsv) - { - await streamWriter.WriteLineAsync( - string.Join( - _options.CsvDelimiter, itemType.GetProperties().Select(x => x.GetCustomAttribute(false)?.Name ?? x.Name) - ) - ); - } - - foreach (var obj in (IEnumerable)context.Object) - { - - var vals = obj.GetType().GetProperties().Select( - pi => new - { - Value = pi.GetValue(obj, null) - } - ); - - string valueLine = string.Empty; - - foreach (var val in vals) - { - if (val.Value != null) - { - - var _val = val.Value.ToString(); - - //Check if the value contans a comma and place it in quotes if so - if (_val.Contains(",")) - _val = string.Concat("\"", _val, "\""); - - //Replace any \r or \n special characters from a new line with a space - if (_val.Contains("\r")) - _val = _val.Replace("\r", " "); - if (_val.Contains("\n")) - _val = _val.Replace("\n", " "); - - valueLine = string.Concat(valueLine, _val, _options.CsvDelimiter); - - } - else - { - valueLine = string.Concat(valueLine, string.Empty, _options.CsvDelimiter); - } - } - - await streamWriter.WriteLineAsync(valueLine.TrimEnd(_options.CsvDelimiter.ToCharArray())); - } - - await streamWriter.FlushAsync(); - } - } -} diff --git a/src/WebApiContrib.Core.Formatter.Csv/Extensions.cs b/src/WebApiContrib.Core.Formatter.Csv/Extensions.cs new file mode 100644 index 0000000..c0428e1 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/Extensions.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public static class Extensions + { + class IsNullVisitor : ExpressionVisitor + { + public bool IsNull { get; private set; } + public object CurrentObject { get; set; } + + protected override Expression VisitMember(MemberExpression node) + { + base.VisitMember(node); + if (CheckNull()) + return node; + var member = (PropertyInfo)node.Member; + CurrentObject = member.GetValue(CurrentObject, null); + CheckNull(); + return node; + } + + private bool CheckNull() + { + if (CurrentObject == null) + IsNull = true; + return IsNull; + } + } + + public static Expression ConstructAssignExpression(this Expression> expression) + { + var memberExpression = expression.GetMemberExpression(); + var target = expression.Parameters[0]; + var parameterExpression = Expression.Parameter(memberExpression.Type, "n"); + var assignments = new List(); + + assignments.Add(Expression.Assign(memberExpression, parameterExpression)); + while (memberExpression.Expression != target) + { + var childMember = (MemberExpression)memberExpression.Expression; + assignments.Add(Expression.IfThen(Expression.ReferenceEqual(childMember, Expression.Constant(null)), + Expression.Assign(childMember, Expression.New(childMember.Type)))); + memberExpression = childMember; + } + assignments.Reverse(); + + var body = assignments.Count > 1 ? Expression.Block(assignments) : assignments[0]; + var parameters = new List { target, parameterExpression }; + var lambda = Expression.Lambda(body, parameters); + return lambda; + } + + public static MemberExpression GetMemberExpression(this Expression expression) + { + switch (expression) + { + case MemberExpression e: + return e; + case LambdaExpression e: + return GetMemberExpression(e.Body); + case UnaryExpression e: + return GetMemberExpression(e.Operand); + default: + return null; + } + } + + public static string GetPropertyPath(this MemberExpression memberExpression) + { + var path = new StringBuilder(); + do + { + if (path.Length > 0) + path.Insert(0, "."); + path.Insert(0, memberExpression.Member.Name); + memberExpression = GetMemberExpression(memberExpression.Expression); + } + while (memberExpression != null); + return path.ToString(); + } + + public static bool WillThrowNullReferenceException(this Expression expression, object entity) + { + var visitor = new IsNullVisitor + { + CurrentObject = entity + }; + var memberExpression = expression.GetMemberExpression(); + visitor.Visit(memberExpression); + return visitor.IsNull; + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/Formatters/CsvInputFormatterBase.cs b/src/WebApiContrib.Core.Formatter.Csv/Formatters/CsvInputFormatterBase.cs new file mode 100644 index 0000000..186f26e --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/Formatters/CsvInputFormatterBase.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public abstract class CsvInputFormatterBase : InputFormatter + { + public CsvInputFormatterBase() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv")); + } + + public override bool CanRead(InputFormatterContext context) + { + if (context.ModelType == null) + throw new ArgumentNullException(nameof(context.ModelType)); + return IsTypeOfIEnumerable(GetType(context)); + } + + protected Type GetType(InputFormatterContext context) + { + return context.ModelType; + } + + protected Type GetItemType(InputFormatterContext context) + { + Type type = GetType(context); + return IsTypeOfGenericList(type) ? type.GetGenericArguments()[0] : type.GetElementType(); + } + + private bool IsTypeOfIEnumerable(Type type) + { + foreach (Type interfaceType in type.GetInterfaces()) + { + if (interfaceType == typeof(IList)) + return true; + } + return false; + } + + protected bool IsTypeOfGenericList(Type type) + { + return type.GetGenericArguments().Length > 0; + } + + protected abstract CsvFormatterOptions GetOptions(InputFormatterContext context); + + protected abstract void SetValues(InputFormatterContext context, object entity, string[] values); + + public override Task ReadRequestBodyAsync(InputFormatterContext context) + { + var request = context.HttpContext.Request; + var serviceProvider = context.HttpContext.RequestServices; + var options = GetOptions(context); + var type = GetType(context); + var itemType = GetItemType(context); + var genericListType = typeof(List<>).MakeGenericType(itemType); + var list = (IList)Activator.CreateInstance(genericListType); + var reader = new StreamReader(request.Body, Encoding.GetEncoding(options.Encoding)); + bool skipFirstLine = options.UseSingleLineHeaderInCsv; + + while (!reader.EndOfStream) + { + var line = reader.ReadLine(); + var values = line.Split(options.CsvDelimiter.ToCharArray()); + + if (skipFirstLine) + { + skipFirstLine = false; + } + else + { + var item = Activator.CreateInstance(itemType); + SetValues(context, item, values); + list.Add(item); + } + } + + if (!IsTypeOfGenericList(type)) + { + Array array = Array.CreateInstance(itemType, list.Count); + list.CopyTo(array, 0); + return InputFormatterResult.SuccessAsync(array); + } + + return InputFormatterResult.SuccessAsync(list); + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/Formatters/CsvOutputFormatterBase.cs b/src/WebApiContrib.Core.Formatter.Csv/Formatters/CsvOutputFormatterBase.cs new file mode 100644 index 0000000..f785c18 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/Formatters/CsvOutputFormatterBase.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public abstract class CsvOutputFormatterBase : OutputFormatter + { + public string ContentType { get; private set; } + + protected CsvOutputFormatterBase() + { + ContentType = "text/csv"; + SupportedMediaTypes.Add(Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse("text/csv")); + } + + protected override bool CanWriteType(Type type) + { + if (type == null) + throw new ArgumentNullException(nameof(type)); + return IsTypeOfIEnumerable(type); + } + + protected bool IsTypeOfIEnumerable(Type type) + { + foreach (Type interfaceType in type.GetInterfaces()) + { + if (interfaceType == typeof(IList)) + return true; + } + return false; + } + + protected Type GetItemType(OutputFormatterWriteContext context) + { + Type type = context.Object.GetType(); + return type.GetGenericArguments().Length > 0 ? type.GetGenericArguments()[0] : type.GetElementType(); + } + + protected abstract IEnumerable GetHeaders(OutputFormatterWriteContext context); + + protected abstract CsvFormatterOptions GetOptions(OutputFormatterWriteContext context); + + protected abstract IEnumerable GetValues(OutputFormatterWriteContext context); + + public async override Task WriteResponseBodyAsync(OutputFormatterWriteContext context) + { + var serviceProvider = context.HttpContext.RequestServices; + var response = context.HttpContext.Response; + var options = GetOptions(context); + var streamWriter = new StreamWriter(response.Body, Encoding.GetEncoding(options.Encoding)); + + if (options.UseSingleLineHeaderInCsv) + { + await streamWriter.WriteLineAsync( + string.Join( + options.CsvDelimiter, GetHeaders(context) + ) + ); + } + + foreach (var values in GetValues(context)) + { + string valueLine = string.Empty; + + foreach (var value in values) + { + if (value != null) + { + var _val = value.ToString(); + + //Check if the value contans a comma and place it in quotes if so + if (_val.Contains(",")) + _val = string.Concat("\"", _val, "\""); + //Replace any \r or \n special characters from a new line with a space + if (_val.Contains("\r")) + _val = _val.Replace("\r", " "); + if (_val.Contains("\n")) + _val = _val.Replace("\n", " "); + + valueLine = string.Concat(valueLine, _val, options.CsvDelimiter); + } + else + { + valueLine = string.Concat(valueLine, string.Empty, options.CsvDelimiter); + } + } + + await streamWriter.WriteLineAsync(valueLine.TrimEnd(options.CsvDelimiter.ToCharArray())); + } + + await streamWriter.FlushAsync(); + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/Formatters/FluentCsvInputFormatter.cs b/src/WebApiContrib.Core.Formatter.Csv/Formatters/FluentCsvInputFormatter.cs new file mode 100644 index 0000000..4d1a7f3 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/Formatters/FluentCsvInputFormatter.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public class FluentCsvInputFormatter : CsvInputFormatterBase + { + private readonly ICollection _registredEntityTypes; + + public FluentCsvInputFormatter(ICollection registredEntityTypes) + { + this._registredEntityTypes = registredEntityTypes; + } + + public override bool CanRead(InputFormatterContext context) + { + var type = GetType(context); + return base.CanReadType(type) && _registredEntityTypes.Contains(type.GenericTypeArguments[0]); + } + + protected override CsvFormatterOptions GetOptions(InputFormatterContext context) + { + var metadataFacade = GetDynamicConfigurationMetadataFacade(context); + return metadataFacade.GetOptions() as CsvFormatterOptions; + } + + protected override void SetValues(InputFormatterContext context, object entity, string[] values) + { + var metadataFacade = GetDynamicConfigurationMetadataFacade(context); + metadataFacade.SetValues(entity, values); + } + + private dynamic GetDynamicConfigurationMetadataFacade(InputFormatterContext context) + { + Type type = GetItemType(context); + var serviceProvider = context.HttpContext.RequestServices; + Type generic = typeof(IFormattingConfigurationMetadataFacade<>); + Type constructed = generic.MakeGenericType(type); + return serviceProvider.GetService(constructed); + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/Formatters/FluentCsvOutputFormatter.cs b/src/WebApiContrib.Core.Formatter.Csv/Formatters/FluentCsvOutputFormatter.cs new file mode 100644 index 0000000..2b8290b --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/Formatters/FluentCsvOutputFormatter.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace WebApiContrib.Core.Formatter.Csv +{ + internal class FluentCsvOutputFormatter : CsvOutputFormatterBase + { + private readonly ICollection _registredEntityTypes; + + public FluentCsvOutputFormatter(ICollection registredEntityTypes) + { + this._registredEntityTypes = registredEntityTypes; + } + + protected override bool CanWriteType(Type type) + { + return base.CanWriteType(type) && _registredEntityTypes.Contains(type.GenericTypeArguments[0]); + } + + protected override IEnumerable GetHeaders(OutputFormatterWriteContext context) + { + var metadataFacade = GetDynamicConfigurationMetadataFacade(context); + return metadataFacade.GetHeaders() as ICollection; + } + + protected override CsvFormatterOptions GetOptions(OutputFormatterWriteContext context) + { + var metadataFacade = GetDynamicConfigurationMetadataFacade(context); + return metadataFacade.GetOptions() as CsvFormatterOptions; + } + + protected override IEnumerable GetValues(OutputFormatterWriteContext context) + { + var metadataFacade = GetDynamicConfigurationMetadataFacade(context); + foreach (var item in (IEnumerable)context.Object) + { + var values = metadataFacade.GetFormattedValues(item) as IDictionary; + yield return values.Values.ToArray(); + } + } + + private dynamic GetDynamicConfigurationMetadataFacade(OutputFormatterWriteContext context) + { + Type type = GetItemType(context); + var serviceProvider = context.HttpContext.RequestServices; + Type generic = typeof(IFormattingConfigurationMetadataFacade<>); + Type constructed = generic.MakeGenericType(type); + return serviceProvider.GetService(constructed); + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/Formatters/StandardCsvInputFormatter.cs b/src/WebApiContrib.Core.Formatter.Csv/Formatters/StandardCsvInputFormatter.cs new file mode 100644 index 0000000..db95060 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/Formatters/StandardCsvInputFormatter.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public class StandardCsvInputFormatter : CsvInputFormatterBase + { + private readonly CsvFormatterOptions _options; + + public StandardCsvInputFormatter(CsvFormatterOptions context) + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv")); + _options = context ?? throw new ArgumentNullException(nameof(context)); + } + + protected override CsvFormatterOptions GetOptions(InputFormatterContext context) + { + return _options; + } + + protected override void SetValues(InputFormatterContext context, object entity, string[] values) + { + var properties = entity.GetType().GetProperties(); + for (int i = 0; i < values.Length; i++) + { + properties[i].SetValue(entity, Convert.ChangeType(values[i], properties[i].PropertyType), null); + } + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/Formatters/StandardCsvOutputFormatter.cs b/src/WebApiContrib.Core.Formatter.Csv/Formatters/StandardCsvOutputFormatter.cs new file mode 100644 index 0000000..750dd1a --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/Formatters/StandardCsvOutputFormatter.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public class StandardCsvOutputFormatter : CsvOutputFormatterBase + { + private readonly CsvFormatterOptions _options; + + public StandardCsvOutputFormatter(CsvFormatterOptions csvFormatterOptions) + { + _options = csvFormatterOptions ?? throw new ArgumentNullException(nameof(csvFormatterOptions)); + } + + protected override IEnumerable GetHeaders(OutputFormatterWriteContext context) + { + return base + .GetItemType(context) + .GetProperties() + .Select(x => x.GetCustomAttribute(false)?.Name ?? x.Name); + } + + protected override CsvFormatterOptions GetOptions(OutputFormatterWriteContext context) + { + return _options; + } + + protected override IEnumerable GetValues(OutputFormatterWriteContext context) + { + foreach (var item in (IEnumerable)context.Object) + { + yield return item + .GetType() + .GetProperties() + .Select(pi => pi.GetValue(item, null)) + .ToArray(); + } + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationBuilder.cs b/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationBuilder.cs new file mode 100644 index 0000000..57be387 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationBuilder.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq.Expressions; + +namespace WebApiContrib.Core.Formatter.Csv +{ + internal class FormattingConfigurationBuilder : + IFormattingConfigurationBuilder, + IFormattingConfigurationPropertyBuilder + { + private string _lastHeader; + private IFormattingConfigurationMetadata _metadata; + + public FormattingConfigurationBuilder( + IFormattingConfigurationMetadata metadata) + { + _metadata = metadata; + } + + public IFormattingConfigurationMetadata Metadata => _metadata; + + public IFormattingConfigurationPropertyBuilder ForHeader() + { + _lastHeader = null; + return this; + } + + public IFormattingConfigurationPropertyBuilder ForHeader(string name) + { + _lastHeader = name; + return this; + } + + public IFormattingConfigurationBuilder UseCsvDelimiter(string delimiter) + { + _metadata.CsvDelimiter = delimiter; + return this; + } + + public IFormattingConfigurationBuilder UseEncoding(string encoding) + { + _metadata.Encoding = encoding; + return this; + } + + public IFormattingConfigurationBuilder UseFormatProvider(IFormatProvider formatProvider) + { + _metadata.FormatProvider = formatProvider; + return this; + } + + public IFormattingConfigurationBuilder UseHeaders() + { + _metadata.UseHeaders = true; + return this; + } + + public IFormattingConfigurationBuilder UseProperty(Expression> propertyExpression) + { + // Validate provided expression + var memberExpression = propertyExpression.GetMemberExpression(); + if (memberExpression is null) + throw new ArgumentException( + "Provided expression can only return primitive property of an entity and should not contain method calls.", + nameof(propertyExpression)); + + var propertyPath = memberExpression.GetPropertyPath(); + // Construct AssignExpression for input formatter + var assignExpression = propertyExpression.ConstructAssignExpression(); + + _metadata.PropertiesMetadata.Add(_lastHeader ?? propertyPath, new PropertyAccessMetadata + { + ReadMetadata = propertyExpression, + WriteMetadata = assignExpression, + PropertyType = memberExpression.Type + }); + + _lastHeader = null; + return this; + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationCollection.cs b/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationCollection.cs new file mode 100644 index 0000000..5c2d0ae --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationCollection.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public class FormattingConfigurationCollection : IFormattingConfigurationCollection + { + private readonly ICollection _registredTypes = new List(); + private readonly IServiceCollection _serviceCollection; + + public FormattingConfigurationCollection(IServiceCollection serviceCollection) + { + this._serviceCollection = serviceCollection; + } + + public ICollection GetRegistredTypes() + { + return _registredTypes; + } + + public void RegisterConfiguration(IFormattingConfiguration config) + { + var metadata = new FormattingConfigurationMetadata(); + var builder = new FormattingConfigurationBuilder(metadata); + config.Configure(builder); + var provider = new FormattingConfigurationMetadataFacade(metadata); + _serviceCollection.AddSingleton>(provider); + _registredTypes.Add(typeof(TEntity)); + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationMetadata.cs b/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationMetadata.cs new file mode 100644 index 0000000..ff65482 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationMetadata.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace WebApiContrib.Core.Formatter.Csv +{ + internal class FormattingConfigurationMetadata + : IFormattingConfigurationMetadata + { + public bool UseHeaders { get; set; } + public string CsvDelimiter { get; set; } = ";"; + public string Encoding { get; set; } = "ISO-8859-1"; + public IFormatProvider FormatProvider { get; set; } + public IDictionary PropertiesMetadata { get; } = new Dictionary(); + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationMetadataFacade.cs b/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationMetadataFacade.cs new file mode 100644 index 0000000..c29774b --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/FormattingConfigurationMetadataFacade.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace WebApiContrib.Core.Formatter.Csv +{ + internal class FormattingConfigurationMetadataFacade : IFormattingConfigurationMetadataFacade + { + private readonly IFormattingConfigurationMetadata _metadata; + + public FormattingConfigurationMetadataFacade(IFormattingConfigurationMetadata metadata) + { + this._metadata = metadata; + } + + public ICollection GetHeaders() + { + return _metadata.PropertiesMetadata.Keys; + } + + public IDictionary GetFormattedValues(object entity) + { + Dictionary values = new Dictionary(); + foreach (var item in _metadata.PropertiesMetadata) + { + var expression = item.Value.ReadMetadata as Expression>; + // Check if entity's nested properties defined by the expression exist + var willThrow = expression.WillThrowNullReferenceException(entity); + var result = willThrow ? String.Empty : expression + .Compile() + .Invoke((TEntity)entity); + values.Add( + item.Key, + Convert.ToString( + result, + _metadata.FormatProvider)); + } + return values; + } + + public CsvFormatterOptions GetOptions() + { + return new CsvFormatterOptions + { + CsvDelimiter = _metadata.CsvDelimiter, + Encoding = _metadata.Encoding, + UseSingleLineHeaderInCsv = _metadata.UseHeaders + }; + } + + public void SetValues(object entity, string[] values) + { + for (int i = 0; i < values.Length; i++) + { + string value = values[i]; + var propertyMetadata = _metadata.PropertiesMetadata.Values.ElementAt(i); + object convertedValue = Convert.ChangeType(value, propertyMetadata.PropertyType); + var assignLambda = propertyMetadata.WriteMetadata as LambdaExpression; + assignLambda.Compile().DynamicInvoke(entity, convertedValue); + } + } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfiguration.cs b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfiguration.cs new file mode 100644 index 0000000..92dfdab --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfiguration.cs @@ -0,0 +1,7 @@ +namespace WebApiContrib.Core.Formatter.Csv +{ + public interface IFormattingConfiguration + { + void Configure(IFormattingConfigurationBuilder builder); + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationBuilder.cs b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationBuilder.cs new file mode 100644 index 0000000..bef9144 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationBuilder.cs @@ -0,0 +1,15 @@ +using System; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public interface IFormattingConfigurationBuilder + { + IFormattingConfigurationPropertyBuilder ForHeader(); + IFormattingConfigurationPropertyBuilder ForHeader(string name); + IFormattingConfigurationBuilder UseCsvDelimiter(string delimiter); + IFormattingConfigurationBuilder UseEncoding(string encoding); + IFormattingConfigurationBuilder UseFormatProvider(IFormatProvider formatProvider); + IFormattingConfigurationBuilder UseHeaders(); + IFormattingConfigurationMetadata Metadata { get; } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationCollection.cs b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationCollection.cs new file mode 100644 index 0000000..538e18d --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationCollection.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public interface IFormattingConfigurationCollection + { + ICollection GetRegistredTypes(); + void RegisterConfiguration(IFormattingConfiguration config); + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationMetadata.cs b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationMetadata.cs new file mode 100644 index 0000000..4b7c8a0 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationMetadata.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public interface IFormattingConfigurationMetadata + { + bool UseHeaders { get; set; } + string CsvDelimiter { get; set; } + string Encoding { get; set; } + IFormatProvider FormatProvider { get; set; } + IDictionary PropertiesMetadata { get; } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationMetadataFacade.cs b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationMetadataFacade.cs new file mode 100644 index 0000000..5fca185 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationMetadataFacade.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace WebApiContrib.Core.Formatter.Csv +{ + internal interface IFormattingConfigurationMetadataFacade + { + ICollection GetHeaders(); + IDictionary GetFormattedValues(object entity); + CsvFormatterOptions GetOptions(); + void SetValues(object entity, string[] values); + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationPropertyBuilder.cs b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationPropertyBuilder.cs new file mode 100644 index 0000000..33eced5 --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/IFormattingConfigurationPropertyBuilder.cs @@ -0,0 +1,10 @@ +using System; +using System.Linq.Expressions; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public interface IFormattingConfigurationPropertyBuilder + { + IFormattingConfigurationBuilder UseProperty(Expression> source); + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/PropertyAccessMetadata.cs b/src/WebApiContrib.Core.Formatter.Csv/PropertyAccessMetadata.cs new file mode 100644 index 0000000..419a01a --- /dev/null +++ b/src/WebApiContrib.Core.Formatter.Csv/PropertyAccessMetadata.cs @@ -0,0 +1,12 @@ +using System; +using System.Linq.Expressions; + +namespace WebApiContrib.Core.Formatter.Csv +{ + public class PropertyAccessMetadata + { + public Expression ReadMetadata { get; set; } + public Expression WriteMetadata { get; set; } + public Type PropertyType { get; set; } + } +} \ No newline at end of file diff --git a/src/WebApiContrib.Core.Formatter.Csv/README.md b/src/WebApiContrib.Core.Formatter.Csv/README.md index 0bc8f56..ca8ee2b 100644 --- a/src/WebApiContrib.Core.Formatter.Csv/README.md +++ b/src/WebApiContrib.Core.Formatter.Csv/README.md @@ -4,6 +4,7 @@ WebApiContrib.Core.Formatter.Csv [![NuGet Status](http://img.shields.io/nuget/v/ # History +2018.06.10: Adding support for fluent based configuration of input and output formatters 2018.04.18: Adding support for customization of the header with the display attribute 2018.04.12: Using the encoding from the options in the CsvOutputFormatter, Don't buffer CSV 2017.02.14: update to csproj @@ -11,13 +12,250 @@ WebApiContrib.Core.Formatter.Csv [![NuGet Status](http://img.shields.io/nuget/v/ # Documentation -The InputFormatter and the OutputFormatter classes are used to convert the csv data to the C# model classes. +The InputFormatter and the OutputFormatter classes are used to convert the csv data to/from the C# model classes. + +**Aside from that, there are two types of formatters (standard and fluent), which differ in the way they work and get configured––both are described in the following sections.** **Code sample:** https://github.com/WebApiContrib/WebAPIContrib.Core/tree/master/samples/WebApiContrib.Core.Samples -The LocalizationRecord class is used as the model class to import and export to and from csv data. +## Fluent Formatters + + +Fluent formatters use lambda expressions to generate csv (output) or models from incoming csv (input). They support multi-level object hierarchy and can be hooked to any class having public properties–in the sample project, the AuthorModel and AddressModel are used as an example. + +```csharp +using System; + +namespace WebApiContrib.Core.Samples.Model +{ + public class AuthorModel + { + public long Id { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public DateTime DateOfBirth { get; set; } + public int IQ { get; set; } + public object Signature { get; set; } + + public AuthorAddress Address { get; set; } + } + + public class AuthorAddress + { + public string Street { get; set; } + public string City { get; set; } + public string Country { get; set; } + } +} + +``` + +The MVC Controller **FluentCsvTestController** makes it possible to import and export the data. The Get method exports the data using the Accept header in the HTTP Request. Per default, Json will be returned. If the Accept Header is set to 'text/csv', the data will be returned as csv. The GetDataAsCsv method always returns csv data because the Produces attribute is used to force this. This makes it easy to download the csv data in a browser. + +The Import method uses the Content-Type HTTP Request header to decide how to handle the request body. If the 'text/csv' is defined, the custom csv input formatter will be used. + +**AuthorModelConfiguration** defines the configuration and a single set of lambda expressions that will be used for **both** input and output formatters. + +When you want to define a new configuration, implement IFormattingConfiguration interface respecting the following guidelines: + +1. Only primitive value type properties are allowed for UseProperty (no reference types and no method calls are allowed) +2. Chain parameterless ForHeader() method when: + - Not using headers (UseProperty is always chained after UseHeader) + - Using headers but you want them generated automatically based on property name (or path) +3. Chain method UseCsvDelimiter(string) when you want to override default delimiter (semilocolon) +4. Chain method UseEncoding when you want to override default encoding (ISO-8859-1) +5. Chain method UseFormatProvider when you want to provide custom formatting for your primitive types + +```csharp +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.AspNetCore.Mvc; +using WebApiContrib.Core.Formatter.Csv; +using WebApiContrib.Core.Samples.Model; + +namespace WebApiContrib.Core.Samples.Controllers +{ + public class AuthorModelConfiguration : IFormattingConfiguration + { + public void Configure(IFormattingConfigurationBuilder builder) + { + CultureInfo culture = CultureInfo.CreateSpecificCulture("en-US"); + DateTimeFormatInfo dtfi = culture.DateTimeFormat; + dtfi.DateSeparator = "-"; + builder + .UseHeaders() + .UseFormatProvider(culture) + .ForHeader("Identifier") + .UseProperty(x => x.Id) + .ForHeader("First Name") + .UseProperty(x => x.FirstName) + .ForHeader("Last Name") + .UseProperty(x => x.LastName) + .ForHeader("Date of Birth") + .UseProperty(x => x.DateOfBirth) + .ForHeader("IQ") + .UseProperty(x => x.IQ) + .ForHeader("Street") + .UseProperty(x => x.Address.Street) + .ForHeader("City") + .UseProperty(x => x.Address.City) + // Header name will be inferred from property path 'Address.City' + .ForHeader() + .UseProperty(x => x.Address.Country) + // Header name will be inferred from property name 'Signature' + .ForHeader() + .UseProperty(x => x.Signature); + } + } + + [Route("api/[controller]")] + public class FluentCsvTestController : Controller + { + // GET api/fluentcsvtest + [HttpGet] + public IActionResult Get() + { + return Ok(DummyDataList()); + } + + [HttpGet] + [Route("data.csv")] + [Produces("text/csv")] + public IActionResult GetDataAsCsv() + { + return Ok(DummyDataList()); + } + + [HttpGet] + [Route("dataarray.csv")] + [Produces("text/csv")] + public IActionResult GetArrayDataAsCsv() + { + return Ok(DummyDataArray()); + } + + private static IEnumerable DummyDataList() + { + return new List + { + new AuthorModel + { + Id = 1, + FirstName = "Joanne", + LastName = "Rowling", + DateOfBirth = DateTime.Now, + IQ = 70, + Signature = "signature", + Address = new AuthorAddress + { + Street = null, + City = "London", + Country = "UK" + } + }, + new AuthorModel + { + Id = 1, + FirstName = "Hermann", + LastName = "Hesse", + DateOfBirth = DateTime.Now, + IQ = 180, + Signature = "signature" + } + }; + } + + private static AuthorModel[] DummyDataArray() + { + return new AuthorModel[] + { + new AuthorModel + { + Id = 1, + FirstName = "Joanne", + LastName = "Rowling", + DateOfBirth = DateTime.Now, + IQ = 70, + Signature = "signature", + Address = new AuthorAddress + { + Street = null, + City = "London", + Country = "UK" + } + }, + new AuthorModel + { + Id = 1, + FirstName = "Hermann", + LastName = "Hesse", + DateOfBirth = DateTime.Now, + IQ = 180, + Signature = "signature", + Address = new AuthorAddress + { + Street = null, + City = "Berlin", + Country = "Germany" + } + } + }; + } + } +} + +``` + +The formatters can be added to the ASP.NET Core project in the Startup class in the ConfigureServices method. + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsvSerializerFormatters( + builder => + { + builder.RegisterConfiguration(new AuthorModelConfiguration()); + }) +} + +``` + +**Note: As opposed to standard formatters, the fluent formatters cannot be configured directly.** + +When the data.csv link is requested, a csv type response is returned to the client, which can be saved. This data contains the header texts and the value of each property in each object. This can then be opened in excel. + +http://localhost:10336/api/fluentcsvtest/data.csv + +```csharp +Identifier;First Name;Last Name;Date of Birth;IQ;Street;City;Address.Country;Signature +1;Joanne;Rowling;6-10-18 11:12:10 AM;70;;London;UK;signature +1;Hermann;Hesse;6-10-18 11:12:10 AM;180;;;;signature +``` + +This data can then be used to upload the csv data to the server which is then converted back to a C# object. I use fiddler, postman or curl can also be used, or any HTTP Client where you can set the header Content-Type. + +```csharp + + http://localhost:10336/api/fluentcsvtest/import + + User-Agent: Fiddler + Content-Type: text/csv + Host: localhost:10336 + Content-Length: 503 + + +Identifier;First Name;Last Name;Date of Birth;IQ;Street;City;Address.Country;Signature +1;Joanne;Rowling;6-10-18 11:12:10 AM;70;;London;UK;signature +1;Hermann;Hesse;6-10-18 11:12:10 AM;180;;;;signature + +``` + +## Standard Formatters + +Standard formatters use reflection to generate csv based on models (output) or models from incoming csv (input). They support only one level of object hierarchy and thus has limited usages. It also requires the creation of a DTO that will 'carry' the data. In the sample project, the LocalizationRecord class is used as a DTO to import and export to and from csv data. -You can customize header with the **DisplayAttribute**. +You can customize header with the **DisplayAttribute**. ```csharp using System.ComponentModel.DataAnnotations; @@ -39,7 +277,7 @@ namespace WebApiContrib.Core.Samples.Model ``` -The MVC Controller CsvTestController makes it possible to import and export the data. The Get method exports the data using the Accept header in the HTTP Request. Per default, Json will be returned. If the Accept Header is set to 'text/csv', the data will be returned as csv. The GetDataAsCsv method always returns csv data because the Produces attribute is used to force this. This makes it easy to download the csv data in a browser. +The MVC Controller **CsvTestController** makes it possible to import and export the data. The Get method exports the data using the Accept header in the HTTP Request. Per default, Json will be returned. If the Accept Header is set to 'text/csv', the data will be returned as csv. The GetDataAsCsv method always returns csv data because the Produces attribute is used to force this. This makes it easy to download the csv data in a browser. The Import method uses the Content-Type HTTP Request header to decide how to handle the request body. If the 'text/csv' is defined, the custom csv input formatter will be used. @@ -146,8 +384,8 @@ public void ConfigureServices(IServiceCollection services) services.AddMvc(options => { - options.InputFormatters.Add(new CsvInputFormatter(csvFormatterOptions)); - options.OutputFormatters.Add(new CsvOutputFormatter(csvFormatterOptions)); + options.InputFormatters.Add(new StandardCsvInputFormatter(csvFormatterOptions)); + options.OutputFormatters.Add(new StandardCsvOutputFormatter(csvFormatterOptions)); options.FormatterMappings.SetMediaTypeMappingForFormat("csv", MediaTypeHeaderValue.Parse("text/csv")); }); } @@ -188,7 +426,7 @@ The following image shows that the data is imported correctly. Notes -The implementation of the InputFormatter and the OutputFormatter classes are specific for a list of simple classes with only properties. If you require or use more complex classes, these implementations need to be changed. +The implementation of the InputFormatter and the OutputFormatter classes are specific for a list of simple classes with only properties. If you require or use more complex classes, these implementations need to be changed (or use fluent formatters). Links