DEV Community

Justin Hunter
Justin Hunter

Posted on

I Built My First Chrome Extension To Improve The Software My Wife Uses

It will never feel like it's a thing I do because I started it so late in life compared to many other developers, but I'm a developer. I've founded two companies now that require me to code. The first one is where I truly learned how to code, and the second one is where I've excelled at it. But there is always something new to learn. For a wandering mind like mine, that's the beauty of coding. And this past weekend I completed a project tied to my newest learning experience.

I built a Chrome extension.

When you think of a Chrome extension, you probably think of something that will be made available for anyone to use on the Chrome Web Store. However, this extension was custom built to solve a very specific problem. And it wasn't even my problem. Except for that whole "my wife's problems are my problems" thing. She has a piece of software she uses for work, and the data displayed in the interface just isn't enough. There were pieces of information she knew had to be somewhere, but there was no export feature and no way to show the data in the interface. And that's where the journey began.

First, I had her pop open the developer tools in Chrome and go to the network tab. Then, I had her make a request for the data-that-wasn't-quite-enough. When she did so, I had her open up the response tab in the network window. There, it seemed, was a gold mine of data not readily available in the interface. Data she needed to do her job better. So, I had an idea.

I grabbed the request URL and the Bearer token from the request that yielded that data, then I plugged it into Postman. As expected, it returned the same data. So, I took that data, converted the JSON to CSV, and sent it over to my wife.

"Does this help?"

Turns out, it did. Having access to the full payload of data—data that should already be easily accessible, mind you—made life so much easier for her. The fact that it was in CSV form? Even better. The software her company uses, as most software as a service companies do, returns all the data from the server but only displays what they think customers want to see. So, my wife could continue to use the interface for that data, but when she needed more, she had access to this CSV.

Except that CSV was just a snapshot in time. The data she works with changes frequently. That one time CSV became multiple requests for the data to be converted to a CSV. Which, really isn't a problem. I could have kept doing what I did for that first CSV forever, but I like to learn new things and this felt like the perfect opportunity. What if my wife had a Chrome extension that when she ran a report in the software her company uses would also make available a CSV export of the full payload? She wouldn't need to ask me to do manual work to get the data and convert it to CSV. She could download a new CSV as many times as she wanted. And, in theory, this could be extended to as many data requests throughout the software as she wanted.

Only problem was, I'd never built a Chrome extension before. I took to the interwebs and found Stackoverflow posts, YouTube videos, and blog posts. I especially liked this post from Thoughtbot. Armed with the basic knowledge of what I needed to do, I wrote out the design of how this extension should work.

Listen to network requests at a given origin

  • The extension would need to know when GET requests were made to the API that fed my wife's company's software with its data.
  • The extension would need to ignore GET requests to other domains besides the one the API lived on.
  • The extension would need to ignore any request that was not a GET request.

Get payload from the server

  • The extension would need access to the response payload from the API used by my wife's company.
  • The extension would need to be able to parse that data and store it in memory.
  • The extension would need to be able to pass that data to a handler for CSV export only when the extension was clicked.

Convert the JSON payload to CSV and download

  • The extension would need to be able to take in the JSON data and convert it to CSV without any external libraries.
  • The extension would need to then render a hidden element on the page with a click handler.
  • The extension would need to activate that click handler to kick off the download.

Let's take a look at the code for each of these three main design items. But first, here's how I set up the Chrome extension manifest:

{
  "manifest_version": 2,
  "name": "Company Data to CSV",
  "version": "0.1", 
  "permissions": [ "webRequest", "webRequestBlocking", "webNavigation", "tabs", "myWifesCompanyUrl", "debugger" ],
  "background": {
    "scripts": [
      "background.js"
    ]
  }, 
  "content_scripts": [
    {
      "matches": [
        "myWifesCompanyUrl"
      ],
      "js": ["content.js"]
    }
  ],
  "browser_action": {
    "default_title": "Get CSV", 
    "default_icon": "icon.png"
  }
}

I learned pretty quickly that listening for and intercepting network requests had to be handled in a background script in Chrome extensions. Background scripts cannot interact with the DOM, but they can handle data and network requests.

The first thing I needed to do in my background.js script was fetch the request headers for the specific request I was looking for. Before I go into the code, this is probably the right time to explain that Chrome extensions do not have a built in method to access response payloads. So, rather than build some hacky solutions that could somehow grab the response body from the network request initiated when my wife took action in the company software, I decided I'd simply grab the necessary pieces from the outbound request and build my own request. That way I'd have access to the response payload from directly within the Chrome extension.

Here's how I started that process in background.js:

chrome.webRequest.onBeforeSendHeaders.addListener(
  function (info) {
    const requirements =
      (info.method === "GET") &&
      info.url.includes("url_to_check_for");
    if (requirements) {
      chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
        chrome.tabs.sendMessage(tabs[0].id, { headers: info }, async function (
          response
        ) {
          if (response.authToken.found) {
            //  Make post request with token
            const token = response.authToken.token;
            chrome.tabs.sendMessage(tabs[0].id, { testingToken: token })
            const url = response.authToken.url;
            try {
              const data = await fetchData(token);
              dataInMemory = data;          
            } catch (error) {
              console.log(error);
            }
          }
        });
      });
    }
  },
  {
    urls: ["url_to_check_for"],
  },
  ["blocking", "requestHeaders"]
);

There's a bit going on here, so let's walk through. According to the Chrome documentation, the onBeforeSendHeaders method will allow you to listen to the headers of a request before that request is made to the server. This is useful in the event that you need to manipulate the headers before they are sent. We don't need to do that in this extension, though. We just need access to the headers.

Next, there is a requirements variable that checks if the requirements for a network request are met. Remember, we only care about GET requests to a certain API.

If the requirements are met, we get the active tab (this is necessary for communicating with other scripts in the Chrome extension), and we send the headers data to our content.js script. Why? Because the content.js script can handle DOM and console type actions. For this particular piece of data, the only action being taken in the content.js script is filtering out request headers we don't need and returning that to the background.js script. I could have kept that processing in the background.js script, but to be honest, I was console.log'ing the hell out of things when I was learning, and this was the only way to get the results of a console.log to print in the console.

So, to summarize the above, the headers from the network request did not need to be sent to the content.js script, but I sent them there anyway.

In content.js, I set up a listener and waited for the header data to be sent:

chrome.runtime.onMessage.addListener(
  function(request, sender, sendResponse) {
    //  Pick off the right request header to get the bearer token to fetch our data
    if(request.headers && request.headers.requestHeaders) {
      const headers = request.headers.requestHeaders;
      for(const header of headers) {
        if(header.name === 'Authorization') {
          sendResponse({authToken: {found: true, token: header.value, url: request.headers.url }})
        }        
      }           
    }
  }
);

As you can see, the header I was looking for was the Authorization header. This had the Bearer token necessary to make my GET request to the server from the Chrome extension and ultimately access the full JSON response payload. When that header was found, I send it back using a similar pattern to what we saw in background.js.

If you take another look at background.js, you'll see this particular block of code:

if (response.authToken.found) {
    //  Make post request with token
    const token = response.authToken.token;
    const url = response.authToken.url;
    try {
       const data = await fetchData(token, url);
       dataInMemory = data;          
    } catch (error) {
       console.log(error);
    }
}

We grab the token and the URL to make the network request ourselves from within the Chrome extension. Then, we call a function called fetchData. That function, as would expect, makes the request:

async function fetchData(token, url) {
  var myHeaders = new Headers();
  myHeaders.append(
    "Authorization",
    token
  );

  var requestOptions = {
    method: "GET",
    headers: myHeaders,
    redirect: "follow",
  };
  return new Promise(async (resolve, reject) => {
    try {
      const res = await fetch(
        url,
        requestOptions
      );

      const data = await res.json();
      resolve(data);
    } catch (error) {
      reject(error);
    }
  })  
}

This gets me the data I needed. The full JSON payload. Now, I just needed somewhere to store that data until my wife needed to export it to CSV. In-memory would do just fine for this type of work. If you look back at the code earlier where we call the fetchData function, you'll see the response is stored in a global variable:

const data = await fetchData(token, url);
dataInMemory = data;

That leaves us with two things left to do: Convert the data to CSV and download it. Both of those things could be handled at once, so it made sense to only do it when the extension button was clicked. Fortunately, the Chrome API makes this easy. We start with a click listener.

chrome.browserAction.onClicked.addListener(function(tab) {
  chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
    //  Do something here on click
  });
});

What's happening here? Well, we are listening for the Chrome extension itself to be clicked. That's what the browserAction is. We are also getting ourselves set up to find the active tab. This is necessary as we saw before when communicating with the content.js script. So, the next step is to actually communicate with the content.js script. That's where the csv will be created and downloaded. Why? If you remember, the background.js script does not have access to the DOM, but content.js does. We are going to create an invisible element on the page and trigger a click event to handle the actual csv file download. But first, here's how we finish the browserAction click handler:

chrome.browserAction.onClicked.addListener(function(tab) {
  chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
    var activeTab = tabs[0];
    if(dataInMemory) {
      chrome.tabs.sendMessage(activeTab.id, {"payload": dataInMemory });
    } else {
      chrome.tabs.sendMessage(activeTab.id, {"error": 'No data found' });
    }
  });
});

We are checking to make sure the payload from our API request is still in memory. If it is, we send that payload. If not, we send an error. But why not just send nothing if the data isn't in memory? Well, we want to let the user (my wife) know that there's no data if the Chrome extension is clicked and there's nothing to download. So, again, we need access to the DOM.

Alright, let's finish this thing up by editing the content.js script to convert the JSON payload data to CSV and to download that CSV file.

chrome.runtime.onMessage.addListener(
  function(request, sender, sendResponse) {
    //  If error, pop alert
    if(request.error) {
      alert(request.error)
    }

    //  This is where the JSON payload will be returned and we will need to handle 
    //  the csv conversion based on the data returned here
    if(request.payload) {

      createCSV(request.payload);
    }

    //  Pick off the right request header to get the bearer token to fetch our data
    if(request.headers && request.headers.requestHeaders) {
      const headers = request.headers.requestHeaders;
      for(const header of headers) {
        if(header.name === 'Authorization') {
          sendResponse({authToken: {found: true, token: header.value, url: request.headers.url }})
        }        
      }           
    }
  }
);

We previously had the listener that grabbed the request headers and sent them back to background.js, but now we have two other conditionals listening for an error or listening for the returned data. If there is no data to return when the extension is clicked, we just pop an alert. Otherwise, we kick of the process of creating the CSV but calling the createCSV function with our payload. Let's see what that function looks like:

function createCSV(JSONData, ShowLabel=true) {
     //If JSONData is not an object then JSON.parse will parse the JSON string in an Object
     const arrData = typeof JSONData != 'object' ? JSON.parse(JSONData) : JSONData;

     let CSV = '';    
     //Set Report title in first row or line

     CSV += 'OperationsData' + '\r\n\n';

     //This condition will generate the Label/Header
     if (ShowLabel) {
         let row = "";

         //This loop will extract the label from 1st index of on array
         for (let index in arrData[0]) {

             //Now convert each value to string and comma-seprated
             row += index + ',';
         }

         row = row.slice(0, -1);

         //append Label row with line break
         CSV += row + '\r\n';
     }

     //1st loop is to extract each row
     for (let i = 0; i < arrData.length; i++) {
         var row = "";

         //2nd loop will extract each column and convert it in string comma-seprated
         for (var index in arrData[i]) {
             row += '"' + arrData[i][index] + '",';
         }

         row.slice(0, row.length - 1);

         //add a line break after each row
         CSV += row + '\r\n';
     }

     if (CSV == '') {        
         alert("Invalid data");
         return;
     }   

     //Generate a file name
     const fileName = "MyReport_"; 

     //Initialize file format you want csv or xls
     const uri = 'data:text/csv;charset=utf-8,' + escape(CSV);

     const link = document.createElement("a");    
     link.href = uri;

     //set the visibility hidden so it will not effect on your web-layout
     link.style = "visibility:hidden";
     link.download = fileName + ".csv";

     //this part will append the anchor tag and remove it after automatic click
     document.body.appendChild(link);
     link.click();
     document.body.removeChild(link);
}

There's a lot going on there, and the focus of this post is not necessarily about converting JSON to CSV. But you can see that we essentially just loop through the JSON payload and comma-separated values in string format. At the end of the function, a temporary element is placed on the DOM and clicked to trigger the download.

And there we have it. My wife can now just click a Chrome extension to generate the report that she previously had to ask me to manually capture for her. And this whole extension can be extended to any data her company uses. If they find later that they need data from another request, this extension can easily be updated to support that.

This was my first Chrome extension and it was a lot of fun to build. So, you can bet it won't be my last.

Top comments (0)