@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
JavaScript
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;
}
}