rexq

Extensible Query for REST API

Usage no npm install needed!

<script type="module">
  import rexq from 'https://cdn.skypack.dev/rexq';
</script>

README

REXQ

Extensible Query for REST API

Installation

npm

npm i rexq --save

yarn

yarn add rexq

Features

  • No schema/typeDefs required
  • Lightweight
  • Easy to use and setup
  • Simple query language
  • Resolver middleware supported (apply for root/resolver level)
  • Modularize supported
  • No dependencies
  • Compatible with many REST libs/frameworks
  • File download/upload supported
  • HTTP Redirect supported
  • Can handle query and mutate data in one request
  • Parallel/Serial executing supported

Getting started

Building rexq app using express

import express from "express";
import rexq from "rexq";

// create express app
const app = express();

// define resolvers
const resolvers = {
  greeting: (_, { name }) => `Hello ${name}!`,
};

// creating query resolver
const { resolve } = rexq(resolvers);

app.get("/", (req, res) =>
  resolve(
    // rexq query
    req.query.query,
    // using request query as rexq query variables
    req.query
  )
    // resolve function returns a promise
    // wait until the promise resolved and send the result to client in JSON format
    .then((result) => res.json(result))
);

app.listen(3000);

Open this url "http://localhost:3000/?query=greeting($name:name)&name=World" in the browser you will got the result below

{ "data": { "greeting": "Hello World!" }, "errors": [] }

Explaining query syntax

In this example we try to call greeting resolver and pass name argument to the resolver

greeting( => resolver name
    $name:name => resolver argument
)

The name argument's value is extracted from req.query

&name=World

The greeting can retrieve name its arguments by destructing second function argument

const greeting = (parent, args) => {
  const { name } = args;
  return `Hello ${name}!`;
};

Rexq Query Syntax

Selector

Rexq parses the query and call all resolvers which matches given selector. The query can contains multiple root selectors, the selectors are separated by comma

Query

    selector1, selector2, selector2

Example

const resolvers = {
  selector1: () => 1,
  selector2: () => 2,
  selectro3: () => 3,
};

const result = {
  data: {
    selector1: 1,
    selector2: 2,
    selector3: 3,
  },
  errors: [],
};

Nested Selector

Query

    search(id, title)

Example

const resolvers = {
  search: () => {
    return [
      { id: 1, title: "result 1", description: "desc 1" },
      { id: 2, title: "result 2", description: "desc 2" },
      { id: 3, title: "result 3", description: "desc 3" },
    ];
  },
};

const result = {
  data: {
    search: [
      // the description field values will be ignored because the client only selects id and title fields
      { id: 1, title: "result 1" },
      { id: 2, title: "result 2" },
      { id: 3, title: "result 3" },
    ],
  },
};

Passing arguments

Query

    search(
        $term: searchTermVariable
    )
    $term => argument name of search resolver
    searchTermVariable => variable name

Example

const resolvers = {
  search: (_, args) => {
    return `Search results of ${args.term}`;
  },
};
const variables = { searchTermVariable: "Something" };
const result = resolve(query, variables);
/*
{
  data: { search: "Search results of Something" },
  errors: [],
}
*/

Argument shorthand

Query

  search($term) => using term variable value for term argument

Middlware

Root level middleware

const resolvers = { hello: () => "Hello World" };
const LogMiddleware = async (next, parent, args, context, info) => {
  console.log("start");
  // dont need to pass (parent, args, context, info) to next middleware
  // rexq fills missing args automatically
  // or you can call next(modifiedParent, modifiedArgs) rexq will fill the rest args (context, info)
  const result = await next();
  console.log("end");
  return result;
};
const { resolve } = rexq(resolvers, {
  // can pass multiple middlewares here  { middleware: [m1, m2, m3] }
  middleware: LogMiddleware,
});

Resolver level middleware

Let's say we have some middlewares for security and validation

import * as yup from "yup";

const auth =
  (user) =>
  // return a resolver
  (parent, args, context, info) =>
  // if the resolver returns a function, that function will retrive next resolver as first argument
  (next) => {
    if (!context.user || (user !== "*" && user !== context.user)) {
      throw new Error("Access Denied");
    }
    // dont need to pass all arguments to next middleware
    // rexq will do that for you
    return next();
  };

const validate = (schema) => (parent, args) => (next) => {
  const transformedArgs = await schema.isValid(args);
  return next(parent, transformedArgs);
};

const searchResolver = (parent, { term }) => {
  return `Search result of ${term}`;
};

const profileResolver = (parent, args, context) => {
  return { name: context.user };
};

const userListResolver = () => {
  return [{ id: 1 }, { id: 2 }, { id: 3 }];
};

const resolvers = {
  search: [
    validate(
      yup.object().shape({
        term: yup.string().required(),
      })
    ),
    searchResolver,
  ],
  // this resolver can be called by any users
  profile: [auth("*"), profileResolver],
  // this resolver can be called by admin users only
  userList: [auth("admin"), userListResolver],
};

Indicate query executing mode

By default, rexq executes query in parallel mode, but users can force query executing in Serial mode

Query

  resolver1,resolver2,resolver3

Example

const resolvers = {
  resolver1: async () => {
    await delay(1000);
  },
  resolver2: async () => {
    await delay(200);
  },
  resolver3: async () => {
    await delay(500);
  },
};
resolve(query, { $execute: "serial" });
with $execute = "serial"
=> resolver1, resolver2, resolver3

without $execute = "serial" or $execute = "parallel"
=> resolver2, resolver3, resolver1

Using context for uploading/downloading file and redirecting

import fileUpload from "express-fileupload";

// app is an express app object
const resolvers = {
  downloadReport: (parent, args, { res }) => {
    res.download(filePath, fileName);
  },
  // the query might be "uploadPhoto($photo)"
  // client user must submit the photo with name=photo
  uploadPhoto: (parent, { photo }) => {
    // the photo now is file object
    console.log(photo);
  },
  redirect: (parent, { url }, { res }) => res.redirect(url),
};
const { resolve } = rexq(resolvers, {
  // context can be object or factory
  // in this case, we retrive $res and $req from variables and assign these objects to context object
  context: ({ $res, $req }) => ({
    res: $res,
    req: $req,
    otherContextProp: null,
  }),
});
app.use(fileUpload());
app.use("/", async (req, res) => {
  const result = await resolve(
    req.query.query || req.body.query,
    // passing request and response objects to context factory
    {
      // using query, body, files as query variables
      ...req.query,
      ...req.body,
      ...req.files,
      $res: res,
      $req: req,
    }
  );
  setTimeout(() => {
    // do nothing if response already sent headers to client
    // this means there is a resolver triggered file download/redirect
    if (res.headersSent) return;
    res.json(result);
  });
});

Modularize

// modules/user/index.js
module.exports = {
  me: [
    // specific result type is User
    "User",
    () => ({ name: "my user name" }),
  ],
  // user type resolver
  User: {
    photo: () => {},
  },
  userList: () => {},
};

// modules/post/index.js
module.exports = {
  searchPost: () => {},
  // user type resolver
  User: {
    // return posts of current user
    posts: (user) => [1, 2, 3],
  },
};

// index.js
import user from "./modules/user";
import post from "./modules/user";

// create rexq with user module only
rexq([user]);
resolve("me(name,posts)"); // { data: { name: "my user name", post: null } }

// create rexq with user and post modules
rexq([user, post]);
resolve("me(name,posts)"); // { data: { name: "my user name", post: [1, 2, 3] } }

// if the post module specified its required modules
module.exports = {
  // require accepts module or array of module
  require: user,
  // resolvers...
};
// we use post module only, user module will import later automatically
rexq([post]);

Using DataLoader to optimize object loading

import express from "express";
import rexq from "rexq";
import DataLoader from "dataloader";

// create express app
const app = express();

const resolvers = {
  userList: (root, { top }, context) =>
    new Array(parseInt(top, 10)).fill().map((_, index) =>
      // call data loader
      context.users.load(index)
    ),
};

const { resolve } = rexq(resolvers, {
  context: {
    users: new DataLoader(async (ids) => {
      console.log("loading... " + ids.join(","));
      return ids.map((id) => ({ id, name: "Name of " + id }));
    }),
  },
});

app.get("/", async (req, res) => {
  const result = await resolve(req.query.query, req.query);
  res.json(result);
});

app.listen(3000);

If you open the URL below in the browser

http://localhost:3000?query=userList:top3( $top:top3, id, name ), userList:top5( $top:top5, id, name )&top3=3&top5=5

You will get the result look like this

{
  "data": {
    "top3": [
      { "id": 0, "name": "Name of 0" },
      { "id": 1, "name": "Name of 1" },
      { "id": 2, "name": "Name of 2" }
    ],
    "top5": [
      { "id": 0, "name": "Name of 0" },
      { "id": 1, "name": "Name of 1" },
      { "id": 2, "name": "Name of 2" },
      { "id": 3, "name": "Name of 3" },
      { "id": 4, "name": "Name of 4" }
    ]
  },
  "errors": []
}

And the output console looks like this

loading... 0,1,2,3,4 # just one request to load all data