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'})
});
12 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:

Question about APIs and Meteor.

We’re looking to create an API for accessing/extending our existing Meteor app. It will be used alongside the existing app.

It will need to work with the existing data we have in the app’s Mongo database and it will need to do user authentication. But beyond that, it doesn’t need to have anything else from Meteor.

We want the API to be isolated as its own service. It won’t be written into the same Meteor app, running on the same containers. So no interest in all the Meteor packages that make/help with APIs within a Meteor app. As this will just be the raw API code.

What recommendations is anyone using to write your own API service, preferably in JavaScript and/or Node.js. Would this be something we could do in AWS Lamda? As some of the endpoints will do some heavier data crunching and return an object of computed data. What about other PaaS-style API runners? Does stuff like that exist? How would you do user-authentication - have an API key that’s added to the Meteor user account and check against it?

It sounds like you need a separated app which works with Meteor’s database.

Hi @elovross - this is just a suggestion, but in my mind putting all of this API into a separate codebase means duplicating your database access, duplicating your model definitions and authenication - a lot of repeated code to maintain.

In my experience, my simple API routines above are fairly efficient and since they’re running in the Meteor backend they already have access to the database, including the user’s table to check authentication (via an Api key).

Most API endpoints are simple CRUD operations so don’t slow anything down more than an equivalent Meteor method would.

For your heavier number crunching endpoints I would either:

  1. Run them in the Meteor backend but with lots of calls to Meteor._sleepForMs() to give the thread plenty of time to respond to other events. If the number crunching involves regular db operations then it will be deferring the thread while it waits anyway. This could slow your number crunching down though.
  2. Put just your number crunching routines in a Lamda, possibly with no database access. Route the API via your Meteor backend as normal for authentication, Meteor can gather the required data from your database, send it to the Lambda and wait for the response, then forward the response to the user. Obviously this requires your number crunching to not require additional db access once it has the initial data set. If it does, then it may be easier to give it access to the db. But at least you can perform the authentication on the Meteor side. Your Lamda would be locked down to only accept requests from your Meteor server, ideally with a secret key as well.

Another solution, if you still wanted to host the entire API on a separate server but without duplicating code, is to write the API and routines within the same Meteor code base, but deploy a separate instance to the API server with the frontend disabled via an environment variable or something.

1 Like

Same idea with @wildhart. Authentication and DB models and validations should pass through the same code. In our case, all are Meteor apps using shared subrepos for those 3 (authentication, DB models and validations).