Skip to content

Commit

Permalink
added resource access, started work on zod validation.
Browse files Browse the repository at this point in the history
  • Loading branch information
Morgul committed Oct 3, 2024
1 parent a28deaf commit 8633722
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 139 deletions.
12 changes: 6 additions & 6 deletions docs/revamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ Back at it again.
* [X] Switch to Bootstrap v5 (and Bootstrap-Vue-Next)
* [X] Switch to Vue 3
* [ ] Change FontAwesome imports to [tree-shakable][] version
* ~~[ ] Switch to New [Color-picker][]~~ Old one works fine
* [ ] Convert from decoders to better validation/data model
* [ ] Evaluate options in 2024 ([ajv][], [ajv-ts][] [joi][], [zod][], etc)
* [X] ~~Switch to New [Color-picker][]~~ Old one works fine
* [ ] Add local user registration, link with Google / Facebook / Twitter
* [ ] Remove/replace the 'supplements' system with a more customized system per system.
* [ ] Migrate to a plain data model
* [ ] Convert from decoders to better validation/data model
* [X] Evaluate options in 2024 (~~[ajv][], [ajv-ts][] [joi][],~~ [zod][]~~, etc~~)
* [ ] Remove/replace the 'supplements' system with a more customized system per system.
* [ ] Move project to GitLab (and make GitHub a mirror)
* [ ] Set Pipelines for CI/CD
* [ ] Dev deploys on all master merges
* [ ] Beta deploys on all beta tags
* [ ] Beta deploys on all merges
* [ ] Prod deploys on all release tags

## New Features
Expand Down
51 changes: 51 additions & 0 deletions src/server/engines/validation/models/character.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// ---------------------------------------------------------------------------------------------------------------------
// Character Validation Model
// ---------------------------------------------------------------------------------------------------------------------

import { z } from 'zod';

// Models
import { HashID } from './common';

// Utils
import { cssColorRegEx, jsonSchema } from '../utils';
import { AccountID } from './account';

// ---------------------------------------------------------------------------------------------------------------------

export const CharacterID = HashID;

export const Character = z.object({
id: CharacterID,
system: z.string().min(1), // This could be an enum of known systems? How can I generate it?
name: z.string(),
description: z.string().optional(),
portrait: z.string().url()
.optional(),
thumbnail: z.string().url()
.optional(),
color: z.string().regex(cssColorRegEx)
.optional(),
campaign: z.string().optional(),
accountID: z.string(),
noteID: z.string(),
details: jsonSchema.optional() // This will need to be based on the system.
});

// ---------------------------------------------------------------------------------------------------------------------
// Request Validations
// ---------------------------------------------------------------------------------------------------------------------

export const RouteParams = z.object({
charID: CharacterID
});

export const CharFilter = z.object({
id: z.union([ AccountID, z.array(AccountID) ]).optional(),
email: z.union([ z.string().email(), z.array(z.string().email()) ])
.optional(),
name: z.union([ z.string().min(1), z.array(z.string().min(1)) ])
.optional()
});

// ---------------------------------------------------------------------------------------------------------------------
19 changes: 19 additions & 0 deletions src/server/engines/validation/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// ---------------------------------------------------------------------------------------------------------------------
// Validation Utils
// ---------------------------------------------------------------------------------------------------------------------

import { z } from 'zod';

// ---------------------------------------------------------------------------------------------------------------------

export const cssColorRegEx = /(#(?:[0-9a-f]{2}){2,4}$|(#[0-9a-f]{3}$)|(rgb|hsl)a?\((-?\d+%?[,\s]+){2,3}\s*[\d.]+%?\)$|black$|silver$|gray$|whitesmoke$|maroon$|red$|purple$|fuchsia$|green$|lime$|olivedrab$|yellow$|navy$|blue$|teal$|aquamarine$|orange$|aliceblue$|antiquewhite$|aqua$|azure$|beige$|bisque$|blanchedalmond$|blueviolet$|brown$|burlywood$|cadetblue$|chartreuse$|chocolate$|coral$|cornflowerblue$|cornsilk$|crimson$|currentcolor$|darkblue$|darkcyan$|darkgoldenrod$|darkgray$|darkgreen$|darkgrey$|darkkhaki$|darkmagenta$|darkolivegreen$|darkorange$|darkorchid$|darkred$|darksalmon$|darkseagreen$|darkslateblue$|darkslategray$|darkslategrey$|darkturquoise$|darkviolet$|deeppink$|deepskyblue$|dimgray$|dimgrey$|dodgerblue$|firebrick$|floralwhite$|forestgreen$|gainsboro$|ghostwhite$|goldenrod$|gold$|greenyellow$|grey$|honeydew$|hotpink$|indianred$|indigo$|ivory$|khaki$|lavenderblush$|lavender$|lawngreen$|lemonchiffon$|lightblue$|lightcoral$|lightcyan$|lightgoldenrodyellow$|lightgray$|lightgreen$|lightgrey$|lightpink$|lightsalmon$|lightseagreen$|lightskyblue$|lightslategray$|lightslategrey$|lightsteelblue$|lightyellow$|limegreen$|linen$|mediumaquamarine$|mediumblue$|mediumorchid$|mediumpurple$|mediumseagreen$|mediumslateblue$|mediumspringgreen$|mediumturquoise$|mediumvioletred$|midnightblue$|mintcream$|mistyrose$|moccasin$|navajowhite$|oldlace$|olive$|orangered$|orchid$|palegoldenrod$|palegreen$|paleturquoise$|palevioletred$|papayawhip$|peachpuff$|peru$|pink$|plum$|powderblue$|rosybrown$|royalblue$|saddlebrown$|salmon$|sandybrown$|seagreen$|seashell$|sienna$|skyblue$|slateblue$|slategray$|slategrey$|snow$|springgreen$|steelblue$|tan$|thistle$|tomato$|transparent$|turquoise$|violet$|wheat$|white$|yellowgreen$|rebeccapurple$)/i;

// ---------------------------------------------------------------------------------------------------------------------

export const literalSchema = z.union([ z.string(), z.number(), z.boolean(), z.null() ]);
export type Literal = z.infer<typeof literalSchema>;
export type Json = Literal | { [key : string] : Json } | Json[];
export const jsonSchema : z.ZodType<Json> = z.lazy(() =>
z.union([ literalSchema, z.array(jsonSchema), z.record(jsonSchema) ]));

// ---------------------------------------------------------------------------------------------------------------------
131 changes: 13 additions & 118 deletions src/server/managers/character.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,149 +4,44 @@

// Managers
import * as notebookMan from './notebook';
import systemMan from './system';

// Models
import { Character } from '../models/character';
import { Character } from '../../common/interfaces/models/character';

// Resource Access
import * as characterRA from '../resource-access/character';

// Utils
import { getDB } from '../utils/database';
import { MultipleResultsError, NotFoundError } from '../errors';
import { FilterToken } from '../routes/utils';
import { applyFilters } from '../knex/utils';
import { shortID } from '../utils/misc';
import { broadcast } from '../utils/sio';

//----------------------------------------------------------------------------------------------------------------------

export async function get(id : string) : Promise<Character>
{
const db = await getDB();
const characters = await db('character as char')
.select(
'char.character_id as id',
'char.system',
'char.name',
'char.description',
'char.portrait',
'char.thumbnail',
'char.color',
'char.campaign',
'char.details',
'acc.account_id as accountID',
'note.note_id as noteID'
)
.join('note', 'note.note_id', '=', 'char.note_id')
.join('account as acc', 'acc.account_id', '=', 'char.account_id')
.where({ 'char.character_id': id });

if(characters.length > 1)
{
throw new MultipleResultsError('character');
}
else if(characters.length === 0)
{
throw new NotFoundError(`No character with id '${ id }' found.`);
}
else
{
const char = Character.fromDB(characters[0]);
return systemMan.validateCharacterDetails(char);
}
return characterRA.get(id);
}

export async function list(filters : Record<string, FilterToken> = {}) : Promise<Character[]>
{
const db = await getDB();
let query = db('character as char')
.select(
'char.character_id as id',
'char.system',
'char.name',
'char.description',
'char.portrait',
'char.thumbnail',
'char.color',
'char.campaign',
'char.details',
'acc.account_id as accountID',
'note.note_id as noteID'
)
.join('note', 'note.note_id', '=', 'char.note_id')
.join('account as acc', 'acc.account_id', '=', 'char.account_id');

// Apply any filters
query = applyFilters(query, filters);

return Promise.all((await query)
.map(Character.fromDB)
.map(async(char) => systemMan.validateCharacterDetails(char)));
return characterRA.list(filters);
}

export async function add(accountID : string, newCharacter : Record<string, unknown>) : Promise<Character>
export async function add(accountID : string, newCharacter : Omit<Character, 'id'>) : Promise<Character>
{
const notebook = await notebookMan.add();

const char = Character.fromJSON({ ...newCharacter, id: shortID(), noteID: notebook.id, accountID });

const db = await getDB();
await db('character')
.insert(char.toDB());

// We know this is a string since it's set above.
return get(char.id as string);
return characterRA.add(accountID, { ...newCharacter, noteID: notebook.id });
}

export async function update(charID : string, updateChar : Record<string, unknown>) : Promise<Character>
export async function update(charID : string, updateChar : Partial<Character>) : Promise<Character>
{
const char = await get(charID);

// Mix the current character with the allowed updates.
const allowedUpdate = {
...char.toJSON(),
name: updateChar.name,
description: updateChar.description,
portrait: updateChar.portrait,
thumbnail: updateChar.thumbnail,
color: updateChar.color,
campaign: updateChar.campaign,
details: updateChar.details
};

// Make a new character object
const newCharacter = Character.fromJSON(allowedUpdate);

// Update the database
const db = await getDB();
await db('character')
.update(newCharacter.toDB())
.where({ character_id: charID });

const newChar = await get(charID);

// Broadcast the update
await broadcast('/characters', {
type: 'update',
resource: charID,
payload: newChar.toJSON()
});

// Return the updated record
return newChar;
return characterRA.update(charID, updateChar);
}

export async function remove(charID : string) : Promise<{ status : 'ok' }>
{
const db = await getDB();
await db('character')
.where({ character_id: charID })
.delete();

// Broadcast the update
await broadcast('/characters', {
type: 'remove',
resource: charID
});
const char = await characterRA.get(charID);
await notebookMan.remove(char.noteID);
await characterRA.remove(charID);

return { status: 'ok' };
}
Expand Down
130 changes: 130 additions & 0 deletions src/server/resource-access/character.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//----------------------------------------------------------------------------------------------------------------------
// Character Manager
//----------------------------------------------------------------------------------------------------------------------

// Models
import { Character } from '../../common/interfaces/models/character';

// Transforms
import * as CharTransforms from './transforms/character';

// Resource Access
import systemRA from './system';

// Utils
import { getDB } from '../utils/database';
import { FilterToken } from '../routes/utils';
import { applyFilters } from '../knex/utils';
import { shortID, snakeCaseKeys } from '../utils/misc';
import { broadcast } from '../utils/sio';

import { MultipleResultsError, NotFoundError } from '../errors';

//----------------------------------------------------------------------------------------------------------------------

export async function get(id : string) : Promise<Character>
{
const db = await getDB();
const characters = await db('character as char')
.select()
.where({ 'char.character_id': id });

if(characters.length > 1)
{
throw new MultipleResultsError('character');
}
else if(characters.length === 0)
{
throw new NotFoundError(`No character with id '${ id }' found.`);
}
else
{
const char = CharTransforms.fromDB(characters[0]);
return systemRA.validateCharacterDetails(char);
}
}

export async function list(filters : Record<string, FilterToken> = {}) : Promise<Character[]>
{
const db = await getDB();
let query = db('character as char')
.select();

// Snake case the filters
filters = snakeCaseKeys(filters);

// Apply any filters
query = applyFilters(query, filters);

return Promise.all((await query)
.map(CharTransforms.fromDB)
.map(async(char) => systemRA.validateCharacterDetails(char)));
}

export async function add(accountID : string, newCharacter : Omit<Character, 'id'>) : Promise<Character>
{
const char = CharTransforms.toDB({ ...newCharacter, id: shortID(), accountID });

const db = await getDB();
await db('character')
.insert(char);

// We know this is a string since it's set above.
return get(char.character_id);
}

export async function update(charID : string, updateChar : Partial<Character>) : Promise<Character>
{
const char = await get(charID);

// Mix the current character with the allowed updates.
const allowedUpdate = {
...char,
name: updateChar.name ?? char.name,
description: updateChar.description ?? char.description,
portrait: updateChar.portrait ?? char.portrait,
thumbnail: updateChar.thumbnail ?? char.thumbnail,
color: updateChar.color ?? char.color,
campaign: updateChar.campaign ?? char.campaign,
details: updateChar.details ?? char.details
};

// Make a new character object
const newCharacter = CharTransforms.toDB(allowedUpdate);

// Update the database
const db = await getDB();
await db('character')
.update(newCharacter)
.where({ character_id: charID });

const newChar = await get(charID);

// Broadcast the update
await broadcast('/characters', {
type: 'update',
resource: charID,
payload: newChar
});

// Return the updated record
return newChar;
}

export async function remove(charID : string) : Promise<{ status : 'ok' }>
{
const db = await getDB();
await db('character')
.where({ character_id: charID })
.delete();

// Broadcast the update
await broadcast('/characters', {
type: 'remove',
resource: charID
});

return { status: 'ok' };
}

//----------------------------------------------------------------------------------------------------------------------
Loading

0 comments on commit 8633722

Please sign in to comment.