What's the preferred package to create a restful API in 2021?

Hello,
in 2017 we started to implement an API in our Meteor project. At this time GitHub - Lepozepo/meteor-restivus: REST APIs for the Best of Us! - A Meteor 0.9+ package for building REST APIs https://atmospherejs.com/nimble/restivus
was a good choice for a set of basic API function. But the last release of that package is from January 2017.
Additionally I found simple:rest (REST for Meteor). But here the last update is from November 2017.

Are there any active API packages for Meteor? Which package would you use to build a RESTful API for Meteor in 2021?

webapp package

3 Likes

As @rjdavid says, you only need to use the built-in webapp package to add a simple API. imo those packages you mentioned are not only old, they try to do too much when it’s actually quite simple to do it yourself.

To get you started, here’s some of my own code which:

  • puts a generic API entpoint at /api-data/[v1]/...
  • supports authentication, rate limits and parameters in the url ("/users/:userId/status").

Here I use a single WebApp.connectHandlers.use('/api-data/' ... call to set up the generic endpoint and handle authentication, body parsing, etc, then individual API endpoints are added at the end ("endpoints.add("GET", "users"...)").

Code block
import http from "http";
import qs from "querystring";
import { WebApp } from "meteor/webapp";

enum Codes {
    OK = 200,
    SEE_OTHER = 303,
    BAD_REQUEST = 400,
    UNAUTHORIZED = 401,
    NOT_FOUND = 404,
    TOO_MANY_REQUESTS = 429,
    METHOD_NOT_ALLOWED = 405,
    SERVER_ERROR = 500,
}

const rateLimits = {
	GET: 5000,
	POST: 500,
	DELETE: 500,
}

type Method = keyof typeof rateLimits;

type Handler = (options: {
	user: Meteor.User,
	params?: Record<string, string>,
	json: Record<string, any>,
	key: string,
	sendResult: (result?: any) => void,
	sendError: (code: Codes, msg?: string) => void,
}) => void;

const endpoints = {
	GET: {} as Record<string, Handler>,
	POST: {} as Record<string, Handler>,
	DELETE: {} as Record<string, Handler>,
	add: function(method: Method, url: string, handler: Handler) {
		this[method][url] = handler;
	},
};

WebApp.connectHandlers.use('/api-data/', (req: http.IncomingMessage, res: http.ServerResponse /*, next*/) => {
	var body = "";
	const now = new Date;
	let handled = false;
	req.on('data', Meteor.bindEnvironment(data => body += data));

	req.on('end', Meteor.bindEnvironment(() => {
		res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
		const method = req.method as Method | 'OPTIONS';
		console.log('api', method, req.url);

		// respond to a CORS preflight request: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
		if (method=="OPTIONS") {
			// if (req.headers["access-control-request-method"] && req.headers.origin) {
			res.setHeader('Access-Control-Allow-Origin', '*');
			res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
			res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
			res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours
			res.writeHead(Codes.OK);
			res.end();
			console.log('api', 'CORS preflight', req.headers.origin);
			return;
		}

		function sendError(code: Codes, msg?: string) {
			handled = true;
			console.warn('api', code, msg || http.STATUS_CODES[code], method, req.url, req.headers, body);
			res.writeHead(code, { 'Content-Type': 'text/plain'});
			res.end(msg || http.STATUS_CODES[code] || '');
		}

		try {
			// check method
			if (!rateLimits[method] || !req.url) return sendError(Codes.METHOD_NOT_ALLOWED);

			// get api end point and version
			const parts = req.url.substr(1).split('/');
			let reqVersion = version; // default to latest version
			if (/^v\d+$/.test(parts[0])) {
				reqVersion = parseFloat(parts[0].substr(1)) * 1;
				parts.shift();
			}
			req.url = parts.filter(a => a).join('/'); // use filter() to remove trailing slash.

			// check auth
			let code = (req.headers.authorization || (req.headers.Authorization as string) || '');
			const [, userId, key] = code.match(/^(?:basic|Bearer) (\S+):(\S+)$/i) || [];
			// console.log({userId, key});
			if (!userId) return sendError(Codes.UNAUTHORIZED);
			let user = Meteor.users.findOne({
				_id: userId,
				"apiKeys.key": key,
				deleted: {$exists: false},
			}, {fields: {
				apiKeys: {$elemMatch: {key}} as any,
			}});
			if (!user?.apiKeys) return sendError(Codes.UNAUTHORIZED);

			// check rate limit
			const apiKey = user.apiKeys[0];
			if (now.valueOf() - apiKey.touched.valueOf() < rateLimits[method]) {
				sendError(Codes.TOO_MANY_REQUESTS);
				return;
			}

			let json: Record<string, any> = {};
			if (body) {
				try {
					json = req.headers['content-type'] == 'application/x-www-form-urlencoded'
						? qs.parse(body)
						: JSON.parse(body);
				} catch(e) {
					return sendError(Codes.BAD_REQUEST, 'Could not parse body');
				}
			}

			// find the handler method for this url
			let params: Record<string, string> | undefined;
			let handler = endpoints[method][req.url];
			if (!handler) {
				// find a handler matching /:userId/ and replace with /(?<userId>\\w+)/ so we can supply the named parameters to the handler
				const found = Object.keys(endpoints[method]).find(p => {
					if (p.indexOf(":") == -1) return false;
					p = p.replace(/(:\w+)/g, a => `(?<${a.replace(':', '')}>\\w+)`)
					return params = req.url!.match(new RegExp(p))?.groups;
				});
				if (found) {
					handler = endpoints[method][found];
				}
			}
			if (!handler) {
				return sendError(Codes.NOT_FOUND);
			}

			function sendResult(result: any) {
				handled = true;
				// update the touched time for rate limiting
				Meteor.users.update({_id: userId, 'apiKeys.key': key}, {$set: {"apiKeys.$.touched": now}});

				res.writeHead(Codes.OK, { 'Content-Type': 'application/json' });
				const response = JSON.stringify(result)
				res.end(response);
				console.log('api', method, 'v'+reqVersion, req.url, userId, body, response, (new Date().valueOf() - now.valueOf())+'ms');
			}

			// call the handler - each handler must call sendResult or sendError
			handler({user: user!, params, json, key, sendResult, sendError});
			if (!handled) throw new Error("Handler didn't call sendResult or sendError");

		} catch (e) {
			console.error('api', e.message);
			console.log(e.stack);
			sendError(Codes.SERVER_ERROR);
			throw e; // this gets the error logged in MontiAPM
		}
	}));
});

endpoints.add("GET", "users", ({user, sendResult}) => {
	const users = findUsersThisUserIsAllowedToSee(user);
	sendResult(users);
});

endpoints.add("GET", "user/status", ({user, sendResult}) => {
	const userStatus = findUserStatus(user);
	sendResult(userStatus);
});

endpoints.add("GET", "users/:userId/status", ({user, params, sendResult, sendError}) => {
	const userId = params!.userId;
	if (!isUserAllowedToSeeUserId(user, userId)) {
		return sendError(Codes.UNAUTHORIZED, "User exists but this API key doesn't have permission to view this user's status");
	}
	const user = Meteor.users.findOne(userId, {fields: {fields the user is permitted to see}))
	if (!user) {
		return sendError(Codes.NOT_FOUND);
	}
	sendResult(user);
});

endpoints.add("POST", "user/status", ({user, json, sendResult}) => {
	setUserStatus(user, json);
	sendResult({status: 'success'})
});
10 Likes

I agree. I am using WebApp.connectHandlers myself for all of my APIs, and ist just works. I guess the only reason for using a package like simple:rest is that it automatically deploys REST endpoints for all of your existing Meteor methods and pubs, so you don’t have additional boilerplate. But I don’t need this anyways, as I am using my REST endpoints for a different purpose.

Thanks for sharing. Curious, what’s the benefit of using Meteor.bindEnvironment here rather than simply replacing it with async?

I used bindEnvironment to prevent Meteor code must always run in a fiber errors. The function doesn’t need async because it doesn’t await anything - async is handled using fibers with the mongo operations.

1 Like

For many years, Wekan https://wekan.github.io has just used FlowRouter and Jsonroutes: