From a28deafebe10dbf83404381f3316722b02a7c2ae Mon Sep 17 00:00:00 2001 From: "Christopher S. Case" Date: Thu, 23 May 2024 16:09:36 -0500 Subject: [PATCH] converted supplements over to use plain objects. --- src/server/decoders/supplement.ts | 40 -------- .../engines/validation/models/supplement.ts | 32 +++++++ src/server/managers/supplement.ts | 6 +- src/server/models/supplement.ts | 95 ------------------- src/server/resource-access/supplement.ts | 51 +++++----- .../resource-access/transforms/supplement.ts | 79 +++++++++++++++ src/server/routes/systems/utils/supplement.ts | 90 +++++++++++------- 7 files changed, 196 insertions(+), 197 deletions(-) delete mode 100644 src/server/decoders/supplement.ts create mode 100644 src/server/engines/validation/models/supplement.ts delete mode 100644 src/server/models/supplement.ts create mode 100644 src/server/resource-access/transforms/supplement.ts diff --git a/src/server/decoders/supplement.ts b/src/server/decoders/supplement.ts deleted file mode 100644 index 9d9f3bb6..00000000 --- a/src/server/decoders/supplement.ts +++ /dev/null @@ -1,40 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Supplements -// --------------------------------------------------------------------------------------------------------------------- - -import { string, truthy, Decoder, optional } from 'decoders'; - -// System Decoders -import { getEotESupplementDecoder, getGenesysSupplementDecoder } from './systems/eote'; - -// Utils -import { enumStr, nullToUndefined, stringWithLength, withDefault } from './utils'; -import { MissingDecoderError } from '../errors'; - -// --------------------------------------------------------------------------------------------------------------------- - -export const supplementalDecoderPartial = { - name: stringWithLength(1, 255), - owner: nullToUndefined(optional(string)), - scope: withDefault(enumStr([ 'public', 'user' ]), 'user'), - official: withDefault(truthy, false) // This is 'truthy', because sqlite returns 0 or 1 for booleans. -}; - -// --------------------------------------------------------------------------------------------------------------------- - -export function getSupplementDecoder(system : string, type : string) : Decoder -{ - switch (system) - { - case 'eote': - return getEotESupplementDecoder(type); - - case 'genesys': - return getGenesysSupplementDecoder(type); - - default: - throw new MissingDecoderError(`${ system }/${ type }`); - } -} - -// --------------------------------------------------------------------------------------------------------------------- diff --git a/src/server/engines/validation/models/supplement.ts b/src/server/engines/validation/models/supplement.ts new file mode 100644 index 00000000..da0866cb --- /dev/null +++ b/src/server/engines/validation/models/supplement.ts @@ -0,0 +1,32 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Supplement Validation Model +// --------------------------------------------------------------------------------------------------------------------- + +import { z } from 'zod'; + +// Models +import { HashID } from './common'; +import { NotebookID } from './notebook'; + +// --------------------------------------------------------------------------------------------------------------------- + +export const SupplementID = HashID; + +export const Supplement = z.object({ + id: SupplementID, + name: z.string().min(1), + owner: z.string().min(1) + .optional(), + scope: z.enum([ 'public', 'user' ]), + official: z.boolean() +}).passthrough(); + +// --------------------------------------------------------------------------------------------------------------------- +// Request Validations +// --------------------------------------------------------------------------------------------------------------------- + +export const RouteParams = z.object({ + suppID: SupplementID +}); + +// --------------------------------------------------------------------------------------------------------------------- diff --git a/src/server/managers/supplement.ts b/src/server/managers/supplement.ts index 518fbb27..f5276ec4 100644 --- a/src/server/managers/supplement.ts +++ b/src/server/managers/supplement.ts @@ -4,7 +4,7 @@ // Models import { Account } from '../../common/interfaces/models/account'; -import { Supplement } from '../models/supplement'; +import { Supplement } from '../../common/interfaces/models/supplement'; // Resource Access import * as suppRA from '../resource-access/supplement'; @@ -34,7 +34,7 @@ export async function exists(id : number, type : string, systemPrefix : string, } export async function add( - newSupplement : Record, + newSupplement : Supplement, type : string, systemPrefix : string, account ?: Account ) : Promise @@ -44,7 +44,7 @@ export async function add( export async function update( id : number, - updateSup : Record, + updateSup : Partial, type : string, systemPrefix : string, account ?: Account ) : Promise diff --git a/src/server/models/supplement.ts b/src/server/models/supplement.ts deleted file mode 100644 index 36542761..00000000 --- a/src/server/models/supplement.ts +++ /dev/null @@ -1,95 +0,0 @@ -//---------------------------------------------------------------------------------------------------------------------- -// Supplement -//---------------------------------------------------------------------------------------------------------------------- - -import * as JsonDecoder from 'decoders'; - -// Decoders -import { getSupplementDecoder } from '../decoders/supplement'; - -// Models -import { SupplementOptions } from '../../common/interfaces/models/supplement'; - -// Utils -import { snakeCaseKeys } from '../utils/misc'; - -//---------------------------------------------------------------------------------------------------------------------- - -export class Supplement -{ - public readonly id : number; - public readonly name : string; - - public owner ?: string; - public scope : 'public' | 'user' = 'user'; - public official = false; - public record : Record = {}; - - constructor(options : SupplementOptions) - { - // So we store the full thing as a record, to make serialization easier. - this.record = options; - - // However, we pull some parts to the top level to make working with the supplement API portions easier. - this.id = options.id; - this.name = options.name; - this.owner = options.owner; - this.scope = options.scope; - this.official = options.official; - } - - //------------------------------------------------------------------------------------------------------------------ - // Serialization - //------------------------------------------------------------------------------------------------------------------ - - public toJSON() : Record - { - return { - ...this.record, - id: this.id, - name: this.name, - owner: this.owner, - scope: this.scope, - official: this.official - }; - } - - public toDB() : Record - { - const { id, ...suppDef } = snakeCaseKeys(this.toJSON()); - - // And nested keys need to become json strings - for(const key in suppDef) - { - const value = suppDef[key]; - - if(typeof value === 'object' && value !== null) - { - suppDef[key] = JSON.stringify(value); - } - } - - return { - ...suppDef, - owner: this.owner ?? null - }; - } - - //------------------------------------------------------------------------------------------------------------------ - // Deserialization - //------------------------------------------------------------------------------------------------------------------ - - static fromDB(system : string, type : string, supplementRecord : Record) : Supplement - { - const decoder = JsonDecoder.guard(getSupplementDecoder(system, type) as JsonDecoder.Decoder); - return new Supplement(decoder(supplementRecord)); - } - - static fromJSON(system : string, type : string, jsonObj : Record) : Supplement - { - const decoder = JsonDecoder.guard(getSupplementDecoder(system, type) as JsonDecoder.Decoder); - return new Supplement(decoder(jsonObj)); - } -} - -//---------------------------------------------------------------------------------------------------------------------- diff --git a/src/server/resource-access/supplement.ts b/src/server/resource-access/supplement.ts index 04fc22e6..0102c119 100644 --- a/src/server/resource-access/supplement.ts +++ b/src/server/resource-access/supplement.ts @@ -6,18 +6,18 @@ import { inspect } from 'node:util'; import { Knex } from 'knex'; import logging from '@strata-js/util-logging'; -// Managers - // Models import { Account } from '../../common/interfaces/models/account'; -import { Supplement } from '../models/supplement'; +import { Supplement } from '../../common/interfaces/models/supplement'; + +// Transforms +import * as SuppTransforms from '../resource-access/transforms/supplement'; // Utilities import { getDB } from '../utils/database'; -import * as permMan from '../utils/permissions'; +import { hasPerm } from '../utils/permissions'; import { applyFilters } from '../knex/utils'; import { FilterToken } from '../routes/utils'; -import { camelCaseKeys } from '../utils/misc'; // Errors import { MultipleResultsError, DuplicateSupplementError, NotFoundError, NotAuthorizedError } from '../errors'; @@ -37,7 +37,7 @@ async function $checkViewAccess( if(account && systemPrefix) { // Generally, this is just going to be admins; but hey, why not let admins see everything? - if(!permMan.hasPerm(account, `${ systemPrefix }/canViewContent`)) + if(!hasPerm(account, `${ systemPrefix }/canViewContent`)) { // Add scoping in query = query.where(function() @@ -60,7 +60,7 @@ async function $checkModAccess( if(account) { // Check if we have permission to remove - const hasRight = permMan.hasPerm(account, `${ systemPrefix }/canModifyContent`); + const hasRight = hasPerm(account, `${ systemPrefix }/canModifyContent`); const isOwner = supplement.scope === 'user' && account.id === supplement.owner; if(!hasRight && !isOwner) { @@ -81,7 +81,7 @@ async function $ensureOfficialAllowed( if(account) { // Check if we have permission to set official - const hasRight = permMan.hasPerm(account, `${ systemPrefix }/canSetOfficial`); + const hasRight = hasPerm(account, `${ systemPrefix }/canSetOfficial`); if(!hasRight) { supplement.official = false; @@ -97,7 +97,7 @@ async function $ensureCorrectOwner( { if(account && systemPrefix) { - const hasRight = permMan.hasPerm(account, `${ systemPrefix }/canModifyContent`); + const hasRight = hasPerm(account, `${ systemPrefix }/canModifyContent`); const isOwner = account.id === supplement.owner; // If we're not an admin user, and therefore allowed to add/edit content for other people, we have to make sure @@ -123,8 +123,7 @@ export async function get(id : number, type : string, systemPrefix : string, acc const tableName = `${ systemPrefix }_${ type }`; const db = await getDB(); const query = db(`${ tableName } as t`) - .select('t.*', 'a.account_id as ownerHash') - .leftJoin('account as a', 'a.account_id', '=', 't.owner') + .select('t.*') .where({ id }); // Handle retrieval @@ -139,8 +138,8 @@ export async function get(id : number, type : string, systemPrefix : string, acc } else { - const { ownerHash, ...restSupp } = supplements[0]; - return Supplement.fromDB(systemPrefix, type, { ...camelCaseKeys(restSupp), owner: ownerHash }); + // TODO: We should probably ask the system to decode the non-supplement fields. + return SuppTransforms.fromDB(supplements[0]); } } @@ -153,8 +152,7 @@ export async function list( const tableName = `${ systemPrefix }_${ type }`; const db = await getDB(); let query = db(`${ tableName } as t`) - .select('t.*', 'a.account_id as ownerHash') - .leftJoin('account as a', 'a.account_id', '=', 't.owner'); + .select('t.*'); // Add filters for only what we have access to query = await $checkViewAccess(query, systemPrefix, account); @@ -164,8 +162,8 @@ export async function list( return (await query).map((supp) => { - const { ownerHash, ...restSupp } = supp; - return Supplement.fromDB(systemPrefix, type, { ...camelCaseKeys(restSupp), owner: ownerHash }); + // TODO: We should probably ask the system to decode the non-supplement fields. + return SuppTransforms.fromDB(supp); }); } @@ -181,14 +179,15 @@ export async function exists(id : number, type : string, systemPrefix : string, } export async function add( - newSupplement : Record, + newSupplement : Supplement, type : string, systemPrefix : string, account ?: Account ) : Promise { const db = await getDB(); const tableName = `${ systemPrefix }_${ type }`; - const supplement = Supplement.fromJSON(systemPrefix, type, newSupplement); + + const supplement = SuppTransforms.toDB(newSupplement); // Ensure the supplement's ownership is valid. await $ensureCorrectOwner(supplement, systemPrefix, account); @@ -209,13 +208,13 @@ export async function add( { logger.warn( 'Attempted to add supplement with the same name, scope and owner as an existing one:', - inspect(supplement.toJSON(), { depth: null }) + inspect(supplement, { depth: null }) ); throw new DuplicateSupplementError(`${ systemPrefix }/${ type }/${ supplement.name }`); } // Now, we insert the supplement - const [ id ] = await db(tableName).insert(supplement.toDB()); + const [ id ] = await db(tableName).insert(supplement); // Return the inserted supplement return get(id, type, systemPrefix, account); @@ -223,7 +222,7 @@ export async function add( export async function update( id : number, - updateSup : Record, + updateSup : Partial, type : string, systemPrefix : string, account ?: Account ) : Promise @@ -236,13 +235,13 @@ export async function update( // updatable, we assume everything but ID is. Instead of trying to destructure just id out, we apply everything, // and re-apply id. It's less efficient, but more explicit. const allowedUpdate = { - ...supplement.toJSON(), + ...supplement, ...updateSup, id }; - // Make a new character object - const newSupplement = Supplement.fromJSON(systemPrefix, type, allowedUpdate); + // Make a new supplement object + const newSupplement = SuppTransforms.toDB(allowedUpdate); // Ensure the supplement's ownership is valid. await $ensureCorrectOwner(supplement, systemPrefix, account); @@ -255,7 +254,7 @@ export async function update( // Now, we update the supplement await db(tableName) - .update(newSupplement.toDB()) + .update(newSupplement) .where({ id }); // Return the updated supplement diff --git a/src/server/resource-access/transforms/supplement.ts b/src/server/resource-access/transforms/supplement.ts new file mode 100644 index 00000000..05fbbf37 --- /dev/null +++ b/src/server/resource-access/transforms/supplement.ts @@ -0,0 +1,79 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Supplement Database Transform +// --------------------------------------------------------------------------------------------------------------------- + +import { Supplement } from '../../../common/interfaces/models/supplement'; +import { camelCaseKeys, snakeCaseKeys } from '../../utils/misc'; + +// --------------------------------------------------------------------------------------------------------------------- + +export interface SupplementDBSchema extends Supplement +{ + // No additional fields +} + +// --------------------------------------------------------------------------------------------------------------------- + +export function fromDB(dbObj : SupplementDBSchema) : Supplement +{ + const { id, name, scope, owner, official, ...rest } = dbObj; + const suppDef = camelCaseKeys(rest); + + // And nested keys need to be parsed from json strings + for(const key in suppDef) + { + const value = suppDef[key]; + + // TODO: We need to handle nested objects better, but for now we'll just attempt to parse all strings, and + // ignore failures. That should work well enough, but it's very non-optimal. + if(typeof value === 'string') + { + try + { + suppDef[key] = JSON.parse(value); + } + catch (err) + { + // Ignore failures and just return the string + suppDef[key] = value; + } + } + } + + return { + ...suppDef, + id, + name, + scope, + owner, + official + }; +} + +export function toDB(model : Supplement) : SupplementDBSchema +{ + const { id, name, scope, owner, official, ...rest } = model; + const suppDef = snakeCaseKeys(rest); + + // And nested keys need to become json strings + for(const key in suppDef) + { + const value = suppDef[key]; + + if(typeof value === 'object' && value !== null) + { + suppDef[key] = JSON.stringify(value); + } + } + + return { + ...suppDef, + id, + name, + scope, + owner, + official + }; +} + +// --------------------------------------------------------------------------------------------------------------------- diff --git a/src/server/routes/systems/utils/supplement.ts b/src/server/routes/systems/utils/supplement.ts index 3339a7c2..eb964cda 100644 --- a/src/server/routes/systems/utils/supplement.ts +++ b/src/server/routes/systems/utils/supplement.ts @@ -1,5 +1,5 @@ //---------------------------------------------------------------------------------------------------------------------- -// SupplementUtils +// Supplement Utils //---------------------------------------------------------------------------------------------------------------------- import { IRouter } from 'express'; @@ -7,6 +7,10 @@ import { IRouter } from 'express'; // Managers import * as suppMan from '../../../managers/supplement'; +// Validation +import * as SuppValidators from '../../../engines/validation/models/supplement'; +import { processRequest, validationErrorHandler } from '../../../engines/validation/express'; + // Utils import { ensureAuthenticated, parseQuery, convertQueryToRecord } from '../../utils'; @@ -25,7 +29,7 @@ export function buildSupplementRoute(router : IRouter, path : string, type : str resp.json(await suppMan.list(filters, type, systemPrefix, req.user)); }); - router.get(`${ path }/:suppID`, async(req, resp) => + router.get(`${ path }/:suppID`, processRequest({ params: SuppValidators.RouteParams }), async(req, resp) => { const suppID = parseInt(req.params.suppID); if(Number.isFinite(suppID)) @@ -42,44 +46,64 @@ export function buildSupplementRoute(router : IRouter, path : string, type : str } }); - router.post(path, ensureAuthenticated, async(req, resp) => - { - resp.json(await suppMan.add(req.body, type, systemPrefix, req.user)); - }); - - router.patch(`${ path }/:suppID`, ensureAuthenticated, async(req, resp) => - { - const suppID = parseInt(req.params.suppID); - if(Number.isFinite(suppID)) - { - resp.json(await suppMan.update(suppID, req.body, type, systemPrefix, req.user)); - } - else + router.post( + path, + ensureAuthenticated, + processRequest({ body: SuppValidators.Supplement.omit({ id: true }) }), async(req, resp) => { - resp.status(404) - .json({ - type: 'NotFound', - message: `No ${ type } with id '${ suppID }' found.` - }); + resp.json(await suppMan.add(req.body, type, systemPrefix, req.user)); } - }); + ); - router.delete(`${ path }/:suppID`, ensureAuthenticated, async(req, resp) => - { - const suppID = parseInt(req.params.suppID); - if(Number.isFinite(suppID)) + router.patch( + `${ path }/:suppID`, + ensureAuthenticated, + processRequest({ params: SuppValidators.RouteParams, body: SuppValidators.Supplement }), + async(req, resp) => { - resp.json(await suppMan.remove(suppID, type, systemPrefix, req.user)); + const suppID = parseInt(req.params.suppID); + if(Number.isFinite(suppID)) + { + resp.json(await suppMan.update(suppID, req.body, type, systemPrefix, req.user)); + } + else + { + resp.status(404) + .json({ + type: 'NotFound', + message: `No ${ type } with id '${ suppID }' found.` + }); + } } - else + ); + + router.delete( + `${ path }/:suppID`, + ensureAuthenticated, + processRequest({ params: SuppValidators.RouteParams }), + async(req, resp) => { - resp.status(404) - .json({ - type: 'NotFound', - message: `No ${ type } with id '${ suppID }' found.` - }); + const suppID = parseInt(req.params.suppID); + if(Number.isFinite(suppID)) + { + resp.json(await suppMan.remove(suppID, type, systemPrefix, req.user)); + } + else + { + resp.status(404) + .json({ + type: 'NotFound', + message: `No ${ type } with id '${ suppID }' found.` + }); + } } - }); + ); + + //------------------------------------------------------------------------------------------------------------------ + + router.use(validationErrorHandler); + + //------------------------------------------------------------------------------------------------------------------ } //----------------------------------------------------------------------------------------------------------------------