Using the builder pattern to create imgix image URLs

March 27, 2024

260 views

You work on something for long enough and it stops being fun.

I've maintained and built imgix SDKs for generating image URLs for years now. They're wonderful and if you use imgix you should use them!

But they're also... I don't know, safe? Where is the "sizzle"? Where is the unnecessary complexity?

I want to have some fun.

What we'll build

I want to end up with a composed image like the one below.

image showing all four layers, base, mark64, text, and blend

I'm going to make polymorphic function that builds URLs around the imgix image rendering API. It will append each method call as a query parameter to a URL. Then, imgix will transform our image based off of those paramters.

My goals:

  1. use the "builder pattern" so I can add parameters by chaining method calls
  2. write as little parameter-specific code as possible
  3. make it easy to "layer" imgix images on top of each other, creating composites

To keep things simple fun, and because my blog is hardly 'mission critical infrastructure', I'm not going to use TypeScript.

I want an API that's fun to use and looks a little like zod's. A single function call with a bunch of chained method calls.

const image = buildURL({ domain })
  .path("/blog-og-image.png")
  .width(400)
  .height(400)
  .fm("auto")
  .fit("crop")
  .finalize();

console.log(image);
// https://luis-ball.imgix.net/blog-og-image.png?w=400&h=400&fm=auto&fit=crop

Writing buildURL

The first order of business is the "Director".

A Director is the thing that will take my URL through the assembly line of parameter construction. I want it to feed each imgix parameter to the final image path.

I'll call it buildURL.

There are a lot of imgix parameters I need to account for. But I don't want to make a special handler for them all. Instead, I'll have the method arguments tell me how to construct the parameter I need.

This is where "polymorphism" comes into play.

I'm going to call buildURL many times but with slightly different signatures. I need it to be smart enough to handle each signature differently.

To do this I can use "proxy handlers".

Proxy handlers allow you to modify the properties of an object by chaining method calls. I use them to define properties, in this case, imgix parameters, on the object returned by buildURL.

So if I want my image to use the fm=auto and fit=crop parameters, I could chain calls like the example from earlier.

const image = buildURL({ domain })
  .path("/blog-og-image.png")
  .fm("auto")
  .fit("crop")
  .finalize();

Creating the Proxy Object

To chain method calls like this, I need to buildURL methods to always return a proxy object.

I'm giving all the Proxies a get() handler, which intercepts access to properties. It has two arguments, the Proxy target, or original object, and the prop, or string of the name property being accessed.

I can use the get() handler to override whatever behavior would have taken place and always return a new Proxy instead.

export function buildURL({ protocol = "https://", domain }) {
  let baseURL;
  // ...
  const proxyHandler = {
    get(target, prop) {
      return function (paramValue) {
        // ...
        return new Proxy(target, proxyHandler); // allow method chaining
      };
    }
  },

  // Expose the proxy to the parent scope object
  const parentScope = new Proxy({}, proxyHandler);

  return parentScope;
}

All this is doing right now is returning a proxy object.

I need it to append the query param to the URL using the target and prop parameters.

Appending parameters to the URL using chained method calls

Since all query parameter have a similar signature, they can share the same method, appendQueryParam().

export function buildURL({ protocol = "https://", domain }) {
  // ...
  const proxyHandler = {
    get(target, prop) {
      return function (paramValue) {
        // ...
        appendQueryParam(prop, paramValue);
        return new Proxy(target, proxyHandler); // allow method chaining
      };
    }
  },
  // ...
}

This function uses the paramName and paramValue to append the right things to my URL query parameters, which imgix uses to build my image.

In order for some params to work correctly, I URL encode my param values. This might cause headaches in the future, so a nice improvement later on would be to conditionally disable this behavior.

/**
 * Appends a query parameter to the URL object
 */
function appendQueryParam(paramName, paramValue) {
  // Check if the baseURL is a relative path
  const isRelativePath = !baseURL.href.startsWith("http");

  // Validate that the protocol and domain are set for relative paths
  if (isRelativePath && !domain) {
    throw new Error("If the URL is a relative path, the domain must be set.");
  }

  const separator = baseURL.href.includes("?") ? "&" : "?";
  const encodedValue =
    typeof paramValue === "object"
      ? encodeURIComponent(JSON.stringify(paramValue))
      : encodeURIComponent(paramValue);
  baseURL.href += `${separator}${encodeURIComponent(
    paramName
  )}=${encodedValue}`;

  return this; // To allow method chaining
}

My favorite part of this implementation is that I don't have to define the fm() or any other parameter, they all share the same method!

Now I can use this in buildURL to start, well, building the URL I need.

Finalizing the built URL

I need a way to return the final result string. In other words, a finalize() method that stops the method chaining. Recall the example we had before.

const image = buildURL({ domain })
  .path("/blog-og-image.png")
  .fm("auto")
  .fit("crop")
  .finalize(); // tell our builder to return the URL

I can use the prop argument from the Proxy's get() method to decide to opt out of future method chaining and just return the final URL.

const proxyHandler = {
  get(target, prop) {
    // 👇 return the URL string
    if (prop === "finalize") {
      return function () {
        return baseURL;
      };
    } else {
      return function (paramValue) {
        // ...
        appendQueryParam(prop, paramValue);
        return new Proxy(target, proxyHandler);
      };
    }
  },
};

That's it!...

I lied.

There are of course some edge cases I need to account for.

Layering 4 images together 🤯

Ultimately what I want to use buildURL for is layering images on top of each other. In other words, creating composites.

The mark64, txt, and blend work wonderfully for this purpose, but I need to handle them differently from the reset.

Layer 1, the "base" image

Our first layer is the "base" layer, or the background of our image. I already made this layer above, but as a reminder:

const image = buildURL({ domain }).path("/blog-og-image.png");
// https://luis-ball.imgix.net/blog-og-image.png

Layer 2, the mark64 parameter

The mark64 needs extra special attention because I plan to use it in combination with the ~txt parameter to allow me to overlay two different text "images" on my final image.

Chaining off our "base" layer with this parameter would look something like this.

  .bri(-15) // lower the brightness so we can see the text
  .mark64({
    x: 180,
    y: 180,
    text: {
      align: 'middle, left',
      pad: 20,
      color: 'fff',
      font: 'avenir-black',
      size: 28,
      txt: 'Using the builder pattern to create imgix image URLs',
      w: 400,
    }
  })

// https://luis-ball.imgix.net/blog-og-image.png?bri=-15&mark64=aHR0cHM6Ly9sdWlzLWJhbGwuaW1naXgubmV0L350ZXh0P3R4dC1hbGlnbj1taWRkbGUlMkMrbGVmdCZ0eHQtcGFkPTIwJnR4dC1jb2xvcj1mZmYmdHh0LWZvbnQ9YXZlbmlyLWJsYWNrJnR4dC1zaXplPTI4JnR4dD1Vc2luZyt0aGUrYnVpbGRlcitwYXR0ZXJuK3RvK2NyZWF0ZStpbWdpeCtpbWFnZStVUkxzJnc9NDAw&mark-x=180&mark-y=180

an image showing the mark64 text layered on top

My mark64() method needs to first create an image URL from the ~txt endpoint and encode that URL into base-64 to use as the value for the mark64 parameter. Then it needs to add any mark-<some-param> params as well that style the text.

That's a lot of code just for some text, I know. But doing so allows me to free up the txt parameter to add even more text to our image.

Got it? Good. Let's look at some code.

/**
 * Base64-encodes an imgix/~txt URL for use with the mark64 query parameter
 * @see https://docs.imgix.com/apis/rendering/text/txt
 */
function mark64({ text, ...rest }) {
  // start building the `~text` URL
  const markURl = new URL(`${protocol + domain}/~text`);

  // append params to the ~txt URL that we'll encode to base64
  Object.entries(text).forEach(([key, value]) => {
    if (!(key === "txt" || key === "w")) {
      markURl.searchParams.set(`txt-${key}`, value);
    } else {
      markURl.searchParams.set(key, value);
    }
  });
  const mark64 = markURl.toString();

  // clear the search params
  baseURL.searchParams.delete("mark64");
  Object.entries(rest).forEach(([key, _value]) => {
    baseURL.searchParams.delete(`mark-${key}`);
  });

  // set the new search params
  baseURL.searchParams.set("mark64", Buffer.from(mark64).toString("base64"));
  Object.entries(rest).forEach(([key, value]) => {
    // console.log(`key = ${key}, value = ${value}`);
    baseURL.searchParams.set(`mark-${key}`, value);
  });

  return this;
}

Why not do both texts in one layer? I want fine-tuned control over the styling and positioning of the text. I can't do that if they live in the same layer of the image.

Creating the third layer with txt

Building on top of the previous example, this is what I want using the txt() method to look like.

  .txt({
    align: 'middle, left',
    color: 'fff',
    font64: 'avenir',
    pad: 200,
    size: 18,
    text64: 'Nobody asked for this, but it was fun!',
    y: 350
  })
// https://luis-ball.imgix.net/blog-og-image.png?bri=-15&mark64=aHR0cHM6Ly9sdWlzLWJhbGwuaW1naXgubmV0L350ZXh0P3R4dC1hbGlnbj1taWRkbGUlMkMrbGVmdCZ0eHQtcGFkPTIwJnR4dC1jb2xvcj1mZmYmdHh0LWZvbnQ9YXZlbmlyLWJsYWNrJnR4dC1zaXplPTI4JnR4dD1Vc2luZyt0aGUrYnVpbGRlcitwYXR0ZXJuK3RvK2NyZWF0ZStpbWdpeCtpbWFnZStVUkxzJnc9NDAw&mark-x=180&mark-y=180&txt64=bm9ib2R5IGFza2VkIGZvciB0aGlzLCBidXQgaXQgd2FzIGZ1biE%3D&txt-font64=YXZlbmly&txt-align=middle%2C+left&txt-color=fff&txt-pad=200&txt-size=18&txt-y=350

image showing 3 layers, the base, mark64, and txt layers

I'm kind of lying agin here. I'm not really using the txt parameter, but it's far more capable variant, txt64.

That's why need some special sauce to handle txt().

It's not because I'm lazy and I don't want to write txt64, but because only some of the parameters we use with txt need to be base64 encoded: The text itself and the font I want to use for it. The rest of the txt-<some-param> parameters don't need to be encoded.

/**
 * Base64-encodes a text string for use with the imgix txt64 query parameter
 * @see https://docs.imgix.com/apis/rendering/text/txt
 */
function txt({ text64, font64, ...rest }) {
  const encodedTxt = Buffer.from(text64).toString("base64");
  const encodedFont = Buffer.from(font64).toString("base64");

  // clear the search params
  baseURL.searchParams.delete("txt64");
  baseURL.searchParams.delete("txt-font64");
  Object.entries(rest).forEach(([key, _value]) => {
    baseURL.searchParams.delete(`txt-${key}`);
  });

  // set the new search params
  baseURL.searchParams.set("txt64", encodedTxt);
  baseURL.searchParams.set("txt-font64", encodedFont);
  Object.entries(rest).forEach(([key, value]) => {
    baseURL.searchParams.set(`txt-${key}`, value);
  });

  return this;
}

Is there a pattern emerging here around using base64 parameters? Yes. Are we going to abstract this sensibly right now? No. Fuck it ship it.

Creating the fourth layer with blend

I saved the best, sexiest param for last. It's magical and you can do some truly insanely cool things with it, like swapping car colors depending on user input.

I'm going to use it to add a preview image to our background.

.blend({
    mode: 'multiply',
    url: `blog-placeholder-border-14.webp?w=960&h=480&fit=crop&auto=format`,
    w: 600,
    x: 180,
    y: 120,
  })
// https://luis-ball.imgix.net/blog-og-image.png?bri=-15&mark64=aHR0cHM6Ly9sdWlzLWJhbGwuaW1naXgubmV0L350ZXh0P3R4dC1hbGlnbj1taWRkbGUlMkMrbGVmdCZ0eHQtcGFkPTIwJnR4dC1jb2xvcj1mZmYmdHh0LWZvbnQ9YXZlbmlyLWJsYWNrJnR4dC1zaXplPTI4JnR4dD1Vc2luZyt0aGUrYnVpbGRlcitwYXR0ZXJuK3RvK2NyZWF0ZStpbWdpeCtpbWFnZStVUkxzJnc9NDAw&mark-x=180&mark-y=180&txt64=bm9ib2R5IGFza2VkIGZvciB0aGlzLCBidXQgaXQgd2FzIGZ1biE%3D&txt-font64=YXZlbmly&txt-align=middle%2C+left&txt-color=fff&txt-pad=200&txt-size=18&txt-y=350&blend=blog-placeholder-border-14.webp?w=960&h=480&fit=crop&auto=format&blend-mode=multiply&blend-w=600&blend-x=180&blend-y=120&fm=auto

image showing all four layers, base, mark64, text, and blend

Blissfully, I don't need to do much here other than prepend the parameter options with blend-<my-option>.

/**
 * Constructs URL params for use with the imgix blend query parameter
 * @see https://docs.imgix.com/apis/rendering/blending
 */
function blend({ url, ...rest }) {
  // clear the search params
  baseURL.searchParams.delete("blend");
  Object.entries(rest).forEach(([key, _value]) => {
    baseURL.searchParams.delete(`blend-${key}`);
  });

  // set the new search params
  baseURL.searchParams.set("blend", url);
  Object.entries(rest).forEach(([key, value]) => {
    baseURL.searchParams.set(`blend-${key}`, value);
  });

  return this;
}

That's it! I now have four images layered on top of each other.


I hope you enjoyed the post! I'd love your feedback if you've got it. Find me @luqven on Twitter and say hey 👋🏼.