UNPKG

@statezero/core

Version:

The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate

288 lines (287 loc) 10.9 kB
import { configInstance } from "../../config.js"; import { isNil } from "lodash-es"; import { DateParsingHelpers } from "./dates.js"; /** * Gets the backend timezone for a model class * @param {Class} ModelClass - The model class * @returns {string} The backend timezone or 'UTC' as fallback */ function getBackendTimezone(ModelClass) { if (!ModelClass || !ModelClass.configKey) { return 'UTC'; // Default fallback } const config = configInstance.getConfig(); const backendConfig = config.backendConfigs[ModelClass.configKey] || config.backendConfigs.default; return backendConfig.BACKEND_TZ || 'UTC'; } /** * File field serializer - handles both camelCase (frontend) and snake_case (backend) formats */ export const fileFieldSerializer = { // Store backend snake_case format internally for consistency with API toInternal: (value, context = {}) => { // Handle plain strings - throw error if (typeof value === "string") { throw new Error("File field expects a file object, not a string path"); } if (isNil(value)) { return null; } value = { file_path: value.filePath || value.file_path, file_name: value.fileName || value.file_name, file_url: value.fileUrl || value.file_url, size: value.size, mime_type: value.mimeType || value.mime_type, }; return value; }, // Return object with both formats for maximum compatibility toLive: (value, context = {}) => { if (!value || typeof value !== "object") return value; // Build the proper file URL using the same logic as FileObject const backendKey = context.model?.constructor?.configKey || "default"; const fullFileUrl = value.file_url ? configInstance.buildFileUrl(value.file_url, backendKey) : null; return { // snake_case (backend format) file_path: value.file_path, file_name: value.file_name, file_url: fullFileUrl, // Use the built URL here size: value.size, mime_type: value.mime_type, // camelCase (frontend format) - for compatibility filePath: value.file_path, fileName: value.file_name, fileUrl: fullFileUrl, // Use the built URL here too mimeType: value.mime_type, }; }, }; /** * Date/DateTime field serializer - uses DateParsingHelpers like the model does */ export const dateFieldSerializer = { toInternal: (value, context = {}) => { if (isNil(value)) return value; // If it's already a string, keep it (from API) if (typeof value === "string") return value; // If it's a Date object, serialize it using DateParsingHelpers if (value instanceof Date) { const { model, field } = context; if (model?.schema) { return DateParsingHelpers.serializeDate(value, field, model.schema); } // Fallback if no schema context return value.toISOString(); } return value; }, toLive: (value, context = {}) => { if (isNil(value) || typeof value !== "string") return value; const { model, field } = context; if (model?.schema) { // Get the backend timezone for this model const timezone = getBackendTimezone(model); // Use DateParsingHelpers with timezone awareness // This ensures date strings are parsed in the server timezone, not browser timezone return DateParsingHelpers.parseDate(value, field, model.schema, timezone); } // Fallback parsing if no schema context try { return new Date(value); } catch (e) { console.warn(`Failed to parse date: ${value}`); return value; } }, }; /** * Foreign Key / One-to-One field serializer - follows existing relationship logic */ export const relationshipFieldSerializer = { toInternal: (value, context = {}) => { if (isNil(value)) return value; // Extract PK or use value directly const pk = value.pk || value; // Assert it's a valid PK type if (typeof pk !== "string" && typeof pk !== "number") { throw new Error(`Invalid primary key type for relationship field: expected string or number, got ${typeof pk}`); } return pk; }, toLive: (value, context = {}) => { if (isNil(value)) return value; // Follow the exact same pattern as the original getField code const { model, field } = context; if (model.relationshipFields.has(field) && value) { const fieldInfo = model.relationshipFields.get(field); // For foreign-key and one-to-one, value is guaranteed to be a plain PK return fieldInfo.ModelClass().fromPk(value); } return value; }, }; /** * Many-to-Many field serializer - follows existing m2m logic */ export const m2mFieldSerializer = { toInternal: (value, context = {}) => { if (isNil(value) || !Array.isArray(value)) return value; return value.map((item) => { // Extract PK or use item directly const pk = item.pk || item; // Assert it's a valid PK type if (typeof pk !== "string" && typeof pk !== "number") { throw new Error(`Invalid primary key type for m2m field: expected string or number, got ${typeof pk}`); } return pk; }); }, toLive: (value, context = {}) => { if (isNil(value)) return value; // Follow the exact same pattern as the original getField code const { model, field } = context; if (model.relationshipFields.has(field) && value) { const fieldInfo = model.relationshipFields.get(field); // Data corruption check like the original if (!Array.isArray(value) && value) { throw new Error(`Data corruption: m2m field for ${model.modelName} stored as ${value}`); } // Map each pk to the full model object - exactly like original return value.map((pk) => fieldInfo.ModelClass().fromPk(pk)); } return value; }, }; const serializers = { string: { "file-path": fileFieldSerializer, "image-path": fileFieldSerializer, "date": dateFieldSerializer, "date-time": dateFieldSerializer, "foreign-key": relationshipFieldSerializer, "one-to-one": relationshipFieldSerializer, }, integer: { "foreign-key": relationshipFieldSerializer, "one-to-one": relationshipFieldSerializer, }, // Add other PK types as needed uuid: { "foreign-key": relationshipFieldSerializer, "one-to-one": relationshipFieldSerializer, }, array: { "many-to-many": m2mFieldSerializer, }, }; /** * Serializes an action payload based on the action's input schema. * Automatically handles model instances (extracts PK), files, dates, etc. * * @param {Object} payload - The raw payload with potentially model instances * @param {Object} inputProperties - The action's input_properties schema * @returns {Object} Serialized payload ready for API transmission */ export function serializeActionPayload(payload, inputProperties) { if (!payload || !inputProperties) return payload; const serializedPayload = {}; for (const [field, value] of Object.entries(payload)) { const fieldSchema = inputProperties[field]; if (!fieldSchema || value === undefined) { serializedPayload[field] = value; continue; } const { type, format } = fieldSchema; // Check if we have a serializer for this type/format combo const typeSerializers = serializers[type]; const serializer = typeSerializers?.[format]; if (serializer) { serializedPayload[field] = serializer.toInternal(value, { field }); } else { serializedPayload[field] = value; } } return serializedPayload; } export class ModelSerializer { constructor(modelClass) { this.modelClass = modelClass; } toInternalField(field, value, context = {}) { const fieldType = this.modelClass.schema?.properties[field]?.type; const fieldFormat = this.modelClass.schema?.properties[field]?.format; if (serializers[fieldType] && serializers[fieldType][fieldFormat]) { return serializers[fieldType][fieldFormat].toInternal(value, { model: this.modelClass, field, ...context, }); } // Fallback to default serialization return value; } toInternal(data) { const serializedData = {}; for (const field in data) { serializedData[field] = this.toInternalField(field, data[field]); } return serializedData; } toLiveField(field, value, context = {}) { const fieldType = this.modelClass.schema?.properties[field]?.type; const fieldFormat = this.modelClass.schema?.properties[field]?.format; if (serializers[fieldType] && serializers[fieldType][fieldFormat]) { return serializers[fieldType][fieldFormat].toLive(value, { model: this.modelClass, field, ...context, }); } // Django-style type coercion for basic types (mimics get_prep_value) if (value !== null && value !== undefined) { if (fieldType === 'integer') { const num = Number(value); return Number.isNaN(num) ? value : Math.trunc(num); } if (fieldType === 'number') { const num = Number(value); return Number.isNaN(num) ? value : num; } if (fieldType === 'boolean') { if (typeof value === 'string') { const lower = value.toLowerCase(); if (['true', '1', 't', 'yes'].includes(lower)) return true; if (['false', '0', 'f', 'no', ''].includes(lower)) return false; } return Boolean(value); } if (fieldType === 'string' && typeof value !== 'string') { return String(value); } } return value; } toLive(data) { const serializedData = {}; for (const field in data) { serializedData[field] = this.toLiveField(field, data[field]); } return serializedData; } }