grest.js
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.
Install
Grest is known to work on Gjs 1.55 with CommonJS runtime.
npm i -S grest
Usage
Routing 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();
Receving POST
In constructor, assign a sample body. Usually an array including a model example.
class GreetingController extends Context {
constructor() {
super();
this.body = [new Greeting()];
}
async post() {
const greetings = this.body;
for (const greeting of greetings) {
greeting.hello = "earth";
}
this.body = greetings;
}
}
Index
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"
}
]
}
}
Fetch
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";
const path = "/issues";
const { body } = await Context.fetch(`${base}${path}`, {
headers: {
"Private-Token": GLib.getenv("GITLAB_TOKEN")
}
});
print(body.length);
Sending POST
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"
});
Test
Check yourself with Gunit to get coverage.
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");
});
Database
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");
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:");
Db.connect("mysql://user:pass@host:post/db");
Db.connect(imports.gi.GLib.getenv("DB"));
For every request, Grest constructs your controller with your services as props:
class ProductController extends Context {
constructor(props) {
super(props);
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
:
async get() {
this.body = await this.repo.get().parse(this.query);
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:
async delete() {
if (!/^(name|price)=eq\.[a-z0-9-]+$/.test(this.query)) {
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:
async post() {
await this.repo.post(this.body);
await this.repo.post([
{ id: "p1", name: "chair", price: 2.0 },
{ id: "p2", name: "table", price: 5 },
{ id: "p3", name: "glass", price: 1.1 },
]);
}
Wrap your PATCH body in an array as well, to reuse this.body
type:
async patch() {
await this.repo.patch(this.body[0]).parse(this.query);
await this.repo
.patch({ name: "armchair" })
.name.eq("chair")
.price.lte(3);
}
Db test shows how to make lower level SQL queries.
WebSocket
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 {
}
ProductController.watch = [Product];
exports.ProductController = ProductController;
Give Socket.watch
your routes and services in your entry point:
const services = { db };
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.
Logging
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 };
const routes = [{ path: "/products", controller: ProductController }];
const App = Route.server(routes, services);
Socket.watch(App, routes, services);
App.listen_all(3000, 0);
App.run();
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") {
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
}
Context toString()
returns Combined Log Format.
print(context);
License
MIT