This is the template project that's checked out and configured when you run the bando-up command from ljsthw-bandolier. This is where the code really lives.
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.
bandolier-template/lib/api.js

412 lines
15 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";
/*
The main class you use in `api/` handlers. Primarily used like this:
```javascript
import { API } from '../lib/api.js';
export const get = async (req, res) => {
const api = new API(req, res);
}
```
It is a very thin layer over the regular Express.js request/response objects,
but it's main purpose is to return consistent errors when validation fails.
If you don't want to use this, then look at `validation_error` to see what
you need to replicate for form validation.
*/
export class API {
/*
Constructor for the `API` class. Takes the Express.js request/response
objects.
+ `req Object` -- Express.js Request object.
+ `res Object` -- Express.js Response object.
*/
constructor(req, res) {
this.req = req;
this.res = res;
}
/*
Simply returns a status and JSON data to the browser. This normalizes
the type of the response to it's always JSON. I use this with `return`
even though it doesn't matter, mostly to make sure the control flow
exits when I want. This also helps `eslint` detect when I've missed a
branch on the returns. Ultimately Express.js doesn't care about the
return so it's harmless.
+ `status number` -- The status code as an integer.
+ `data Object` -- The object to encode as JSON. Must be JSON.stringify() capable.
+ ___return___ undefined -- Whatever `res.status().json()` returns.
*/
reply(status, data) {
return this.res.status(status).json(data);
}
/*
Redirects the browser with status 301 by default. If you want a different
status then change the second parameter (which defaults to 301).
+ `url string` -- The URL to redirect the browser to.
+ `status number (301)` -- The redirect status code to use, by default 301.
*/
redirect(url, status=301) {
return this.res.redirect(status, url);
}
/*
Returns an error status to the browser. The `err` parameter can either
be a plain `string` or an Object. Since this is an entirely JSON API
system the function does this:
1. If it's a string, it wraps it in `{message: err}`.
2. If it's an object is sends it directly as JSON.
+ `status number` -- Status code for the error.
+ `err (string|Object)` -- Either a string message that will be wrapped, or a raw object.
*/
error(status, err) {
if(typeof err === 'string') {
return this.res.status(status).send({message: err});
} else {
return this.res.status(status).json(err);
}
}
/*
A `get accessor` that returns whether the current user is authenticated
or not. Normally you would tag your handler as requiring authentication
with `get.authenticated = true` but there's some instances when you need
to do your own auth inside a handler.
+ ___return___ boolean -- whether the user is authenticated
*/
get user_authenticated() {
return this.req.user !== undefined;
}
/*
Another `get accessor` that tells you if this user is authenicated _and_ an
admin. An admin user is defined by having the `User.admin` field of the `user`
table in the database set to 1.
+ ___return___ boolean -- whether the user is authenticated and an admin.
*/
get admin_authenticated() {
return this.req.user !== undefined && this.req.user.admin === 1;
}
/*
A `get accessor` that retuns the current user object if they're authenticated.
+ ___return___ User -- the currently authenticated user.
*/
get user() {
return this.req.user;
}
/*
Validates the rules given using the Laravel rules system. You can combine
with with `Model.validation` to generate the rules based on the schema. You
can use `extra` to pass in a callback to do extra validation. The `extra(form)`
callback has access to the final result _after_ all validation has ran, so you
can alter the results how you want.
___WARNING___: This documentation needs to be improved to show exactly how to use it.
+ `rules Object` -- Uses the validtion rules system from Laravel. See this module's docs at the top for more information.
+ `extra(form)` --- Callback that takes the validated form and does additional validation or cleaning.
*/
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;
}
/*
Returns a properly formatted validation error to the browser. This can then be used by
the `client/components/FormField.svelte` to display validation errors, and since the
rules are included in the response you can do further validation in the browser.
+ `res Object` -- The response object from Express.js.
+ `form Object` -- The failed form from `.validate()`.
*/
validation_error(res, form) {
return res.status(400).json(form);
}
/*
Cleans a form you've received from the internet to have only fields
matching `rules`. You can also give an `extra` list of fields to
keep. You combine this with `Model.validation` to create a way to
remove bad inputs from submitted forms before validation.
+ `form Object` -- The form to clean before validation.
+ `rules Object` -- The rules to base the cleaning on.
+ `extra Array` -- A list of additional fields to keep.
*/
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];
}
}
}
/*
BUG: This is a copy of the `defer()` in `client/helpers.js` but
really both of these should be in another library.
*/
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;
}