json-fetchfy
Version:
A lightweight Node.js module to fetch, validate, and manipulate JSON data from various sources seamlessly.
483 lines (412 loc) • 14.3 kB
JavaScript
// External dependencies
import "./js/plugin.mjs";
import fileReader from "./js/fileReader.mjs";
import findInArray from "./js/findInArray.mjs";
import findInObject from "./js/findInObject.mjs";
import jsonGenerator from "./js/jsonGenerator.mjs";
import { validatePathSync } from "./js/validatePath.mjs";
// Node.js built-in modules
import fs from "fs";
import path from "path";
/**
* Fetchfy - A utility module for data processing and caching
* @module fetchfy
* @description Provides functionality for JSON data manipulation, searching, and caching
* @version 1.0.0
*/
// Constants
const CONTENT_TYPES = {
NUMBERS: "Numbers",
STRINGS: "Strings",
OBJECTS: "Objects",
SINGLE: "Single",
UNKNOWN: "Unknown",
INVALID: "Invalid JSON",
};
const DEFAULT_OPTIONS = {
cache: true,
deep: false,
caseSensitive: true,
limit: Infinity,
single: false,
};
// Private store for caching
const _store = new Map();
// Main module object
const fetchfy = {};
/**
* Detects content type in a JSON string or parsed object.
* @param {string|object} jsonInput - JSON string or parsed object to analyze
* @returns {string} Detected content type from CONTENT_TYPES
* @throws {Error} If input is null or undefined
*/
fetchfy.detectJsonContentType = (jsonInput) => {
if (jsonInput == null) {
throw new Error("JSON input cannot be null or undefined");
}
try {
const parsedData =
typeof jsonInput === "string" ? JSON.parse(jsonInput) : jsonInput;
if (Array.isArray(parsedData)) {
if (parsedData.length === 0) return CONTENT_TYPES.UNKNOWN;
const typeChecks = {
[CONTENT_TYPES.NUMBERS]: (item) => typeof item === "number",
[CONTENT_TYPES.STRINGS]: (item) => typeof item === "string",
[CONTENT_TYPES.OBJECTS]: (item) =>
typeof item === "object" && item !== null && !Array.isArray(item),
};
for (const [type, check] of Object.entries(typeChecks)) {
if (parsedData.every(check)) return type;
}
return CONTENT_TYPES.UNKNOWN;
}
return typeof parsedData === "object" && parsedData !== null
? CONTENT_TYPES.SINGLE
: CONTENT_TYPES.UNKNOWN;
} catch (error) {
return CONTENT_TYPES.INVALID;
}
};
/**
* Manages module options.
* @param {object|string} options - Configuration object or 'get' to retrieve current options
* @throws {TypeError} If options parameter is invalid
*/
fetchfy.options = (options = {}) => {
const OPTIONS_KEY = "options";
const getOptions = () => _store.get(OPTIONS_KEY) || null;
const setOptions = (newOptions) => {
const currentOptions = getOptions() || {};
_store.set(OPTIONS_KEY, { ...currentOptions, ...newOptions });
};
if (typeof options === "string" && options.toLowerCase() === "get") {
return getOptions();
}
if (typeof options === "object" && options !== null) {
setOptions(options);
return;
}
throw new TypeError(
"Options parameter must be an object or the string 'get'."
);
};
/**
* Retrieves and processes data based on input.
* @param {*} data - Input data (array or object)
* @param {*} query - Search query
* @param {string} [key=null] - Key to search within objects
* @param {object} [options={}] - Processing options
* @returns {*} Processed results
*/
fetchfy.get = function (data = null, query, key = null, options = {}) {
// Validate and process file path if provided
const pathValidation = validatePathSync(data);
if (pathValidation.isValid && pathValidation.isFile) {
data = fileReader.readFileSync(data);
}
// Validate input data
const contentType = fetchfy.detectJsonContentType(data);
if (
contentType === CONTENT_TYPES.UNKNOWN ||
contentType === CONTENT_TYPES.INVALID
) {
console.error(
"Invalid input: Must be an array of numbers/strings/objects or a single object"
);
return null;
}
// Merge options
const mergedOptions = {
..._store.get("options"),
...options,
};
mergedOptions.removeKeys(["cache", "single", "update", "save"]);
// Process based on content type
if (
contentType === CONTENT_TYPES.OBJECTS ||
contentType === CONTENT_TYPES.NUMBERS
) {
const results = findInArray(data, query, key, mergedOptions);
return options.single ? (results.length > 0 ? results[0] : null) : results;
}
return findInObject(data, query, key, mergedOptions);
};
/**
* Updates JSON elements based on query.
* @param {*} data - Input data to update
* @param {*} query - Search query for items to update
* @param {string} [key=null] - Key to search within objects
* @param {object} [options={}] - Update options
* @returns {boolean|object} Updated data or false if update fails
*/
fetchfy.update = function (data = null, query, key = null, options = {}) {
let filePath = null;
// Handle file input
const pathValidation = validatePathSync(data);
if (pathValidation.isValid && pathValidation.isFile) {
filePath = data;
data = fileReader.readFileSync(data);
}
// Validate input
const contentType = fetchfy.detectJsonContentType(data);
if (
contentType === CONTENT_TYPES.UNKNOWN ||
contentType === CONTENT_TYPES.INVALID
) {
console.error(
"Invalid input: Must be an array of numbers/strings/objects or a single object"
);
return false;
}
// Process updates
if (
contentType === CONTENT_TYPES.OBJECTS ||
contentType === CONTENT_TYPES.NUMBERS
) {
const mergedOptions = { ..._store.get("options"), ...options };
mergedOptions.removeKeys(["cache", "single", "update", "save"]);
const matches = fetchfy.get(data, query, key, mergedOptions);
if (matches?.length > 0) {
const updates = options.update || {};
matches.forEach((item) => {
Object.keys(updates).forEach((prop) => {
if (Object.prototype.hasOwnProperty.call(item, prop)) {
item[prop] = updates[prop];
}
});
});
if (options.save && filePath) {
fetchfy.save(data, filePath);
}
return data;
}
console.log("No matching items found for update");
return false;
}
console.error("Update only supports arrays of objects or numbers");
return false;
};
/**
* Validates new item against existing data structure
* @private
* @param {Array|Object} data - Existing data
* @param {*} newItem - Item to validate
* @returns {boolean} Whether the item is valid for the data structure
*/
const _validateNewItem = (data, newItem) => {
const dataType = fetchfy.detectJsonContentType(data);
const itemType = fetchfy.detectJsonContentType(newItem);
// For arrays, check if new item matches the type of existing items
if (dataType === CONTENT_TYPES.NUMBERS) {
return typeof newItem === "number";
}
if (dataType === CONTENT_TYPES.STRINGS) {
return typeof newItem === "string";
}
if (dataType === CONTENT_TYPES.OBJECTS) {
return (
itemType === CONTENT_TYPES.SINGLE || itemType === CONTENT_TYPES.OBJECTS
);
}
if (dataType === CONTENT_TYPES.SINGLE) {
return itemType === CONTENT_TYPES.SINGLE;
}
return false;
};
/**
* Reorders object properties with ID first and optional alphabetical sorting
* @private
* @param {Object} obj - Object to reorder
* @param {Object} options - Ordering options
* @param {boolean} options.alphabetical - Whether to sort properties alphabetically
* @param {string|null} options.idKey - Name of the ID field (null if no ID)
* @returns {Object} New object with ordered properties
*/
const _orderProperties = (obj, { alphabetical = false, idKey = null }) => {
// Create a new object to store ordered properties
const ordered = {};
// Get all keys from the object
let keys = Object.keys(obj);
// If ID exists, remove it from keys array as we'll handle it separately
if (idKey && obj.hasOwnProperty(idKey)) {
keys = keys.filter(key => key !== idKey);
// Add ID first
ordered[idKey] = obj[idKey];
}
// Sort remaining keys if alphabetical ordering is requested
if (alphabetical) {
keys.sort((a, b) => a.localeCompare(b));
}
// Add remaining properties in order
keys.forEach(key => {
ordered[key] = obj[key];
});
return ordered;
};
/**
* Gets the next available ID from an array of objects
* @private
* @param {Array<Object>} data - Array of objects to check
* @param {string} idKey - Name of the ID field
* @returns {number} Next available ID
*/
const _getNextId = (data, idKey = 'id') => {
if (!Array.isArray(data) || data.length === 0) return 1;
const maxId = Math.max(...data.map(item =>
typeof item[idKey] === 'number' ? item[idKey] : 0
));
return maxId + 1;
};
/**
* Adds new items to the data structure
* @param {Array|Object} data - Target data structure
* @param {*} items - Single item or array of items to add
* @param {Object} [options={}] - Addition options
* @param {boolean} [options.validate=true] - Whether to validate items before adding
* @param {boolean} [options.save=false] - Whether to save to file if data is from file
* @param {boolean} [options.unique=false] - Prevent duplicate objects when adding
* @param {string|string[]} [options.uniqueKeys=[]] - Keys to check for uniqueness
* @param {string} [options.id] - Auto-increment ID field name (use "auto" to enable)
* @param {boolean} [options.alphabetical=false] - Sort properties alphabetically
* @returns {Object} Result object containing success status and updated data
*/
fetchfy.add = function(data = null, items, options = {}) {
// Default options
const defaultOptions = {
validate: true,
save: false,
unique: false,
uniqueKeys: [],
id: null,
alphabetical: false
};
const opts = { ...defaultOptions, ...options };
let filePath = null;
// Handle file input
const pathValidation = validatePathSync(data);
if (pathValidation.isValid && pathValidation.isFile) {
filePath = data;
data = fileReader.readFileSync(data);
}
// Validate input data
const contentType = fetchfy.detectJsonContentType(data);
if (contentType === CONTENT_TYPES.UNKNOWN || contentType === CONTENT_TYPES.INVALID) {
throw new Error("Invalid target data structure");
}
// Convert single item to array for consistent processing
const itemsToAdd = Array.isArray(items) ? items : [items];
try {
// Handle different data types
if (contentType === CONTENT_TYPES.SINGLE) {
if (itemsToAdd.length > 1) {
throw new Error("Cannot add multiple items to a single object");
}
if (opts.validate && !_validateNewItem(data, itemsToAdd[0])) {
throw new Error("New item does not match target object structure");
}
// Merge objects and ensure proper ordering
const mergedData = { ...data, ...itemsToAdd[0] };
Object.assign(data, _orderProperties(mergedData, {
alphabetical: opts.alphabetical,
idKey: mergedData.hasOwnProperty('id') ? 'id' : null
}));
} else {
if (opts.validate) {
const invalidItems = itemsToAdd.filter(item => !_validateNewItem(data, item));
if (invalidItems.length > 0) {
throw new Error(`${invalidItems.length} items do not match array structure`);
}
}
// Handle auto-increment ID if specified
let nextId = null;
const useAutoId = opts.id === 'auto' && contentType === CONTENT_TYPES.OBJECTS;
if (useAutoId) {
nextId = _getNextId(data);
}
// Process each item
if (opts.unique && contentType === CONTENT_TYPES.OBJECTS) {
const uniqueKeys = Array.isArray(opts.uniqueKeys) ? opts.uniqueKeys :
(opts.uniqueKeys ? [opts.uniqueKeys] : Object.keys(itemsToAdd[0] || {}));
itemsToAdd.forEach(newItem => {
// Create a new object for the item
const processedItem = { ...newItem };
// Add auto-increment ID if enabled
if (useAutoId) {
processedItem.id = nextId++;
}
// Order properties
const orderedItem = _orderProperties(processedItem, {
alphabetical: opts.alphabetical,
idKey: processedItem.hasOwnProperty('id') ? 'id' : null
});
const isDuplicate = data.some(existingItem =>
uniqueKeys.every(key =>
(useAutoId && key === 'id') ? false :
existingItem[key] === orderedItem[key]
)
);
if (!isDuplicate) {
data.push(orderedItem);
}
});
} else {
// Process items without uniqueness check
itemsToAdd.forEach(item => {
const processedItem = { ...item };
if (useAutoId) {
processedItem.id = nextId++;
}
const orderedItem = _orderProperties(processedItem, {
alphabetical: opts.alphabetical,
idKey: processedItem.hasOwnProperty('id') ? 'id' : null
});
data.push(orderedItem);
});
}
}
// Save if requested and file path exists
if (opts.save && filePath) {
fetchfy.save(data, filePath);
}
return {
success: true,
data: data,
added: itemsToAdd.length,
message: "Items added successfully"
};
} catch (error) {
return {
success: false,
data: data,
added: 0,
message: error.message
};
}
};
/**
* Saves data to a file.
* @param {*} data - Data to save
* @param {string} filePath - Path to save the file
* @returns {boolean} Success status
* @throws {Error} If filePath is invalid
*/
fetchfy.save = function (data, filePath) {
if (!filePath || typeof filePath !== "string") {
throw new Error("Valid file path required");
}
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
return true;
} catch (error) {
console.error("Failed to save data:", error);
return false;
}
};
// Initialize with default options
fetchfy.options(DEFAULT_OPTIONS);
// Export combined module
export default { ...fetchfy, ...jsonGenerator };