meteor: how we define methods
I've been inspired by the meteor guide project to start talking more about the coding practices at Edthena. Keep in mind that the focus of these "how we" articles will be the techniques we have discovered while maintaining a large meteor codebase over several years. In other words, some of these concepts may be overkill for small applications.
In this post I'll explain how we define methods for insert/update/remove operations. Here's the basic outline:
- Only the core packages will be used.
- Collections can only be updated through methods (no client-side operations).
- We'll use two-tiered methods along with the mutator pattern.
In the interest of keeping the code samples short, we'll focus on a single method. This is a simplified version of our actual code for inserting comments. You only need to know that comments have a many-to-one relationship with conversations.
First Tier
Here's the implementation of comments.insert
:
var INSERT_FIELDS = {
conversationId: String,
message: isNonEmptyString,
time: isPositiveNumber
};
Meteor.methods({
'comments.insert': function(comment) {
check(comment, INSERT_FIELDS);
// Ensure the conversation exists.
var conversation = findOrThrow(comment.conversationId, Conversations);
// Ensure the group exists.
var group = findOrThrow(conversation.groupId, Groups);
// The author is always the caller.
var author = Meteor.userId();
// You can only leave a comment if you are the owner of the
// conversation or a member of its group.
if (!(conversation.owner === author || group.isMember(author)))
throw new Meteor.Error(403, 'You can not insert this comment');
// Return the result of calling the second-tier mutator.
return Comments.mutate.insert(comment);
}
});
comments.insert
is the public-facing API which can be called from anywhere in the application. As the first tier, its job is to enforce the integrity of the data with respect to both the schema and the authorization rules.
Implementation Notes
- We prefer period-separated method names to camel case for purely aesthetic reasons.
isNonEmptyString
andisPositiveNumber
are Match.Where checks.findOrThrow
is a helper that does what its name implies - it finds a single document or throws a404
error. We use it in all of our methods instead offindOne
for any caller-supplied ids.- We currently use
Meteor.userId()
in our methods instead ofthis.userId
because we prefer to stubMeteor.userId()
in our tests. group.isMember
is a helper on the group model.
Second Tier (mutator)
Here's the implementation of Comments.mutate.insert
:
var INSERT_MUTATE_FIELDS = _.extend(_.clone(INSERT_FIELDS), {
author: Match.Optional(String),
createdAt: Match.Optional(Date)
});
Comments.mutate = {
insert: function(comment) {
check(comment, INSERT_MUTATE_FIELDS);
// Clean up the input.
comment.message = comment.message.trim();
// Add the default fields.
_.defaults(comment, {
author: tryUserId(),
createdAt: new Date
});
// Return the id of the inserted document.
return Comments.insert(comment);
}
};
The job of Comments.mutate.insert
is to modify the database and enforce the schema. It's only available to trusted code. Not only do we skip the authorization rules, but we also add a backdoor which allows the caller to insert with properties that are not available to the public API.
Mutators have the following benefits:
- Tests can be set up without elaborate permissions juggling. Server tests can use mutators instead of calling methods so schema-correct test data can be easily injected.
- Hooks can call mutators to directly manipulate the database after a critical event without having to worry about collection-specific details. For example, after a conversation is removed, a hook calls
Comments.mutate.remove
without needing to worry about the details of how comments clean themselves up internally. - In development mode, the database can be seeded with comments using a random
author
orcreatedAt
without a second update operation.
Implementation Notes
tryUserId
is justMeteor.userId()
inside of a try/catch. It's necessary becauseMeteor.userId()
isn't available everywhere on the server, and defaults evaluates all of its values even if the keys won't be added.
Packages
We currently don't use any additional schema-validation packages (e.g. simple-schema). While I can see the value of knowing if a given document conforms to a schema, it's hard to imagine generating an invalid document with the techniques described above. A combination of checking and testing gives us a lot of confidence in our data integrity. Furthermore, we tend to avoid community packages which can potentially have a large footprint in our codebase.
Latency Compensation
The majority of our methods are defined only on the server. We found that writing, testing, and maintaining them was much easier when we could assume a fibers-enabled environment. Additionally, it's a lot less code to have to ship to the client.
Of course this comes at the cost of latency compensation. We handle this by defining separate stub methods for those instances where latency compensation can have a meaningful impact on the user experience.
Conclusion
Although two-tiered methods are more verbose than the standard examples you may be used to seeing, using them has helped us keep our API both flexible and powerful.
Thanks to Dave Luzius for reading a draft of this.