-
Notifications
You must be signed in to change notification settings - Fork 3
/
index.js
198 lines (175 loc) · 7.56 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
/*!
* JSON-Entity
*
* Copyright 2017-2020 Josh Swan
* Released under the MIT license
* https://github.com/joshswan/json-entity/blob/master/LICENSE
*/
const assert = require('assert');
const isArray = require('lodash/isArray');
const isFunction = require('lodash/isFunction');
const isObject = require('lodash/isObject');
// Error message helper
const error = err => `json-entity: ${err}`;
class Entity {
/**
* Static helper to determine if passed object is an Entity instance
* @param {Object} entity Object to test
* @return {Boolean}
*/
static isEntity(entity) {
return entity instanceof Entity;
}
/**
* Entity constructor
* Exposes properties using the definition objects passed as arguments.
* @param {Object} entityDefinitions Entity definitions object(s)
*/
constructor(...entityDefinitions) {
// Initialize properties array that will store properties exposed via the `expose` method
this.properties = [];
// Loop through all arguments passed to constructor
entityDefinitions.forEach((definition) => {
// Allow both arrays (for extending) and objects
Object.keys(definition).forEach((key) => {
const opts = definition[key];
// Check for key option among options to avoid setting property keys to numeric indexes when
// using properties array from extended instance
const property = opts.key || key;
// Call expose method with appropriate options
switch (typeof opts) {
// { [property]: true } always exposes
case 'boolean':
if (opts === true) this.expose(property);
break;
// { [property]: [function] } runs custom function to determine value
// Return `undefined` to hide in JSON
case 'function':
this.expose(property, { value: opts });
break;
// { [property]: {options} } allows specification of options (e.g. as, if)
case 'object':
this.expose(property, opts);
break;
// Throw error for any other options types
default:
throw new TypeError(error(`unknown options type for property ${property}`));
}
});
});
}
/**
* Expose specified property with provided options
* Note: This method is meant to be internal so that Entity definitions are clear and easy to
* follow for better security (same reason there is no `unexpose` method). Use at your own risk!
*
* Available Options (all optional):
* - as {String} Expose property using a different name
* - default {Any} Provide a default value to use if property does not exist
* - filter {Function} Filter an array using specified function
* - if {Function} Expose property only if specified function returns truthy
* - merge {Boolean} Combine arrays or merge object keys into parent object
* - require {Boolean} Throw an error if this key is missing in data during represent
* - using {Entity} Use specified Entity to represent value before exposing
* - value {Any} Provide a hard-coded value that will always be used for property
*
* @param {String} property Property name/key
* @param {Object} [options={}] Expose options (as, default, if, merge, using, value)
*/
expose(property, options = {}) {
// Validate options
if (options.filter) assert(isFunction(options.filter), error('"filter" must be a function!'));
if (options.if) assert(isFunction(options.if), error('"if" must be a function!'));
if (options.using) assert(Entity.isEntity(options.using), error('"using" must be an Entity!'));
// Set "mode" flag to avoid having to check later if value option exists and/or is function
if (options.value !== undefined) options.mode = isFunction(options.value) ? 'fn' : 'val';
this.properties.push(Object.assign(options, {
// Make sure "as" is defined as final property value (alias or current name)
as: options.as || property,
// Specify actual property key for extracting value
key: property,
}));
}
/**
* Extend an Entity instance with additional properties
* Note: The extended Entity will inherit all exposed properties from its parent. To hide
* properties, you must create a new Entity instead.
* @param {Object} entityDefinitions Entity definitions object(s)
* @return {Entity}
*/
extend(...entityDefinitions) {
return new Entity(this.properties, ...entityDefinitions);
}
/**
* Use Entity to create a representation of supplied data
* Only properties that have been exposed in the Entity will be included in the resulting
* object(s) and any specified options will be applied during representation.
* @param {Array|Object} data Data to be represented
* @param {Object} [options={}] Options (passed to if/value functions & nested Entities)
* @return {Array|Object}
*/
represent(data, options = {}) {
// If data is an array, call represent on all objects within
if (isArray(data)) return data.map(entity => this.represent(entity, options));
// Create new representation object
return this.properties.reduce((result, opts) => {
// Check "if" returns truthy, if specified
if (opts.if && !opts.if(data, options)) return result;
let val;
// Determine property value using "mode" flag
switch (opts.mode) {
// Function specified; call to retrieve value
case 'fn':
val = opts.value(data, options);
break;
// Hard-coded value specified; use value
case 'val':
val = opts.value;
break;
// Retrieve value from data using property key
default:
val = data[opts.key];
}
// Check if value has been set
if (val === undefined) {
// Apply default, if specified
if (opts.default !== undefined) {
val = opts.default;
} else if (opts.require || options.safe === false) {
// Throw error if property required or safe mode disabled when calling represent
throw new Error(error(`data missing required property ${opts.as}!`));
} else {
// Otherwise, skip property
return result;
}
}
// Apply "using" Entity to value, if specified
if (opts.using && isObject(val)) val = opts.using.represent(val, options);
// Apply "filter" option, if specified
if (opts.filter) {
// Throw an error if val is not an array
assert(isArray(val), error(`filter cannot be applied to non-array value for property ${opts.as}!`));
val = val.filter(item => opts.filter(item, data, options));
}
// Check if merge option specified and if val is an array
if (opts.merge) {
// Check if val is an array that we need to merge
if (isArray(val)) {
// Throw an error if merging with non-array
assert(result[opts.as] === undefined || isArray(result[opts.as]), error(`attempting to merge array with non-array for property ${opts.as}!`));
// Set array at appropriate key and merge in any existing values
result[opts.as] = [...(result[opts.as] || []), ...val];
// Otherwise, check if val is an object
} else if (isObject(val)) {
// Merge child keys by setting them directly instead of mounting entire object
Object.keys(val).forEach((key) => { result[key] = val[key]; });
}
} else {
// Otherwise, just set value with appropriate key on result object
result[opts.as] = val;
}
return result;
}, {});
}
}
module.exports = Entity;