@freelygive/canvas-jsonapi 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/package.json +30 -0
- package/src/client.js +181 -0
- package/src/create.js +120 -0
- package/src/delete.js +68 -0
- package/src/entity.js +233 -0
- package/src/get.js +95 -0
- package/src/index.js +121 -0
- package/src/list.js +197 -0
- package/src/update.js +140 -0
- package/src/upload-document.js +125 -0
- package/src/upload-image.js +122 -0
- package/src/upload-video.js +127 -0
- package/src/utils.js +147 -0
- package/src/uuid.js +10 -0
- package/src/validate.js +298 -0
- package/src/whoami.js +34 -0
package/src/validate.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/* global process */
|
|
2
|
+
/**
|
|
3
|
+
* Component input validation module.
|
|
4
|
+
*
|
|
5
|
+
* Validates component inputs against their component.yml definitions
|
|
6
|
+
* to catch errors before sending to the server (which returns generic 503).
|
|
7
|
+
*
|
|
8
|
+
* Reads componentDir from canvas.config.json in the current working directory,
|
|
9
|
+
* falls back to CANVAS_COMPONENT_DIR env var, then ./src/components.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
12
|
+
import { join, resolve } from 'path';
|
|
13
|
+
import yaml from 'js-yaml';
|
|
14
|
+
|
|
15
|
+
function getComponentsDir() {
|
|
16
|
+
// Prefer canvas.config.json
|
|
17
|
+
const configPath = resolve(process.cwd(), 'canvas.config.json');
|
|
18
|
+
if (existsSync(configPath)) {
|
|
19
|
+
try {
|
|
20
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
21
|
+
if (config.componentDir) {
|
|
22
|
+
return resolve(process.cwd(), config.componentDir);
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// Fall through to env var / default
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fall back to env var
|
|
30
|
+
const envDir = process.env.CANVAS_COMPONENT_DIR;
|
|
31
|
+
if (envDir) {
|
|
32
|
+
return resolve(envDir);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return resolve(process.cwd(), 'src/components');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Cache for loaded component definitions
|
|
39
|
+
const componentCache = new Map();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load and cache a component definition from its component.yml file.
|
|
43
|
+
* @param {string} machineName - The component machine name (without js. prefix)
|
|
44
|
+
* @returns {object|null} The parsed component definition or null if not found
|
|
45
|
+
*/
|
|
46
|
+
export function loadComponentDefinition(machineName) {
|
|
47
|
+
if (componentCache.has(machineName)) {
|
|
48
|
+
return componentCache.get(machineName);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const componentsDir = getComponentsDir();
|
|
52
|
+
const componentPath = join(componentsDir, machineName, 'component.yml');
|
|
53
|
+
|
|
54
|
+
if (!existsSync(componentPath)) {
|
|
55
|
+
componentCache.set(machineName, null);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const content = readFileSync(componentPath, 'utf-8');
|
|
61
|
+
const definition = yaml.load(content);
|
|
62
|
+
componentCache.set(machineName, definition);
|
|
63
|
+
return definition;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`Error parsing ${componentPath}: ${error.message}`);
|
|
66
|
+
componentCache.set(machineName, null);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract the machine name from a component_id.
|
|
73
|
+
* @param {string} componentId - The component ID (e.g., "js.button")
|
|
74
|
+
* @returns {string} The machine name (e.g., "button")
|
|
75
|
+
*/
|
|
76
|
+
export function getMachineNameFromId(componentId) {
|
|
77
|
+
if (componentId.startsWith('js.')) {
|
|
78
|
+
return componentId.slice(3);
|
|
79
|
+
}
|
|
80
|
+
return componentId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate a single prop value against its schema.
|
|
85
|
+
* @param {string} propName - The name of the prop
|
|
86
|
+
* @param {*} value - The value to validate
|
|
87
|
+
* @param {object} schema - The prop schema from component.yml
|
|
88
|
+
* @returns {string[]} Array of error messages (empty if valid)
|
|
89
|
+
*/
|
|
90
|
+
function validateProp(propName, value, schema) {
|
|
91
|
+
const errors = [];
|
|
92
|
+
|
|
93
|
+
if (value === undefined || value === null) {
|
|
94
|
+
return errors; // Let required check handle missing values
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Type validation
|
|
98
|
+
if (schema.type) {
|
|
99
|
+
const actualType = Array.isArray(value) ? 'array' : typeof value;
|
|
100
|
+
|
|
101
|
+
// Handle object type for complex inputs like image
|
|
102
|
+
if (schema.type === 'object') {
|
|
103
|
+
if (actualType !== 'object' || Array.isArray(value)) {
|
|
104
|
+
errors.push(`${propName}: expected object, got ${actualType}`);
|
|
105
|
+
}
|
|
106
|
+
} else if (schema.type === 'string') {
|
|
107
|
+
// Accept multiple valid string representations:
|
|
108
|
+
// - Plain strings
|
|
109
|
+
// - Objects with 'value' property (for HTML content from Drupal)
|
|
110
|
+
// - Objects with 'uri' property (for links from Drupal Canvas)
|
|
111
|
+
const isPlainString = actualType === 'string';
|
|
112
|
+
const isHtmlObject = actualType === 'object' && 'value' in value;
|
|
113
|
+
const isLinkObject =
|
|
114
|
+
actualType === 'object' &&
|
|
115
|
+
'uri' in value &&
|
|
116
|
+
(schema.format === 'uri' || schema.format === 'uri-reference');
|
|
117
|
+
|
|
118
|
+
if (!isPlainString && !isHtmlObject && !isLinkObject) {
|
|
119
|
+
errors.push(`${propName}: expected string, got ${actualType}`);
|
|
120
|
+
}
|
|
121
|
+
} else if (schema.type === 'boolean') {
|
|
122
|
+
if (actualType !== 'boolean') {
|
|
123
|
+
errors.push(`${propName}: expected boolean, got ${actualType}`);
|
|
124
|
+
}
|
|
125
|
+
} else if (schema.type === 'number' || schema.type === 'integer') {
|
|
126
|
+
if (actualType !== 'number') {
|
|
127
|
+
errors.push(`${propName}: expected number, got ${actualType}`);
|
|
128
|
+
}
|
|
129
|
+
} else if (schema.type === 'array') {
|
|
130
|
+
if (!Array.isArray(value)) {
|
|
131
|
+
errors.push(`${propName}: expected array, got ${actualType}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Enum validation
|
|
137
|
+
if (schema.enum && Array.isArray(schema.enum)) {
|
|
138
|
+
// Get the actual string value (handle objects with value property)
|
|
139
|
+
const checkValue =
|
|
140
|
+
typeof value === 'object' && 'value' in value ? value.value : value;
|
|
141
|
+
|
|
142
|
+
// Only validate strings against enum
|
|
143
|
+
if (typeof checkValue === 'string' && !schema.enum.includes(checkValue)) {
|
|
144
|
+
errors.push(
|
|
145
|
+
`${propName}: "${checkValue}" is not a valid option. ` +
|
|
146
|
+
`Valid options: ${schema.enum.join(', ')}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return errors;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validate a component's inputs against its definition.
|
|
156
|
+
* @param {string} componentId - The component ID (e.g., "js.button")
|
|
157
|
+
* @param {object} inputs - The input values to validate
|
|
158
|
+
* @returns {{valid: boolean, errors: string[]}} Validation result
|
|
159
|
+
*/
|
|
160
|
+
export function validateComponentInputs(componentId, inputs) {
|
|
161
|
+
const machineName = getMachineNameFromId(componentId);
|
|
162
|
+
const definition = loadComponentDefinition(machineName);
|
|
163
|
+
const errors = [];
|
|
164
|
+
|
|
165
|
+
if (!definition) {
|
|
166
|
+
// Component not found - skip validation (might be external)
|
|
167
|
+
return {
|
|
168
|
+
valid: true,
|
|
169
|
+
errors: [],
|
|
170
|
+
warning: `Unknown component: ${componentId}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const props = definition.props?.properties || {};
|
|
175
|
+
const required = definition.required || [];
|
|
176
|
+
|
|
177
|
+
// Check required props
|
|
178
|
+
for (const propName of required) {
|
|
179
|
+
if (inputs[propName] === undefined || inputs[propName] === null) {
|
|
180
|
+
errors.push(`${propName}: required prop is missing`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate each provided input
|
|
185
|
+
for (const [propName, value] of Object.entries(inputs || {})) {
|
|
186
|
+
const schema = props[propName];
|
|
187
|
+
|
|
188
|
+
if (!schema) {
|
|
189
|
+
// Unknown prop - warn but don't fail
|
|
190
|
+
errors.push(`${propName}: unknown prop for ${componentId}`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const propErrors = validateProp(propName, value, schema);
|
|
195
|
+
errors.push(...propErrors);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
valid: errors.length === 0,
|
|
200
|
+
errors,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Validate all components in a content entity.
|
|
206
|
+
* @param {object} data - The content data (with data.attributes.components or attributes.components)
|
|
207
|
+
* @returns {{valid: boolean, errors: Array<{component: string, uuid: string, errors: string[]}>}}
|
|
208
|
+
*/
|
|
209
|
+
export function validateContentComponents(data) {
|
|
210
|
+
const attributes = data.data?.attributes || data.attributes || {};
|
|
211
|
+
const components = attributes.components;
|
|
212
|
+
|
|
213
|
+
if (!Array.isArray(components)) {
|
|
214
|
+
return { valid: true, errors: [] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const allErrors = [];
|
|
218
|
+
const warnings = [];
|
|
219
|
+
|
|
220
|
+
for (const component of components) {
|
|
221
|
+
const componentId = component.component_id;
|
|
222
|
+
const uuid = component.uuid || '(no uuid)';
|
|
223
|
+
|
|
224
|
+
// Parse inputs if they're a JSON string
|
|
225
|
+
let inputs = component.inputs;
|
|
226
|
+
if (typeof inputs === 'string') {
|
|
227
|
+
try {
|
|
228
|
+
inputs = JSON.parse(inputs);
|
|
229
|
+
} catch {
|
|
230
|
+
allErrors.push({
|
|
231
|
+
component: componentId,
|
|
232
|
+
uuid,
|
|
233
|
+
errors: ['inputs: invalid JSON string'],
|
|
234
|
+
});
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = validateComponentInputs(componentId, inputs);
|
|
240
|
+
|
|
241
|
+
if (result.warning) {
|
|
242
|
+
warnings.push(result.warning);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!result.valid) {
|
|
246
|
+
allErrors.push({
|
|
247
|
+
component: componentId,
|
|
248
|
+
uuid,
|
|
249
|
+
errors: result.errors,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
valid: allErrors.length === 0,
|
|
256
|
+
errors: allErrors,
|
|
257
|
+
warnings,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Format validation errors for display.
|
|
263
|
+
* @param {Array<{component: string, uuid: string, errors: string[]}>} errors
|
|
264
|
+
* @returns {string} Formatted error message
|
|
265
|
+
*/
|
|
266
|
+
export function formatValidationErrors(errors) {
|
|
267
|
+
const lines = ['Component validation failed:'];
|
|
268
|
+
|
|
269
|
+
for (const { component, uuid, errors: componentErrors } of errors) {
|
|
270
|
+
lines.push(`\n ${component} (${uuid}):`);
|
|
271
|
+
for (const error of componentErrors) {
|
|
272
|
+
lines.push(` - ${error}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return lines.join('\n');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* List all available components.
|
|
281
|
+
* @returns {string[]} Array of component machine names
|
|
282
|
+
*/
|
|
283
|
+
export function listAvailableComponents() {
|
|
284
|
+
const componentsDir = getComponentsDir();
|
|
285
|
+
|
|
286
|
+
if (!existsSync(componentsDir)) {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const entries = readdirSync(componentsDir, { withFileTypes: true });
|
|
291
|
+
|
|
292
|
+
return entries
|
|
293
|
+
.filter((entry) => entry.isDirectory())
|
|
294
|
+
.filter((entry) =>
|
|
295
|
+
existsSync(join(componentsDir, entry.name, 'component.yml')),
|
|
296
|
+
)
|
|
297
|
+
.map((entry) => entry.name);
|
|
298
|
+
}
|
package/src/whoami.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* global process */
|
|
2
|
+
/**
|
|
3
|
+
* Shows current OAuth authentication status.
|
|
4
|
+
*/
|
|
5
|
+
import { authenticatedFetch, getSiteUrl } from './client.js';
|
|
6
|
+
|
|
7
|
+
export async function whoami() {
|
|
8
|
+
try {
|
|
9
|
+
const url = `${getSiteUrl()}/oauth/userinfo`;
|
|
10
|
+
const response = await authenticatedFetch(url);
|
|
11
|
+
const contentType = response.headers.get('content-type') || '';
|
|
12
|
+
|
|
13
|
+
// Handle non-JSON responses
|
|
14
|
+
if (!contentType.includes('application/json')) {
|
|
15
|
+
const text = await response.text();
|
|
16
|
+
console.error(`HTTP ${response.status}: ${response.statusText}`);
|
|
17
|
+
console.error(text.slice(0, 500));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result = await response.json();
|
|
22
|
+
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
console.error(`HTTP ${response.status}: ${response.statusText}`);
|
|
25
|
+
console.error(JSON.stringify(result, null, 2));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(JSON.stringify(result, null, 2));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error('Error:', error.message);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|