/* __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; }