Strongly Typed Meteor methods

I’m pretty sure a lot of people from the Meteor community started using Typescript recently. I’ve been battling with finding a good way to type Meteor methods and I almost found a pretty decent one. If you have any clues how to improve it, I would really like to know about it.

The article:

7 Likes

Nice!

My approach differs from yours in that I wanted to type both the usage and the implementation registration and I also had a separate agenda to prepare to move away from DDP calls completely and therefore wanted to hide the Meteor DDP parts as much as possible.

I came up with the following patterns - helper classes are found below.

// typically imports/api/somefile.ts so that both client and server can access it
export const SendReferenceRequestMail = new ApiHelper<
  (
    candidateId: string,
    settings: RequestReferencesSettings,
    preview: boolean
  ) => OutreachResultPreview
>("Candidates.sendReferenceRequestMail", "project:mine:candidate:communicate");

Using the API in client code:

SendReferenceRequestMail.invokeAsync(candidateId, settings, true).then(result=>{...},err=>\...});

Using the API in unit tests code where I create DDP.DDPStatic connections:

const result = SendReferenceRequestMail.testInvoke(conn, candidateId, settings, true);

The method registration itself is pretty straightforward:

SendReferenceRequestMail.register(function(
  candidateId: string,
  settings: RequestReferencesSettings,
  preview: boolean
) {
  const requester = getRequester(this);
  ...
  return ...;
});

That’s about it. The ApiHelper class and some helper types and functions are found below.

Enjoy,
Per

import { Meteor } from "meteor/meteor";
import { DDP } from "meteor/ddp";

// Also imports my own permissions model

/**
 * Utility method for invoking Meteor.call in client code for a given method
 * and return the results as a promise.
 */
export const invokeApi = <F extends (...params: any[]) => any>(
  method: string,
  ...parameters: Parameters<F>
): Promise<ReturnType<F>> => {
  if (!Meteor.isClient) {
    throw new Meteor.Error(
      "not-client",
      "invokeApi can only be called from client code"
    );
  }
  return new Promise<ReturnType<F>>((resolve, reject) => {
    if (!method) {
      reject(
        new Meteor.Error(
          "no-method-name",
          "No method name was provided to invokeApi"
        )
      );
    }
    Meteor.call(method, ...parameters, (err, res: ReturnType<F>) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(res);
    });
  });
};

/**
 * Utility method for invoking Meteor.call in test code for a given method
 * and return the results.
 */
export const invokeServerInTest = <F extends (...params: any[]) => any>(
  conn: DDP.DDPStatic,
  method: string,
  ...parameters: Parameters<F>
): ReturnType<F> => {
  if (!Meteor.isServer || !Meteor.isTest) {
    throw new Meteor.Error(
      "not-test",
      "invokeServerInTest can only be called from server test code"
    );
  }
  if (!method) {
    throw new Meteor.Error(
      "no-method-name",
      "No method name was provided to invokeServerInTest"
    );
  }
  return conn.call(method, ...parameters);
};

/**
 * Converts the provided function type to an async version with the same signature
 */
export interface makeAsync<F extends (...params: any[]) => any> {
  (...params: Parameters<F>): Promise<ReturnType<F>>;
}

/**
 * Converts the provided function type to a version with an
 * added ddp connection parameter
 */
export interface toServerTest<F extends (...params: any[]) => any> {
  (connection: DDP.DDPStatic, ...params: Parameters<F>): ReturnType<F>;
}

/**
 * Utility class for registering and invoking meteor functions
 */
export class ApiHelper<F extends (...params: any[]) => any> {
  constructor(
    private readonly methodName: string,
    public readonly requiredPermission?: RequiredPermission
  ) {}

  /**
   * Invoke asynchronously on client
   */
  public readonly invokeAsync: makeAsync<F> = this.doInvokeAsync.bind(this);

  private doInvokeAsync(...params: Parameters<F>): Promise<ReturnType<F>> {
    return invokeApi<F>(this.methodName, ...params);
  }

  /**
   * For invoking the server-side method in unit tests via an established DDP
   * connection
   */
  public testInvoke: toServerTest<F> = (conn, ...params) =>
    invokeServerInTest(conn, this.methodName, ...params);

  /**
   * Server-side registers methods to be wrapped,
   * e.g. for logging errors.
   */
  public static wrapMeteorMethods = <T extends {}>(methods: T): T => {
    let wrappedMethods = methods;
    if (Meteor.isServer) {
      const helper = require("../../server/utils/install-method-wrapper");
      wrappedMethods = helper.wrapMeteorMethods(methods);
    }
    return wrappedMethods;
  };

  /**
   * Server-side verifies that a registered method call
   * is performed by a user that has permissions to do so.
   * Client-side implementation is a no-op.
   */
  public static verifyHasPermission = (
    requiredPermission: RequiredPermission
  ) => {
    if (Meteor.isServer) {
      const helper = require("../../server/utils/install-method-wrapper");
      helper.verifyHasPermission(requiredPermission);
    }
  };

  private doRegister<G extends (...params: any[]) => any>(func: G) {
    // Wrap with a permission check if there is a required permission
    const permissionWrappedFunc = wrapWithPermissionCheck(
      func,
      this.requiredPermission
    );
    // Wrap with an error handler
    const entry = { [this.methodName]: permissionWrappedFunc };
    const wrappedMethods = ApiHelper.wrapMeteorMethods(entry);
    Meteor.methods(wrappedMethods);
  }

  /**
   * Register method on server OR client
   */
  public register(f: F) {
    this.doRegister(f);
  }

  /**
   * Register async version of method
   */
  public asyncRegister(f: makeAsync<F>) {
    this.doRegister(f);
  }

}
3 Likes

Thanks for your reply @permb.
Your solution seems more robust. In our case, we are still in the transitioning period to TS, and I was trying to find a way to retrofit some kind of typing pattern around the code we already had in place, while not disturbing it too much. I think it was more about testing how hard I can stretch the possibilities in TS.

FWIW I did use this pattern also during a transition period of a few months since it’s 100% compatible with old untyped code

Yeah, I guess want I wanted to say is that when I came to the project, all the methods were created using ValidatedMethod and invoked using the standard meteor api. The only wrapper we had was the callAsync. Switching them to an API like yours would’ve been a hard sell for the team. So this hacky approach is some kind of way to add some typing without changing the way things were done.