When writing the last blog post I wanted to format my Caddyfile. I didn’t have a terminal open and I couldn’t find a website to do it. So I made one.
What and why
When writing the previous blog post the Caddyfile I wrote wasn’t formatted nicely. Caddy warns if the file isn’t formatted canonically and it looked pretty ugly. Normally you’d fix it by running caddy fmt
, this runs the built-in formatter over the file and outputs to stdin. But I had already closed my ssh session and didn’t fancy loading it back up so I looked online for alternatives to the command. Caddy has a smaller internet presence than it’s competitors and I couldn’t find an online formatter. I thought it wouldn’t be to hard to make one but I put a pin in the idea, booted up the ssh and quickly used the command line to make the blog post look nice.
Later on I found Caddapto. This is a website which converts Caddyfile’s to their internal JSON representation. This looked quite similar to the what I wanted the online formatter to achieve so I had a look at the source code. Caddapto isn’t particulary complex, it has a React frontend and a Go backend. The React takes input from a textarea and makes a call to the Go API, updating the UI with the result. The Go code was more interesting:
import (
...
"github.com/caddyserver/caddy/v2/caddyconfig"
...
)
...
func Handler(w http.ResponseWriter, r *http.Request) {
var cfgAdapter caddyconfig.Adapter
...
cfgAdapter = caddyconfig.GetAdapter(adapterName)
...
adaptedConfig, _, err := cfgAdapter.Adapt(...)
}
...
The backend imports the source code from the official Caddy repo and calls the adapt function in the API’s handler. My plan was to copy this approach, swapping the Adapt
call for whatever caddy fmt
called.
Before starting development I wanted to get a headstart on the DNS propagation - the wait for my wiki’s A record was painful - so I acquired caddyfmt.online
for $8 /2-years, created another tiny DO Droplet (same spec as the wiki’s) and created records to point between them. The domain was a great fit.
Construction
The work neatly divided into 3 sections, frontend, backend and integration/deployment. I wasn’t sure how technical to make the frontend, my gut instinct was to use Vue.js (in keeping with Caddapto’s React) but on further consideration I decided to use vanilla JS (yes, back to Y2K). The application would only have a single component and the logic only requires getting an element’s value and making an API call, Vue seemed overkill for this simple task. In hindsight I believe I made the right decision, I don’t see what extra value Vue would have brought to the table and vanilla JS can be served statically. I’ve used Bootstrap many times so it was simple to create the split-screen layout in my mind’s eye. The first draft of the frontend didn’t look great but it would enough for the backend, refinement would come later.
Before starting the backend I needed to figure out what happens when you run the built in formatter. I searched the Caddy repository’s commit history to find when the formatter was introduced. It was added in 2020 in this commit. Most of the command’s code deals with flags and options but it calls cmdFormatConfig
which unpacks the flags and then calls caddyfile.Format
. The caddyfile.Format
function is just what I wanted and could be dropped straight into an API. It has the signature func Format(body []byte) []byte
, this is a nice example of Inversion Of Control. The formatter doesn’t care where the input comes from, nor does it care where the output goes - it simply takes in a byte slice and returns the bytes in the correct format. Generally, this allows reuse in many different contexts and in our specific use-case it allowed changing the input from a command-line to an API with no changes to the method itself. I wanted to replicate the CLI as much as possible so I gave no special consideration to edge cases or invalid files, it treats them like caddy fmt
would.
The backend required a bit more effort than the frontend. Whilst I was familiar with Go’s syntax I didn’t have much experience with the packaging system so it was a bit fiddly. To reduce complexity I used the standard net/http
library. The backend is very simple, here’s a snippet from the handler:
import (
...
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
)
var incoming Request
decoder := json.NewDecoder(request.Body).Decode(&incoming)
...
var outputFile = string(caddyfile.Format([]byte(incoming.UnformattedFile)))
...
outgoing := Response{
FormattedFile: outputFile,
}
json.NewEncoder(responseWriter).Encode(outgoing)
This is the bulk of the backend, the body is deserialised into the Request struct (a wrapper around string). The Caddy format method is called on the request’s data and then the output is written to the reponse as JSON. Using the standard library was a boost as it cut down on the amount of knowledge I needed to know, no need to go learn a framework as well. I decided to throw an error if the JSON isn’t exactly what we expected. I’m not sure if the was the “correct” choice as it contradicts Postel’s Law but as only my frontend will be making the API calls it seems reasonable.
With the backend finished I fleshed out the API call in the frontend, a simple fetch
was enough. There’s wasn’t any need for a more complicated call. A message when the promise fails would be nice so it’s on the todo list. I also wanted a copy-to-clipboard button, this proved to be much simpler than I thought. However, copying to the clipboard doesn’t produce any visible change so to boost the usability I wanted something to feedback to the user that the copy operation succeeded . I found a neat clipboard icon and added that to the button. After a button press the icon switches to a tick to show that it succeeded and then resets after a couple of seconds. I used Bootstrap Icons to do this. One class represents the unticked clipboard and another the ticked, using classList.toggle(...)
the code is able to flick between them. A setTimeout
controls the reset. The next snippet is the entire frontend script.
function formatFile() {
fetch("/api/format", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ UnformattedFile: document.getElementById("floatingTextarea").value })
})
.then((response) => response.json())
.then((data) => document.getElementById("output").innerHTML = data.FormattedFile);
}
function copyOutput() {
var copyText = document.getElementById("output").innerHTML;
navigator.clipboard.writeText(copyText);
toggleClipIcon()
setTimeout(toggleClipIcon, 2500)
}
function toggleClipIcon() {
document.getElementById("clipIcon").classList.toggle("bi-clipboard-check-fill");
document.getElementById("clipIcon").classList.toggle("bi-clipboard");
}
The final step was deployment. I’m getting better with Caddy so this didn’t take too long. The API is reverse-proxied and the index is served statically. Simple to deploy it out to the Droplet. I might move it to containers if it gets a unwieldy but I can’t see the system expanding much.
Wrap Up
I’m quite pleased with how the website works. It solves the problem I set out to solve and wasn’t overengineered. There are a couple of things left on the todo list but I don’t mind leaving them off for now.