Skip to content

Instantly share code, notes, and snippets.

@josephwegner
Last active December 23, 2015 05:39
Show Gist options
  • Save josephwegner/6588788 to your computer and use it in GitHub Desktop.
Save josephwegner/6588788 to your computer and use it in GitHub Desktop.
Simple API Tutorial Post

APIs are commonly a pain to build - there's all kinds of moving pieces: routing, data parsing, serialization, database interactions, etc. For years I've struggled with writing web APIs in Node.js - there's just so much complexity required in an API boilerplate. That's why I wrote Simple API. Simple API is an incredibly simple module for writing web-based APIs in Node.js

I often hear people say that they've written a library that makes something "simple", but in reality they've just made it a different kind of complex. I'm going to prove that's not the case with Simple API. This tutorial is going to teach you to write an API for a ToDo list, where the entire project is less than 300 lines of code. That's the entire project - the API related files are less than 200 lines of code.

The examples will all be written in CoffeeScript, but can easily be compiled to Javascript

Setup

Of course, every module requires a little bit of time to install and setup the boilerplate. That's where we'll start. First off, you need to install Simple API. This is very easy using NPM.

 npm install simple-api

See, that was simple! Now we need to include the module and run some simple setup code. Put the code below into your index.coffee file.

#Load the Simple API Module
api = require 'simple-api'

#Create API Server
v0 = new api
	prefix: ["api", "v0"]
	host: "localhost"
	port: "3333"
	logLevel: 0

Pretty simple, but let's talk through each of those lines.

api = require 'simple-api': This loads the Simple API module. The api object here is a class waiting to be instantiated.
v0 = new api: This instantiates the API object. I'm calling this API 'v0', so I named the variable that. Everything after this line is in the config object for the API
prefix: ["api", "v0"]: This is the path that will prefix all API calls. Each element in the array is a part of the URL. This would match "http://host/api/v0". You can also use a string to configure the prefix, like "api/v0".
host: "localhost": This is the host that the API should be listening on. If you want to listen to all hosts, set this to null
port: 3333: This is the port that the API will be listening on. If you want to listen on port 80, set this to null.
logLevel: 0: Simple API has 5 log levels. Lower numbers mean less logs.

There you go! If you run coffee index you will see the server startup, and Simple API will output a log that it's listening for requests.

Controllers

Unfortunately, as you'll notice, your API doesn't actually do anything at this point. If you make any requests to it you will get a 404 Not Found response. That's because you don't have any Controllers. Controllers are a pretty broad way to represent any object that your API can talk about. You might have a Controller for a user, a task, a tweet - any "Object". For the ToDo app, we will have a single controller for tasks.

I prefer to organize my API by creating an api folder, with a version folder, and then place controllers and models folders inside of that. So my controllers can be found in ./api/v0/controllers.

Controllers have three different things that need to be configured. Routes tell Simple API which URLs are related to the controller. Routes also tell Simple API which part of each URL should be parsed as a parameter. Actions are the meat of the controller - they decide what happens for each API request. Helpers are functions that may need to be called from multiple actions. There's also an options key, but for now it is not used for anything (but are accessable from actions, if you want them).

I like to start my controllers by writing the routes. This allows me to think in an abstract way about how I want people to interact with my API, rather than getting distracted by the technical implementation for each action. First, though, we need to write the overall structure of a controller definition:

TasksController =
	options: {} #This doesn't get used
	routes: {}
	actions: {}
	helpers:  {}

Now we can get started writing the routes. It's important to name your routes descriptively, because they will need to match the name of your actions. When you come back to maintain your code, the name of the routes and actions are the easiest way to find the section you're looking for.

TasksController =
	options: {}
	routes:
		getAllTasks: 
			method: "GET"
			path: []

This is the most simple type of route you can get. It uses the GET method, and has no path. That means it will respond from /api/v0/tasks. It is named getAllTasks, so when an API call matches this route it will call the getAllTasks action. You'll notice that I'm using an array for the path definition here, similar to the API prefix definition; you can use a string instead.

getTask:
	method: "GET"
	path: ["*identifier"]

The getTask route is similar to getAllTasks, but adds a little spice. This route will allow the user to request a single task by ID. You can see that I've defined a single piece to the path: *identifier. The * tells Simple API to match any alphanumeric string for that portion of the URL. There are other match types for alpha-only, numeric-only, or RegExp matching. You can read about those in the docs. Whatever matches for this first portion of the URL will be stored in a variable called identifier. You could name that variable anything, but identifier makes the most sense here.

getCategoryTasks:
	method: "GET"
	path: ["category", "*category"]

getCategoryTasks again builds upon the previous route. This route has a static string match of category in the front, and then another alphanumeric parameter match on the end. That means it will match URLs like http://host/api/v0/category/4lph4Num3r1C. The matched string in the second part of the URL will be stored in the category variable.

createTask: 
	method: "POST"
	path: []

The createTask route looks pretty basic, but it has one important change. The HTTP method is now POST. You can define any HTTP method you want with Simple API, but there are four commonly used methods: POST is for Creating data, GET is for Reading data, PUT is for Updating data, and DELETE is for Deleting data. These can easily be remembered as CRUD. The rest of the routes will build upon the ideas that you already know.

updateTask:
	method: "PUT"
	path: ["*identifier"]

deleteTask:
	method: "DELETE"
	path: ["*identifier"]

completeTask:
	method: "PUT"
	path: ["*identifier", "complete"]

Great! Now we've got all of our routes defined, and can move on to the actual action code. As I've mentioned, the names of your actions need to be the same as your route names. This is how Simple API knows which action to call for which route. Each action receives three parameters: req, res, and params (naturally, you can name them anything). req and res are the HTTP request and response objects, respectively. The params object is a key-value Object of all the matches from your route. For instance, getCategoryTasks will have params.category set from the second key in the URL. These keys will always be defined, otherwise the route would not have gotten called.

TasksController =
	options: {}
	routes: #Removed to save space
	actions:
		getAllTasks: (req, res, params) ->
			Tasks = mongoose.model "Tasks" 

			Tasks.getAll (err, allTasks) =>
				if err
					console.log err
					@responses.internalError res
				else
					@responses.respond res, allTasks

Most of the code in that action is interacting with the Model, which is not important for this tutorial. Simple API is still early in its development, so it does not officially support any structured form of models. It will in the future, but for now you have to write your own models. The important parts of this action are those two @responses lines. These are convenience functions that have been defined so you don't have to write your own response handling. There are five convenience functions: internalError, notAuth, notAvailable, redirect, and respond. Each of them requires res as the first parameter, and accepts a response body as the second parameter. This response can either be a string or an Object that will be turned into JSON.

getTask: (req, res, params) ->
	Tasks = mongoose.model "Tasks"

	Tasks.getById params.identifier, (err, task) =>
		if err
			console.log err
			@responses.internalError res
		else
			if task
				@responses.respond res, task
			else
				@responses.notAvailable res

This action is very similar to the getAll action, but it gets the task ID from params.identifier. As I mentioned, you don't need to check if the parameter exists. The action could not have been called if the parameters didn't exist. The rest of the actions will follow a similar format to these past two actions.

getCategoryTasks: (req, res, params) ->
	Tasks = mongoose.model "Tasks"

	Tasks.getAllFromCategory params.category, (err, catTasks) =>
		if err
			console.log err
			@responses.internalError res
		else
			@responses.respond res, catTasks

createTask: (req, res, params) ->
	Tasks = mongoose.model "Tasks"

	data = ""

	req.on 'data', (chunk) ->
		data += chunk

	req.on 'end', () =>
		#You should do this in a try/catch, but I'm leaving it simple for the example
		taskInfo = JSON.parse data
		Tasks.create taskInfo, (err, task) =>
			if err
				console.log err
				@responses.internalError res
			else
				@responses.respond res, task

updateTask: (req, res, params) ->

	Tasks = mongoose.model "Tasks"

	data = ""

	req.on 'data', (chunk) ->
		data += chunk

	req.on 'end', () =>
		#You should do this in a try/catch, but I'm leaving it simple for the example
		taskInfo = JSON.parse data
		Tasks.updateById params.identifier, taskInfo, (err, task) =>
			if err
				console.log err
				@responses.internalError res
			else
				@responses.respond res, task

deleteTask: (req, res, params) ->
	Tasks = mongoose.model "Tasks"

	Tasks.deleteById params.identifier, (err) =>
		if err
			console.log err
			@responses.internalError res
		else
			@responses.respond res

completeTask: (req, res, params) ->
	Tasks = mongoose.model "Tasks"

	Tasks.getById params.identifier, (err, task) =>
		if err
			console.log err
			@responses.internalError res
		else
			if task.completed
				#If the task is already completed, just return a 200 because it's already done
				@responses.respond res
			else
				task.completed = true
				task.save (err) =>
					if err
						@responses.internalError res
					else
						@responses.respond res

The only interesting difference here is in the completeTask action. You can see there that I'm doing a bit more logic than in the other controllers. I'm first fetching the task from my model, then checking to see if it is already completed. If it is, I respond immediately - otherwise I update the task and send the response.

If you were to run this project right now, you might notice one very major bug. I am using MongoDB for the database, and using Mongo's built-in ObjectId type as the identifier. ObjectIds are 24 character alphanumeric strings, but they only use capital letters between A-F. Our current controller actions will accept any alphanumeric string as an ID, but Mongo throws an error if we send an incompatible string for an id. We could copy+paste some code into every action to make sure the IDs are valid, but it would probably be better to do that in a separate function. In order to keep your code organized, the cleanest place to put this is in the helpers object.

TasksController =
	options: {}
	routes: #Removed to save space
	actions: #Removed to save space
	helpers: 
		isValidID: (id) ->
			id.match /^[0-9a-fA-F]{24}$/

The isValidID helper takes an id as a parameter, compares it to a RegExp written to match MongoDB ObjectIDs, and returns a boolean of their match state. When you're in a controller, this refers to the controller, so the helpers can be accessed in this.helpers. So isValidID is access by this.helpers.isValidID(id);. We will need to update each of the actions that reference a task by ID with this helper.

getTask: (req, res, params) ->

	if @helpers.isValidID params.identifier
		Tasks = mongoose.model "Tasks"

		Tasks.getById params.identifier, (err, task) =>
			if err
				console.log err
				@responses.internalError res
			else
				if task
					@responses.respond res, task
				else
					@responses.notAvailable res
	else
		@responses.notAvailable res

I've updated the getTask action so that it will continue the normal code flow if the ObjectID is valid, otherwise it will send a notAvailable response. The changes to the rest of the actions will be identical to this one, so I will leave them out. You can view the updated actions in the repository.

It's finished! If you run your project using coffee index, you will have a fully functioning ToDo list API. I've written some cURL commands you can use to test the API.

#To create a new task
curl -X POST -d '{"name":"Create Example Project","category":"Simple Blog Post"}'

#To update an existing task (get the ID from the GET endpoints)
curl -X PUT -d '{"name":"Create An Example Project"}' http://localhost:3333/api/v0/tasks/52347045a543628e13000001

#To complete an existing task (get the ID from the GET endpoints)
curl -X PUT http://localhost:3333/api/v0/tasks/52347193bf6400d713000003/complete

#To delete an existing task (get the ID from the GET endpoints)
curl -X DELETE  http://localhost:3333/api/v0/tasks/52347154bf6400d713000001

And here are the important GET URLs:

Get all tasks: http://localhost:3333/api/v0/tasks
Get a single task by ID: http://localhost:3333/api/v0/tasks/52347154bf6400d713000001
Get all tasks in a category: http://localhost:333/api/v0/tasks/category/somecategory

I hope you've enjoyed learning how to use Simple API. As I've mentioned a few times here and many times in the docs, Simple API is a very new library. It is being used in production in a few places, but it is far from done. I would greatly appreciate feedback, bug reports, new ideas, or any comments. Simple API will evolve with its users, so feel free to tell me how you would like it to work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment