Skip to content

Understanding Mlem's Middleware System

Sjmarf edited this page Jun 14, 2024 · 3 revisions

Last updated 2024-06-14 by Sjmarf

ApiClient

Lemmy uses a REST API (Docs). We have an public ApiClient class that is responsible for all communication with Lemmy's API. It provides methods that can be called from Mlem, such as getPosts, createPost etc.

The ApiClient initialiser accepts two parameters - url: URL and token: String?. A new ApiClient should be created for every Lemmy account that we need to communicate with. If token is nil, the ApiClient acts as a guest. Once an ApiClient is created, you cannot change which account it is bound to. Create an ApiClient like so:

// Create a guest lemmy.world client
let url = URL(string: "lemmy.world")!
let token: String? = nil
let api: ApiClient = .getApiClient(for: url, with: token)

Note that we're using the .getApiClient static method of ApiClient to create the ApiClient, rather than a direct ApiClient initialiser. This is needed because we cache instances of ApiClient - if an instance for the given URL and token already exists when you call .getApiClient, that existing instance will be returned. Otherwise, a new instance will be created and returned.

API Types

When a response is received from the API, ApiClient decodes it into a struct. These structs all have the prefix Api-. For example, here is the ApiGetPostsResponse definition:

public struct ApiGetPostsResponse: Codable {
    public let posts: [ApiPostView]
    public let nextPage: String?
}

Auto-generation and versioning

We have a script that auto-generates these structs by reading the lemmy-js-client source code. The script examines the source code for the range of Lemmy versions supported by Mlem (at the time of writing, this is 0.18.0 and above). If it finds a difference between two versions of an API response, it add a comment to that line:

public struct ApiPost: Codable {
    // ...
    public let altText: String? // Exists only in 0.19.4
    // ...
}

Tiers

Lemmy can use one of several different API types to represent a single entity. Take for example these three API types:

public struct ApiPerson: Codable {
    public let id: Int
    public let name: String
    public let displayName: String?
    public let avatar: URL?
    // ...
}
public struct ApiPersonView: Codable {
    public let person: ApiPerson
    public let counts: ApiPersonAggregates
    // ...
}
public struct ApiGetPersonDetailsResponse: Codable {
    public let personView: ApiPersonView
    public let moderates: [ApiCommunityModeratorView]
    // ...
}

We refer to these API types as being "tiered". The least descriptive API type is said to be "tier 1", and the tier number increases for types of increasing descriptive-ness. Each higher-tier type wraps an instance of the previous tier. Not all API types are tiered; only some.

ApiPerson is tier 1, ApiPersonView is tier 2, and ApiGetPersonDetailsResponse is tier 3. Generally speaking, the name of the second tier of an entity will end in "View". That has absolutely nothing to do with SwiftUI "views" - we use the same model names as lemmy-js-client, which is why it's named that way.

Content Models

In Mlem v1, the UI was directly interacting with the API types described above. This was nice and simple, but caused a few issues. In Mlem v2, we're using a series of "content models" as an in-between. The UI interacts with the content models, and the content models interact with the API. This man-in-the-middle system is referred to as Mlem's "Middleware".

API types are still made public to the Mlem repository, but you won't be interacting with them very often.

Just like API types, content models are tiered. Each tiered API type maps 1:1 onto a content model type. For example, these are the content models that represent the API "person" types we looked at earlier:

  • Person1 (represents ApiPerson)
  • Person2 (represents ApiPersonView)
  • Person3 (represents ApiGetPersonDetailsResponse)

Each tier of model has the same properties as the API type it represents (though they may have different names). The main difference between API types and content models is that content models are classes, with SwiftUI's @Observable macro applied, rather than structs. This allows them to be passed between SwiftUI views easily - when a view changes some property of a content model, that change will be visible across every view that is watching the model. For example, if you upvote a post in the expanded view and then go back to the feed, the post will also show as upvoted in the feed. This behavior is one of the main advantages of the middleware system.

Just like tiered API types, each tiered content model stores an instance of the tier below it. Person3 stores a Person2 instance, and Person2 stores a Person1 instance. For convenience, each higher-tier model has computed properties that allow for easy access to the stored properties of lower tiers. For example, Person1 has a stored name: String property. Both Person2 and Person3 have a computed property that accesses this value:

var name: String { person1.name }

This means that every tier of the person entity has a gettable name property. To represent this, all person models conform to a Person protocol. This allows you to create functions that accept any tier of model, rather than a specific tier:

func sayHello(person: any Person) {
    print("Hello \(person.name)!")
}

However, some properties aren't available from all tiers of an entity. For example, Person2 has a stored postCount: Int property. Person3 has a computed property that returns person2.postCount, but Person1 is not able to. What if we wanted to write a function that accepts any tier of model with access to postCount?

To do this, we can use the Person2Providing protocol. Person2Providing requires that all Person2 properties be implemented. The below function will accept a Person2 or a Person3, but not a Person1.

func printPostCount(person: any Person2Providing) {
    print("You have \(person.postCount) posts.")
}

There is a -Providing protocol for each tier of content model. The Person protocol is actually a typealias of Person1Providing.

SwiftUI Examples

Here's an example view in SwiftUI that renders some information about a Person.

struct PersonView: View {
    let person: any Person

    var body: some View {
        VStack {
            Text(person.name)
            if let person = person as? any Person2Providing {
                Text("\(person.postCount) posts")
            } else {
                ProgressView()
            }
        }
    }
}

The view will display the post count if it is available, or show a ProgressView if not. We use optional type casting to check whether the model is at tier 2 or higher.

There's another way we could do this, too. All person models have an optional postCount_ property. Models that don't store postCount will return nil for this property, and models that do will return postCount. This allows you to write this:

struct PersonView: View {
    let person: any Person

    var body: some View {
        VStack {
            Text(person.name)
            Text("\(person.postCount_ ?? 0) posts")
        }
    }
}

There are optional variants of every content model property.

Creation and Caching

Content models are returned by ApiClient methods. When ApiClient.getPerson is called, a Person3 instance is returned. All content models are cached, just like ApiClient is. This means that if you call getPerson and the correct Person3 already exists in memory, the existing Person3 will be returned rather than a new one being created.

Each ApiClient instance stores its own cache of content models. There will every only be one instance of an entity with a given ID fetched from the same ApiClient. Each content model stores a reference to the ApiClient that created it in an api property.

Methods

Some content models have methods which mutate their values. For example, Post2 has a toggleUpvote() method. When this method is called, it will upvote the post via the ApiClient that the Post2 was created from. These methods are constricted to certain tiers in the same way that properties are. toggleUpvote can only be called from Post2 or higher - it cannot be called from Post1. Just like with properties, you can use the -Providing protocols to check whether you can call one of these methods.

Other protocols

Their are some additional protocols that cover multiple different content model types. Interactable1Providing covers any model that can be upvoted, downvoted and saved. Post and Comment models from tier 1 and above conform to this. Interactable2Providing is similar, but only tier 2 Post and Comment models conform to it.

Upgrading and stubs

(This section discusses how upgrading works as of this PR, which hasn't been merged yet as of the time of writing. This system is subject to change).

Each series of tiered models also has a "tier 0" of sorts, the name of which is suffixed with -Stub (for example, PersonStub). Stubs only store two properties - the actorId (unique identifier) of the model, and an ApiClient. Stubs can be "upgraded" into a higher-tier model using the upgrade() method. This method upgrades to the highest tier that is possible to achieve with just one API request.

All tiers of content model have this upgrade() method. If you want to upgrade all the way to the highest tier of model, you may need to call upgrade() again on the result of the first call. If it isn't able to upgrade any further, upgrade() returns self.

AnyPost, AnyPerson etc.

AnyPost stores a single wrappedValue: any Post property. It has an upgrade() method that repeatedly calls upgrade() on its wrappedValue to upgrade it to the highest possible tier. This abstracts the upgrading logic away from the frontend.

In Mlem, there exists a ContentLoader SwiftUI View that upgrades the given content model as soon as it appears.