Skip to content

Instantly share code, notes, and snippets.

@jasonhofer
Last active October 5, 2021 14:02
Show Gist options
  • Save jasonhofer/da6d992035ce6d51cd38536bfcbd006d to your computer and use it in GitHub Desktop.
Save jasonhofer/da6d992035ce6d51cd38536bfcbd006d to your computer and use it in GitHub Desktop.
Svelte/Sapper project files.
import isEmpty from 'lodash/isEmpty';
import pickBy from 'lodash/pickBy';
import axios from 'axios';
import { uri } from 'Utils/uriTools';
function send({ method, url, data, token, files }) {
url = url.replace(/^\//, '');
if ('GET' === method && !isEmpty(data)) {
url = uri(url, data);
data = null;
}
let headers = {
'X-Requested-With': 'XMLHttpRequest',
};
if (['PUT', 'DELETE', 'PATCH'].includes(method)) {
headers['X-HTTP-Method-Override'] = method;
}
if (token) {
headers['Authorization'] = `Token ${token}`;
}
if (process.browser) {
if ('GET' !== method) {
// headers['X-CSRF-Token'] = '...CSRF_TOKEN';
if (data && !files) {
[ data, files ] = findFiles(data);
}
}
//console.log({ data, files });
return sendAxios({ method, url, data, headers, files })
}
// Server-side requests shouldn't contain file uploads.
return sendNodeFetch({ method, url, data, headers });
}
function sendNodeFetch({ method, url, data, headers }) {
const options = { method, headers };
if (!isEmpty(data)) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(data);
}
return require('node-fetch')(`http://localhost:3000/${url}`, options).then(res => res.json());
}
function sendAxios({ method, url, data, headers, files }) {
url = url.replace(/^\/*/, '/');
files = pickBy(files); // Remove keys with empty values.
if (!isEmpty(files)) {
headers['Content-Type'] = 'multipart/form-data';
data = Object.entries({
_formJsonData: JSON.stringify(data),
...files,
}).reduce((form, [ name, value ]) => (form.append(name, value), form), new FormData());
}
const options = { method, url, data, headers, withCredentials: true };
return axios.request(options).then(res => res.data);
}
export function get(url, query = {}) {
return send({ method: 'GET', url, data: query });
}
export function post(url, data = {}) {
return send({ method: 'POST', url, data });
}
export function put(url, data = {}) {
return send({ method: 'PUT', url, data });
}
export function del(url, data = {}) {
return send({ method: 'DELETE', url, data });
}
export default {
get,
post,
put,
del,
};
// Only handles `_files` that are at most one level deep.
function findFiles(data) {
let files = data._files || {};
delete data._files;
Object.values(data).forEach(value => {
if (value && value._files) {
files = { ...files, ...value._files };
delete value._files;
}
});
return [ data, files ];
}
/*
axios.interceptors.request.use(request => {
return request
});
axios.interceptors.response.use(response => {
return response
});
*/
import api from 'Services/api';
import { uri } from 'Utils/uriTools';
export default function buildFrontendApi(baseUrl) {
function buildGetUrl(id = '', params = {}) {
if (id && 'object' === typeof id) [id, params] = ['', id];
return uri(baseUrl + (id ? `/${id}` : '') + '.json', params);
}
function buildPostUrl(id = '', params = {}) {
if (id && 'object' === typeof id) [id, params] = ['', id];
return uri(baseUrl + (id ? `/${id}` : ''), params);
}
return {
buildGetUrl,
buildPostUrl,
// "GET" API endpoints are expected to return a "data" property containing the results.
fetchAll: async (params = {}) => (await api.get(buildGetUrl(params))).data,
fetchOne: async (id, params = {}) => (await api.get(buildGetUrl(id, params))).data,
fetchNew: async (params = {}) => (await api.get(buildGetUrl('-new', params))).data,
// "POST" and "PUT" API endpoints will be provided their data in a "data" property.
create: async (data, params = {}) => await api.post(buildPostUrl(params), { data }),
update: async (data, params = {}) => await api.put(buildPostUrl(data._id, params), { data }),
remove: async (data, params = {}) => await api.del(buildPostUrl(data._id || data, params)),
// @TODO createMany()
updateMany: async (data, params = {}) => await api.put(buildPostUrl(params), { data }),
removeMany: async (data, params = {}) => await api.del(buildPostUrl(params), { data }),
/**
* Only use this in the "preload()" function of ".svelte" route files.
* This is technically frontend AND backend, but the backend usage is transparent to the developer.
*
* export async function preload() {
* const { data: users } = userApi.preload(this).fetchAll();
* return { users };
* }
*/
preload(context) {
return {
fetchAll: (params = {}) => preloadFetch.call(context, buildGetUrl(params)),
fetchOne: (id, params = {}) => preloadFetch.call(context, buildGetUrl(id, params)),
fetchNew: (params = {}) => preloadFetch.call(context, buildGetUrl('-new', params)),
};
},
};
}
function preloadFetch(url) {
try {
return this.fetch(url).then(res => res.json());
} catch (error) {
this.error(500, error);
return { data: false };
}
}
import xhrSecure from 'Services/xhrSecure';
import isEmpty from 'lodash/isEmpty';
export function getMany({ check, repository }) {
const handler = async (_req, res) => {
try {
// @TODO Process and pass browsing parameters (filters, sorting, paging).
res.json({ data: await repository.browse() });
} catch (error) {
res.json({ error });
}
};
if (check) {
return xhrSecure({ check, grant: handler });
}
return handler;
}
export function getOne({ check, repository, idParam = 'id' }) {
let handler = async (req, res) => {
const id = req.params[idParam];
try {
let data;
if (id.startsWith('-')) {
data = repository.create();
} else {
data = await repository.fetchOne(id);
}
if (!data) {
throw new Error('No data found.');
}
res.json({ data });
} catch (error) {
res.json({ error, [idParam]: id });
}
};
if (check) {
return xhrSecure({ check, grant: handler });
}
return handler;
}
export function post({ check, repository }) {
const handler = async (req, res) => {
try {
res.json({ result: await repository.add(getFormData(req)) });
} catch (error) {
res.json({ error });
}
};
if (check) {
return xhrSecure({ check, grant: handler });
}
return handler;
}
export function put({ check, repository, idParam = 'id' }) {
const handler = async (req, res) => {
const id = req.params[idParam];
try {
res.json({ result: await repository.update(id, getFormData(req)) });
} catch (error) {
res.json({ error, [idParam]: id });
}
};
if (check) {
return xhrSecure({ check, grant: handler });
}
return handler;
}
export function putMany({ check, repository }) {
const handler = async (req, res) => {
try {
res.json({ result: await repository.updateMany(getFormData(req)) });
} catch (error) {
res.json({ error });
}
};
if (check) {
return xhrSecure({ check, grant: handler });
}
return handler;
}
export function del({ check, repository, idParam = 'id' }) {
const handler = async (req, res) => {
const id = req.params[idParam];
try {
res.json({ result: await repository.remove(id) });
} catch (error) {
res.json({ error, [idParam]: id });
}
};
if (check) {
return xhrSecure({ check, grant: handler });
}
return handler;
}
// @private
function getFormData(req) {
if (Array.isArray(req.body.data)) {
// @TODO Would it matter if an array was empty?
// @TODO Currently ignores file uploads.
return req.body.data;
}
const data = Object.assign({}, req.body.data, req.files);
if (isEmpty(data)) {
throw new Error('Request had no data.');
}
return data;
}
export default {
getMany,
getOne,
post,
put,
putMany,
del,
};
import isEmpty from 'lodash/isEmpty';
/* import isValidObjectId from 'Utils/isValidObjectId'; */
/* import matchByIdOr from 'Utils/matchByIdOr'; */
/**
* Assumes you are using Mongoose.
*/
export default class CrudRepository
{
#ModelClass;
constructor(ModelClass)
{
this.#ModelClass = ModelClass;
}
get ModelClass()
{
return this.#ModelClass;
}
async browse({ match, select, sort, page = 1, limit = 0, populate } = {})
{
const options = {};
if (!isEmpty(sort)) {
options.sort = sort;
}
if (limit > 0) {
options.limit = limit;
options.skip = limit * (page - 1);
}
if (!isEmpty(populate)) {
options.populate = populate;
}
return await this.ModelClass.find(match, select, options);
}
async fetchOne(idOrMatch, { select, populate } = {})
{
const options = {};
if (!isEmpty(populate)) {
options.populate = populate;
}
return await this.ModelClass.findOne(this.makeMatcher(idOrMatch), select, options);
}
async update(idOrMatch, data)
{
return await this.ModelClass.findOneAndUpdate(
this.makeMatcher(idOrMatch),
this.modelData(data, idOrMatch),
{
runValidators: true, // When using find*AndUpdate methods, Mongoose doesn't automatically run validation.
context: 'query',
}
);
}
async updateMany(objects, { upsert = false } = {})
{
return await Promise.all(objects.map(data => this.ModelClass.findByIdAndUpdate(data._id, data, {
runValidators: true, // When using find*AndUpdate methods, mongoose doesn't automatically run validation.
context: 'query',
upsert,
})));
}
async remove(idOrMatch)
{
let result;
const session = await this.ModelClass.startSession();
await session.withTransaction(async _ => {
await this.beforeRemove(idOrMatch);
result = await this.ModelClass.deleteOne(this.makeMatcher(idOrMatch));
await this.afterRemove(idOrMatch, result);
});
session.endSession();
return result;
}
async add(data)
{
return await this.create(data).save();
}
create(data)
{
return new this.ModelClass(data && this.modelData(data));
}
modelData(data)
{
return data;
}
async beforeRemove() {}
async afterRemove() {}
makeMatcher(idOrMatch)
{
return isValidObjectId(idOrMatch) ? {_id: idOrMatch} : idOrMatch;
}
matchByIdOr(id, match)
{
return matchByIdOr(id, match);
}
}
// Utils/isValidObjectId.js
import { Types } from 'mongoose';
/* export default */ function isValidObjectId(value) {
return Types.ObjectId.isValid(value);
}
// Utils/matchByIdOr.js
/* import { Types } from 'mongoose'; */
/* export default */ function matchByIdOr(value, match) {
const matchById = { _id: value };
if (!match || value instanceof Types.ObjectId) {
return matchById;
}
if ('string' === typeof match) {
match = { [match]: value };
}
if (!Types.ObjectId.isValid(value)) {
return match;
}
return { $or: [ matchById, match ] };
}
import { writable, get } from 'svelte/store';
import buildFrontendApi from 'Services/api/buildFrontendApi';
/* import getFileInputFiles from 'Utils/getFileInputFiles'; */
export default function makeFrontendCrudService({ api, apiPath, notify }) {
api = api || buildFrontendApi(apiPath);
const objToCreate = writable();
const objToUpdate = writable();
const objToRemove = writable();
const objToEdit = writable();
const errors = writable({});
let isCreating = false;
objToCreate.subscribe($creating => (objToEdit.set($creating), isCreating = true));
objToUpdate.subscribe($updating => (objToEdit.set($updating), isCreating = false));
async function handleCrudAction(fn, obj, successMsg) {
const { result, error } = await fn(obj);
if (error) return self.handleError(error);
self.reset();
if (notify && successMsg) notify.success(successMsg);
return fn === api.update ? result : obj;
}
const self = {
get api() { return api; },
stores() {
return {
objToCreate,
objToUpdate,
objToRemove,
objToEdit,
errors,
};
},
newObject: async () => await api.fetchNew(),
prepNewObject: async () => objToCreate.set(await self.newObject()),
createObject: async (obj, successMsg) => await handleCrudAction(api.create, obj, successMsg),
updateObject: async (obj, successMsg) => await handleCrudAction(api.update, obj, successMsg),
removeObject: async (obj, successMsg) => await handleCrudAction(api.remove, obj, successMsg),
saveObject: async (obj, createdMsg, updatedMsg) => (
await (isCreating ? self.createThisObject(obj, createdMsg) : self.updateThisObject(obj, updatedMsg))
),
// @TODO createObjects()
updateObjects: async (objects, successMsg) => await handleCrudAction(api.updateMany, objects, successMsg),
removeObjects: async (objects, successMsg) => await handleCrudAction(api.removeMany, objects, successMsg),
reset() {
objToCreate.set(undefined);
objToUpdate.set(undefined);
objToRemove.set(undefined);
errors.set({});
isCreating = false;
},
setFilesOnModel(model, fileInputs) {
if (fileInputs) {
const filesObject = getFileInputFiles(fileInputs);
if (filesObject) model._files = filesObject;
}
return model;
},
handleError(error) {
console.error(error);
if (error.errors) {
errors.set(error.errors);
} else {
notify && notify.error(error.message || error);
}
return false;
},
};
return self;
}
// Utils/getFileInputFiles.js
/* export default */ function getFileInputFiles(inputs, forceObject = false) {
inputs = Array.isArray(inputs) ? inputs : [ inputs ];
const files = {};
let hasFiles = false;
for (let input of inputs) {
if (!input) continue;
let name, file;
if (input instanceof HTMLInputElement) {
name = input.name;
file = input.files && input.files[0];
} else if (input.getFile) {
name = input.getName();
file = input.getFile();
} else {
throw new Error('Invalid file input element/object.');
}
if (!name) throw new Error('File inputs must have a "name" attribute if you want to use "getFileInputFiles()"');
if (!file) continue;
if (files[name]) {
if (!Array.isArray(files[name])) {
files[name] = [ files[name] ];
}
files[name].push(file);
} else {
files[name] = file;
}
hasFiles = true;
}
if (!hasFiles) {
return forceObject ? {} : undefined;
}
return files;
}
import uniq from 'lodash/uniq';
import difference from 'lodash/difference';
export function updateRoles(user, roles) {
Object.entries(roles).forEach(([ role, grant ]) => {
setRole(user, role, grant);
});
return user;
}
export function setRoles(user, roles, grant = true) {
return (grant ? grantRoles : revokeRoles)(user, roles);
}
export function grantRoles(user, roles) {
user.roles = uniq([ ...(user.roles || []), ...normalizeRoles(roles) ]);
return user;
}
export function revokeRoles(user, roles) {
user.roles = difference(user.roles || [], normalizeRoles(roles));
return user;
}
export const setRole = setRoles;
export const grantRole = grantRoles;
export const revokeRole = revokeRoles;
export function hasAnyRole(user, roles) {
if (!user.roles || !user.roles.length) return false;
roles = normalizeRoles(roles);
if (!roles.length) return true;
return Boolean(roles.find(role => user.roles.includes(role)));
}
export function hasAllRoles(user, roles) {
if (!user.roles || !user.roles.length) return false;
roles = normalizeRoles(roles);
if (!roles.length) return true; // or false?
return roles.every(role => user.roles.includes(role));
}
function normalizeRoles(roles) {
if ('string' === typeof roles) {
roles = roles.split(/[ ,]+/g).filter(Boolean);
} else if (!Array.isArray(roles)) {
return [];
}
return roles.map(role => role.toUpperCase());
}
import pick from 'lodash/pick';
import omit from 'lodash/omit';
import uriTemplate from 'uri-templates';
import qs from 'qs';
export function uri(path, params, options) {
let pathQuery, segment;
if (Array.isArray(path)) {
options = options || params;
[ path, params ] = path;
}
params = params || {};
[ path, segment ] = path.split('#');
[ path, pathQuery ] = path.split('?');
pathQuery = pathQuery && qs.parse(pathQuery);
({ path, params } = resolveUriTemplate(path, params));
params = Object.assign({}, pathQuery, params);
let query = qs.stringify(params, options || {});
query = query ? `?${query}` : '';
segment = segment ? `#${segment}` : '';
return path + query + segment;
}
/**
* Handles RFC 6570 URI templates. Any parameters not defined in the template are returned in the "params" property.
*
* resolveUriTemplate('/foo/{bar}/baz', {bar: 42, qux: 53}) === { path: '/foo/42/baz', params: {qux: 53} }
*
* @param {string} path
* @param {object} params
*
* @returns {object} Resolved path and params with used params omitted.
*/
export function resolveUriTemplate(path, params) {
if (path && path.includes('{')) {
const tpl = uriTemplate(path);
const vars = pick(params, tpl.varNames);
if (tpl.varNames.length > Object.keys(vars).length) {
// @TODO uh-oh, not all vars were defined.
}
path = tpl.fill(vars);
params = omit(params, tpl.varNames);
}
return { path, params };
}
export default {
uri,
resolveUriTemplate,
};
import get from 'lodash/get';
import { hasAnyRole } from 'Utils/roleUtils';
export default function xhrSecure({ check = 'ROLE_USER', grant: onGranted, deny: onDenied }) {
return (req, res, next) => {
// Do server-side requests have access to the user session?
const denied = denyAccess({ check, req, res, next });
if (!denied) { return onGranted(req, res, next); }
if (onDenied) { return onDenied(req, res, next); }
res.status(403).json({ error: `[xhrSecure] Permission denied: ${denied}` });
};
}
function denyAccess({ check, req, res, next }) {
const { user } = req;
if (!user) {
// For now we assume all xhrSecure() checks require a logged in user.
return 'User is not logged in.';
}
let allow, message, typeOfCheck = typeof check;
if ('object' === typeof check) {
({ check, message } = check);
typeOfCheck = typeof check;
}
switch (typeOfCheck) {
case 'function':
allow = check(req, res, next);
if ('string' === typeof allow) return allow;
return !allow && (message || 'Check function returned false.');
case 'array': // For now assume an array of roles.
check = check.join(',');
// Intentional fall-through...
case 'string':
if (check.includes('ROLE_')) {
// @TODO hasAllRoles()
return !hasAnyRole(user, check) && (message || 'User does not have the required role.');
}
allow = get(user, check);
if ('function' === typeof allow) {
allow = allow.call(user);
if ('string' === typeof allow) return allow;
return !allow && (message || 'User check method returned false.');
}
return !allow && (message || 'User check property was false.');
default:
return `Invalid "check:" type: ${typeOfCheck}`;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment