Skip to content

Instantly share code, notes, and snippets.

@romgrk
Last active October 11, 2021 22:20
Show Gist options
  • Save romgrk/f9e31680b82ec707985eee63c2af28d3 to your computer and use it in GitHub Desktop.
Save romgrk/f9e31680b82ec707985eee63c2af28d3 to your computer and use it in GitHub Desktop.
REST considered harmful

REST considered harmful

Albeit the title might (probably) be a bit inflammatory, I've come to feel like REST is a bad solution for the problem that it solves.

The thing with REST, is that at its core, it's basically a RPC (Remote Procedure Call) that we've been trying to cram in the HTTP protocol. There is no good reason to do that. HTTP wasn't made for this, and we should instead be abstracting that layer away instead of trying to use it for purposes it isn't meant to fulfill.

My main issue with REST is that there are too many ways to pass arguments! Here is the list:

  • HTTP method: GET, POST, PUT, PATCH, DELETE
  • Pathname parts: /user/:id
  • Query parameters: /search?category=xxxxx
  • JSON body payload: { data: { ... } }
  • Multipart form data body payload: text fields & files (text or binary)
  • Headers: Stripe-Version: 2020-08-27

This means we end up needing for every route to create a handler function that grabs the arguments, wherever they come from, and pass them to our actual function. In express/nodejs, it would be something like:

// frontend
fetch(`/user/${id}/upload?tag=${tag}`, { method: 'POST', body: file })

// backend
const User = require('models/user')

router.post('/user/:id/upload', checkPermissions, (req, res) => {
  const id = req.params.id
  const tag = req.query.tag
  const file = req.files[0]
  User.upload(id, tag, file)
})

This is so verbose! After all, what we really want is simply to be able to call a backend function from the frontend. An ideal solution would be completely declarative and would hide those pesky transport layer details:

// frontend
User.upload(id, tag, file)

// backend
class User {
  upload(id, tag, file) { /* ... */ }
}

api.expose(User, {
  upload: checkPermissions,
})

Doesn't this look much clearer? Gone are the implementation details, the programmer is here able to focus fully on business logic and make sure that important details such as permissions & security are handled as they should.

An HTTP RPC

I've been implementing an HTTP RPC centered around those principles in my latest project. I haven't open-sourced it yet because it's embedded in a closed-source project, but I may do so if there is demand. However, what I'm proposing is so simple that I'd rather explain it.

The setup is simple: there is a single API endpoint:

app.use('/api', apiEndpoint)

This endpoint is responsible for receiving which method is being called and with which arguments. How those arguments are passed is, in the end, not really relevant, the transport just needs to make sure that they are carried correctly. In my implementation, everything is passed as multipart form data in a POST request. This is simply to ensure that anything can be passed: primitive types, objects and files.

fetch('/api', {
  method: 'POST',
  body: createFormData({
    method: 'User.upload',
    0: userId /* number */,
    1: tag /* string */,
    2: file /* File object */,
  })
})

The API endpoint is the responsible of passing those arguments to the chosen method:

User.upload(id, tag, file)

Simple and easy. Once our transport layer is implemented, we can then create our API very declaratively on the server:

api.expose(User, {
  create: permissions.user(CREATE),
  get:    permissions.user(READ),
  update: permissions.user(WRITE),
  delete: permissions.user(DELETE),
})
api.expose(Group, {
  create: permissions.group(CREATE),
  get:    permissions.group(READ),
  update: permissions.group(WRITE),
  delete: permissions.group(DELETE),
})
api.expose( /* etc */)

For a nodejs express application, this means we can get rid of the whole router/ directory! The benefits of having such a clear and declarative API go also a bit further, as I've also been using the api.expose method to auto-generate the front-end code to call the backend functions:

// Auto-generated code
const api = {
  group: {
    create: (values, options) => apiCall('group.create', [values, options]),
    update: (id, fields) => apiCall('group.update', [id, fields]),
    destroy: (id) => apiCall('group.destroy', [id]),
    get: (id) => apiCall('group.get', [id]),
  },
  user: {
    create: (values, options) => apiCall('user.create', [values, options]),
    update: (id, fields) => apiCall('user.update', [id, fields]),
    destroy: (id) => apiCall('group.destroy', [id]),
    get: (id) => apiCall('user.get', [id]),
  },
  /* etc */
}

This allows proper auto-completion, and a bit more instrumentation could also add typings for even more security.

That's all, if I had one sentence to sum up this post it would be: remember that REST is just an RPC.

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