Skip to content
/ grest Public

REST API Gjs framework, talks JSON, fluent Libgda database queries, live patch over Libsoup WebSocket

License

Notifications You must be signed in to change notification settings

nykula/grest

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

32 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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();

    /** @type {Greeting[]} */
    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";

// 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);

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.

// 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");
});

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"); // 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.

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 {
  // ...
}

// 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.

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 }; // 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)"

License

MIT

About

REST API Gjs framework, talks JSON, fluent Libgda database queries, live patch over Libsoup WebSocket

Resources

License

Stars

Watchers

Forks

Packages

No packages published