Skip to content

Commit d6988a9

Browse files
tathagat2241Tathagat Sharma
andauthored
feat: add configuration management APIs (#1063)
## Summary This PR adds new configuration management methods to support API-driven configuration updates ## Changes Made ### Configuration Model (`configuration.model.js`) - Added `updateQueues(queues)` - Update queue configurations - Added `updateJob(type, properties)` - Update job properties (interval, group) - Added `updateHandlerProperties(type, properties)` - Update handler settings (enabledByDefault, productCodes, dependencies, thresholds) - Added `updateConfiguration(data)` - Flexible method to update multiple configuration sections at once - Made `productCodes` a required and non-empty field for handlers - Added `restore(version)` - restores the last good version - Enhanced `register(audit)` and `unregister(audit)` - remove AUDIT_TYPES static list validation. ### Configuration Schema (`configuration.schema.js`) - Updated handler schema to require `productCodes` with minimum 1 item ### Configuration Index (`index.js`) - Exported `checkConfiguration` function for response validation ## Testing - All tests passing (1133 tests) Please ensure your pull request adheres to the following guidelines: - [X] make sure to link the related issues in this description - [X] when merging / squashing, make sure the fixed issue references are visible in the commits, for easy compilation of release notes ## Related Issues https://jira.corp.adobe.com/browse/LLMO-908 Thanks for contributing! --------- Co-authored-by: Tathagat Sharma <ftathagat+adobe@adobe.com>
1 parent eeed8a8 commit d6988a9

File tree

6 files changed

+794
-42
lines changed

6 files changed

+794
-42
lines changed

packages/spacecat-shared-data-access/src/models/configuration/configuration.model.js

Lines changed: 196 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { isNonEmptyObject, isNonEmptyArray } from '@adobe/spacecat-shared-utils'
1414

1515
import { sanitizeIdAndAuditFields } from '../../util/util.js';
1616
import BaseModel from '../base/base.model.js';
17-
import { Audit } from '../audit/index.js';
1817
import { Entitlement } from '../entitlement/index.js';
1918

2019
/**
@@ -47,6 +46,11 @@ class Configuration extends BaseModel {
4746
FORTNIGHTLY_SUNDAY: 'fortnightly-sunday',
4847
MONTHLY: 'monthly',
4948
};
49+
50+
static AUDIT_NAME_REGEX = /^[a-z0-9-]+$/;
51+
52+
static AUDIT_NAME_MAX_LENGTH = 37;
53+
5054
// add your custom methods or overrides here
5155

5256
getHandler(type) {
@@ -253,50 +257,209 @@ class Configuration extends BaseModel {
253257
this.updateHandlerOrgs(type, orgId, false);
254258
}
255259

260+
/**
261+
* Updates the queue URLs configuration by merging with existing queues.
262+
* Only the specified queue URLs will be updated; others remain unchanged.
263+
*
264+
* @param {object} queues - Queue URLs to update (merged with existing)
265+
* @throws {Error} If queues object is empty or invalid
266+
*/
267+
updateQueues(queues) {
268+
if (!isNonEmptyObject(queues)) {
269+
throw new Error('Queues configuration cannot be empty');
270+
}
271+
const existingQueues = this.getQueues() || {};
272+
const mergedQueues = { ...existingQueues, ...queues };
273+
this.setQueues(mergedQueues);
274+
}
275+
276+
/**
277+
* Updates a job's properties (interval, group).
278+
*
279+
* @param {string} type - The job type to update
280+
* @param {object} properties - Properties to update (interval, group)
281+
* @throws {Error} If job not found or properties are invalid
282+
*/
283+
updateJob(type, properties) {
284+
const jobs = this.getJobs();
285+
const jobIndex = jobs.findIndex((job) => job.type === type);
286+
287+
if (jobIndex === -1) {
288+
throw new Error(`Job type "${type}" not found in configuration`);
289+
}
290+
291+
if (properties.interval && !Object.values(Configuration.JOB_INTERVALS)
292+
.includes(properties.interval)) {
293+
throw new Error(`Invalid interval "${properties.interval}". Must be one of: ${Object.values(Configuration.JOB_INTERVALS).join(', ')}`);
294+
}
295+
296+
if (properties.group && !Object.values(Configuration.JOB_GROUPS).includes(properties.group)) {
297+
throw new Error(`Invalid group "${properties.group}". Must be one of: ${Object.values(Configuration.JOB_GROUPS).join(', ')}`);
298+
}
299+
300+
jobs[jobIndex] = { ...jobs[jobIndex], ...properties };
301+
this.setJobs(jobs);
302+
}
303+
304+
/**
305+
* Updates a handler's properties.
306+
*
307+
* @param {string} type - The handler type to update
308+
* @param {object} properties - Properties to update
309+
* @throws {Error} If handler not found or properties are invalid
310+
*/
311+
updateHandlerProperties(type, properties) {
312+
const handlers = this.getHandlers();
313+
if (!handlers[type]) {
314+
throw new Error(`Handler "${type}" not found in configuration`);
315+
}
316+
317+
if (properties.productCodes !== undefined) {
318+
if (!isNonEmptyArray(properties.productCodes)) {
319+
throw new Error('productCodes must be a non-empty array');
320+
}
321+
const validProductCodes = Object.values(Entitlement.PRODUCT_CODES);
322+
if (!properties.productCodes.every((pc) => validProductCodes.includes(pc))) {
323+
throw new Error('Invalid product codes provided');
324+
}
325+
}
326+
327+
if (isNonEmptyArray(properties.dependencies)) {
328+
for (const dep of properties.dependencies) {
329+
if (!handlers[dep.handler]) {
330+
throw new Error(`Dependency handler "${dep.handler}" does not exist in configuration`);
331+
}
332+
}
333+
}
334+
335+
if (properties.movingAvgThreshold !== undefined && properties.movingAvgThreshold < 1) {
336+
throw new Error('movingAvgThreshold must be greater than or equal to 1');
337+
}
338+
339+
if (properties.percentageChangeThreshold !== undefined && properties
340+
.percentageChangeThreshold < 1) {
341+
throw new Error('percentageChangeThreshold must be greater than or equal to 1');
342+
}
343+
344+
handlers[type] = { ...handlers[type], ...properties };
345+
this.setHandlers(handlers);
346+
}
347+
348+
/**
349+
* Updates the configuration by merging changes into existing sections.
350+
* This is a flexible update method that allows updating one or more sections at once.
351+
* Changes are merged, not replaced - existing data is preserved.
352+
*
353+
* @param {object} data - Configuration data to update
354+
* @param {object} [data.handlers] - Handlers to merge (adds new, updates existing)
355+
* @param {Array} [data.jobs] - Jobs to merge (updates matching jobs by type)
356+
* @param {object} [data.queues] - Queues to merge (updates specific queue URLs)
357+
* @throws {Error} If validation fails
358+
*/
359+
updateConfiguration(data) {
360+
if (!isNonEmptyObject(data)) {
361+
throw new Error('Configuration data cannot be empty');
362+
}
363+
364+
if (data.handlers !== undefined) {
365+
if (!isNonEmptyObject(data.handlers)) {
366+
throw new Error('Handlers must be a non-empty object if provided');
367+
}
368+
const existingHandlers = this.getHandlers() || {};
369+
const mergedHandlers = { ...existingHandlers };
370+
371+
Object.keys(data.handlers).forEach((handlerType) => {
372+
mergedHandlers[handlerType] = {
373+
...existingHandlers[handlerType],
374+
...data.handlers[handlerType],
375+
};
376+
});
377+
378+
this.setHandlers(mergedHandlers);
379+
}
380+
381+
if (data.jobs !== undefined) {
382+
if (!isNonEmptyArray(data.jobs)) {
383+
throw new Error('Jobs must be a non-empty array if provided');
384+
}
385+
const existingJobs = this.getJobs() || [];
386+
const mergedJobs = [...existingJobs];
387+
388+
data.jobs.forEach((newJob) => {
389+
const existingIndex = mergedJobs.findIndex(
390+
(job) => job.type === newJob.type && job.group === newJob.group,
391+
);
392+
393+
if (existingIndex !== -1) {
394+
mergedJobs[existingIndex] = { ...mergedJobs[existingIndex], ...newJob };
395+
} else {
396+
mergedJobs.push(newJob);
397+
}
398+
});
399+
400+
this.setJobs(mergedJobs);
401+
}
402+
403+
if (data.queues !== undefined) {
404+
if (!isNonEmptyObject(data.queues)) {
405+
throw new Error('Queues must be a non-empty object if provided');
406+
}
407+
const existingQueues = this.getQueues() || {};
408+
const mergedQueues = { ...existingQueues, ...data.queues };
409+
410+
this.setQueues(mergedQueues);
411+
}
412+
}
413+
256414
registerAudit(
257415
type,
258416
enabledByDefault = false,
259417
interval = Configuration.JOB_INTERVALS.NEVER,
260418
productCodes = [],
261419
) {
262-
// Validate audit type
263-
if (!Object.values(Audit.AUDIT_TYPES).includes(type)) {
264-
throw new Error(`Audit type ${type} is not a valid audit type in the data model`);
420+
if (!type || typeof type !== 'string' || type.trim() === '') {
421+
throw new Error('Audit type must be a non-empty string');
422+
}
423+
424+
if (type.length > Configuration.AUDIT_NAME_MAX_LENGTH) {
425+
throw new Error(`Audit type must not exceed ${Configuration.AUDIT_NAME_MAX_LENGTH} characters`);
426+
}
427+
if (!Configuration.AUDIT_NAME_REGEX.test(type)) {
428+
throw new Error('Audit type can only contain lowercase letters, numbers, and hyphens');
429+
}
430+
431+
const handlers = this.getHandlers();
432+
if (handlers && handlers[type]) {
433+
throw new Error(`Audit type "${type}" is already registered`);
265434
}
266435

267-
// Validate job interval
268436
if (!Object.values(Configuration.JOB_INTERVALS).includes(interval)) {
269437
throw new Error(`Invalid interval ${interval}`);
270438
}
271439

272-
// Validate product codes
273440
if (!isNonEmptyArray(productCodes)) {
274441
throw new Error('No product codes provided');
275442
}
276443
if (!productCodes.every((pc) => Object.values(Entitlement.PRODUCT_CODES).includes(pc))) {
277444
throw new Error('Invalid product codes provided');
278445
}
279446

280-
// Add to handlers if not already registered
281-
const handlers = this.getHandlers();
282-
if (!handlers[type]) {
283-
handlers[type] = {
284-
enabledByDefault,
285-
enabled: {
286-
sites: [],
287-
orgs: [],
288-
},
289-
disabled: {
290-
sites: [],
291-
orgs: [],
292-
},
293-
dependencies: [],
294-
productCodes,
295-
};
296-
this.setHandlers(handlers);
297-
}
298-
299-
// Add to jobs if not already registered
447+
const updatedHandlers = handlers || {};
448+
updatedHandlers[type] = {
449+
enabledByDefault,
450+
enabled: {
451+
sites: [],
452+
orgs: [],
453+
},
454+
disabled: {
455+
sites: [],
456+
orgs: [],
457+
},
458+
dependencies: [],
459+
productCodes,
460+
};
461+
this.setHandlers(updatedHandlers);
462+
300463
const jobs = this.getJobs();
301464
const exists = jobs.find((job) => job.group === 'audits' && job.type === type);
302465
if (!exists) {
@@ -310,19 +473,18 @@ class Configuration extends BaseModel {
310473
}
311474

312475
unregisterAudit(type) {
313-
// Validate audit type
314-
if (!Object.values(Audit.AUDIT_TYPES).includes(type)) {
315-
throw new Error(`Audit type ${type} is not a valid audit type in the data model`);
476+
if (!type || typeof type !== 'string' || type.trim() === '') {
477+
throw new Error('Audit type must be a non-empty string');
316478
}
317479

318-
// Remove from handlers
319480
const handlers = this.getHandlers();
320-
if (handlers[type]) {
321-
delete handlers[type];
322-
this.setHandlers(handlers);
481+
if (!handlers || !handlers[type]) {
482+
throw new Error(`Audit type "${type}" is not registered`);
323483
}
324484

325-
// Remove from jobs
485+
delete handlers[type];
486+
this.setHandlers(handlers);
487+
326488
const jobs = this.getJobs();
327489
const jobIndex = jobs.findIndex((job) => job.group === 'audits' && job.type === type);
328490
if (jobIndex !== -1) {

packages/spacecat-shared-data-access/src/models/configuration/configuration.schema.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const handlerSchema = Joi.object().pattern(Joi.string(), Joi.object(
4040
actions: Joi.array().items(Joi.string()),
4141
},
4242
)),
43-
productCodes: Joi.array().items(Joi.string()),
43+
productCodes: Joi.array().items(Joi.string()).min(1).required(),
4444
},
4545
)).unknown(true);
4646

packages/spacecat-shared-data-access/src/models/configuration/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212

1313
import Configuration from './configuration.model.js';
1414
import ConfigurationCollection from './configuration.collection.js';
15+
import { checkConfiguration } from './configuration.schema.js';
1516

1617
export {
1718
Configuration,
1819
ConfigurationCollection,
20+
checkConfiguration,
1921
};

packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,22 @@ const configurations = [
5353
handlers: {
5454
404: {
5555
enabledByDefault: true,
56+
productCodes: ['ASO'],
5657
},
5758
'rum-ingest': {
5859
enabledByDefault: false,
60+
productCodes: ['ASO'],
5961
enabled: {
6062
sites: ['c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe'],
6163
},
6264
},
6365
'organic-keywords': {
6466
enabledByDefault: false,
67+
productCodes: ['ASO'],
6568
},
6669
cwv: {
6770
enabledByDefault: true,
71+
productCodes: ['ASO'],
6872
disabled: {
6973
sites: [
7074
'5d6d4439-6659-46c2-b646-92d110fa5a52',
@@ -83,6 +87,7 @@ const configurations = [
8387
},
8488
sitemap: {
8589
enabledByDefault: true,
90+
productCodes: ['ASO'],
8691
enabled: {
8792
sites: [],
8893
orgs: [],
@@ -94,6 +99,7 @@ const configurations = [
9499
},
95100
'lhs-mobile': {
96101
enabledByDefault: false,
102+
productCodes: ['ASO'],
97103
enabled: {
98104
sites: ['c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe'],
99105
orgs: ['757ceb98-05c8-4e07-bb23-bc722115b2b0'],

packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ describe('Configuration IT', async () => {
7777

7878
const data = {
7979
enabledByDefault: true,
80+
productCodes: ['ASO'],
8081
enabled: {
8182
sites: ['site1'],
8283
orgs: ['org1'],

0 commit comments

Comments
 (0)