From 85830853ad3bec930c4f973194632c7fb2a46efd Mon Sep 17 00:00:00 2001 From: "Zed A. Shaw" Date: Mon, 26 Dec 2022 13:32:57 +0700 Subject: [PATCH] Documented the client/api.js file. That's mostly the whole system documented. --- client/api.js | 267 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 265 insertions(+), 2 deletions(-) diff --git a/client/api.js b/client/api.js index 8c8aaf8..e0c002c 100644 --- a/client/api.js +++ b/client/api.js @@ -1,9 +1,145 @@ +/* + This is the analog to `lib/api.js` and provides features that help work + consistently with the JSON data returned by `commands/api.js` handlers in + `api/`. This library is designed so you can start working on your UI without + anything in the backend. That means you can create "mock" routes using the + `mock()` function, design your data along with your UI in the same file, and + then "move" that configuration to an `api/` handler right after. + + ### Getting Started + + Easiest way to get a client going is to use the `bando.js djent` command: + + ```shell + node bando.js djent --template ./static/djenterator/client.svelte --output client/pages/Test.svelte + ``` + + You can also go to the [Djenterator](http://127.0.0.1:5001/admin/#/djenterator/) with your + browser to visually alter the output. I recommend this if you already know what you want + your data mock to start with. + + ### Updating Routes + + Once you run this example command you'll have a file in + `client/pages/Test.svelte`. Currently you manually add these the file + `client/routes.js` like this: + + ```javascript + import Test from "./pages/Test.svelte"; + + export default { + "/test/": Test, + } + ``` + + If you open this file now you'll see many other routes that make up the stock + web application. Obviously you'll "augment" this file, rather than use the + above as the only contents. + + ### UI First + + After this you need to edit the `client/pages/Test.svelte` file, and use the + sample data in the mock configuration: + + ```javascript + api.mock({ + "/api/user/profile": { + "get": [200, {"message": "OK"}], + } + }); + ``` + + You can then use the `client/api.js` functions to work with this pretend data + to develop your UI. Just do your `api.get` (or `post`, or whatever) and + augment the fake data you return as you create the visual experience. + + ### Craft Tests? + + Testing can come right at this point if you use the Playwright testing setup + in `lib/testing.js`. Since you have the UI sort of working, you can take a + bit of time writing a test and speed up the later steps. The idea is you + write your test to click on all the buttons, fill out forms, and cause + errors. When the tests mostly run with your fake UI then later development + steps will be quicker because you'll automate testing that the new code + works. + + For example, if you have a working test for a fake UI, then you craft a + `api/` handler, your test will tell you that your new `api/` is properly + replacing your fake data automatically. + + ### Migrate to `api/` + + When your UI is pretty good you can take the fake data you've been working on in `api.mock()` + and simply move it to your `api/` handler. Just generate one using the + [Djenterator](http://127.0.0.1:5001/admin/#/djenterator/) but plug in your fake data. + This will craft a handler that returns this data, and in _theory_ this will then keep working + with your UI. + + Once you have your fake data flowing out of the new `api/` handler then you + can work on the form validation, logic, and other things you need to refine + the UI further. + + ### Create Models + + The final step is to take your futher developed data and create a model for it in `lib/models.js`. + This will usually require crafting a migration with: + + ```shell + npm run knex migrate:migrate some_unique_description + ```` + + ### Refine Tests + + If you made a UI test then in _theory_ most of your testing is done and just needs refinement. You + should try to cause as many errors as you can, and then use the coverage system to make sure you're + at _least_ running all of the code. It's hard to hit every line, but aim for as high as you can. + + After that, I recommend tests for your Models, but tests that hit the `api/` + are usually low value as long as you're working the UI with Playwright. The + reason is the _UI_ is already hitting the `api/` so other tests are largely + duplicate. + */ const MOCK_ROUTES = {}; import { user, cache_reset } from "./stores.js"; import { log } from "$/client/logging.js"; import Validator from 'Validator'; +/* + This performs a _client side_ validation of a form, updating the internal metadata + so your `FormField.svelte` displays errors. You can look in `client/components/Login.svelte` + for an example, but the data format is: + + ```javascript + let form = { + email: "", + password: "", + _valid: false, + _errors: {}, + _rules: { + email: 'required|email', + password: 'required' + } + } + ``` + + One feature of `lib/api.js:API.validate()` is it returns the validation rules you create in the `api/` handler. This means you can keep validation rules in the most important place: the backend. You then submit your first form attempt, handle the error, and update this form with the form the backend returns. + + If you do this, then your UI pattern is: + + 1. No validation feedback on the first form fillout. This improve usability as it doesn't confuse the user with fake errors while they're typing. + 2. Submit the form, then the `api/` handler validates, and returns the validation Rules in the response. + 3. After this response your UI handles the validation with local fast UI feedback, and when it's correct submits it again to the `api/` handler. + 4. Your `api/` handler then still keeps validating, but there's less useless network traffic and faster user feedback. + + I've found this pattern is better for security and usability. It's more usable because the user + isn't slammed with useless validations while they type, giving them a chance to make edits and fix + problems. It's more secure because the rules for validation and the true validation are all in the + `api/` handler backend where it's required. The Validation rules can also come from the `lib/models.js:Model.validation` generator so they're based on the database schema. + + + `form Object` -- The form fields, with any validation settings. Things starting with `_` are considered internal. + + `extra(form)` -- A callback you can use to do additional validation too complex for the rules. + */ export const validate = (form, extra) => { if(form._rules) { let validation = Validator.make(form, form._rules); @@ -18,10 +154,25 @@ export const validate = (form, extra) => { } } +/* + Determines if the form can be submitted. If you read the docs for `validate` I say you + should submit the first attempt, then validate the remaining ones in the browser (and backend). + This function helps you do that as it will say `true` if there are no `form._rules` set. + These only get set when you handle the response from the `api/` handler, so the first request + will go through, then after that this function looks for `form._valid`. + + + `form Object` -- The form fields, with any validation settings. + */ export const can_submit = (form) => { return form._rules === undefined || form._valid; } +/* + Cleans a form of junk before submitting to the `api/` handler. It removes + + + `form Object` -- The form fields, with any validation settings. + + `extras Array` -- Additional fields to remove. + */ export const clean_form = (form, extras=[]) => { form._errors = {}; // errors is accessed so needs to exist delete form._valid; @@ -29,15 +180,49 @@ export const clean_form = (form, extras=[]) => { for(let field of extras) delete form[field]; } -// use these when you do your own fetch -const fetch_opts = { credentials: 'same-origin',}; +/* + The default fetch_opts are `{ credentials: 'same-origin',}` to enforce + authentication. Don't really change these here unless you know what + you're doing. + */ +export const fetch_opts = { credentials: 'same-origin'}; + +/* + Create a mock data response, so you can pretend you have a `api/` handler + without having to actaully make one at first. This makes development easier + since you don't need to bounce around between tons of files just to get a + UI going. + ### Config Format + + The primary key is the URL, and it contains an object mapping each `get`, `post`, etc. + to the data returned. Here's an example: + + ```javascript + api.mock({ + "/api/user/profile": { + "get": [200, {"message": "OK"}], + } + }); + ``` + + You can have multiple URLs and multiple actions per URL, but the data returned is static. + If you're at a point where you need to change the data response at random then it's time + to write an `api/` handler. + + + `config Object` -- Configuration of routes to data response. + */ export const mock = (config) => { for(let [route, value] of Object.entries(config)) { MOCK_ROUTES[route] = value; } } +/* + The internal version of `raw()` that does a mock call instead. + The `raw()` functionw will call this if the URL is in `api.mock()`. + Otherwise it's mostly internal. + */ export const raw_mock = (url, raw_method, body, unauthed_action) => { let error; const method = raw_method.toLowerCase(); @@ -60,6 +245,10 @@ export const raw_mock = (url, raw_method, body, unauthed_action) => { } } +/* + JavaScript's URL parsing only things full complete URLs with ports and hosts are + real URLs. This fakes it out. + */ const parse_url_because_js_is_stupid = (url) => { try { // javascript is dumb as hell and thinks a typical /this/that is not a URL @@ -70,6 +259,28 @@ const parse_url_because_js_is_stupid = (url) => { } } +/* + All of the calls to `api/` handlers are mostly the same, so this one + function does them all. This is then called by every request method + like `get()` and `post()`. It's used internally so only access it if + you're really desperate. + + The main thing to understand is that `client/api.js` tries to normalize + as much as possible, including authentication. When the handler returns + a 403/401 response this function will return the error like normal, but + if you add the `unauthed_action()` callback then you can catch this and + do a redirect to the login page. Look at the `client/components/LoggedIn.svelte` + for an example of using this. + + ___FOOTGUN___: This calls `raw_mock` if the URL you requested is in a `api.mock()` + specification. If you're having trouble with requests not going through delete + your `api.mock()`. + + + `url string` -- URL to request. + + `method string` -- ALL CAPS METHOD NAME LIKE POST GET. + + `body Object` -- Body for the JSON request. + + `unauthed_action()` -- Callback for what happens when a 403/401 is returned. + */ export const raw = async (url, method, body, unauthed_action) => { const parsed = parse_url_because_js_is_stupid(url); @@ -119,28 +330,70 @@ export const raw = async (url, method, body, unauthed_action) => { } } +/* + The GET method request. To keep things consistent with the + other requests this accepts a `data` parameter, but it URL + encodes them and attaches them to the URL before calling `raw()`. + + + `url string` -- The url to request. + + `data Object` -- Data to URL encode and append to the URL. + + `unauthed_action()` -- Callback on authentication failure to let you redirect to a login. + */ export const get = async (url, data, unauthed_action) => { const params = new URLSearchParams(data || {}); const param_url = `${url}?${params.toString()}`; return await raw(param_url, 'GET', undefined, unauthed_action); } +/* + The POST method request. Refer to `raw()` for more documentation. + + + `url string` -- The url to request. + + `data Object` -- Data to URL encode and append to the URL. + + `unauthed_action()` -- Callback on authentication failure to let you redirect to a login. + */ export const post = async (url, data, unauthed_action) => { return await raw(url, 'POST', data, unauthed_action); } +/* + The PUT method request. Refer to `raw()` for more documentation. + + + `url string` -- The url to request. + + `data Object` -- Data to URL encode and append to the URL. + + `unauthed_action()` -- Callback on authentication failure to let you redirect to a login. + */ export const put = async (url, data, unauthed_action) => { return await raw(url, 'PUT', data, unauthed_action); } +/* + The DELETE method request. Refer to `raw()` for more documentation. + + + `url string` -- The url to request. + + `unauthed_action()` -- Callback on authentication failure to let you redirect to a login. + */ export const del = async (url, unauthed_action) => { return await raw(url, 'DELETE', undefined, unauthed_action); } +/* + The OPTIONS method request. Refer to `raw()` for more documentation. + + + `url string` -- The url to request. + + `unauthed_action()` -- Callback on authentication failure to let you redirect to a login. + */ export const options = async (url, unauthed_action) => { return await raw(url, 'OPTIONS', undefined, unauthed_action); } +/* + Properly logs the user out of the backend _and_ frontend. The one + major drawback to the SPA model is you have to sync the user's state + with the backend. This cleans out the `client/stores.js:user` state, + resets any caches being used, send a `get('/api/logout')`, and redirects + the window to `/client/#/login`. + */ export const logout_user = async () => { user.update(() => { return {authenticated: undefined} @@ -156,6 +409,16 @@ export const logout_user = async () => { window.location.replace("/client/#/login"); } +/* + Gets the schema for the database, which isn't needed for most operations, + but if you're working on anything that modifies the database or needs the + database schema this is useful. + + ___WARNING___: This is only accessible to users with `admin=1` and blocked in the + `api/admin/schema.js` handler. Don't think that because this is in `client/api.js` + that the schema information should be exposed to the internet. The handler is read + only, but you never know what weird things people can figure out from your schema. + */ export const schema = async (table) => { let [status, tables] = await get('/api/admin/schema');