App-Idea 7: CSV to JSON Converter

Our next app-idea is the build the CSV to JSON Converter. This allows us to go back from a CSV definition to a JSON file format (the inverse of App-Idea 6). We allow users to input free-from text in CSV format or load the data from a CSV file. Then provide a conversion function to output the results in JSON format.

Because this app is the inverse of App-Idea 6 (JSON to CSV Converter), we are going to add this functionality to the existing app rather than creating a whole duplicate app from scratch. We are going to refactor the TextConverter model class to better support different conversions, add the ConvertToJson operation to our view model, and expose another button to the converter page to do this second conversion.

You can find the source for the Blazor.AppIdeas.Converters on GitHub. And the running sample of the Blazor app online.

So, let’s start by loading Blazor.AppIdeas.Converters solution from App-Idea 6 – Part 3 into Visual Studio.

Refactor TextConverter

In the initial version of TextConverter, we placed a lot of JSON to CSV parsing code there. That was fine for the initial functionality, but now if we were to add CSV to JSON conversion code to this class it would become large and unwieldy. Also it would mix JSON to CSV methods with CSV to JSON methods and would soon become very confusing as to what the code is responsible for.

And this code would also break the design principles of doing one thing per class. So, this is a good opportunity to refactor this class and make it more modular.

We are going to keep the TextConverter class as the public entry point for any conversions, but we are going to use the Strategy design pattern to provide separate classes for JsonToCsv and CsvToJson implementations. That way each class is responsible for one direction of conversion. And the TextConverter class itself doesn’t have any internal knowledge of how either conversion is done.

First, let’s define the ITextConvertStrategy interface (in the Blazor.AppIdeas.Converters project and Models folder) that provides the definition for all of our strategy classes.

namespace Blazor.AppIdeas.Converters.Models
{
    public interface ITextConvertStrategy
    {
        string Convert(string source);
    }
}

Our strategy interface defines one simple method that takes the source text and returns the converted text.

Then, let’s move all of the JSON to CSV conversion logic from TextConverter into the JsonToCsvConverter class – in the Blazor.AppIdeas.Converters project and Models folder.

using System;
using System.Linq;
using System.Text;
using System.Text.Json;

namespace Blazor.AppIdeas.Converters.Models
{
    internal class JsonCsvConverter : ITextConvertStrategy
    {
        private string _sourceText;

        public string Convert(string source)
        {
            _sourceText = source;
            var result = new StringBuilder();
            var element = GetJsonRootArray();

            result.AppendLine(ParseJsonPropertyNames(element));
            result.Append(ParseJsonElementArray(element));

            return result.ToString();
        }

        private JsonElement GetJsonRootArray()
        {
            var doc = JsonDocument.Parse(_sourceText);
            if (doc.RootElement.ValueKind == JsonValueKind.Object)
            {
                doc = JsonDocument.Parse($"[{_sourceText}]");
            }

            if (doc.RootElement.ValueKind != JsonValueKind.Array)
            {
                throw new NotSupportedException("Unsupported JSON root type.");
            }

            return doc.RootElement;
        }

        private string ParseJsonPropertyNames(JsonElement root)
        {
            var result = new StringBuilder();
            var firstElement = root.EnumerateArray().First();

            foreach (var property in firstElement.EnumerateObject())
            {
                result.Append($"{property.Name}, ");
            }

            return result.ToString().TrimEnd(',', ' ');
        }

        private string ParseJsonElementArray(JsonElement element)
        {
            StringBuilder result = new StringBuilder();

            foreach (var e in element.EnumerateArray())
            {
                result.AppendLine(ParseJsonElement(e));
            }

            return result.ToString();
        }

        private string ParseJsonElement(JsonElement element)
        {
            var result = new StringBuilder();
            foreach (var property in element.EnumerateObject())
            {
                result.Append($"{property.Value}, ");
            }

            return result.ToString().TrimEnd(',', ' ');
        }
    }
}
  • We made the strategy implementation class internal because it shouldn’t be accessed directly.
  • We moved the helper methods (GetJsonRootArray, ParseJsonPropertyNames, ParseJsonElementArray, and ParseJsonElement) directly from TextConverter to this class, but their logic is largely unchanged.
  • We implemented the Convert method (lines #12-22) to:
    • Place the specified text into the _sourceText member variable.
    • Get the root element from the parsed JsonDocument.
    • Then, get all of the property names from the root element.
    • Next, get the parsed version of all of the elements in the Json array.
    • Finally, return the string that represents the completed conversion.

Next, let’s create the CsvToJsonConverter to implement the reverse logic – in the Blazor.AppIdeas.Converters project and Models folder.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Blazor.AppIdeas.Converters.Models
{
    internal class CsvJsonConverter : ITextConvertStrategy
    {
        private static readonly char[] _csvSeparators = new char[] { ',' };
        private const string _jsonSeparator = ", ";

        public string Convert(string source)
        {
            StringBuilder builder = new StringBuilder();
            builder.AppendLine("[");

            var lines = GetCsvLines(source);
            var lineCount = lines.Count();

            var properties = ParseCsvPropertyNames(lines.First());

            for (int ctr = 1; ctr < lineCount; ctr++)
            {
                var json = ParseCsvLine(lines.ElementAt(ctr), properties);
                var suffix = (ctr != lineCount - 1) ? _jsonSeparator : string.Empty;
                builder.AppendLine(json + suffix);
            }

            builder.AppendLine("]");
            return builder.ToString();
        }

        private IEnumerable<string> GetCsvLines(string text) =>
            text.Split(System.Environment.NewLine,
                       StringSplitOptions.RemoveEmptyEntries);

        private IEnumerable<string> ParseCsvPropertyNames(string line) =>
            line.Split(_csvSeparators,
                       StringSplitOptions.RemoveEmptyEntries);

        private string ParseCsvLine(string line, IEnumerable<string> properties)
        {
            var values = line.Split(_csvSeparators,
                                    StringSplitOptions.RemoveEmptyEntries);

            if (values.Length < properties.Count())
                throw new FormatException();

            string jsonLine = string.Empty;
            for (int ctr = 0; ctr < properties.Count(); ctr++)
            {
                jsonLine += FormatJsonProperty(properties.ElementAt(ctr).Trim(),
                                               values.ElementAt(ctr).Trim());
            }

            return FormatJsonLine(jsonLine);
        }

        private string FormatJsonProperty(string property, string value) =>
            @$"""{property}"" : ""{value}"", ";

        private string FormatJsonLine(string jsonLine) =>
            "    { " + jsonLine.TrimEnd(',', ' ') + " }";
    }
}

This class contains the new logic to read CSV and produce JSON, so let’s look at the methods in detail.

  • The Convert method (lines #13-32), as the main entry point to this class:
    • Produces the initiator of a JSON array.
    • Separates all of the CSV lines to an array.
    • Gets the array of the CSV properties from the first line of the source text.
    • Then, loops through the remaining lines calling ParseCsvLine to transform it into a JSON line.
    • And it appends the converted line to the result.
    • At the end, it closes off the JSON array.
    • Finally, it returns the resulting converted string.
  • The GetCsvLines method (lines #34-36) parses each new line to a separate row in the CSV and returns an array with each line.
  • The ParseCsvPropertyNames method (lines #38-40) separates out each property name in a line (based on the comma separator), and returns an array of property names.
  • The ParseCsvLine method (lines #42-58) takes a line of CSV and the list of properties.
    • It splits the CSV line into its property values (also by the comma separator).
    • It loops through the defined properties and formats the JSON result with the CSV property name and value.
    • Concatenating the JSON properties along the way.
    • Finally it returns the converted JSON row with the appropriate start and end tokens and property list.
  • The FormatJsonProperty method (lines #60-61) simply knows how to format a JSON property name-value pair from its parameters.
  • The FormatJsonLine method (lines #63-64) formats the appropriate start and end tokens to the JSON property list.

Finally, let’s update the TextConverter class to use the right conversion strategy class depending on its context.

using Blazor.AppIdeas.Converters.Services;
using System;

namespace Blazor.AppIdeas.Converters.Models
{
    public class TextConverter
    {
        public TextConverter(string text)
        {
            SourceText = text ?? throw new ArgumentNullException(nameof(text));
        }

        public string SourceText { get; }

        public string JsonToCsv() =>
            PerformConversionTo(IBrowserFileAdapter.FileType.CSV);

        public string CsvToJson() =>
            PerformConversionTo(IBrowserFileAdapter.FileType.JSON);

        private string PerformConversionTo(IBrowserFileAdapter.FileType type)
        {
            if (string.IsNullOrEmpty(SourceText)) return string.Empty;

            ITextConvertStrategy converter = (type == IBrowserFileAdapter.FileType.CSV) ?
                                                new JsonCsvConverter() : 
                                                new CsvJsonConverter();
            return converter.Convert(SourceText);
        }
    }
}
  • First, we deleted all of the code in JsonToCsv method and replaced it with a call to the PerformConversionTo helper method.
  • Then, we add the CsvToJson method that also calls the PerformConversionTo method with the appropriate context parameter.
  • We defined separate entry points for each, so the callers would not need to know about the FileType enum required for the conversion.
  • Finally, the PerformConversionTo method (lines #21-29) takes which format to convert to as its context.
    • It verifies we have a valid SourceText property.
    • Uses the FileType context to decide which converter strategy class to use (creating the appropriate instance).
    • Then, calls the Convert method with the SourceText property as input… return that result from this method.

Update JsonCsvConverterViewModel

With the model classes refactored, our changes to the view model are trivial. Let’s make these changes to the JsonCsvConverterViewModel class:

using Blazor.AppIdeas.Converters.Models;
using Blazor.AppIdeas.Converters.Services;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using System;
using System.Threading.Tasks;

namespace Blazor.AppIdeas.Converters.ViewModels
{
    public class JsonCsvConverterViewModel
    {
        private const string _jsCopyTextMethod = "clipboardCopy.copyText";
        private const string _convertedTextFilename = "ConvertedText";
        private readonly IJSRuntime _jsRuntime;
        private readonly IBrowserFileAdapter _browserFileAdapter;

        public JsonCsvConverterViewModel(
            IJSRuntime jsRuntime, IBrowserFileAdapter fileAdapter)
        {
            _jsRuntime = jsRuntime ?? 
                throw new ArgumentNullException(nameof(jsRuntime));

            _browserFileAdapter = fileAdapter ?? 
                throw new ArgumentNullException(nameof(fileAdapter));
        }

        public string SourceText { get; set; }

        public string ConvertedText { get; set; }

        public bool IsConvertedTextEmpty => string.IsNullOrEmpty(ConvertedText);

        public IBrowserFileAdapter.FileType ConvertedType { get; set; }

        public string ErrorMessage { get; private set; }

        public bool HasError => !string.IsNullOrEmpty(ErrorMessage);

        public void ConvertToCsv()
        {
            try
            {
                ErrorMessage = null;

                var converter = new TextConverter(SourceText);
                ConvertedText = converter.JsonToCsv();
                ConvertedType = IBrowserFileAdapter.FileType.CSV;
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot parse source text. {ex.Message}";
                ConvertedText = null;
            }
        }

        public void ConvertToJson()
        {
            try
            {
                ErrorMessage = null;

                var converter = new TextConverter(SourceText);
                ConvertedText = converter.CsvToJson();
                ConvertedType = IBrowserFileAdapter.FileType.JSON;
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot parse source text. {ex.Message}";
                ConvertedText = null;
            }
        }

        public void ClearAll()
        {
            SourceText = null;
            ConvertedText = null;
            ErrorMessage = null;
        }

        public async Task Copy() => await _jsRuntime.InvokeVoidAsync(
                                        _jsCopyTextMethod,
                                        ConvertedText);

        public async Task OpenInputFile(InputFileChangeEventArgs e)
        {
            try
            {
                ErrorMessage = null;

                _ = e ?? throw new ArgumentNullException(nameof(e));
                if (e.FileCount > 1)
                {
                    ErrorMessage = "Application does not support multiple file selection.";
                }
                else
                {
                    SourceText = await _browserFileAdapter.ReadTextAsync(e.File)
                                                          .ConfigureAwait(false);
                }
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot read file '{e?.File.Name}'. {ex.Message}";
            }
        }

        public async Task DownloadConvertedText()
        {
            try
            {
                ErrorMessage = null;

                await _browserFileAdapter.SaveTextAsAsync(
                                            _jsRuntime,
                                            _convertedTextFilename,
                                            ConvertedType,
                                            ConvertedText)
                                         .ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot download converted text file. {ex.Message}";
            }
        }
    }
}

We made two changes to the view model:

  • We added the ConvertedType property (line #33) to track the ConvertedText‘s format type. Then, we updated the ConvertToCsv method (line #47) to set that property to CSV type when it was processed. And, in the DownloadConvertedText method (line #116), we the current ConvertedType property in the call to IBrowserFileAdapter.SaveTextAsAsync, so that the appropriate file extension is used during the download.
  • We added the ConvertToJson operation (lines #56-71) which:
    • Creates the TextConverter class with the current SourceText.
    • Updates ConvertedText by calling the TextConverter.CsvToJson method.
    • Sets the ConvertedType to JSON.
    • And handle any exceptions throughout the process by providing an appropriate ErrorMessage.

That was a bit of a trek in refactoring our model classes to be more flexible and more loosely coupled. But the work was worth it, because we simplified the TextConverter class and provided a great place for extending our conversion types with the ITextConverterStrategy. If we had a new requirement to convert JSON to XML (for example), it would be trivial to add a new converter strategy class to handle that functionality.

And, since we have lots of tests in place with these projects, we feel comfortable doing this refactoring and knowing that we can validate the new code still works as we expect.

Add JSON Button to JsonCsvConverter Page

Next, we need to make minor UI changes to the page:

@page "/json-csv-convert"
@inject JsonCsvConverterViewModel vm

<div class="col-12 mt-3 pb-2 mb-3 container">
    <h3 class="mt-3">JSON-CSV Converter</h3>
    <hr />
    <EditForm class="form-row" Model=@vm>
        <div class="form-group col-lg-5 col-md-12">
            <InputTextArea id="convert-from-value" class="form-control"
                           placeholder="Type JSON or CSV..." rows="8"
                           @bind-Value=vm.SourceText />
            <div class="button-wrap">
                <label class="btn btn-secondary" for="convert-open-file">
                    <span class="oi oi-cloud-upload" aria-hidden="true" /> Load File
                </label>
                <InputFile id="convert-open-file" style="width: 100%"
                           OnChange="vm.OpenInputFile"/>
            </div>
        </div>
        <div class="col-lg-2 col-md-12">
            <button id="btn-convert-csv"
                    class="btn btn-primary col-lg-10 offset-lg-1 col-4 mb-lg-1 mb-3"
                    @onclick="vm.ConvertToCsv">
                To CSV
            </button>
            <button id="btn-convert-json"
                    class="btn btn-primary col-lg-10 offset-lg-1 col-4 mb-lg-1 mb-3"
                    @onclick="vm.ConvertToJson">
                To JSON
            </button>
            <button id="btn-clear"
                    class="btn btn-primary col-lg-10 offset-lg-1 col-3 mb-lg-1 mb-3"
                    @onclick="vm.ClearAll">
                Clear
            </button>
        </div>
        <div class="form-group col-lg-5 col-12">
            <InputTextArea id="converted-value" class="form-control" rows="8"
                           readonly @bind-Value=vm.ConvertedText />
            <div>
                <button id="download-file" class="btn btn-outline-secondary text-nowrap"
                        style="width: 52%" disabled="@vm.IsConvertedTextEmpty"
                        @onclick="vm.DownloadConvertedText">
                    <span class="oi oi-cloud-download" aria-hidden="true" />
                    Download File
                </button>
                <button id="clipboard-copy" class="btn btn-outline-secondary"
                        style="width: 46.5%; margin-left: -2px"
                        disabled="@vm.IsConvertedTextEmpty" @onclick="vm.Copy">
                    <span class="oi oi-clipboard" aria-hidden="true" />
                    Copy
                </button>
            </div>
        </div>
        @if (vm.HasError)
        {
         <div class="alert alert-danger col-12 ml-1">
             <strong>Error:</strong> @vm.ErrorMessage
         </div>
        }
    </EditForm>
</div>

@code {

}

First, we update buttons btn-convert-csv and btn-clear to take up less space in portrait mode (lines #22 & 32).

And, we add a new button element (named: To JSON) for the user to perform a CSV->JSON conversion (lines #26-30). This button’s onclick event is bound to the JsonCsvConverterViewModel.ConvertToJson method.

With all of our code changes in place, we can build and run (Ctrl + F5) our application locally again. The app visually is exactly the same except for the new ‘To JSON’ button. We can validate the new functionality by editing or loading some valid CSV content and converting it to the corresponding JSON format.

Fig 1 – CSV to JSON Conversion
Fig 2 – CSV to JSON Portrait View

In conclusion, we have built the reverse conversion to go from CSV format to JSON format. In this lesson, we have learned:

  • To define and implement the Strategy design pattern to better encapsulate our code.
  • To use the appropriate conversion strategy class based on context.
  • How to do a large code refactoring to enable the Strategy pattern.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s