You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
303 lines
10 KiB
303 lines
10 KiB
/*
|
|
__WARNING__: Early documentation. This should be treated as notes to
|
|
remind you of how to use the `api/` handlers with this, rather than
|
|
a full guide.
|
|
|
|
This is a convenience library that helps with normalizing JSON API
|
|
requests and responses with [Express.js](https://expressjs.com). The
|
|
`API` class contains useful functions for redirecting, building replies,
|
|
sending back errors, authenticating users, and validating forms. It is
|
|
primarily used in writing `api/` handlers.
|
|
|
|
## How `api/` Handlers Run
|
|
|
|
There's a command in `commands/api.js` that runs everything, and contains a lot
|
|
of tricks for Express.js that you should study. You run this with the `bando.js`
|
|
command runner like this:
|
|
|
|
```shell
|
|
node bando.js api
|
|
```
|
|
|
|
This command then imports all of the files in `api/` recursively, as well as
|
|
the `socket/` handlers, and sets them up to handle requests. Requests are mapped
|
|
by URL to `api/` by taking the `.js` off the file and using that as the URL. So
|
|
`api/running.js` becomes `/api/running`.
|
|
|
|
## Writing `api/` Handlers
|
|
|
|
The code in `api/` is mapped to URLs almost directly, so to create a new
|
|
URL just make a file in that directory. For example, if you want `/api/running`
|
|
as a check if your server is running, then create the file `api/running.js`.
|
|
|
|
The easiest way to make this file is to use the `node bando.js djent` command like this:
|
|
|
|
```shell
|
|
node bando.js djent --template static/djenterator/api.js --output api/running.js
|
|
```
|
|
|
|
This will use the template in `static/djenterator/api.js` to craft a starter file
|
|
for your `api/running.js`. If you want to change the output you can access
|
|
[http://127.0.0.1:5001/admin/#/djenterator/](http://127.0.0.1:5001/admin/#/djenterator/)
|
|
and visually choose the template to change, and edit the `.json` variables file it uses.
|
|
|
|
You should study this template output for all of the things you might need, although most
|
|
of it you'll strip out in this little tutorial.
|
|
|
|
## Handling Requests
|
|
|
|
To handle a `GET`, `POST`, or other request you simply create an exported function that
|
|
matches the lowercase version of the name:
|
|
|
|
```javascript
|
|
export const get = async (req, res) => {
|
|
// do you thing here
|
|
}
|
|
```
|
|
|
|
In the example file `api/running.js` you just created with `bando.js djent`
|
|
you should see this function. Next you'll strip out everything that has
|
|
nothing to do with replying with a status.
|
|
|
|
## Using `class API`
|
|
|
|
You create the `API` class using the Express `request` and `response`
|
|
objects in your `api/` handler, then use the methods `.reply()`, `.redirect()`,
|
|
or `.error()` to report status of the HTTP request. Let's strip down
|
|
your generated `api/running.js` file to the minimum necessary:
|
|
|
|
```javascript
|
|
import logging from '../lib/logging.js';
|
|
import { API } from '../lib/api.js';
|
|
|
|
const log = logging.create(import.meta.url);
|
|
|
|
export const get = async (req, res) => {
|
|
const api = new API(req, res);
|
|
|
|
try {
|
|
api.reply(200, { message: "OK"});
|
|
} catch (error) {
|
|
log.error(error);
|
|
api.error(500, error.message || "Internal Server Error");
|
|
}
|
|
}
|
|
```
|
|
|
|
Here you can see I am only leaving logging, `API`, and returning simple replies
|
|
with `api.reply(200, {message: "OK"))` or a more complex error reply if that fails.
|
|
|
|
## Authenticating Requests
|
|
|
|
Authentication for a handler is very simple and mostly done for you:
|
|
|
|
```javascript
|
|
// add this after the above get method
|
|
get.authenticated = true;
|
|
```
|
|
|
|
You "tag" any handlers you want protected with authentication by setting the `authenticated`
|
|
variable on them. The `commands/api.js` runner then knows to demand authentication before
|
|
running this handler. When it is run you can then use `API.user` to get at the authenticated
|
|
user.
|
|
|
|
## Form Validation
|
|
|
|
Validating forms in a server is very important, as you can't trust that any
|
|
browser actually performed the validation. The `API` class uses the wonderful
|
|
[Laravel validation rules](https://laravel.com/docs/9.x/validation) via the
|
|
very nice [Validation](https://www.npmjs.com/package/Validator) module by
|
|
[jfstn](https://github.com/jfstn/Validator). These rules are also the same
|
|
as returned by the `lib/ormish.js:Model.validation` method. That means you
|
|
can:
|
|
|
|
1. Get your Model from `lib/models.js`.
|
|
2. Call `TheModel.validation(rules)` with empty '' for any rules you want filled in based on the database schema.
|
|
3. Pass those rules to `API.validate()` to check inputs match the Model's rules.
|
|
4. Return a `API.validation_error()` when they fail.
|
|
|
|
Here's an example taken from the `api/register.js` handler, but stipped down to
|
|
the essentials so it only validates a user form then returns the cleaned up form:
|
|
|
|
```javascript
|
|
import { User, Site, } from "../lib/models.js";
|
|
|
|
const rules = User.validation({
|
|
email: '',
|
|
full_name: '',
|
|
initials: 'required|alpha|max:3',
|
|
password_repeat: 'required|same:password',
|
|
password: '',
|
|
tos_agree: 'required|boolean|accepted',
|
|
});
|
|
|
|
export const post = async (req, res) => {
|
|
const api = new API(req, res);
|
|
const form = api.validate(rules);
|
|
|
|
if(!form._valid) {
|
|
return api.validation_error(res, form);
|
|
} else {
|
|
api.clean_form(form, rules);
|
|
api.reply(200, {clean_user: form});
|
|
}
|
|
}
|
|
```
|
|
|
|
## A Good Validation Pattern
|
|
|
|
Validating forms and input is ver important, but you want to do it in a way that
|
|
doesn't interfere with a user's typing, yet still uses the client-side browser to
|
|
speed up the validation feedback. There's also the problem that _all_ form validation
|
|
systems seem to need the validation rules in both the backend and frontend of the
|
|
application.
|
|
|
|
I've found the best pattern to handle all of these is to "validate on first submit, return
|
|
the rules for later submits." It works like this:
|
|
|
|
1. On the first view of the form do _not_ provide any validation feedback. This removes the annoying noise of fields claiming inputs are wrong when they're simply being worked on. For example, as someone types their email it's annoying to flash it right/wrong until they're done.
|
|
2. Always validate based on the rules, and even better based on the database schema.
|
|
3. When this first validation fails, return a validation failure _and_ return the rules for the browser to use for later attempts.
|
|
4. The browser then has the rules, shows that there was an error, and can then provide immediate fast feedback to the user and block submits until its correct.
|
|
5. This also places the rules in only one place: the backend, where they're absolutely required.
|
|
|
|
|
|
|
|
## Client Side Validation
|
|
|
|
The validation process I described needs one more component on the client side, which is
|
|
demonstrated in the `client/pages/Registration.svelte` page:
|
|
|
|
```javascript
|
|
const register = async () => {
|
|
let [status, data] = await api.post("/api/register", form);
|
|
|
|
if(status === 200) {
|
|
$user.new_registration = true;
|
|
push('/');
|
|
} else {
|
|
form = Object.assign(form, data);
|
|
}
|
|
}
|
|
```
|
|
|
|
The `api.post("/api/register", form)` then uses `API.validation_error` to return an
|
|
error response which contains the errors _and_ validation rules that the browser
|
|
should use. Remember, the `api.post()` above is from `client/api.js:post` while
|
|
the `API.validation_error` is from this file.
|
|
|
|
___BUG___: Currently `client/pages/Registration.svelte` doesn't demonstrate the other part
|
|
of using `client/api.js:validate` to do more validations in the browser.
|
|
|
|
## Going Further
|
|
|
|
Once you get this working that's mostly all there is to writing an `api/` handler in
|
|
The Bandolier. You'll then want to learn how `lib/ormish.js` and `lib/models.js` works,
|
|
and how to create new UIs in `client/` or `admin/`. On the client side you'll want to
|
|
study the `client/api.js` which is the counterpart to this module, and look at the
|
|
`client/components/FormField.svelte` plus `client/pages/Registration.svelte` for other
|
|
examples.
|
|
*/
|
|
import Validator from 'Validator';
|
|
|
|
/*
|
|
Used internally to handle security decisions based on whether someone
|
|
runs the app with `DANGER_ADMIN=1` environment variable set or not.
|
|
Originally I was very loose in how this can be set, with sometimes `DANGER_ADMING=true`
|
|
or `DANGER_ADMIN="1"`, but I've since normalized that it will _only_ work if
|
|
`DANGER_ADMIN==="1"`.
|
|
*/
|
|
export const developer_admin = process.env.DANGER_ADMIN === "1";
|
|
|
|
|
|
export class API {
|
|
constructor(req, res) {
|
|
this.req = req;
|
|
this.res = res;
|
|
}
|
|
|
|
reply(status, data) {
|
|
return this.res.status(status).json(data);
|
|
}
|
|
|
|
redirect(url, status=301) {
|
|
return this.res.redirect(status, url);
|
|
}
|
|
|
|
error(status, err) {
|
|
if(typeof err === 'string') {
|
|
return this.res.status(status).send({message: err});
|
|
} else {
|
|
return this.res.status(status).send(err);
|
|
}
|
|
}
|
|
|
|
get user_authenticated() {
|
|
return this.req.user !== undefined;
|
|
}
|
|
|
|
get admin_authenticated() {
|
|
return this.req.user !== undefined && this.req.user.admin === 1;
|
|
}
|
|
|
|
get user() {
|
|
return this.req.user;
|
|
}
|
|
|
|
validate(rules, extra) {
|
|
const form = this.req.method === "GET" ? this.req.query : this.req.body;
|
|
let validation = Validator.make(form, rules);
|
|
|
|
// keep the rules as they're needed everywhere
|
|
form._rules = rules;
|
|
|
|
// BUG: validator has a bug that considers an empty
|
|
// form valid even if there are required rules
|
|
if(Object.getOwnPropertyNames(form).length === 0) {
|
|
// add in a fake entry in the form
|
|
form["_empty"] = null;
|
|
// validate it
|
|
form._valid = validation.passes();
|
|
// then remove _empty to keep it clean
|
|
delete form["_empty"];
|
|
} else {
|
|
form._valid = validation.passes();
|
|
}
|
|
|
|
form._errors = validation.getErrors();
|
|
if(extra) extra(form);
|
|
return form;
|
|
}
|
|
|
|
validation_error(res, form) {
|
|
return res.status(400).json(form);
|
|
}
|
|
|
|
clean_form(form, rules, extras=[]) {
|
|
// what I wouldn't give for set operations
|
|
for(let [key, rule] of Object.entries(form)) {
|
|
if(!(key in rules)) {
|
|
delete form[key];
|
|
}
|
|
}
|
|
|
|
for(let field of extras) {
|
|
delete form[field];
|
|
}
|
|
}
|
|
}
|
|
|
|
export const defer = () => {
|
|
let res;
|
|
let rej;
|
|
|
|
let promise = new Promise((resolve, reject) => {
|
|
res = resolve;
|
|
rej = reject;
|
|
});
|
|
|
|
promise.resolve = res;
|
|
promise.reject = rej;
|
|
|
|
return promise;
|
|
}
|
|
|