Last active
October 5, 2021 14:02
-
-
Save jasonhofer/da6d992035ce6d51cd38536bfcbd006d to your computer and use it in GitHub Desktop.
Svelte/Sapper project files.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
}); | |
*/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ] }; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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