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);
}
}