@adobe/spacecat-shared-data-access 1.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/src/index.js ADDED
@@ -0,0 +1,24 @@
1
+ /*
2
+ * Copyright 2023 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { createDataAccess } from './service/index.js';
14
+
15
+ export default function dataAccessWrapper(fn) {
16
+ return async (request, context) => {
17
+ if (!context.dataAccess) {
18
+ const { log } = context;
19
+ context.dataAccess = createDataAccess(log);
20
+ }
21
+
22
+ return fn(request, context);
23
+ };
24
+ }
@@ -0,0 +1,103 @@
1
+ /*
2
+ * Copyright 2023 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { hasText, isIsoDate, isObject } from '@adobe/spacecat-shared-utils';
14
+ import { Base } from './base.js';
15
+
16
+ export const AUDIT_TYPE_CWV = 'cwv';
17
+ export const AUDIT_TYPE_LHS = 'lhs';
18
+
19
+ const EXPIRES_IN_DAYS = 30;
20
+
21
+ const AUDIT_TYPE_PROPERTIES = {
22
+ [AUDIT_TYPE_CWV]: [],
23
+ [AUDIT_TYPE_LHS]: ['performance', 'seo', 'accessibility', 'best-practices'],
24
+ };
25
+
26
+ /**
27
+ * Validates if the auditResult contains the required properties for the given audit type.
28
+ * @param {object} auditResult - The audit result to validate.
29
+ * @param {string} auditType - The type of the audit.
30
+ * @returns {boolean} - True if valid, false otherwise.
31
+ */
32
+ const validateAuditResult = (auditResult, auditType) => {
33
+ const expectedProperties = AUDIT_TYPE_PROPERTIES[auditType];
34
+ if (!expectedProperties) {
35
+ throw new Error(`Unknown audit type: ${auditType}`);
36
+ }
37
+
38
+ for (const prop of expectedProperties) {
39
+ if (!(prop in auditResult)) {
40
+ throw new Error(`Missing expected property '${prop}' for audit type '${auditType}'`);
41
+ }
42
+ }
43
+
44
+ return true;
45
+ };
46
+
47
+ /**
48
+ * Creates a new Audit.
49
+ * @param {object } data - audit data
50
+ * @returns {Readonly<Audit>} audit - new audit
51
+ */
52
+ const Audit = (data = {}) => {
53
+ const self = Base(data);
54
+
55
+ self.getSiteId = () => self.state.siteId;
56
+ self.getAuditedAt = () => self.state.auditedAt;
57
+ self.getAuditResult = () => self.state.auditResult;
58
+ self.getAuditType = () => self.state.auditType.toLowerCase();
59
+ self.getExpiresAt = () => self.state.expiresAt;
60
+ self.getFullAuditRef = () => self.state.fullAuditRef;
61
+ self.getScores = () => self.getAuditResult();
62
+
63
+ return Object.freeze(self);
64
+ };
65
+
66
+ /**
67
+ * Creates a new Audit.
68
+ *
69
+ * @param {object} data - audit data
70
+ * @returns {Readonly<Audit>} audit - new audit
71
+ */
72
+ export const createAudit = (data) => {
73
+ const newState = { ...data };
74
+
75
+ if (!hasText(newState.siteId)) {
76
+ throw new Error('Site ID must be provided');
77
+ }
78
+
79
+ if (!isIsoDate(newState.auditedAt)) {
80
+ throw new Error('Audited at must be a valid ISO date');
81
+ }
82
+
83
+ if (!hasText(newState.auditType)) {
84
+ throw new Error('Audit type must be provided');
85
+ }
86
+
87
+ if (!isObject(newState.auditResult)) {
88
+ throw new Error('Audit result must be an object');
89
+ }
90
+
91
+ validateAuditResult(data.auditResult, data.auditType);
92
+
93
+ if (!hasText(newState.fullAuditRef)) {
94
+ throw new Error('Full audit ref must be provided');
95
+ }
96
+
97
+ if (!newState.expiresAt) {
98
+ newState.expiresAt = new Date(newState.auditedAt);
99
+ newState.expiresAt.setDate(newState.expiresAt.getDate() + EXPIRES_IN_DAYS);
100
+ }
101
+
102
+ return Audit(newState);
103
+ };
@@ -0,0 +1,42 @@
1
+ /*
2
+ * Copyright 2023 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { v4 as uuidv4 } from 'uuid';
14
+ import { isString } from '@adobe/spacecat-shared-utils';
15
+
16
+ /**
17
+ * Base model.
18
+ *
19
+ * @param {object} data data
20
+ * @returns {Base} base model
21
+ */
22
+ export const Base = (data = {}) => {
23
+ const self = { state: { ...data } };
24
+ const newRecord = !isString(self.state.id);
25
+ const nowISO = new Date().toISOString();
26
+
27
+ if (newRecord) {
28
+ self.state.id = uuidv4();
29
+ self.state.createdAt = nowISO;
30
+ self.state.updatedAt = nowISO;
31
+ }
32
+
33
+ self.getId = () => self.state.id;
34
+ self.getCreatedAt = () => self.state.createdAt;
35
+ self.getUpdatedAt = () => self.state.updatedAt;
36
+
37
+ self.touch = () => {
38
+ self.state.updatedAt = new Date().toISOString();
39
+ };
40
+
41
+ return self;
42
+ };
@@ -0,0 +1,103 @@
1
+ /*
2
+ * Copyright 2023 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { hasText, isValidUrl } from '@adobe/spacecat-shared-utils';
14
+ import { Base } from './base.js';
15
+
16
+ /**
17
+ * Creates a new Site.
18
+ *
19
+ * @param {object} data - site data
20
+ * @returns {Readonly<Site>} site - new site
21
+ */
22
+ const Site = (data = {}) => {
23
+ const self = Base(data);
24
+
25
+ self.getAudits = () => self.state.audits;
26
+ self.getBaseURL = () => self.state.baseURL;
27
+ self.getImsOrgId = () => self.state.imsOrgId;
28
+
29
+ // TODO: updating the baseURL is not supported yet, it will require a transact write
30
+ // on dynamodb (put then delete) since baseURL is part of the primary key, something like:
31
+ // const updateSiteBaseURL = async (oldBaseURL, updatedSiteData) => {
32
+ // const params = {
33
+ // TransactItems: [
34
+ // {
35
+ // Put: {
36
+ // TableName: 'YourSiteTableName',
37
+ // Item: updatedSiteData,
38
+ // },
39
+ // },
40
+ // {
41
+ // Delete: {
42
+ // TableName: 'YourSiteTableName',
43
+ // Key: {
44
+ // baseURL: oldBaseURL,
45
+ // },
46
+ // },
47
+ // },
48
+ // ],
49
+ // };
50
+ //
51
+ // await dynamoDbClient.transactWrite(params).promise();
52
+ //
53
+ // return createSite(updatedSiteData);
54
+ // };
55
+ /* self.updateBaseURL = (baseURL) => {
56
+ if (!isValidUrl(baseURL)) {
57
+ throw new Error('Base URL must be a valid URL');
58
+ }
59
+
60
+ self.state.baseURL = baseURL;
61
+ self.touch();
62
+
63
+ return self;
64
+ }; */
65
+
66
+ self.updateImsOrgId = (imsOrgId) => {
67
+ if (!hasText(imsOrgId)) {
68
+ throw new Error('IMS Org ID must be provided');
69
+ }
70
+
71
+ self.state.imsOrgId = imsOrgId;
72
+ self.touch();
73
+
74
+ return self;
75
+ };
76
+
77
+ self.setAudits = (audits) => {
78
+ self.state.audits = audits;
79
+ return self;
80
+ };
81
+
82
+ return Object.freeze(self);
83
+ };
84
+
85
+ /**
86
+ * Creates a new Site.
87
+ *
88
+ * @param {object} data - site data
89
+ * @returns {Readonly<Site>} site - new site
90
+ */
91
+ export const createSite = (data) => {
92
+ const newState = { ...data };
93
+
94
+ if (!isValidUrl(newState.baseURL)) {
95
+ throw new Error('Base URL must be a valid URL');
96
+ }
97
+
98
+ if (!Array.isArray(newState.audits)) {
99
+ newState.audits = [];
100
+ }
101
+
102
+ return Site(newState);
103
+ };
@@ -0,0 +1,231 @@
1
+ /*
2
+ * Copyright 2023 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { isObject } from '@adobe/spacecat-shared-utils';
14
+
15
+ import { AuditDto } from '../../dto/audit.js';
16
+ import { createAudit } from '../../models/audit.js';
17
+
18
+ const TABLE_NAME_AUDITS = 'audits';
19
+ const TABLE_NAME_LATEST_AUDITS = 'latest_audits';
20
+ const INDEX_NAME_ALL_LATEST_AUDIT_SCORES = 'all_latest_audit_scores';
21
+ const PK_ALL_LATEST_AUDITS = 'ALL_LATEST_AUDITS';
22
+
23
+ /**
24
+ * Retrieves audits for a specified site. If an audit type is provided,
25
+ * it returns only audits of that type.
26
+ *
27
+ * @param {DynamoDbClient} dynamoClient - The DynamoDB client.
28
+ * @param {Logger} log - The logger.
29
+ * @param {string} siteId - The ID of the site for which audits are being retrieved.
30
+ * @param {string} [auditType] - Optional. The type of audits to retrieve.
31
+ * @returns {Promise<Readonly<Audit>[]>} A promise that resolves to an array of audits
32
+ * for the specified site.
33
+ */
34
+ export const getAuditsForSite = async (dynamoClient, log, siteId, auditType) => {
35
+ const queryParams = {
36
+ TableName: TABLE_NAME_AUDITS,
37
+ KeyConditionExpression: 'siteId = :siteId',
38
+ ExpressionAttributeValues: {
39
+ ':siteId': siteId,
40
+ },
41
+ };
42
+
43
+ if (auditType !== undefined) {
44
+ queryParams.KeyConditionExpression += ' AND begins_with(SK, :auditType)';
45
+ queryParams.ExpressionAttributeValues[':auditType'] = `${auditType}#`;
46
+ }
47
+
48
+ const dynamoItems = await dynamoClient.query(queryParams);
49
+
50
+ return dynamoItems.map((item) => AuditDto.fromDynamoItem(item));
51
+ };
52
+
53
+ /**
54
+ * Retrieves a specific audit for a specified site.
55
+ *
56
+ * @param {DynamoDbClient} dynamoClient - The DynamoDB client.
57
+ * @param {Logger} log - The logger.
58
+ * @param {string} siteId - The ID of the site for which to retrieve the audit.
59
+ * @param {string} auditType - The type of audit to retrieve.
60
+ * @param auditedAt - The ISO 8601 timestamp of the audit.
61
+ * @returns {Promise<Readonly<Audit>|null>}
62
+ */
63
+ export const getAuditForSite = async (
64
+ dynamoClient,
65
+ log,
66
+ siteId,
67
+ auditType,
68
+ auditedAt,
69
+ ) => {
70
+ const audit = await dynamoClient.query({
71
+ TableName: TABLE_NAME_AUDITS,
72
+ KeyConditionExpression: 'siteId = :siteId AND SK = :sk',
73
+ ExpressionAttributeValues: {
74
+ ':siteId': siteId,
75
+ ':sk': `${auditType}#${auditedAt}`,
76
+ },
77
+ Limit: 1,
78
+ });
79
+
80
+ return audit.length > 0 ? AuditDto.fromDynamoItem(audit[0]) : null;
81
+ };
82
+
83
+ /**
84
+ * Retrieves the latest audits of a specific type across all sites.
85
+ *
86
+ * @param {DynamoDbClient} dynamoClient - The DynamoDB client.
87
+ * @param {Logger} log - The logger.
88
+ * @param {string} auditType - The type of audits to retrieve.
89
+ * @param {boolean} ascending - Determines if the audits should be sorted ascending
90
+ * or descending by scores.
91
+ * @returns {Promise<Readonly<Audit>[]>} A promise that resolves to an array of the latest
92
+ * audits of the specified type.
93
+ */
94
+ export const getLatestAudits = async (
95
+ dynamoClient,
96
+ log,
97
+ auditType,
98
+ ascending = true,
99
+ ) => {
100
+ const dynamoItems = await dynamoClient.query({
101
+ TableName: TABLE_NAME_LATEST_AUDITS,
102
+ IndexName: INDEX_NAME_ALL_LATEST_AUDIT_SCORES,
103
+ KeyConditionExpression: 'GSI1PK = :gsi1pk AND begins_with(GSI1SK, :auditType)',
104
+ ExpressionAttributeValues: {
105
+ ':gsi1pk': PK_ALL_LATEST_AUDITS,
106
+ ':auditType': `${auditType}#`,
107
+ },
108
+ ScanIndexForward: ascending, // Sorts ascending if true, descending if false
109
+ });
110
+
111
+ return dynamoItems.map((item) => AuditDto.fromDynamoItem(item));
112
+ };
113
+
114
+ /**
115
+ * Retrieves latest audits for a specified site.
116
+ *
117
+ * @param {DynamoDbClient} dynamoClient - The DynamoDB client.
118
+ * @param {Logger} log - The logger.
119
+ * @param {string} siteId - The ID of the site for which audits are being retrieved.
120
+ * @returns {Promise<Readonly<Audit>[]>} A promise that resolves to an array of latest audits
121
+ * for the specified site.
122
+ */
123
+ export const getLatestAuditsForSite = async (dynamoClient, log, siteId) => {
124
+ const queryParams = {
125
+ TableName: TABLE_NAME_LATEST_AUDITS,
126
+ KeyConditionExpression: 'siteId = :siteId',
127
+ ExpressionAttributeValues: { ':siteId': siteId },
128
+ };
129
+
130
+ const dynamoItems = await dynamoClient.query(queryParams);
131
+
132
+ return dynamoItems.map((item) => AuditDto.fromDynamoItem(item));
133
+ };
134
+
135
+ /**
136
+ * Retrieves the latest audit for a specified site and audit type.
137
+ *
138
+ * @param {DynamoDbClient} dynamoClient - The DynamoDB client.
139
+ * @param {Logger} log - The logger.
140
+ * @param {string} siteId - The ID of the site for which the latest audit is being retrieved.
141
+ * @param {string} auditType - The type of audit to retrieve the latest instance of.
142
+ * @returns {Promise<Audit|null>} A promise that resolves to the latest audit of the
143
+ * specified type for the site, or null if none is found.
144
+ */
145
+ export const getLatestAuditForSite = async (
146
+ dynamoClient,
147
+ log,
148
+ siteId,
149
+ auditType,
150
+ ) => {
151
+ const latestAudit = await dynamoClient.query({
152
+ TableName: TABLE_NAME_LATEST_AUDITS,
153
+ KeyConditionExpression: 'siteId = :siteId AND begins_with(SK, :auditType)',
154
+ ExpressionAttributeValues: {
155
+ ':siteId': siteId,
156
+ ':auditType': `${auditType}#`,
157
+ },
158
+ Limit: 1,
159
+ });
160
+
161
+ return latestAudit.length > 0 ? AuditDto.fromDynamoItem(latestAudit[0]) : null;
162
+ };
163
+
164
+ /**
165
+ * Adds an audit.
166
+ *
167
+ * @param {DynamoDbClient} dynamoClient - The DynamoDB client.
168
+ * @param {Logger} log - The logger.
169
+ * @param {object} auditData - The audit data.
170
+ * @returns {Promise<Readonly<Audit>>}
171
+ */
172
+ export const addAudit = async (dynamoClient, log, auditData) => {
173
+ const audit = createAudit(auditData);
174
+ const existingAudit = await getAuditForSite(
175
+ dynamoClient,
176
+ log,
177
+ audit.getSiteId(),
178
+ audit.getAuditType(),
179
+ audit.getAuditedAt(),
180
+ );
181
+
182
+ if (isObject(existingAudit)) {
183
+ throw new Error('Audit already exists');
184
+ }
185
+
186
+ // TODO: Add transaction support
187
+ await dynamoClient.putItem('audits', AuditDto.toDynamoItem(audit));
188
+ await dynamoClient.putItem('latest_audits', AuditDto.toDynamoItem(audit, true));
189
+
190
+ return audit;
191
+ };
192
+
193
+ /**
194
+ * Removes audits from the database.
195
+ * @param {DynamoDbClient} dynamoClient - The DynamoDB client.
196
+ * @param {Logger} log - The logger.
197
+ * @param audits
198
+ * @param latest
199
+ * @returns {Promise<void>}
200
+ */
201
+ async function removeAudits(dynamoClient, audits, latest = false) {
202
+ const tableName = latest ? TABLE_NAME_LATEST_AUDITS : TABLE_NAME_AUDITS;
203
+ // TODO: use batch-remove (needs dynamo client update)
204
+ const removeAuditPromises = audits.map((audit) => dynamoClient.removeItem(tableName, {
205
+ siteId: audit.getSiteId(),
206
+ SK: `${audit.getAuditType()}#${audit.getAuditedAt()}`,
207
+ }));
208
+
209
+ await Promise.all(removeAuditPromises);
210
+ }
211
+
212
+ /**
213
+ * Removes all audits for a specified site and the latest audit entry.
214
+ *
215
+ * @param {DynamoDbClient} dynamoClient - The DynamoDB client.
216
+ * @param {Logger} log - The logger.
217
+ * @param {string} siteId - The ID of the site for which audits are being removed.
218
+ * @returns {Promise<void>}
219
+ */
220
+ export const removeAuditsForSite = async (dynamoClient, log, siteId) => {
221
+ try {
222
+ const audits = await getAuditsForSite(dynamoClient, log, siteId);
223
+ const latestAudits = await getLatestAuditsForSite(dynamoClient, log, siteId);
224
+
225
+ await removeAudits(dynamoClient, audits);
226
+ await removeAudits(dynamoClient, latestAudits, true);
227
+ } catch (error) {
228
+ log.error(`Error removing audits for site ${siteId}: ${error.message}`);
229
+ throw error;
230
+ }
231
+ };
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Copyright 2023 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import {
14
+ addAudit, getAuditForSite,
15
+ getAuditsForSite,
16
+ getLatestAuditForSite,
17
+ getLatestAudits,
18
+ removeAuditsForSite,
19
+ } from './accessPatterns.js';
20
+
21
+ export const auditFunctions = (dynamoClient, log) => ({
22
+ getAuditForSite: (siteId, auditType, auditedAt) => getAuditForSite(
23
+ dynamoClient,
24
+ log,
25
+ siteId,
26
+ auditType,
27
+ auditedAt,
28
+ ),
29
+ getAuditsForSite: (siteId, auditType) => getAuditsForSite(
30
+ dynamoClient,
31
+ log,
32
+ siteId,
33
+ auditType,
34
+ ),
35
+ getLatestAudits: (auditType, ascending) => getLatestAudits(
36
+ dynamoClient,
37
+ log,
38
+ auditType,
39
+ ascending,
40
+ ),
41
+ getLatestAuditForSite: (siteId, auditType) => getLatestAuditForSite(
42
+ dynamoClient,
43
+ log,
44
+ siteId,
45
+ auditType,
46
+ ),
47
+ addAudit: (auditData) => addAudit(
48
+ dynamoClient,
49
+ log,
50
+ auditData,
51
+ ),
52
+ removeAuditsForSite: (siteId) => removeAuditsForSite(
53
+ dynamoClient,
54
+ log,
55
+ siteId,
56
+ ),
57
+ });
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Copyright 2023 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { createClient } from '@adobe/spacecat-shared-dynamo';
14
+ import { auditFunctions } from './audits/index.js';
15
+ import { siteFunctions } from './sites/index.js';
16
+
17
+ /**
18
+ * Creates a data access object.
19
+ *
20
+ * @param {Logger} log logger
21
+ * @returns {object} data access object
22
+ */
23
+ export const createDataAccess = (log = console) => {
24
+ const dynamoClient = createClient(log);
25
+
26
+ const auditFuncs = auditFunctions(dynamoClient, log);
27
+ const siteFuncs = siteFunctions(dynamoClient, log);
28
+
29
+ return {
30
+ ...auditFuncs,
31
+ ...siteFuncs,
32
+ };
33
+ };