REST API framework for GNOME JavaScript. Talks JSON. Wraps libsoup, a native HTTP client/server library, and libgda, a data abstraction layer, with Promise-based plumbing.
Grest is known to work on Gjs 1.55 with CommonJS runtime.
npm i -S grestRouting is resourceful, model-centric. Entity classes are plain JS. Controllers extend Context which resembles Koa, and have HTTP verbs (e.g. GET) as method names.
const { ServerListenOptions } = imports.gi.Soup;
const { Context, Route } = require("grest");
class Greeting {
constructor() {
this.hello = "world";
}
}
class GreetingController extends Context {
async get() {
await Promise.resolve();
this.body = [new Greeting()];
}
}
const App = Route.server([
{ path: "/greetings", controller: GreetingController }
]);
App.listen_all(3000, ServerListenOptions.IPV6_ONLY);
App.run();In constructor, assign a sample body. Usually an array including a model example.
class GreetingController extends Context {
constructor() {
super();
/** @type {Greeting[]} */
this.body = [new Greeting()];
}
async post() {
const greetings = this.body;
for (const greeting of greetings) {
greeting.hello = "earth";
}
this.body = greetings;
}
}Your app self-documents at /, keying example models by corresponding routes. Reads optional metadata from package.json in current working directory. Omits repository link if private is true.
{
"app": {
"description": "Gjs REST API microframework, talks JSON, wraps libsoup",
"name": "grest",
"repository": "https://github.com/makepost/grest",
"version": "1.0.0"
},
"examples": {
"GET /greetings": [
{
"hello": "world"
}
]
}
}Makes a request with optional headers. Returns another Context.
const GLib = imports.gi.GLib;
const { Context } = require("grest");
const base = "https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgjs";
// Returns an array of issues.
const path = "/issues";
const { body } = await Context.fetch(`${base}${path}`, {
headers: {
"Private-Token": GLib.getenv("GITLAB_TOKEN")
}
});
print(body.length);Grest converts your body to JSON.
const base = "https://httpbin.org";
const path = "/post";
const { body } = await Context.fetch(`${base}${path}`, {
body: {
test: Math.floor(Math.random() * 1000)
},
method: "POST"
});Check yourself with Gunit to get coverage.
// src/app/Greeting/GreetingController.test.js
// Controller and entity are from the examples above.
const { Context, Route } = require("grest");
const { test } = require("gunit");
const { Greeting } = require("../domain/Greeting/Greeting");
const { GreetingController } = require("./GreetingController");
test("gets", async t => {
const App = Route.server([
{ path: "/greetings", controller: GreetingController }
]);
const port = 8000 + Math.floor(Math.random() * 10000);
App.listen_all(port, 0);
const { body } = await Context.fetch(`http://localhost:${port}/greetings`);
t.is(body[0].hello, "world");
});Assume you have a Product table with the following schema:
create table Product (
id varchar(64) not null primary key,
name varchar(64) not null,
price real
)Define an entity class to match your table:
class Product {
constructor() {
this.id = "";
this.name = "";
this.price = 0;
}
}Tell Grest where your db is, and give Route.server an extra parameter:
const { Db, Route } = require("grest");
const db = Db.connect("sqlite:example"); // example.db in project root
const services = { db };
const routes = [{ path: "/products", controller: ProductController }];
const App = Route.server(routes, services);
App.listen_all(3000, 0);
App.run();In-memory SQLite and other backends supported by Libgda can work too:
Db.connect("sqlite::memory:");
// Grest parses database config from URL.
Db.connect("mysql://user:pass@host:post/db");
// When deploying, read your database config from an environment variable.
Db.connect(imports.gi.GLib.getenv("DB"));For every request, Grest constructs your controller with your services as props:
class ProductController extends Context {
/** @param {{ db: Db }} props */
constructor(props) {
super(props);
/** @type {Product[]} */
this.body = [new Product()];
this.repo = props.db.repo(Product);
}
// ...
}Based on your entity class fields, Grest builds SQL from common queries, executing when you call await:
/**
* @example GET /products?name=not.in.(chair,table)
* @example GET /products?limit=2&order=price.desc&price=gte.1
*/
async get() {
this.body = await this.repo.get().parse(this.query);
// Or build your SELECT query programmatically, with a fluent chain:
this.body = await this.repo
.get()
.name.not.in(["flowers"])
.order.price.desc()
.limit(3)
.offset(1);
}Whitelist or otherwise limit what a user can do:
/** @example DELETE /products?name=eq.chair */
async delete() {
if (!/^(name|price)=eq\.[a-z0-9-]+$/.test(this.query)) {
// Beginning digits, if any, define the HTTP response code.
throw new Error("403 Forbidden Delete Not By Name Or Price");
}
await this.repo.delete().parse(this.query);
}Pass a JSON array as body when POSTing:
/** @example POST /products */
async post() {
await this.repo.post(this.body);
// Or CREATE manually:
await this.repo.post([
{ id: "p1", name: "chair", price: 2.0 },
{ id: "p2", name: "table", price: 5 },
{ id: "p3", name: "glass", price: 1.1 },
]);
// Won't do nulls, GDA_TYPE_NULL isn't usable through introspection.
}Wrap your PATCH body in an array as well, to reuse this.body type:
/** @example PATCH [{ name: "armchair" }] /products?name=eq.chair */
async patch() {
await this.repo.patch(this.body[0]).parse(this.query);
// Doing an UPDATE manually:
await this.repo
.patch({ name: "armchair" }) // New values.
// WHERE conditions:
.name.eq("chair")
.price.lte(3);
}Db test shows how to make lower level SQL queries.
Grest optionally exposes your API through WebSocket, and lets users subscribe to receive a patch whenever you update the Product repo:
class ProductController extends Context {
// ...
}
// Whitelist entities that trigger a route refresh.
ProductController.watch = [Product];
exports.ProductController = ProductController;Give Socket.watch your routes and services in your entry point:
const services = { db }; // Required.
const App = Route.server(routes, services);
Socket.watch(App, routes, services);Routes exposed to WebSocket can be same as HTTP, or a different set:
const App = Route.server(
[
{ path: "/greetings", controller: GreetingController },
{ path: "/products", controller: ProductController }
],
services
);
Socket.watch(
App,
[{ path: "/products", controller: ProductController }],
services
);Socket test shows how to set up the client side, and Patch test shows what subscribers recieve.
Goes to stdout and stderr by default. You can provide a custom logger instead:
const { Context, Db, Route } = require("grest");
const db = Db.connect("sqlite:example");
const services = { db, log }; // Pass your logger as a service.
const routes = [{ path: "/products", controller: ProductController }];
const App = Route.server(routes, services);
Socket.watch(App, routes, services);
App.listen_all(3000, 0);
App.run();
/** @param {Error?} error @param {Context?} context */
function log(error, context) {
if (error) {
printerr(error, error.stack);
} else {
// ...
}
}For example, if you have a Log entity and want to save the IP address:
const { ip, path, protocol } = context;
if (path !== "/logs" || protocol !== "websocket") { // Avoid loop if watching.
db.repo(Log).post([{ createdAt: date.now(), ip }]);
}Same fields are available as in controller:
class Context {
// ...
headers: { [key: string]: string; }
id: string
ip: string
method: string
path: string
protocol: string
query: string
status: number
userId: string // Unused internally. You can set in controller.
// ...
}Context toString() returns Combined Log Format.
print(context);
// -> ::1 - - [12/Nov/2018:12:34:56 +0000] "GET /products?limit=3&name=not.in.(flowers)&offset=1&order=price.desc HTTP/1.1" 200 276 "-" "DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)"MIT