DEV Community

Cover image for The Lightweight Alternative to GraphQL, Resolvers Instead of Endpoints
Anatoly Tarnavsky
Anatoly Tarnavsky

Posted on

The Lightweight Alternative to GraphQL, Resolvers Instead of Endpoints

A few months ago, I began working on a personal project that involved client-server communication. To take advantage of this, I tried using GraphQL. While I generally liked the approach of having a single API endpoint and resolvers, the overhead of setting up and maintaining a GraphQL server was too much for a one-person operation like mine.

That's when I came up with the idea of replacing GraphQL with a more lightweight and easier-to-manage approach. My solution, which I'll describe in more detail below, has proven to be a game-changer for me and has significantly improved my efficiency and speed of development.

Unlike GraphQL, my solution does not require the overhead of setting up and maintaining multiple schemas which need to be set for the client and server. Instead, it defines resolver functions to provide flexible alternative. In this article, I'll walk you through the details of my solution and the benefits it has brought to my pet project."

Step 1 - Creating Basic Express App

At the beginning let's define a basic expressjs application which will have a single endpoint.

app.js

import express from 'express';
import apiRouter from "./api-route.js";
import cors from "cors";

const app = express();
app.use(express.json());
app.use('/api', apiRouter);
app.use('cors');
const port = process.env.PORT || 5000;
const server = app.listen(port, () => {
    console.log(`Listening to port ${port}`);
});
Enter fullscreen mode Exit fullscreen mode

and

api-route.js

import express from "express";
const router = express.Router();

router.get('*', async (req, res, next) => {
    const response = {
        text: 'Hello World!'
    };
    return res.json(response);
})

export default router;
Enter fullscreen mode Exit fullscreen mode

This is simplest express app possible, every GET request to localhost:5000/api/* will be responded with body:

{
  "text": "Hello World"
}
Enter fullscreen mode Exit fullscreen mode

You can find this code at the following GitHub repository in branch step-1: anatoly314/server-boilerplate-article

Step 2 - A Better Approach to API Development: Resolvers Provider

To add more endpoints to our app, we could follow the traditional approach of grouping all the endpoints into route files and importing them one by one.
However, I'd like to suggest a new way that simplifies this process.
Instead, we can create an api folder and define a new file within it called resolvers-provider.js, which will contain the following content:

resolvers-provider.js

import glob from 'glob';

const __registeredResolvers = {};

export const registerResolver = (resolverName, resolver) => {
    if (__registeredResolvers[resolverName]) {
        throw new Error(`Resolver with ${resolverName} name already exists`);
    }
    __registeredResolvers[resolverName] = resolver;
}

export const getResolver = resolverName => {
    const resolver = __registeredResolvers[resolverName];
    if (!resolver) {
        throw new Error(`Resolver with ${resolverName} wasn't registered`);
    }
    return resolver;
}

export const registerResolvers = async directoryName => {
    const files = glob.sync(directoryName + '/**/*.js', {
        absolute: true
    });
    const resolverFiles = files.filter(file => file.endsWith('-resolvers.js'));
    for (const resolverFile of resolverFiles) {
        const module = await import(resolverFile);
        Object.keys(module).forEach(key => {
            const resolver = module[key];
            registerResolver(resolver);
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

To invoke resolver-provider.js, simply add the following code to your api-route.js file:"

...
const __dirname = dirname(fileURLToPath(import.meta.url));
await registerResolvers(__dirname);
...
Enter fullscreen mode Exit fullscreen mode

When you invoke registerResolver, it will search for all files that end with -resolvers.js within the api directory and its subdirectories.
It will then register all of the exported functions from those files as resolvers.

Next, add a new file called users-resolvers.js to the api/users directory, and include the following content:

users-resolvers.js

export const getUsersByName = ({body}) => {
    const {name} = body;
    const allUsers = [{
        id: 1,
        name: 'Anatoly',
        age: 42
    }, {
        id: 2,
        name: 'Yulia',
        age: 34
    }, {
        id: 3,
        name: 'John',
        age: 55
    }];

    const filteredUsers = allUsers.filter(user => user.name === name);
    return filteredUsers;
}
Enter fullscreen mode Exit fullscreen mode

When you run your server, all exported functions from files that match the pattern api/*-resolvers.js will automatically be registered as resolvers.

To use the registered resolvers, there's one last step we need to take. We need to replace the existing GET endpoint in api-route.js with the following POST endpoint:

router.post('*', async (req, res, next) => {
    try {
        const body = req.body;
        const authorizedUser = req.header('Authorization');
        const resolverName = req.url.substring(1);
        const resolver = getResolver(resolverName);
        const result = await resolver({body, authorizedUser});
        return res.json(result);
    } catch (err) {
        if (err.message === 'Unauthenticated') {
            res.status(401);
            res.send(err.message);
        } else {
            res.status(500);
            res.send("Server error");
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

To invoke our resolver, we can make a POST request to the following URL template:
localhost:5000/api/<RESOLVER_NAME>.
For example, to invoke the getUsersByName resolver, we would make a request to
localhost:5000/api/getUsersByName. The body of the request should contain
the necessary input data for the resolver to process.

{
    "name": "Anatoly"
}
Enter fullscreen mode Exit fullscreen mode

When our server receives this request, it will invoke the resolver
with the name getUsersByName that's defined in the users-resolvers.js file
located in the api/users directory.
The contents of the body will be passed to the resolver as input.

So what have we gained from this approach? Although there was some effort required at the beginning to set up the resolvers provider, the benefits are significant. Now, if we need to add another endpoint, instead of defining the entire endpoint code in the old way, we can simply add a new function to the existing resolvers provider or create a new resolvers provider. This results in less code, fewer bugs, and less time spent on development overall.

Step 3 - Secure Your Resolvers with JavaScript Decorators

With the boilerplate we've created, we've replaced the traditional
ExpressJS API endpoints with a Resolvers approach that simplifies our
codebase and makes developers' lives much easier. However, this
approach lacks middleware support, which means we need to find an
alternative way to handle tasks like authentication and authorization
that would normally be done with middleware. One option is to use
JavaScript decorators. We will create a JavaScript decorator that
requires authentication for requests to a resolver.
One possible implementation is to check that the Authentication
header of the request contains the word admin.

Javascript Decorators considered safe to use now that they're at stage 3 proposal > Stage 3 is the "Candidate" stage. At this stage, the proposal has been accepted by TC39 as a potential addition to the language and has been approved for inclusion in a future ECMAScript version. The proposal is considered mostly complete and has been reviewed and refined by TC39 members, but it may still undergo some changes based on feedback from implementers and developers. Proposals at Stage 3 are generally considered safe to use and are available in some experimental JavaScript runtimes, but they are not yet officially part of the language. For more details look here: [tc39/proposal-decorators](https://github.com/tc39/proposal-decorators)

Since JavaScript decorators are still considered an experimental feature
and Node.js currently does not have built-in support for them,
we will need to use Babel to transpile our code.
To do this, we will create a .babelrc file in the root of our project,
which will allow us to configure Babel and use its transpiling capabilities:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false
      }
    ]
  ],
  "env": {
    "development": {
      "sourceMaps": "inline",
      "retainLines": true
    }
  },
  "plugins": [
    "@babel/plugin-syntax-top-level-await",
    ["@babel/plugin-proposal-decorators", {
      "version": "2022-03"
    }]
  ]
}
Enter fullscreen mode Exit fullscreen mode

To implement the necessary changes, you should first include the code block below in the script section of your package.json file:

{
    "build": "babel src -d dist",
    "start": "npm run build && node dist/app.js",
    "start-nodemon": "nodemon --watch './src/**/*.js' --exec npm run start"
}
Enter fullscreen mode Exit fullscreen mode

Then, execute the following command to install the required devDependencies:

npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/plugin-proposal-decorators

Once this is done, you can start the app by running npm run start-nodemon.
Babel will transpile the code in the src folder to a new dist folder,
execute it, and automatically recompile it upon any changes made
to the project.
Before we can start using JavaScript decorators, we need to make some
changes to our code. Specifically, the following files need to be modified:
src/api/users/users-resolvers.js and src/api/resolvers-provider.js

As per the proposal,
decorators can be used with Classes and their elements such as fields,
methods, and accessors. To leverage this feature, we need to ensure that
our resolvers provider is an instance of a Class.
Therefore, we will modify the code in src/api/users/users-resolvers.js
to the following:

class UsersResolvers {
    getUsersByName ({body}) {
        const {name} = body;
        const allUsers = [{
            id: 1,
            name: 'Anatoly',
            age: 42
        }, {
            id: 2,
            name: 'Yulia',
            age: 34
        }, {
            id: 3,
            name: 'John',
            age: 55
        }];

        const filteredUsers = allUsers.filter(user => user.name === name);
        return filteredUsers;
    }
}

export default UsersResolvers;
Enter fullscreen mode Exit fullscreen mode

We also need to make changes to our src/api/resolvers-provider.js
to allow it to create instances of a class and retrieve references to
its class methods:

...
for (const resolverFile of resolverFiles) {
    const module = await import(resolverFile);
    const instantiatedResolver = new module.default();
    Object.getOwnPropertyNames(module.default.prototype).forEach(key => {
        if (key !== 'constructor') {
            const resolver = instantiatedResolver[key];
            registerResolver(key, resolver.bind(instantiatedResolver.self));
        }
    })
}
...
Enter fullscreen mode Exit fullscreen mode

Now that all the preparations have been completed,
we can start writing our first JavaScript decorator.
Let's create a new folder called src/decorators and add a file to it
called auth-decorator.js:

auth-decorator.js

export function authRequired(userType){
    return function (value, { kind, name }) {
        if (kind === "method" || kind === "getter" || kind === "setter") {
            return function (...args) {
                const {authorizedUser} = args[0];
                if (authorizedUser !== userType) {
                    throw new Error("Unauthenticated");
                }
                const ret = value.call(this, ...args);
                return ret;
            };
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And we can apply it to the getUsersByName
method in users-resolvers.js as follows:

...
@authRequired("admin")
getUsersByName ({body}) {
 ...
}
...
Enter fullscreen mode Exit fullscreen mode

By adding the authRequired decorator to a resolver function, such as
getUsersByName in users-resolvers.js, requests to that resolver will
require authentication. This means that the request must contain a value
of admin in the Authentication header, or else the request will be
rejected with a 401 error indicating that it is unauthenticated.
This is just a basic example of what can be done with decorators.
In general, anything that can be achieved with middleware can be
achieved with decorators, offering great flexibility in the way you
handle requests in your application.

Step 4 - Creating React Hook which will seamlessly invoke resolvers

We have implemented server side approach which replaces regular API with endpoints
by approach with resolvers, let's create ReactJS hook which will call
to these resolvers seamlessly:

import axios from "axios";
import {useState} from "react";

const RESOLVER_API_URL = "http://localhost:4000/api";

export const useResolver = (resolverName) => {
    const [result, setResult] = useState(null);
    const [loading, setLoading] = useState(null);

    const invokeResolver = async data => {
        setLoading(true);
        try {
            const url = `${RESOLVER_API_URL}/${resolverName}`;
            const config = {
                url,
                headers: {
                    Authorization: 'admin'
                },
                method: 'POST',
                data: data
            }
            const response = await axios(config);
            setResult(response.data);
            return response.data;
        } catch (err) {
            const errText = `Error invoking resolver ${resolverName}`;
            console.log(errText, err);
            throw new Error(errText);
        } finally {
            setLoading(false);
        }
    }

    return [invokeResolver, result, loading];
}
Enter fullscreen mode Exit fullscreen mode

A call to any resolver can now be as simple as the following:

const [getUsersByName, users, loadingUsers] = useResolver("getUsersByName");
Enter fullscreen mode Exit fullscreen mode

The getUsersByName function can now be called using a simple syntax.
The results of the invocation are stored in the users property,
while the loadingUsers boolean flag indicates the current invocation state.

Here's the complete example of a React component that uses this hook:

import React, {useState} from "react";
import {useResolver} from "../../resolvers/resolvers-hook";

const Users = () => {

    const [getUsersByName, users, loadingUsers] = useResolver("getUsersByName");
    const [inputValue, setInputValue] = useState('');
    const handleInputChange = (event) => {
        setInputValue(event.target.value);
    }

    return (
        <div>
            {
                loadingUsers && <div>LOADING</div>
            }
            Users with the name:
            <input
                type="text"
                value={inputValue}
                onChange={handleInputChange}
            />
            <button onClick={() => getUsersByName({
                name: inputValue
            })}>Load Users</button>
            <pre>
                {JSON.stringify(users, null, 2)}
            </pre>
        </div>
    )
}

export default Users;
Enter fullscreen mode Exit fullscreen mode

Credits:

  • ChatGPT, I would like to express my gratitude to ChatGPT for the incredible help provided to me. The chatbot was instrumental in guiding me through various questions and tasks, and helped me to improve my English writing skills. The examples and explanations given were invaluable in understanding complex concepts. I would like to thank the entire team behind ChatGPT for creating such an amazing resource.
  • GraphQL developer community, I would like to acknowledge the contributions of the GraphQL developer community, including Facebook and Apollo. Much of what I have accomplished in this project has been inspired by their codebase."

Top comments (0)