-
Notifications
You must be signed in to change notification settings - Fork 1
/
StoryblokSolrIndexer.mjs
332 lines (290 loc) · 12 KB
/
StoryblokSolrIndexer.mjs
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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
import StoryblokClient from 'storyblok-js-client'
/**
* Represents a class that handles indexing of Storyblok content into a Solr search engine.
*/
class StoryblokSolrIndexer {
/**
* Creates an instance of the StoryblokSolrIndexer class.
* @param {Object} storyblok - Configuration object for the Storyblok API client.
* @param {Object} solr - An instance of a Solr client or configuration object.
*/
constructor(storyblok, solr) {
// Initialize the Storyblok API client with the provided access token.
this.storyblokApiClient = new StoryblokClient({ accessToken: storyblok.accessToken });
// Store any additional options needed for the Storyblok API client.
this.storyblokOptions = storyblok.options;
// Assign the provided Solr client or configuration to this instance.
this.solr = solr;
}
/**
* Asynchronously processes story changes based on an action and updates the Solr index.
* @param {Object} data - Data containing the details of the change including the action type.
*/
async indexStoriesBasedOnAction(data) {
try {
// Check if the action property exists in the provided data object using optional chaining.
if (data?.action) {
// Use a switch statement to handle different types of actions.
switch (data.action) {
case 'published':
case 'moved':
// If the story is published or moved, add or update it in the Solr index.
await this.addUpdateStoryInIndex(data);
break;
case 'unpublished':
case 'deleted':
// If the story is unpublished or deleted, remove it from the Solr index.
await this.deleteStoryFromIndex(data);
break;
default:
// For any other action, reindex all stories.
await this.clearSolrIndex();
await this.indexAll();
break;
}
} else {
// If no action is specified, fall back to reindexing all stories.
await this.clearSolrIndex();
await this.indexAll();
}
} catch (error) {
// Log any errors that occur during the indexing process.
console.error('Error processing index action:', error);
}
}
/**
* Asynchronously indexes all stories in the Solr search engine.
*/
async indexAll() {
// Fetch the first page to determine the total number of stories and calculate the number of pages.
const firstPageResponse = await this.fetchStories();
const total = parseInt(firstPageResponse.headers.total); // Total number of stories
const maxPage = Math.ceil(total / this.storyblokOptions.per_page); // Calculate total pages based on per_page option
let contentRequests = [];
// Loop through all pages, fetching each one's stories and adding the promise to an array.
for (let page = 1; page <= maxPage; page++) {
contentRequests.push(this.fetchStories(page));
}
// Await all fetch story promises at once for efficiency, resulting in all page responses.
const pageResponses = await Promise.all(contentRequests);
let docs = []; // Initialize an array to hold all documents for indexing.
// Iterate over each page response, extract the stories, and prepare them for Solr indexing.
pageResponses.forEach(response => {
const stories = response.data.stories;
stories.forEach(story => {
// Transform each story into a Solr-friendly format.
docs.push(this.prepareSolrDoc(story));
});
});
// If there are documents to index, perform the Solr indexing request.
if (docs.length > 0) {
await this.doSolrRequest(docs);
}
}
/**
* Fetches stories from the Storyblok Content Delivery API.
*
* @param {number} page - The page number of the results to retrieve, with a default value of 1.
* @returns {Promise} - A promise that resolves to the response of the Storyblok API request.
*/
async fetchStories(page = 1) {
// Executes a GET request to the Storyblok API using the predefined client instance.
// It combines predefined options with the page number for paginated results.
return await this.storyblokApiClient.get('cdn/stories/', { ...this.storyblokOptions, page });
}
/**
* Fetches a single story from Storyblok by its ID.
*
* @param {string} storyId - The unique identifier for the story to be retrieved.
* @returns {Promise} - A Promise that resolves with the response from the Storyblok API containing the requested story data.
*/
async getStoryById(storyId) {
// Makes an API call using the Storyblok client instance to get the data of a specific story.
// The storyId parameter is interpolated into the URL to specify which story to fetch.
return await this.storyblokApiClient.get(`cdn/stories/${storyId}`);
}
/**
* Prepares a document for indexing in Solr from a Storyblok story object.
*
* @param {Object} story - The story object retrieved from Storyblok which contains the content and metadata of a story.
* @returns {Object} - An object structured for Solr indexing, with necessary fields such as id, type, and appKey.
*/
prepareSolrDoc(story) {
// Extracts all textual values from the story's content using a helper function.
const contentStrings = this.findAllTextValues(story.content);
// Admiral Cloud Image UUID
const admiralCloudImageUuid = this.findAdmiralCloudImageUuid(story);
// Constructs the Solr document with required and additional fields.
let doc = {
id: story.id, // Unique identifier for the document (required for Solr).
type: story?.content?.component, // Type of the component, using optional chaining to prevent errors if story.content is undefined.
appKey: 'StoryblokSolrIndexer', // Identifier for the application performing the indexing (required field).
url: story.full_slug, // URL slug for the story.
title: story.name, // Name of the story.
content: contentStrings.join(' '), // Aggregate content string created by joining all text values with a space.
admiralCloudImageUuid_stringS: admiralCloudImageUuid ? admiralCloudImageUuid : '' // UUID admiral cloud image
};
// Returns the constructed document which is ready for indexing.
return doc;
}
/**
* Adds or updates a story in the Solr index.
* @param {Object} data - The data object containing the ID of the story to be indexed.
*/
async addUpdateStoryInIndex(data) {
// Fetches the story from Storyblok by story ID.
const response = await this.getStoryById(data.story_id);
// Use optional chaining (?.) to safely access deep nested properties.
const story = response?.data?.story;
// Check if the story object exists.
if (story) {
// Prepare the document for indexing in Solr.
const doc = this.prepareSolrDoc(story);
// Add or update the story document in Solr.
await this.doSolrRequest([doc]);
}
}
/**
* Asynchronously deletes a story from the search index.
* @param {Object} data - An object containing the necessary deletion parameters.
*/
async deleteStoryFromIndex(data) {
// Prepare the document for deletion by specifying the ID in a 'delete' operation.
const doc = { 'delete': data.story_id };
// Send the prepared document to Solr for execution of the delete operation.
await this.doSolrRequest(doc);
}
/**
* Asynchronously clears all documents from the Solr search index.
*/
async clearSolrIndex() {
// Prepare the delete command to clear the entire index.
// The JSON body for the delete command uses '*:*' to match all documents.
const deleteCommand = { "delete": { "query": "*:*" } };
try {
// Execute the delete command by making a request to the Solr API.
const response = await this.doSolrRequest(deleteCommand);
// Log the successful clearing of the index.
console.log('Successfully cleared the Solr index.', response);
} catch (error) {
// In case of an error during the deletion process, log the error.
console.error('Error clearing the Solr index:', error);
// Optionally, rethrow the error to allow calling code to handle it.
throw error;
}
}
/**
* Create
* @param {*} solrOptions
* @returns
*/
getSolrApiUrl(solrOptions) {
const protocol = solrOptions.port == 443 ? 'https' : 'http';
return `${protocol}://${solrOptions.host}/${solrOptions.path}/${solrOptions.core}`
}
// Define an asynchronous function to make a request to Solr.
async doSolrRequest(docs) {
console.log('Solr Request Data added/updated/deleted', docs)
try {
// Retrieve the API URL for the Solr instance, assuming it can be accessed from `this` context.
const solrApiUrl = this.getSolrApiUrl(this.solr);
// Create a string with the username and password separated by a colon.
const userPass = `${this.solr.user}:${this.solr.pass}`;
// Encode the username and password in base64 format for the Authorization header (Node.js compatible method).
const base64UserPass = btoa(userPass);
// Convert the documents to a JSON string as the request body.
const body = JSON.stringify(docs)
// Perform the fetch operation using the POST method with the appropriate headers and body.
const response = await fetch(`${solrApiUrl}/update?commit=true`, {
body: body,
method: "POST", // Use POST method to send data to the server.
headers: {
"Authorization": `Basic ${base64UserPass}`, // Set the basic authentication header with encoded credentials.
"content-type": "application/json;charset=UTF-8", // Specify the content type of the request.
}
});
// Check if the HTTP response status indicates a failure.
if (!response.ok) {
console.error('Solr added/updated/deleted', docs)
// Throw an error including the HTTP status code to provide more details about the failure.
throw new Error(`HTTP error! status: ${response.status}`);
} else {
console.log('Solr added/updated/deleted', docs)
}
// Parse the JSON response from the Solr server and return it.
return await response.json();
} catch (error) {
// Log any errors that occur during the request or processing to the console.
console.error("Error occurred during Solr request:", error);
// Rethrow the error to allow caller functions to handle it further if necessary.
throw error;
}
}
/**
* Find all text values in story object
* @param {*} obj
* @returns
*/
findAllTextValues(obj) {
const result = [];
for (let key in obj) {
if (key === "text" && typeof obj[key] === "string") {
result.push(obj[key]);
} else if (typeof obj[key] === "object") {
result.push(...this.findAllTextValues(obj[key]));
}
}
return result;
}
/**
* find AdmiralCloudImageUuid
* @param {*} storyObj
* @returns {String} | {null} UUID or null
*/
findAdmiralCloudImageUuid(storyObj) {
return this.findFirstOccurrence(storyObj,'admiralCloudImageUuid')
}
/**
* find first occurence of given key in given nested object returns value if found
* @param {*} nestedObj
* @param {*} key
* @returns {*} | null
*/
findFirstOccurrence(nestedObj, key) {
// Check if the input is a non-null object
if (typeof nestedObj !== 'object' || nestedObj === null) {
return null; // Return null if it's not an object
}
// Check if the key exists in the current object
if (key in nestedObj) {
return nestedObj[key]; // Return the value if the key is found
}
// Iterate through each key in the object
for (const k in nestedObj) {
// Ensure that the property belongs to the object itself and not its prototype chain
if (nestedObj.hasOwnProperty(k)) {
const value = nestedObj[k];
// If the value is an object, recurse into it
if (typeof value === 'object') {
const result = this.findFirstOccurrence(value, key);
if (result !== null) {
return result; // Return the result if the key is found in the nested object
}
} else if (Array.isArray(value)) { // If the value is an array
// Iterate through each item in the array
for (const item of value) {
if (typeof item === 'object') {
const result = this.findFirstOccurrence(item, key);
if (result !== null) {
return result; // Return the result if the key is found in the nested object within the array
}
}
}
}
}
}
// Return null if the key is not found in the object or its nested objects/arrays
return null;
}
}
export default StoryblokSolrIndexer