- Change the way you think about working with API Data
- Use with Node
- Defining Endpoints
- Return Data
- Supported SQL Features
- Selects with Joins
- Selecting object sets
- Column Aliasing
- JSON Property Accessors
- Path Maps: Nested Routes, and Aliasing
- Query Parameters
- Headers and Authentication
- Cockatiel Policies
- Request Interception
- Debug mode
- Cancellation
- Data Extraction
- Pagination Tokens
- Performance
- Addons
All your Isomporphic (node or browser) API data needs in one, simple query:
import { exec, setConfig } from "querty";
const config = {
apiUrl: "https://my-api.com"
};
setConfig(config);
async function getData(id) {
const data = await exec(`SELECT users.name, body, username FROM users, comments WHERE users.id = ${id}`);
console.log(data);
}
getData(12);NOTE:
Querty is designed to have a minimum of functionalty out of the box, focusing on its core value propositions, and a few standard features. However, it is also designed to be extensible. This way, you have more control over how you will use Querty. This also helps to keep Querty small.
For example, by default, Querty only works in the Browser. If you need to use it in Node (or have an Isomorphic http client), you can do so quite easily. It takes only two steps. See Use with Node for more information.
Other options for extending Querty are detailed below (see Addons).
Finally, please note that Querty currently only supports working with JSON data.
There are a ton of really great http clients out there, like:
- axios
- superagent
- apisauce
- needle
- etc.
And, don't forget fetch.
Why, yet, another HTTP client? Because it's time for a change in the way we think about REST data access. Using a standard HTTP client, getting data from a REST API usually looks something like this example:
const response = await axios.get(baseURL);
updateStateSomehow(response.data);If all you need is two or three props from this endpoint, then your code could like this:
const response = await axios.get(baseURL);
updateStateSomehow(
response.data.map(({ name, age, dob }) => ({
name,
age,
dob
}))
);If you have to get data from several endpoints, and combine them, it can look something like this:
const response = await axios.get(baseURL);
const user = response.data;
const post = await axios.get(`${baseURL}/${user.id}`);
updateStateSomehow(post.data);Querty aims to change the way you think about working with REST API data.
What if you could, similar to GraphQL:
- work with only the data you needed?
- retrieve and manage data from multiple endpoints in one statement?
- utilise knowledge you already have, instead of learning something from scratch?
That's the motivation behind Querty. Querty is a paradigm shift in working with REST API data. What makes Querty different?
- Rather than making calls to directly to a REST API, you simply query Querty. Querty manages all your requests, and gives you back only the data you asked for.
- You can shift your coding, and your thinking to focus from how you get the data, to getting the data you want.
Here's an example of Querty in action, using React:
import { exec, setConfig } from "querty";
function App() {
const config = {
apiUrl: "https://my-api.com"
};
setConfig(config);
useEffect(() => {
async function load() {
const data = await exec("SELECT users.name, body, username FROM users, comments");
setState(data);
}
load();
}, [exec, setState]);
return (
<div className="App">
<div>
<ul>
{state.users
? state.users.map((user, idx) => {
return <li key={idx}>{user.name}</li>;
})
: ""}
</ul>
</div>
</div>
);
}One call to exec along with a SQL-like statement or query is all you need. Querty handles the rest.
It gets the data from the REST API, extracts the information you need, and sends the updated data to your
state management of choice.
To keep Querty small, only a subset of SQL is supported. Querty versions of:
- SELECT
- INSERT
- UPDATE
- DELETE
are supported. In addition, Querty Object syntax supports simplified updates and creations. Below is an example of
UPDATE using both supported syntax forms:
// Update using SQL-like Syntax
await exec(`UPDATE posts SET title = 'Alfred Schmidt', body = 'Frankfurt' WHERE id = 1`);
// Update using Querty Object syntax
await exec(`UPDATE posts WHERE id = 1`, { title: "Alfred Schmidt", body: "Frankfurt" });Stand alone Querty only works in the Browser. However, making Querty Isomorphic (enabling it to work in Node and the Browser) is quite simple.
- Install querty-node using pnpm:
pnpm add querty-node. - Add the following to your Querty
config:
import { nodeProvider } from "querty-node";
const config = {
apiUrl: "https://my-api.com",
nodeProvider
};Afer implementing this configuration, Querty will be Isomorphic.
Querty supports two modes of defining endpoints:
- Base / Default URI
- Individual URIs
To set a base / default URI, which will be used by all queries, set the apiUrl property of the config, as below:
const config = {
apiUrl: "https://my-api"
};Querty also supports mapping endpoints to specific URIs, a feature you can combine with the base / default URI. In the
example below, all endpoints will be mapped to https://my-api, except the users endpoint, which will be mapped to
https://my-users-api. Note that you can provide default fetch options in the main config, and endpoint-specific
options for each path you define:
const options = {
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
};
const config = {
apiUrl: "https://my-api",
options,
path: {
users: {
url: "https://my-users-api",
options
}
}
}
};Querty doesn't care where your data comes from. As long as your configuration is correct, you can select data across
different endpoints. That said, if the endpoints return data in different formats, you should configure your dataExtractor
to support them. An example is below:
const config = {
// ...
dataExtractor(data) {
return data.hasOwnProperty("data") ? data.data : data;
}
};Querty returns data in one of two formats:
- Raw Data: An Array of data is returned.
- Object Sets: An object is returned containing properties that contain data related to the endpoints queried.
// Obect Set
{
"users": [
{"name": "Leanne Graham", "email": "[email protected]"},
{"name": "Ervin Howell", "email": "[email protected]"},
// ...
]
}
// Raw Data
[
{
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"id": 1
},
{
"title": "qui est esse",
"id": 1
},
// ...
]Querty implements a subset of SQL to provide a familiar syntax for working with REST API data. This section details the SQL commands and features supported by Querty.
Querty supports four main SQL commands, each mapped to corresponding HTTP methods:
-
SELECT (mapped to HTTP GET)
- Used to retrieve data from one or more endpoints
- Supports field selection, table selection, WHERE clauses, and JOINs
-
INSERT (mapped to HTTP POST)
- Used to create new resources
- Supports field-value pairs
-
UPDATE (mapped to HTTP PUT)
- Used to modify existing resources
- Supports field-value pairs and WHERE clauses
-
DELETE (mapped to HTTP DELETE)
- Used to remove resources
- Supports WHERE clauses
The SELECT command supports the following syntax:
SELECT field1, field2, ... FROM endpoint1, endpoint2, ... [WHERE condition]
Or with joins:
SELECT field1, field2, ... FROM endpoint1 [JOIN_TYPE] JOIN endpoint2 ON endpoint1.field = endpoint2.field [WHERE condition]
Examples:
// Simple select
await exec("SELECT name, email FROM users");
// Select with WHERE clause
await exec("SELECT name, email FROM users WHERE id = 1");
// Select with column aliasing
await exec("SELECT title as headline FROM posts");
// Select from multiple endpoints
await exec("SELECT users.name, title FROM users, posts WHERE users.id = 1");The INSERT command supports the following syntax:
INSERT INTO endpoint (field1, field2, ...) VALUES (value1, value2, ...)
Example:
await exec("INSERT INTO posts (userId, title, body) VALUES (1, 'test title', 'another value here')");The UPDATE command supports the following syntax:
UPDATE endpoint SET field1 = value1, field2 = value2, ... WHERE condition
Or using Querty Object syntax:
UPDATE endpoint WHERE condition
Examples:
// Update using SQL-like syntax
await exec("UPDATE posts SET title = 'Alfred Schmidt', body = 'Frankfurt' WHERE id = 1");
// Update using Querty Object syntax
await exec("UPDATE posts WHERE id = 1", { title: "Alfred Schmidt", body: "Frankfurt" });The DELETE command supports the following syntax:
DELETE FROM endpoint WHERE condition
Example:
await exec("DELETE FROM posts WHERE id = 1");The WHERE clause is supported for filtering data. It supports simple equality conditions and the IN operator (IN is parsed and exposed via conditions.operator = 'IN' and conditions.values; evaluation depends on your API or custom addons).
Example:
await exec("SELECT name, email FROM users WHERE id = 1");Querty supports three types of joins:
- JOIN (Inner Join): Returns records that have matching values in both tables
- LEFT JOIN: Returns all records from the left table and matched records from the right table
- FULL JOIN: Returns all records when there is a match in either the left or right table
Example:
await exec("SELECT users.name, title FROM users LEFT JOIN posts ON users.id = posts.userId");Column aliasing is supported using the AS keyword.
Example:
await exec("SELECT title AS headline FROM posts");- Querty is designed for REST API data access, not for database operations
- Complex WHERE clauses with multiple conditions are not supported
- Aggregate functions (SUM, COUNT, AVG, etc.) are not supported
- HAVING clause is supported for simple predicates (equality and IN) applied after GROUP BY; aggregate functions remain unsupported
- Subqueries are not supported
- ORDER BY clause is supported for simple field sorting (ASC/DESC, multiple keys, entity-scoped fields)
- The SQL syntax is simplified and does not follow all SQL standards
Querty has support for performing joins. Because Join queries are an amalgamation of endpoint data, they return Raw Data.
Additionally, the id parameters used for joining will be automatically included in the final data.
The following join types are supported:
- Join (an Inner Join)
- Left Join
- Full Join
const state = await exec(
"SELECT users.name, title FROM users FULL JOIN posts ON users.id = posts.userId WHERE users.id = 1"
);You can join on multiple endpoints:
const config = {
apiUrl: "https://my-api.com",
pathMap: {
posts: "users/{users.id}/posts",
todos: "users/{users.id}/todos"
}
};
setConfig(config);
const state = await exec(
"SELECT users.name, posts.title as postTitle, todos.title, completed FROM users " +
"LEFT JOIN posts ON users.id = posts.userId " +
"LEFT JOIN todos ON users.id = todos.userId WHERE users.id = 1"
);Multiple endpoint joins are left-to-right aggregated. In the example above, users is joined with posts, then the result
of that join is joined with todos.
If you select data from multiple endpoints without a JOIN clause, Querty will return an Object with the results for each endpoint scoped to a property.
In the example below, the API is being queried for users and posts by userId. Here, you can see an example
of nested path mapping. The posts endpoint requires a userId. A path map is added to the config to
map any requests to posts to the correct format.
import { exec, setConfig } from "querty";
const config = {
apiUrl: "https://my-api.com",
pathMap: {
posts: "users/{users.id}/posts"
}
};
setConfig(config);
const state = await exec("SELECT users.name, title FROM users, posts WHERE users.id = 1");
/*
* The resulting state will look something like:
*
* {
"users": [{ "name": "Leanne Graham" }],
"posts": [
{ "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" },
{ "title": "qui est esse" },
... More results
]
}
*/Column aliasing is supported, as in the following example using Svelte:
<script>
import { onMount } from "svelte";
import { exec, setConfig } from "querty";
let posts = [];
const config = {
apiUrl: "https://my-api.com"
};
setConfig(config);
onMount(async function () {
const response = await exec("SELECT title as headline FROM posts");
posts = response.posts;
});
export let name;
</script>
<main>
{#each posts as article}
<div>
<p>{article.headline}</p>
</div>
{/each}
</main>Querty supports Postgres-style JSON access operators in SELECT field lists. This lets you extract nested properties from JSON columns/fields in your API responses without custom post-processing.
- -> returns the JSON value (preserves type)
- ->> returns the text value (stringified)
- You can chain accessors, e.g., json_data->'nested'->>'leaf'
- Array indexing is supported when the quoted key is a number string (e.g., json->'arr'->>'0')
- You can scope expressions by entity (users.json->>'name')
- Aliasing: Use AS to name the output column; if AS is omitted, Querty infers the alias from the last quoted key
Examples
// Basic usage with aliasing
await exec(
"SELECT json_data->'property1' AS property1, json_data->'nested'->>'sub' AS sub FROM your_table"
);
// Result rows will include: { property1: <json value>, sub: "<text>" }
// Alias inference (no AS provided) uses the last quoted key as the output name
await exec("SELECT json->'a'->>'b' FROM items");
// Produces rows with a property named 'b'
// Entity-scoped expression
await exec("SELECT users.json->>'name' AS name FROM users");
// Array indexing (index as string) and null handling when path is missing
await exec("SELECT json->'arr'->>'1' AS second, json->'arr'->'9' AS outOfRange FROM tbl");
// 'second' => "<value at index 1>" (as text), 'outOfRange' => nullNotes
- JSON expressions are computed columns; they will be included in results even when the base keys do not exist on every row.
- Missing paths yield null (or string null for ->> if underlying value is null/undefined).
Querty supports nested routes:
// This configuration sets the `posts` endpoint to expect a users.id
const config = {
apiUrl: "https://my-api.com",
pathMap: {
posts: "users/{users.id}/posts"
}
};
// The `users.id` value in the WHERE clause maps to the `{users.id} slug in the pathMap for `posts`
exec("SELECT users.name, title FROM users, posts WHERE users.id = 1");Below is an example of nested routes with multiple endpoints:
// This configuration sets the `posts` endpoint to expect a users.id
const config = {
apiUrl: "https://my-api.com",
path: {
posts: { url: "https://my-posts-api.com" }
},
pathMap: {
posts: "users/{users.id}/posts"
}
};
// The `users.id` value in the WHERE clause maps to the `{users.id} slug in the pathMap for `posts`
exec("SELECT users.name, title FROM users, posts WHERE users.id = 1");You can also alias a route using a path map:
const config = {
apiUrl: "https://my-api.com",
pathMap: {
people: "users"
}
};
exec("SELECT name, email FROM people");NOTE: If you alias a path, the result set returned will be scoped to that path. For example, the output
from the query above would be scoped to a people property, not a users property:
{
"people": [
{"name": "Leanne Graham", "email": "[email protected]"},
//...
]
}There are two ways of providing query parameters:
- Using
pathMap(supported for allexectypes: e.g.,INSERT,UPDATE, etc.):
const config = {
apiUrl: "https://my-api.com",
pathMap: {
comments: "comments?postId={post.id}"
}
};- Passing in a Parameters Object to the
execfunction (only works with SELECT):
exec("SELECT name, email FROM users WHERE id = 1", { page: 1, filter: "my filter param" });Of the two methods, Option 2, the Parameters Object is the recommended method.
You can provide any standard set of fetch options to Querty---which can be useful, for example,
if you need to access restricted endpoints.
import { setConfig } from "querty";
const config = {
apiUrl: "https://my-api.com",
options: {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: "Bearer MY-TOKEN"
}
}
};
setConfig(config);If you are working with an API that supports refresh tokens, you can provide the config with a
refresh function that will run should Querty encounter a 401 (Unauthorised) response. This function
should return a Promise that contains updated config headers. By default,
Querty will make one attempt to requery an endpoint following a 401, if a refresh function is
provided in the config. The refresh function takes one (optional) parameter: entity. If your
Querty implementation supports multiple endpoints, the entity parameter tells you which endpoint has
returned a 401, so you can respond appropriately.
import { setConfig } from "querty";
const config = {
apiUrl: "https://my-api.com",
options: {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: "Bearer MY-TOKEN"
}
},
async refresh(entity) {
// Your refresh logic here.
return {
...this.options.headers,
Authorization: "Bearer MY-NEW-TOKEN"
};
}
};
setConfig(config);Querty supports the use of cockatiel Policies for all requests, or specific endpoints. NOTE:
cockatiel makes use of the Browser's AbortSignal and, therefore, only works in the Browser.
import { Policy, TimeoutStrategy } from "cockatiel";
// Global policy - will apply to all requests
const config = {
apiUrl: "https://my-api.com",
policy: Policy.timeout(10, TimeoutStrategy.Aggressive)
};
// Endpoint-specific policy
const config = {
apiUrl: "https://my-api.com",
users: {
policy: Policy.timeout(10, TimeoutStrategy.Aggressive)
}
};A full example, using Vue:
import { exec, setConfig } from "querty";
const config = {
apiUrl: "https://my-api.com",
policy: Policy.timeout(10, TimeoutStrategy.Aggressive)
};
setConfig(config);
const app = new Vue({
el: "#app",
data: {
todos: []
},
mounted() {
exec("SELECT id, title FROM todos").then((response) => {
this.todos = response.todos;
});
}
});Querty does not come with an interceptor built in. However, because it uses fetch internally, you can intercept
requests using fetch-intercept (which,
according to the docs, also supports Node). For more information, see the fetch-intercept docs.
You can enable a built-in debug mode to log each request that Querty makes with fetch. When enabled, Querty prints the request URL and a safe snapshot of the fetch options just before the request is sent.
import { exec, setConfig } from "querty";
const config = {
apiUrl: "https://my-api.com",
debug: true, // enable debug logging
options: {
headers: {
"Content-Type": "application/json",
Authorization: "Bearer <token>"
}
}
};
setConfig(config);
// Any request made by Querty will be logged
await exec("SELECT id, title FROM todos");Example console output:
[querty][debug] fetch request {
url: "https://my-api.com/todos",
options: { method: "GET", headers: { ... }, signal: "[AbortSignal]" }
}
Notes:
- The AbortSignal is replaced with the string "[AbortSignal]" to keep logs serializable.
- If you include sensitive headers (e.g., Authorization), they will appear in the console. Prefer enabling debug only in local/dev environments.
- Debug mode applies to both Querty’s built-in fetch-based requester and when using a Node
nodeProvider. When using a customnodeProvider, Querty logs the URL and a safe snapshot of the options it passes (method, headers, body). If your provider further transforms options internally, the console output reflects the pre-transformed options. - Disable by removing the debug property or setting
debug: false.
Querty can also (optionally) log the raw response body and headers returned by your API for easier troubleshooting. This works in both browser/fetch and nodeProvider modes.
Requirements:
- Enable
debug: true(as above), and - Enable one of the following flags to turn on raw-response logging:
- Top-level flag:
debugRawResponse: true, or - Nested flag:
debug: { responseRaw: true }
- Top-level flag:
Examples:
// Top-level flag
setConfig({
apiUrl: "https://my-api.com",
debug: true,
debugRawResponse: true
});
// OR nested flag
setConfig({
apiUrl: "https://my-api.com",
debug: { responseRaw: true }
});Example console output:
[querty][debug] api response: {
url: "https://my-api.com/todos",
status: undefined,
headers: { "content-type": "application/json" },
raw: { /* unmodified JSON body as returned by the server */ }
}
Notes:
- Headers objects (e.g., the browser’s
Headers) are normalized to a plain object where possible for readability. - The
rawpayload is the unmodified body returned by the server, logged before any dataExtractor or pagination processing. - Be careful not to enable this in production with sensitive data; prefer local/dev environments.
You can cancel Browser-based requests by setting the canCancel property in the config to true. If you do this,
Querty will add an AbortController to the cancelController property on the config, which you can call to
abort the request, as below:
const config = {
apiUrl: "https://my-api.com",
canCancel: true
};
setConfig(config);
exec("INSERT INTO posts (userId, title, body) VALUES (1, 'test title', 'another value here')").then((data) => {
console.log(data);
});
config.cancelController.abort();By default, Querty expects that the data returned from an API will be in an immediately usable format (i.e., it can
have direct access to the data you requested). Not all APIs return data in this way. If you need to be able to format
the data returned by your API, you can provide Querty with a dataExtractor function in the config, as below:
import { exec, setConfig } from "querty";
const config = {
apiUrl: "https://my-api.com",
dataExtractor(response) {
return response.data;
}
};
setConfig(config);
async function getData(id) {
const data = await exec(`SELECT users.name, body, username " +
"FROM users, comments WHERE users.id = ${id}`);
console.log(data);
}
getData(12);Querty can automatically handle cursor-based pagination for GET requests by storing and reusing a pagination token between calls.
How it works:
- When enabled, the first GET request runs normally. If the response includes a “next page” token (in the body or a response header), Querty stores it.
- The next GET request to the same endpoint with the same query parameters will automatically include the stored token as a query parameter.
- If a subsequent response does not include a next token while a pagination sequence is active, Querty clears the token and returns an empty array ([]) for that call to indicate that there are no more pages.
Enable and configure via setConfig:
import { setConfig } from "querty";
setConfig({
apiUrl: "https://api.example.com",
options: {},
// Configure where to read the next-page token from and how to send it
paginationToken: {
// The name of the query parameter Querty appends on subsequent requests
// Alias: requestParam (either works)
param: "cursor",
// Option 1: Extract from JSON body using a dot path
// e.g., { items: [...], next: "abc" } -> responsePath: "next"
responsePath: "next"
// Option 2: Extract from a response header
// responseHeader: "x-next-token"
// Option 3: Provide a function for full control
// responsePath: ({ data, extracted, headers }) => headers.get("x-next-token") || data?.paging?.next
}
});Notes and behavior:
- Scope: Applies to GET requests when you pass a parameters object. This occurs both when using exec (SELECT with a parameters object) and when using the internal http client.
- Keying: The pagination sequence is keyed by the full request URL and the query parameters, excluding the pagination parameter itself. Changing any non-token query parameter starts a new sequence automatically.
- First vs subsequent calls: On the first call, Querty does not send a token. If a token is found in the response, it is stored. On the next call with the same shape, Querty appends the token (e.g., ?cursor=abc).
- End of pages: When a sequence is active and the response contains no token, Querty clears the stored token and returns []. Make another call to restart or continue with a new token if the server later provides one.
- Data extraction: If you provide a dataExtractor in your config, Querty will attempt token extraction from the original raw response and then from the extracted data if needed.
- Works in both environments:
- Browser/fetch: Can read token from body or headers.
- Node with nodeProvider: Also supported. The same rules apply; tokens are extracted from the provider’s returned data (headers are typically not available unless your provider adds them).
Examples
The snippets below use Querty’s internal http client for brevity; the same pagination behavior applies when using exec with SELECT and a parameters object.
- Body path example (browser/fetch):
setConfig({
apiUrl: "https://api.example.com",
options: {},
dataExtractor: (d) => d.items,
paginationToken: { param: "cursor", responsePath: "next" }
});
await http.get("users", { limit: 2 }); // -> GET /users?limit=2
await http.get("users", { limit: 2 }); // -> GET /users?limit=2&cursor=<stored-token>- Header example (browser/fetch):
setConfig({
apiUrl: "https://api.example.com",
options: {},
dataExtractor: (d) => d.items,
paginationToken: { param: "pageToken", responseHeader: "x-next-token" }
});- Node provider example:
const nodeProvider = async (opts) => {
// your provider implementation
return { status: 200, data: { items: [1], next: "NP-1" } };
};
setConfig({
apiUrl: "https://api.example.com",
options: {},
nodeProvider,
paginationToken: { param: "cursor", responsePath: "next" }
});
await http.get("entries", { q: "x" }); // -> https://api.example.com/entries?q=x
await http.get("entries", { q: "x" }); // -> https://api.example.com/entries?q=x&cursor=NP-1Tips:
- Resetting: Calling setConfig(...) resets the stored pagination state.
- Non-GET requests: POST/PUT/DELETE are unaffected by pagination.
- Changing parameters: If you change a non-token query parameter or endpoint, Querty treats it as a new sequence.
In our preliminary tests, we found that Querty was quite performant! In one test, it outpeformed a major http-client by 4 to 1. We'd perfer to not name names. Rather, we encourage you to test it for yourself.
Querty has an API for creating addons to extend its functionality. Using an addon, you can inject functionality into two stages:
- Query Parsing
- Result Set Processing
Each addon must be created as an object with two methods: queryParser, and resultSetFilter. Each method is
bound by Querty to the object it belongs to. As such, you can refer to properties on the addon using the this
keyword. The queryParser will receive and must return a properties object with three props: fields, entities,
and conditions. fields contains an array of the fields being selected. entities contains an array of the
entities (or "tables") being queried. conditions contains an array of the conditions applied to the query. The
resultSetFilter method will receive and must return a data structure containing the results of the query.
Below is an example:
const first = {
queryParser({ fields, entities, conditions }) {
// Your logic here
return { fields, entities, conditions };
},
resultSetFilter(resultSet) {
// Your logic here
return resultSet;
}
};
const second = {
queryParser({ fields, entities, conditions }) {
// Your logic here
return { fields, entities, conditions };
},
resultSetFilter(resultSet) {
// Your logic here
return resultSet;
}
};
const config = {
apiUrl: "https://jsonplaceholder.typicode.com",
addons: [first, second]
};