Підсумок: У цій статті описано перенесення схеми даних з фронтенду у бекенд у додатку Migraine Pulse. Ви дізнаєтеся, як ми реалізували динамічну схему за допомогою NestJS, валідацію JSON Schema та як це спростило процес експорту/імпорту даних.
Як я згадував у вступному дописі про додаток - перша версія була прототипом. React-додаток, куди я імпортував формат JSON, який зберігався локально. Він заповнював додаток моїми подіями мігрені, симптомами тощо. Я вдосконалював певні речі, змінював (додавав дані). Наприкінці робив експорт, щоб мати останні зміни.
Одночасно я перевіряв і вдосконалював необхідні поля для кожного елемента.
Пізніше я почав створювати бекенд, який уже мав зв’язок з базою даних. Але при створенні модулів NestJS я враховував схему, над якою працював раніше.
Однак до цього часу сам формат схеми був описаний статично на сторінці settings/DataManagement.
Я вирішив зробити його динамічним, тобто при зміні полів або додаванні, щоб не потрібно було вручну редагувати сторінку щоразу.
Реалізація
Перший крок досить простий - створити новий модуль NestJS data-management з сервісом і контролером.
nest g module data-management
nest g service data-management
nest g controller data-managementСервіс повертає об’єкт схеми JSON.
import { Injectable } from '@nestjs/common';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import {
dtoToJsonSchema,
ClassType,
JsonSchema,
} from './utils/dto-to-json-schema';
import { CreateIncidentDto } from '../incidents/dto/create-incident.dto';
import { CreateTriggerDto } from '../triggers/dto/create-trigger.dto';
import { CreateSymptomDto } from '../symptoms/dto/create-symptom.dto';
import { CreateMedicationDto } from '../medications/dto/create-medication.dto';
import { CreateLocationDto } from '../locations/dto/create-locations.dto';
import {
CreateWaterDto,
CreateBloodPressureDto,
CreateHeightDto,
CreateSleepDto,
CreateWeightDto,
} from '../health-logs/dto/create-health-logs.dto';
import { DataValidationResponse } from './interface/validation.interface';
@Injectable()
export class DataManagementService {
private ajv: Ajv;
constructor() {
this.ajv = new Ajv({ allErrors: true });
addFormats(this.ajv);
}
getSchema() {
const incidentObjectSchema = dtoToJsonSchema(
CreateIncidentDto as ClassType<unknown>,
);
incidentObjectSchema.properties = incidentObjectSchema.properties ?? {};
incidentObjectSchema.properties.id = { type: 'string' } as JsonSchema;
incidentObjectSchema.properties.createdAt = {
type: 'string',
format: 'date-time',
} as JsonSchema;
let existingRequired = Array.isArray(incidentObjectSchema.required)
? incidentObjectSchema.required
: [];
let requiredSet = new Set<string>(existingRequired);
['userId', 'type', 'startTime', 'datetimeAt'].forEach((r) =>
requiredSet.add(r),
);
incidentObjectSchema.required = Array.from(requiredSet);
const incidentsSchema: JsonSchema = {
type: 'array',
items: incidentObjectSchema,
};
const rootSchema: JsonSchema = {
type: 'object',
properties: {
incidents: incidentsSchema,
triggers: triggersSchema,
symptoms: symptomsSchema,
medications: medicationsSchema,
locations: locationsSchema,
healthLogs: healthLogsSchema,
exportedAt: { type: 'string', format: 'date-time' },
version: { type: 'string' },
},
required: [
'incidents',
'triggers',
'symptoms',
'medications',
'locations',
'healthLogs',
'exportedAt',
'version',
],
additionalProperties: false,
};
return rootSchema;
}
}У контролері повертається об’єкт схеми JSON із сервісу. Він доступний з роллю USER, оскільки ця сторінка доступна лише авторизованим користувачам.
import { Controller, Get } from '@nestjs/common';
import { DataManagementService } from './data-management.service';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '../auth/enums/roles.enum';
@Controller('data-management')
export class DataManagementController {
constructor(private readonly dataManagementService: DataManagementService) {}
@Roles(Role.USER)
@Get('schema')
getSchema(): object {
return this.dataManagementService.getSchema();
}
}Інтегрувати новостворений у сторінку settings/DataManagement.
export const getDataSchema = async (token: string) => {
const response = await fetch(`${env.MIGRAINE_BACKEND_API_URL}/api/v1/data-management/schema`, {
headers: getHeaders(token),
});
if (!response.ok) {
throw new Error('Failed to fetch data schema');
}
return response.json();
};І перший крок надає можливість модифікувати не HTML, а JSON-схему, описувати її та розділяти на частини. Додати версіонування. Валідувати файли перед імпортом, щоб не зламати структуру даних.
Сервіс та контролер валідації схеми
Додаємо нові npm-пакети для валідації за допомогою json schemas.
pnpm add ajv ajv-formatsimport { Injectable } from '@nestjs/common';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import {
dtoToJsonSchema,
ClassType,
JsonSchema,
} from './utils/dto-to-json-schema';
import { CreateIncidentDto } from '../incidents/dto/create-incident.dto';
import { CreateTriggerDto } from '../triggers/dto/create-trigger.dto';
import { CreateSymptomDto } from '../symptoms/dto/create-symptom.dto';
import { CreateMedicationDto } from '../medications/dto/create-medication.dto';
import { CreateLocationDto } from '../locations/dto/create-locations.dto';
import {
CreateWaterDto,
CreateBloodPressureDto,
CreateHeightDto,
CreateSleepDto,
CreateWeightDto,
} from '../health-logs/dto/create-health-logs.dto';
import { DataValidationResponse } from './interface/validation.interface';
@Injectable()
export class DataManagementService {
private ajv: Ajv;
constructor() {
this.ajv = new Ajv({ allErrors: true });
addFormats(this.ajv);
}
getSchema() {
// Above content
}
validateImportData(data: unknown): DataValidationResponse {
const schema: JsonSchema = this.getSchema();
const validate = this.ajv.compile(schema);
const isValid = validate(data);
if (!isValid) {
return { isValid: false, errors: validate.errors };
}
return { isValid: true };
}
}Допоміжна функція mapValidatorToSchema для конвертації DTO в схему JSON.
import 'reflect-metadata';
import { getMetadataStorage } from 'class-validator';
export interface ClassType<T = unknown> {
new (...args: unknown[]): T;
}
type PrimitiveTypeName = 'string' | 'number' | 'integer' | 'boolean' | 'null';
export interface PrimitiveSchema {
type: PrimitiveTypeName;
format?: string;
enum?: (string | number | boolean | null)[];
}
export interface ArraySchema {
type: 'array';
items: JsonSchema;
}
export interface ObjectSchema {
type: 'object';
properties?: Record<string, JsonSchema>;
required?: string[];
additionalProperties?: boolean;
}
export type JsonSchema = PrimitiveSchema | ArraySchema | ObjectSchema;
interface ValidationMetadataLite {
propertyName: string;
type: string;
constraints?: unknown[];
each?: boolean;
}
interface MetadataStorageLite {
getTargetValidationMetadatas(
targetConstructor: Function,
targetSchema?: string,
): ValidationMetadataLite[];
}
function asMetadataStorage(obj: unknown): MetadataStorageLite | null {
if (obj && typeof obj === 'object' && 'getTargetValidationMetadatas' in obj) {
const cast = obj as any;
if (typeof cast.getTargetValidationMetadatas === 'function') {
return cast as MetadataStorageLite;
}
}
return null;
}
function mapValidatorToSchema(
meta: ValidationMetadataLite | undefined,
designType?: Function,
): JsonSchema {
if (!meta) {
if (designType === String) return { type: 'string' };
if (designType === Number) return { type: 'number' };
if (designType === Boolean) return { type: 'boolean' };
if (designType === Array)
return { type: 'array', items: { type: 'string' } };
return { type: 'object', additionalProperties: true };
}
switch (meta.type) {
case 'isString':
return { type: 'string' };
case 'isNumber':
return { type: 'number' };
case 'isInt':
return { type: 'integer' };
case 'isBoolean':
return { type: 'boolean' };
case 'isDateString':
return { type: 'string', format: 'date-time' };
case 'isUUID':
return { type: 'string', format: 'uuid' };
case 'isEmail':
return { type: 'string', format: 'email' };
case 'isArray':
return { type: 'array', items: { type: 'string' } };
case 'isEnum': {
const maybeEnum = Array.isArray(meta.constraints?.[0])
? (meta.constraints[0] as (string | number | boolean)[])
: undefined;
const schema: PrimitiveSchema = { type: 'string' };
if (maybeEnum) schema.enum = maybeEnum;
return schema;
}
default:
if (designType === String) return { type: 'string' };
if (designType === Number) return { type: 'number' };
if (designType === Boolean) return { type: 'boolean' };
if (designType === Array)
return { type: 'array', items: { type: 'string' } };
return { type: 'object', additionalProperties: true };
}
}
export function dtoToJsonSchema<T = unknown>(
dtoClass: ClassType<T>,
): ObjectSchema {
const rawStorage = getMetadataStorage();
const storage = asMetadataStorage(rawStorage);
const metadatas: ValidationMetadataLite[] = storage
? storage.getTargetValidationMetadatas(dtoClass as unknown as Function, '')
: [];
const byProp = new Map<string, ValidationMetadataLite[]>();
for (const m of metadatas) {
const prop = m.propertyName;
const arr = byProp.get(prop) ?? [];
arr.push(m);
byProp.set(prop, arr);
}
const properties: Record<string, JsonSchema> = {};
const required: string[] = [];
for (const [prop, metas] of byProp.entries()) {
const designType = Reflect.getMetadata(
'design:type',
dtoClass.prototype,
prop,
) as Function | undefined;
const isArray = metas.some((m) => m.type === 'isArray');
if (isArray) {
const eachMeta = metas.find(
(m) => m.each === true && m.type !== 'isArray',
);
const itemsSchema = mapValidatorToSchema(eachMeta, undefined);
properties[prop] = { type: 'array', items: itemsSchema };
} else {
const preferredOrder = [
'isDateString',
'isEnum',
'isNumber',
'isInt',
'isBoolean',
'isUUID',
'isEmail',
'isString',
'isObject',
];
let chosen: ValidationMetadataLite | undefined;
for (const t of preferredOrder) {
chosen = metas.find((m) => m.type === t);
if (chosen) break;
}
if (!chosen) chosen = metas[0];
const propSchema = mapValidatorToSchema(chosen, designType);
const hasNested = metas.some((m) => m.type === 'nestedValidation');
if (
hasNested &&
designType &&
designType !== Object &&
designType !== Array
) {
properties[prop] = dtoToJsonSchema(designType as ClassType<unknown>);
} else {
properties[prop] = propSchema;
}
}
const isOptional = metas.some((m) => m.type === 'isOptional');
const hasNotEmpty = metas.some((m) => m.type === 'isNotEmpty');
if (!isOptional && hasNotEmpty) required.push(prop);
}
const schema: ObjectSchema = {
type: 'object',
properties,
additionalProperties: false,
};
if (required.length) schema.required = required;
return schema;
}Ендпоінт контролера для валідації файлів.
import {
Controller,
Get,
Post,
UploadedFile,
UseInterceptors,
BadRequestException
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { DataManagementService } from './data-management.service';
import { Public } from '../auth/decorators/public.decorator';
@Controller('data-management')
export class DataManagementController {
constructor(private readonly dataManagementService: DataManagementService) {}
@Roles(Role.USER)
@Get('schema')
getSchema() {
return this.dataManagementService.getSchema();
}
@Roles(Role.USER)
@Post('validate-upload')
@UseInterceptors(FileInterceptor('file'))
validateFile(
@UploadedFile() file: Express.Multer.File,
): DataValidationResponse {
if (!file) {
throw new BadRequestException('No file uploaded.');
}
if (file.mimetype !== 'application/json') {
throw new BadRequestException('Uploaded file must be a JSON file.');
}
try {
const fileContent: string = file.buffer.toString('utf-8');
const jsonData: unknown = JSON.parse(fileContent);
const validationResult: unknown =
this.dataManagementService.validateImportData(jsonData);
if (validationResult instanceof Error) {
throw new BadRequestException(validationResult.message);
}
return validationResult as DataValidationResponse;
} catch (error) {
ErrorExceptionLogging(error, DataManagementController.name);
throw error;
}
}
}Приклад використання у фронтенді:
const checkUploadedFile = async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_URL}/api/v1/data-management/validate-upload`, {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.isValid) {
console.log("File is perfect! Proceed with import.");
} else {
console.error("Validation failed:", result.errors);
}
};