mdg:method

v0.1.0Published 9 years ago

This package has not had recent updates. Please investigate it's current state before committing to using it in your project.

mdg:method

Development stage: looking for feedback and ideas before solidifying the initial API

Define Meteor methods in a structured way

1const method = new Method({
2  name, // DDP method name
3  schema, // SimpleSchema for arguments
4  run // method body
5});

This is a simple wrapper package for Meteor.methods. The need for such a package came when the Meteor Guide was being written and we realized there was a lot of best-practices boilerplate around methods that could be easily abstracted away. Here are some of the advantages of declaring methods using this package:

  1. Have an object that represents your method. Refer to it through JavaScript scope rather than

by a magic string name 2. Built-in validation of arguments through aldeed:simple-schema 4. Easily call your method from tests or server-side code, passing in any user ID you want. No need for two-tiered methods anymore! 5. Throw errors from the client-side method simulation to prevent execution of the server-side method - this means you can do complex client-side validation in the body on the client, and not waste server-side resources. 6. Get the return value of the stub by default, to take advantage of consistent ID generation. This way you can implement a custom insert method with optimistic UI.

Todos:

  1. Figure out how to get ValidationError in here
  2. Allow passing options
  3. Add mixin support for simple:rest and similar

See extensive code samples in the Todos example app below:

  1. Todos and Lists method definitions
  2. Lists method tests
  3. Some call sites: 1, 2, 3

Defining a method

new Method({ name, schema, run })

Let's examine a method from the new Todos example app which makes a list private and takes the listId as an argument. The method also does permissions checks based on the currently logged-in user. Note this code uses new ES2015 JavaScript syntax features.

1// Attach your method to a namespace
2Lists.methods.makePrivate = new Method({
3  // The name of the method, sent over the wire. Same as the key provided
4  // when calling Meteor.methods
5  name: 'Lists.methods.makePrivate',
6
7  // Schema for the arguments. Only keyword arguments are accepted, so the
8  // arguments are an object rather than an array
9  schema: new SimpleSchema({
10    listId: { type: String }
11  }),
12
13  // This is the body of the method. Use ES2015 object destructuring to get
14  // the keyword arguments
15  run({ listId }) {
16    // `this` is the same method invocation object you normally get inside
17    // Meteor.methods
18    if (!this.userId) {
19      // Throw errors with a specific error code
20      throw new Meteor.Error('Lists.methods.makePrivate.notLoggedIn',
21        'Must be logged in to make private lists.');
22    }
23
24    const list = Lists.findOne(listId);
25
26    if (list.isLastPublicList()) {
27      throw new Meteor.Error('Lists.methods.makePrivate.lastPublicList',
28        'Cannot make the last public list private.');
29    }
30
31    Lists.update(listId, {
32      $set: { userId: this.userId }
33    });
34
35    Lists.userIdDenormalizer.set(listId, this.userId);
36  }
37});

new Method({ name, validate, run })

If aldeed:simple-schema doesn't work for your validation needs, just define a custom validate method that throws a Meteor.ValidationError instead:

1const method = new Method({
2  name: 'methodName',
3
4  validate({ myArgument }) {
5    const errors = [];
6
7    if (myArgument % 2 !== 0) {
8      errors.push({
9        name: 'myArgument',
10        type: 'not-even',
11        details: {
12          value: myArgument
13        }
14      });
15    }
16
17    if (errors.length) {
18      throw new Meteor.ValidationError(errors);
19    }
20  }
21});

Using a Method

method#call(args: Object)

Call a method like so:

1Lists.methods.makePrivate.call({
2  listId: list._id
3}, (err, res) => {
4  if (err) {
5    handleError(err.error);
6  }
7
8  doSomethingWithResult(res);
9});

The return value of the server-side method is available as the second argument of the method callback.

method#_execute(context: Object, args: Object)

Call this from your test code to simulate calling a method on behalf of a particular user:

1it('only makes the list public if you made it private', () => {
2  // Set up method arguments and context
3  const context = { userId };
4  const args = { listId };
5
6  Lists.methods.makePrivate._execute(context, args);
7
8  const otherUserContext = { userId: Random.id() };
9
10  assert.throws(() => {
11    Lists.methods.makePublic._execute(otherUserContext, args);
12  }, Meteor.Error, /Lists.methods.makePublic.accessDenied/);
13
14  // Make sure things are still private
15  assertListAndTodoArePrivate();
16});

Ideas

  • With a little bit of work, this package could be improved to allow easily generating a form from a method, based on the arguments it takes. We just need a way of specifying some of the arguments programmatically - for example, if you want to make a form to add a comment to a post, you need to pass the post ID somehow - you don't want to just have a text field called "Post ID".

Discussion and in-depth info

Validation and throwStubExceptions

By default, using Meteor.call to call a Meteor method invokes the client-side simulation and the server-side implementation. If the simulation fails or throws an error, the server-side implementation happens anyway. However, we believe that it is likely that an error in the simulation is a good indicator that an error will happen on the server as well. For example, if there is a validation error in the arguments, or the user doesn't have adequate permissions to call that method, it's often easy to identify that ahead of time on the client.

If you already know the method will fail, why call it on the server at all? That's why this package turns on a hidden option to Meteor.apply called throwStubExceptions.

With this option enabled, an error thrown by the client simulation will stop the server-side method from being called at all.

Watch out - while this behavior is good for conserving server resources in the case where you know the call will fail, you need to make sure the simulation doesn't throw errors in the case where the server call would have succeeded. This means that if you have some permission logic that relies on data only available on the server, you should wrap it in an if (!this.isSimulation) { ... } statement.

ID generation and returnStubValue

One big benefit of the built-in client-side Collection#insert call is that you can get the ID of the newly inserted document on the client right away. This is sometimes listed as a benefit of using allow/deny over custom defined methods. Not anymore!

For a while now, Meteor has had a hard-to-find option to Meteor.apply called returnStubValue. This lets you return a value from a client-side simulation, and use that value immediately on the client. Also, Meteor goes to great lengths to make sure that ID generation on the client and server is consistent. Now, it's easy to take advantage of this feature since this package enables returnStubValue by default.

Here's an example of how you could implement a custom insert method, taken from the Todos example app we are working on for the Meteor Guide:

1Lists.methods.insert = new Method({
2  name: 'Lists.methods.insert',
3  schema: new SimpleSchema({}),
4  run() {
5    return Lists.insert({});
6  }
7});

You can get the ID generated by insert by reading the return value of call:

1// The return value of the stub is an ID generated on the client
2const listId = Lists.methods.insert.call((err) => {
3  if (err) {
4    // At this point, we have already redirected to the new list page, but
5    // for some reason the list didn't get created. This should almost never
6    // happen, but it's good to handle it anyway.
7    FlowRouter.go('home');
8    alert('Could not create list.');
9  }
10});
11
12FlowRouter.go('listsShow', { _id: listId });

Running tests

meteor test-packages --driver-package practicalmeteor:mocha ./