-
Notifications
You must be signed in to change notification settings - Fork 0
Understanding Mlem's Middleware System
Last updated 2024-06-14 by Sjmarf
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.
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?
}
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
// ...
}
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.
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
(representsApiPerson
) -
Person2
(representsApiPersonView
) -
Person3
(representsApiGetPersonDetailsResponse
)
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
.
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.
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.
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.
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.
(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
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.