- Delete old Vite+Svelte frontend - Initialize new SvelteKit project with TypeScript - Configure Tailwind CSS v4 + DaisyUI - Implement JWT authentication with auto-refresh - Create login page with form validation (Zod) - Add protected route guards - Update Docker configuration for single-stage build - Add E2E tests with Playwright (6/11 passing) - Fix Svelte 5 reactivity with $state() runes Known issues: - 5 E2E tests failing (timing/async issues) - Token refresh implementation needs debugging - Validation error display timing
283 lines
9.9 KiB
JavaScript
283 lines
9.9 KiB
JavaScript
// Taken from https://github.com/lightsofapollo/joi-to-json-schema and converted to ESM
|
|
// TODO: Need more tests
|
|
function assert(condition, errorMessage) {
|
|
if (!condition)
|
|
throw new Error(errorMessage);
|
|
}
|
|
const TYPES = {
|
|
alternatives: (schema, joi, transformer) => {
|
|
const result = (schema.oneOf = []);
|
|
joi.matches.forEach(function (match) {
|
|
if (match.schema) {
|
|
return result.push(convert(match.schema, transformer));
|
|
}
|
|
if (!match.is) {
|
|
throw new Error('joi.when requires an "is"');
|
|
}
|
|
if (!(match.then || match.otherwise)) {
|
|
throw new Error('joi.when requires one or both of "then" and "otherwise"');
|
|
}
|
|
if (match.then) {
|
|
result.push(convert(match.then, transformer));
|
|
}
|
|
if (match.otherwise) {
|
|
result.push(convert(match.otherwise, transformer));
|
|
}
|
|
});
|
|
return schema;
|
|
},
|
|
date: (schema) => {
|
|
schema.type = 'Date';
|
|
/*
|
|
if (joi._flags.timestamp) {
|
|
schema.type = 'integer';
|
|
schema.format = 'unix-time';
|
|
return schema;
|
|
}
|
|
|
|
schema.type = 'string';
|
|
schema.format = 'date-time';
|
|
*/
|
|
return schema;
|
|
},
|
|
any: (schema) => {
|
|
delete schema.type;
|
|
//schema.type = ['array', 'boolean', 'number', 'object', 'string', 'null'];
|
|
return schema;
|
|
},
|
|
array: (schema, joi, transformer) => {
|
|
schema.type = 'array';
|
|
joi._rules?.forEach((test) => {
|
|
switch (test.name) {
|
|
case 'unique':
|
|
schema.uniqueItems = true;
|
|
break;
|
|
case 'length':
|
|
schema.minItems = schema.maxItems = test.args.limit;
|
|
break;
|
|
case 'min':
|
|
schema.minItems = test.args.limit;
|
|
break;
|
|
case 'max':
|
|
schema.maxItems = test.args.limit;
|
|
break;
|
|
}
|
|
});
|
|
if (joi.$_terms) {
|
|
/*
|
|
Ordered is not a part of the spec.
|
|
if (joi.$_terms.ordered.length) {
|
|
schema.ordered = joi.$_terms.ordered.map((item) => convert(item, transformer));
|
|
}
|
|
*/
|
|
let list;
|
|
if (joi.$_terms._inclusions.length) {
|
|
list = joi.$_terms._inclusions;
|
|
}
|
|
else if (joi.$_terms._requireds.length) {
|
|
list = joi.$_terms._requireds;
|
|
}
|
|
if (list) {
|
|
schema.items = convert(list[0], transformer);
|
|
}
|
|
}
|
|
return schema;
|
|
},
|
|
binary: (schema, joi) => {
|
|
schema.type = 'string';
|
|
schema.contentMediaType =
|
|
joi._meta.length > 0 && joi._meta[0].contentMediaType
|
|
? joi._meta[0].contentMediaType
|
|
: 'text/plain';
|
|
schema.contentEncoding = joi._flags.encoding ? joi._flags.encoding : 'binary';
|
|
return schema;
|
|
},
|
|
boolean: (schema) => {
|
|
schema.type = 'boolean';
|
|
return schema;
|
|
},
|
|
number: (schema, joi) => {
|
|
schema.type = 'number';
|
|
joi._rules?.forEach((test) => {
|
|
switch (test.name) {
|
|
case 'integer':
|
|
schema.type = 'integer';
|
|
break;
|
|
case 'less':
|
|
//schema.exclusiveMaximum = true;
|
|
//schema.maximum = test.args.limit;
|
|
schema.exclusiveMaximum = test.args.limit;
|
|
break;
|
|
case 'greater':
|
|
//schema.exclusiveMinimum = true;
|
|
//schema.minimum = test.args.limit;
|
|
schema.exclusiveMinimum = test.args.limit;
|
|
break;
|
|
case 'min':
|
|
schema.minimum = test.args.limit;
|
|
break;
|
|
case 'max':
|
|
schema.maximum = test.args.limit;
|
|
break;
|
|
case 'precision': {
|
|
let multipleOf;
|
|
if (test.args.limit && test.args.limit > 1) {
|
|
multipleOf = JSON.parse('0.' + '0'.repeat(test.args.limit - 1) + '1');
|
|
}
|
|
else {
|
|
multipleOf = 1;
|
|
}
|
|
schema.multipleOf = multipleOf;
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
return schema;
|
|
},
|
|
string: (schema, joi) => {
|
|
schema.type = 'string';
|
|
joi._rules.forEach((test) => {
|
|
switch (test.name) {
|
|
case 'email':
|
|
schema.format = 'email';
|
|
break;
|
|
case 'pattern':
|
|
case 'regex': {
|
|
const arg = test.args;
|
|
const pattern = arg && arg.regex ? arg.regex : arg;
|
|
schema.pattern = String(pattern).replace(/^\//, '').replace(/\/$/, '');
|
|
break;
|
|
}
|
|
case 'min':
|
|
schema.minLength = test.args.limit;
|
|
break;
|
|
case 'max':
|
|
schema.maxLength = test.args.limit;
|
|
break;
|
|
case 'length':
|
|
schema.minLength = schema.maxLength = test.args.limit;
|
|
break;
|
|
case 'uri':
|
|
schema.format = 'uri';
|
|
break;
|
|
}
|
|
});
|
|
return schema;
|
|
},
|
|
object: (schema, joi, transformer) => {
|
|
schema.type = 'object';
|
|
schema.properties = {};
|
|
schema.additionalProperties = Boolean(joi._flags.allowUnknown || !joi._inner.children);
|
|
schema.pattern =
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
joi.patterns?.map((pattern) => {
|
|
return { regex: pattern.regex, rule: convert(pattern.rule, transformer) };
|
|
}) ?? [];
|
|
if (!joi.$_terms.keys?.length) {
|
|
return schema;
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
joi.$_terms.keys.forEach((property) => {
|
|
if (property.schema._flags.presence !== 'forbidden') {
|
|
if (!schema.properties)
|
|
schema.properties = {};
|
|
schema.properties[property.key] = convert(property.schema, transformer);
|
|
if (property.schema._flags.presence === 'required' ||
|
|
(property.schema._settings &&
|
|
property.schema._settings.presence === 'required' &&
|
|
property.schema._flags.presence !== 'optional')) {
|
|
schema.required = schema.required || [];
|
|
schema.required.push(property.key);
|
|
}
|
|
}
|
|
});
|
|
return schema;
|
|
}
|
|
};
|
|
/**
|
|
* Converts the supplied joi validation object into a JSON schema object,
|
|
* optionally applying a transformation.
|
|
*
|
|
* @param {JoiValidation} joi
|
|
* @param {TransformFunction} [transformer=null]
|
|
* @returns {JSONSchema}
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export default function convert(joi, transformer) {
|
|
assert('object' === typeof joi && 'type' in joi, 'requires a joi schema object');
|
|
if (!TYPES[joi.type]) {
|
|
throw new Error(`sorry, do not know how to convert unknown joi type: "${joi.type}"`);
|
|
}
|
|
if (transformer) {
|
|
assert('function' === typeof transformer, 'transformer must be a function');
|
|
}
|
|
// JSON Schema root for this type.
|
|
const schema = {};
|
|
// Copy over the details that all schemas may have...
|
|
if (joi._description) {
|
|
schema.description = joi._description;
|
|
}
|
|
if (joi._examples && joi._examples.length > 0) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
schema.examples = joi._examples.map((e) => e.value);
|
|
}
|
|
if (joi._examples && joi._examples.length === 1) {
|
|
schema.examples = joi._examples[0].value;
|
|
}
|
|
// Add the label as a title if it exists
|
|
if (joi._settings && joi._settings.language && joi._settings.language.label) {
|
|
schema.title = joi._settings.language.label;
|
|
}
|
|
else if (joi._flags && joi._flags.label) {
|
|
schema.title = joi._flags.label;
|
|
}
|
|
// Checking for undefined and null explicitly to allow false and 0 values
|
|
if (joi._flags && joi._flags.default !== undefined && joi._flags.default !== null) {
|
|
schema['default'] = joi._flags.default;
|
|
}
|
|
if (joi._valids && joi._valids._set && (joi._valids._set.size || joi._valids._set.length)) {
|
|
if (Array.isArray(joi.children) || !joi._flags.allowOnly) {
|
|
return {
|
|
anyOf: [
|
|
{
|
|
type: joi.type,
|
|
enum: [...joi._valids._set]
|
|
},
|
|
TYPES[joi.type](schema, joi, transformer)
|
|
]
|
|
};
|
|
}
|
|
schema['enum'] = [...joi._valids._set];
|
|
}
|
|
let result = TYPES[joi.type](schema, joi, transformer);
|
|
if (transformer) {
|
|
result = transformer(result, joi);
|
|
}
|
|
if (joi._valids?._values && joi._valids._values.size && !joi._flags.allowOnly) {
|
|
const constants = Array.from(joi._valids._values).map((v) => ({
|
|
const: v
|
|
}));
|
|
if (result.anyOf) {
|
|
result.anyOf = [...constants, ...result.anyOf];
|
|
}
|
|
else {
|
|
result = { anyOf: [...constants, result] };
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
//module.exports = convert;
|
|
convert.TYPES = TYPES;
|
|
/**
|
|
* Joi Validation Object
|
|
* @typedef {object} JoiValidation
|
|
*/
|
|
/**
|
|
* Transformation Function - applied just before `convert()` returns and called as `function(object):object`
|
|
* @typedef {function} TransformFunction
|
|
*/
|
|
/**
|
|
* JSON Schema Object
|
|
* @typedef {object} JSONSchema
|
|
*/
|