After my latest blog post on Meridian, a few people took note of the library and asked some questions on Mastodon. Because of these questions, I realized that a lot of the more advanced tricks that I’ve been exploring with Meridian may not be obvious to those who are casually observing, and I thought I would write about some of those ideas.

Parameterizing Responders

Defining an endpoint in Meridian consists of two components — a responder and a route. You implement the Responder protocol, and then when you mount a responder at a specific path, that creates a Route. You’ll almost never deal with the Route type directly in your code, but it’s there.

At first, it can seem a little strange that almost everything about a Responder lives in one place, but the incoming paths it matches against are in a different place (usually a different file). Let’s explore why.

When I was still experimenting with Meridian, I tried putting everything into one type, which looked something like this:

struct InvoicesEndpoint: Responder {
     static let route: Route = .get("/api/invoices")
     
     @QueryParameter("sort") var sort: Sort
     
     // ...
}

Maybe with today’s macros, we could even write something like this:

@Route(.get("/api/invoices"))
struct InvoicesEndpoint: Responder {
     @QueryParameter("sort") var sort: Sort

     // ...
}

The macro might even be able to handle registering the endpoint with the server so that simply defining the type would be enough for it to start responding to requests.

I see two major benefits of putting everything into one type. First, it makes the whole thing very portable. If you wrote a login endpoint for one of your services, you could just move that file to a new project and it would immediately begin working.

Second, it’s really easy to read. You open one file and you know everything you need to know about sending a request to that endpoint, including what url parameters you’ll need, what query parameters are available, and so on. One of the selling points of Swift on the Server is the ease of client engineers to read and understand the server code, and in this regard, a single source of information about every request is a clear win.

However, there are bigger benefits to not colocating the path with everything else. Putting the route inside the responder ties the responder to one and only one route. You can’t easily respond to two different paths with the same content, and more importantly, you can’t treat the responder like a regular object anymore.

To see why this is important, let’s take a look at some SwiftUI code.

struct Counter: View {
    @State var counter = 0
    
    var body: some View {
        Button("\(counter)") { counter += 1 }
    }
}

struct TestingView: View {
    var body: some View {
        let counter = Counter()
 
        VStack {
            counter
       
            counter
        }
    }
}

My readers who are somewhat well-versed in SwiftUI will know that this snippet creates a VStack with two counters in it, each of which maintains its own state. Incrementing the number in the top counter won’t affect the bottom counter. This is nominally because Counter is a value type. (My readers who are even more well-versed with SwiftUI arcana know that the truth is weirder still, and that this snippet relies on deep magic in Swift’s result builders and SwiftUI’s attribute graph to create the illusion of the value semantics for us to rely on.)

Because Meridian takes a lot of its design cues from SwiftUI, responders are just value types. That means they can be moved, configured, and copied. For our invoice example, let’s say you want to make an endpoint available at a new path as well as an old path. You can mount the same responder at two different paths by simply creating two of them:

Server(errorRenderer: JSONErrorRenderer())
    .register({
        InvoicesEndpoint()
            .on(.get("/api/invoices"))

        InvoicesEndpoint()
            .on(.get("/api/v1/invoices"))
    })
    .listen()

In this snippet, we’ve linked one responder to two different routes. I want to be clear that this is possible with the static route design mentioned above — see Meridian’s RouteMatcher, which is a very general way of determining if an incoming request can be handled by a particular route — making a different instance for each route fits cleanly into the mental model of instantiating objects and using them.

A slightly more interesting problem is if we have two slightly different versions of the same endpoint. Let’s say we wanted to respond to /api/invoices/ and /api/invoices/open and reuse most of the code between the two.

enum InvoiceFilterKind: CaseIterable {
    case all, open
}

Witha block-based framework (like Vapor), you can do this in a few different ways. Here’s one solution:

func makeInvoiceClosure(kind: InvoiceFilterKind) -> ((Request) async throws -> Response) {
    return { request in
        // extract parameters and execute request
    }
}

app.get("api", "invoices", use: makeInvoiceClosure(kind: .all))
app.get("api", "invoices", "open", use: makeInvoiceClosure(kind: .open))

With Meridian, there are a few different ways to do this, but the one I think is most interesting is to parameterize the Responder itself. At the call-site, it can look something like this:

Server(errorRenderer: JSONErrorRenderer())
     .register({
         InvoicesEndpoint(kind: .all)
             .on(.get("/api/invoices"))

         InvoicesEndpoint(kind: .open)
             .on(.get("/api/invoices/open"))
     })
     .listen()

Parameterizing the responder in this way is simple — it’s just a regular property!

struct InvoicesEndpoint: Responder {

    let kind: InvoiceFilterKind

    @QueryParameter("sort") var sort: Sort
     
    public init(kind: InvoiceFilterKind) {
        self.kind = kind
    }

    // ...
}

Because Meridian’s responders are simple values, they can be initialized with differing parameters. Furthermore, like SwiftUI, it’s very powerful to be able treat these responders as simple objects whose semantics you already understand. One of the benefits of using Swift on the server is that you don’t have to context switch in order to go from writing your server code to writing your client code. Meridian seeks to make the two even closer.

Smart Responses

Meridian includes a lot of Responses that are useful in day-to-day programming. The intention, as a framework, is to be “batteries included”, keeping tools that you need close at hand. The documentation goes over a lot of these available responses, but I want to talk about some more advanced things they can do.

Like middleware and responders, responses can hook into any property wrapper, including the environment.

Because most people will be building JSON APIs with tools like Meridian, the JSON response is one of the most commonly used responses. Its usage is very simple:

func execute() async throws -> Response {
    try JSON(database.listTodos())
}

There is an option to pass a custom encoder in the initializer for JSON, but by default it will use the \.jsonEncoder from the environment. The default encoder in the environment is one with no customization, but you can easily create a new one and customize it when setting up your server:

 Server(errorRenderer: JSONErrorRenderer())
     .register({
          ListTodos()
           .on(.get("/todos"))
     })
     .environment(\.jsonEncoder, JSONEncoder.myEncoder)
     .listen()

This will be used by all responders that use the JSON response.

Because Response objects can use any property wrapper, this opens up really advanced tricks. For example, if you wanted to include the path and method with every JSON encoded item, a custom Response can solve that very easily:

struct AnyEncodable: Encodable {
    let base: Encodable

    func encode(to encoder: Encoder) throws {
        try base.encode(to: encoder)
    }
}

public struct JSONWithMeta: Response {

    struct Container: Encodable {
        let meta: Meta
        let content: AnyEncodable
    }
    
    struct Meta: Encodable {
        let path: String
        let method: String
    }

    @Path var path

    @RequestMethod var method

    let encodable: any Encodable

    public func body() throws -> Data {
        return try JSONEncoder().encode(Container(meta: Meta(path: path, method: method.name), content: AnyEncodable(base: encodable)))
    }
}

This outputs JSON that looks something like this:

{
  "meta": {
    "method": "GET",
    "path": "/todos"
  },
  "content": [
    // ...
  ]
}

Even though you will almost never need to implement your own responses, they are a first-class component in the library, and having access to the full request context inside a response unlocks a lot of power that would otherwise require messy middleware or a lot of repetition.

Expressiveness

Using a lot of the same design goals as SwiftUI, Meridian aspires to give users the power to implement their backends with small, reusable components that compose together in useful ways.

These are some techniques that I’ve found to be helpful over the last few years of using the library. They’re available when you need them, and even if you don’t use them directly, they’re working behind the scenes to make Meridian a joy to work with.