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/client/api.js

466 lines
17 KiB

/*
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);
form._valid = validation.passes();
if(extra) extra(form);
form._errors = validation.getErrors();
return form;
} else {
return form;
}
}
/*
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;
delete form._rules;
for(let field of extras) delete form[field];
}
/*
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();
const config = MOCK_ROUTES[url];
const data = config[method];
if(data === undefined) {
error = `Mock ${url}:${method} is not in your mocks list. Did you forget to add it to the api.mock call?`;
} else if(data.length !== 2) {
error = `Mock ${url}:${method} does not have the correct config. Must be [status (int), {data}], like [200, {message: 'OK'}].`
}
if(error) {
log.error(error, MOCK_ROUTES);
return [500, {message: error}];
} else {
// this is the same as res.status below
if(unauthed_action && (data[0] === 401 || data[0] === 403)) unauthed_action();
return data;
}
}
/*
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
return new URL(url);
} catch(error) {
// so fake it for the mock by tacking on ... localhost then ignoring it
return new URL(`http://localhost${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);
// if there is a rules and valid is exactly false we should just run
// the validation here and avoid the request
if(body && body._rules && body._valid === false) {
const res = validate(body);
if(!can_submit(res)) {
log.debug("Form invalid, won't HTTP submit.");
return [400, res];
} else {
clean_form(body);
}
}
if(parsed.pathname in MOCK_ROUTES) {
return raw_mock(parsed.pathname, method, body);
} else {
let options = {
method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
}
if(body) options.body = JSON.stringify(body);
/* this handy little gem exists because fetch likes to go:
* "the string did not match the expected pattern”
* when the response body is not json...or at least that's
* what I think it means. Now I get the text, and use it
* for logging.
*/
let res = await fetch(url, options);
let text = await res.text();
try {
if(unauthed_action && (res.status === 401 || res.status === 403)) unauthed_action();
return [res.status, JSON.parse(text)];
} catch(error) {
log.error(error, "Failed to parse reply body as JSON. Text is:", text, "error", error, "URL", url);
return [500, {"message": "Exception processing request. See log.debug."}];
}
}
}
/*
Used for fetching basic resources, not JSON data from the API. Uses the
same return signature as `raw()` but doesn't attempt a JSON conversion,
and only does a "GET" request. It returns the result of `res.blob()`
so you can convert it to a string or JSON as you need, or leave it if
it's a binary file.
+ `url string` -- URL to get.
+ ___return___ `Array[status, Blob|{}]` -- Returns a Blob or when status is 500 a `{"message": "Error message"}`.
*/
export const blob = async (url) => {
let data;
try {
let res = await fetch(url, fetch_opts);
data = await res.blob();
return [res.status, data];
} catch(error) {
log.error(error, "Failed to parse reply body as JSON. Text is:", data, "error", error, "URL", url);
return [500, {"message": "Exception processing request. See log.debug."}];
}
}
/*
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}
});
// really only a problem for multiple people
// using the same browser, but clear it out
cache_reset();
let [status, data] = await get('/api/logout');
if(status !== 200) log.error("Invalid status from logout", status, data);
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');
if(status == 200) {
for(let t of tables) {
if(t.name === table) {
// this exits the function with the schema
return t._columns;
}
}
// this happens if the table isn't in the schema
return undefined;
} else {
return undefined;
}
}
export default {
post, get, put, del, mock, options, fetch_opts,
logout_user, validate, can_submit, clean_form, schema, blob
}