@foxtware/mineral 0.1.25 → 0.1.27
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/.creds.yml.sample +11 -4
- package/api/linear/linearTeamGet.js +51 -8
- package/api/linear/linearTeamsGet.js +13 -0
- package/api/tableau/docs.md +8 -0
- package/api/tableau/tableauUserAdd.js +158 -0
- package/api/tableau/tableauUsersGet.js +155 -0
- package/api/tagalys/docs.md +1 -1
- package/api/tagalys/tagalysSearch.js +156 -0
- package/api/utils.js +94 -0
- package/package.json +1 -1
- package/tagalys/.gitkeep +0 -0
package/.creds.yml.sample
CHANGED
|
@@ -115,7 +115,14 @@ google:
|
|
|
115
115
|
|
|
116
116
|
tagalys:
|
|
117
117
|
store:
|
|
118
|
-
BASE_URL: ____________________________
|
|
119
|
-
CLIENT_CODE: _________________________
|
|
120
|
-
API_KEY: _____________________________
|
|
121
|
-
|
|
118
|
+
BASE_URL: ____________________________
|
|
119
|
+
CLIENT_CODE: _________________________ # unused but surfaced in dashboard
|
|
120
|
+
API_KEY: _____________________________ # unused but surfaced in dashboard
|
|
121
|
+
STOREFRONT_API_KEY: __________________
|
|
122
|
+
STORE_HANDLE: ________________________
|
|
123
|
+
|
|
124
|
+
tableau:
|
|
125
|
+
PAT_NAME: ______________________________
|
|
126
|
+
PAT_SECRET: ____________________________
|
|
127
|
+
SERVER_URL: ____________________________
|
|
128
|
+
SITE_CONTENT_URL: ______________________
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
// https://linear.app/developers/graphql
|
|
2
2
|
|
|
3
|
-
const { ArgsWarden } = require('../utils');
|
|
3
|
+
const { ArgsWarden, objHasAny, oneFromManyInResponse } = require('../utils');
|
|
4
4
|
const { credsValidator } = require('../validators');
|
|
5
5
|
const { linearClient } = require('../linear/linear.utils');
|
|
6
|
+
const { linearTeamsGet } = require('./linearTeamsGet');
|
|
7
|
+
|
|
8
|
+
const teamIdentifierValidator = (teamIdentifier) => objHasAny(teamIdentifier, [
|
|
9
|
+
'teamId',
|
|
10
|
+
'teamName',
|
|
11
|
+
'teamKey',
|
|
12
|
+
]);
|
|
6
13
|
|
|
7
14
|
const argsWarden = new ArgsWarden([
|
|
8
15
|
['credsPayload', credsValidator],
|
|
9
|
-
['
|
|
16
|
+
['teamIdentifier', teamIdentifierValidator],
|
|
10
17
|
]);
|
|
11
18
|
|
|
12
19
|
const linearTeamGet = async (
|
|
13
20
|
credsPayload,
|
|
14
|
-
|
|
21
|
+
teamIdentifier,
|
|
15
22
|
{
|
|
16
23
|
inspect = false,
|
|
17
24
|
fetchClient = linearClient,
|
|
@@ -20,18 +27,54 @@ const linearTeamGet = async (
|
|
|
20
27
|
|
|
21
28
|
const rejectResponse = await argsWarden.responseIfRejectingArgs({
|
|
22
29
|
credsPayload,
|
|
23
|
-
|
|
30
|
+
teamIdentifier,
|
|
24
31
|
});
|
|
25
32
|
if (rejectResponse) {
|
|
26
33
|
return rejectResponse;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
let {
|
|
37
|
+
teamId,
|
|
38
|
+
teamName,
|
|
39
|
+
teamKey,
|
|
40
|
+
} = teamIdentifier;
|
|
41
|
+
|
|
42
|
+
// TODO: Return straight from the multiple teams get, if using a non-id identifier
|
|
43
|
+
if (!teamId) {
|
|
44
|
+
|
|
45
|
+
let teamFilter =
|
|
46
|
+
teamName ? { name: { eq: teamName } }
|
|
47
|
+
: teamKey ? { key: { eq: teamKey } }
|
|
48
|
+
: null;
|
|
49
|
+
|
|
50
|
+
const teamsGetResponse = await linearTeamsGet(credsPayload, { filter: teamFilter });
|
|
51
|
+
|
|
52
|
+
const teamIdProp = teamName ? 'name' : 'key';
|
|
53
|
+
const teamIdValue = teamName ? teamName : teamKey;
|
|
54
|
+
|
|
55
|
+
const teamResponse = oneFromManyInResponse(teamsGetResponse, teamIdProp, teamIdValue);
|
|
56
|
+
if (teamResponse.ok && teamResponse.data) {
|
|
57
|
+
teamId = teamResponse.data.id;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// TODO: This should be ok: true, probably
|
|
62
|
+
if (!teamId) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
data: null,
|
|
66
|
+
meta: {
|
|
67
|
+
message: `No team found with identifier ${ JSON.stringify(teamIdentifier) }`,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
29
72
|
return fetchClient.fetch({
|
|
30
73
|
requestPayload: {
|
|
31
74
|
body: {
|
|
32
75
|
query: `
|
|
33
|
-
query TeamGet($
|
|
34
|
-
team(id: $
|
|
76
|
+
query TeamGet($teamId: String!) {
|
|
77
|
+
team(id: $teamId) {
|
|
35
78
|
id
|
|
36
79
|
name
|
|
37
80
|
key
|
|
@@ -40,7 +83,7 @@ const linearTeamGet = async (
|
|
|
40
83
|
}
|
|
41
84
|
`,
|
|
42
85
|
variables: {
|
|
43
|
-
|
|
86
|
+
teamId,
|
|
44
87
|
},
|
|
45
88
|
},
|
|
46
89
|
},
|
|
@@ -66,6 +109,6 @@ curl -X POST "http://localhost:8000/linearTeamGet" \
|
|
|
66
109
|
-H "Content-Type: application/json" \
|
|
67
110
|
-d '{
|
|
68
111
|
"credsPayload": { "credsPath": "linear" },
|
|
69
|
-
"
|
|
112
|
+
"teamIdentifier": { "teamName": "Elite Four" }
|
|
70
113
|
}'
|
|
71
114
|
*/
|
|
@@ -63,4 +63,17 @@ curl -X POST "http://localhost:8000/linearTeamsGet" \
|
|
|
63
63
|
"limit": 10
|
|
64
64
|
}
|
|
65
65
|
}'
|
|
66
|
+
|
|
67
|
+
curl -X POST "http://localhost:8000/linearTeamsGet" \
|
|
68
|
+
-H "Content-Type: application/json" \
|
|
69
|
+
-d '{
|
|
70
|
+
"credsPayload": { "credsPath": "linear" },
|
|
71
|
+
"options": {
|
|
72
|
+
"filter": {
|
|
73
|
+
"name": {
|
|
74
|
+
"eq": "WF Engineering"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}'
|
|
66
79
|
*/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Tableau
|
|
2
|
+
|
|
3
|
+
- [Developer documentation](https://www.tableau.com/developer/learning/tableau-rest-api)
|
|
4
|
+
- [REST API](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_fundamentals.htm)
|
|
5
|
+
- [REST API reference](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm)
|
|
6
|
+
- [Authentication](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_auth.htm)
|
|
7
|
+
|
|
8
|
+
Server/site-scoped HTTPS JSON (or XML) API at `https://{server}/api/{api-version}/sites/{site-id}/...` for managing users, groups, workbooks, data sources, and other resources, authenticated via a short-lived session token obtained by signing in with a personal access token, username/password, or JWT rather than a single static API key.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const { ArgsWarden, logDeep } = require('../utils');
|
|
2
|
+
const { resolveCreds } = require('../pipelineSteps');
|
|
3
|
+
const {
|
|
4
|
+
FetchClient,
|
|
5
|
+
appendUrlToBase,
|
|
6
|
+
fetchClientCommonSteps,
|
|
7
|
+
} = require('../utils');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_API_VERSION = '3.22';
|
|
10
|
+
|
|
11
|
+
// Tableau sessions are token-based (sign in -> token, valid ~2hrs by default
|
|
12
|
+
// server config), unlike Shopify's static API key. We cache tokens per
|
|
13
|
+
// server+site so we're not re-signing-in on every single call.
|
|
14
|
+
const tokenCache = new Map();
|
|
15
|
+
|
|
16
|
+
const tableauSignIn = async ({
|
|
17
|
+
SERVER_URL,
|
|
18
|
+
PAT_NAME,
|
|
19
|
+
PAT_SECRET,
|
|
20
|
+
SITE_CONTENT_URL = '',
|
|
21
|
+
}) => {
|
|
22
|
+
const cacheKey = `${ SERVER_URL }::${ SITE_CONTENT_URL }`;
|
|
23
|
+
const cached = tokenCache.get(cacheKey);
|
|
24
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const response = await fetch(`${ SERVER_URL }/api/${ DEFAULT_API_VERSION }/auth/signin`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
Accept: 'application/json',
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
credentials: {
|
|
36
|
+
personalAccessTokenName: PAT_NAME,
|
|
37
|
+
personalAccessTokenSecret: PAT_SECRET,
|
|
38
|
+
site: { contentUrl: SITE_CONTENT_URL },
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`Tableau sign-in failed: ${ response.status } ${ await response.text() }`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { credentials } = await response.json();
|
|
48
|
+
|
|
49
|
+
const session = {
|
|
50
|
+
token: credentials.token,
|
|
51
|
+
siteId: credentials.site.id,
|
|
52
|
+
// Refresh a bit early rather than riding right up against the server's
|
|
53
|
+
// idle/absolute timeout.
|
|
54
|
+
expiresAt: Date.now() + 1000 * 60 * 60 * 2,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
tokenCache.set(cacheKey, session);
|
|
58
|
+
return session;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const useUrlAndAuthHeaders = async (state) => {
|
|
62
|
+
const { requestPayload, context } = state;
|
|
63
|
+
const { creds } = context;
|
|
64
|
+
|
|
65
|
+
const { token, siteId } = await tableauSignIn(creds);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
requestPayload: {
|
|
69
|
+
...requestPayload,
|
|
70
|
+
url: appendUrlToBase(
|
|
71
|
+
`${ creds.SERVER_URL }/api/${ DEFAULT_API_VERSION }`,
|
|
72
|
+
requestPayload.url.replace('{siteId}', siteId),
|
|
73
|
+
),
|
|
74
|
+
headers: {
|
|
75
|
+
'X-Tableau-Auth': token,
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
Accept: 'application/json',
|
|
78
|
+
...requestPayload.headers,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const tableauClient = new FetchClient({
|
|
85
|
+
pipeline: [
|
|
86
|
+
resolveCreds,
|
|
87
|
+
useUrlAndAuthHeaders,
|
|
88
|
+
'fetch',
|
|
89
|
+
fetchClientCommonSteps.exitEarlyOnNotOk,
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const argsWarden = new ArgsWarden([
|
|
94
|
+
['username'],
|
|
95
|
+
['siteRole'],
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
// siteRole is the "whatever permissions" lever -> any valid Tableau site
|
|
99
|
+
// role: Creator, Explorer, ExplorerCanPublish, Viewer, SiteAdministratorCreator,
|
|
100
|
+
// SiteAdministratorExplorer, Unlicensed, etc.
|
|
101
|
+
const tableauUserAdd = async (
|
|
102
|
+
username,
|
|
103
|
+
siteRole = 'Viewer',
|
|
104
|
+
{
|
|
105
|
+
authSetting, // optional: 'ServerDefault' | 'SAML' | 'OpenID' | 'TableauIDWithMFA' ...
|
|
106
|
+
} = {},
|
|
107
|
+
) => {
|
|
108
|
+
const rejectResponse = await argsWarden.responseIfRejectingArgs({
|
|
109
|
+
username,
|
|
110
|
+
siteRole,
|
|
111
|
+
});
|
|
112
|
+
if (rejectResponse) {
|
|
113
|
+
return rejectResponse;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const response = await tableauClient.fetch({
|
|
117
|
+
context: {
|
|
118
|
+
credsPayload: { credsPath: ['tableau'] },
|
|
119
|
+
},
|
|
120
|
+
requestPayload: {
|
|
121
|
+
url: '/sites/{siteId}/users',
|
|
122
|
+
method: 'post',
|
|
123
|
+
body: {
|
|
124
|
+
user: {
|
|
125
|
+
name: username,
|
|
126
|
+
siteRole,
|
|
127
|
+
...(authSetting ? { authSetting } : {}),
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const { ok, data, error } = response;
|
|
134
|
+
if (!ok) {
|
|
135
|
+
logDeep({ error });
|
|
136
|
+
return { ok: false, error };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
data: data.user ?? data,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const funcApiConfig = {
|
|
146
|
+
argsWarden,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
tableauUserAdd,
|
|
151
|
+
tableauClient,
|
|
152
|
+
funcApiConfig,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/*
|
|
156
|
+
curl -X POST "http://localhost:8000/tableauUserAdd" \
|
|
157
|
+
-d '{ "username": "dora@example.com", "siteRole": "Explorer" }'
|
|
158
|
+
*/
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// GET /api/{api-version}/sites/{site-id}/users is paginated (default pageSize
|
|
2
|
+
// is 100, max 1000), so this walks all pages and returns the full list.
|
|
3
|
+
|
|
4
|
+
const { ArgsWarden, logDeep } = require('../utils');
|
|
5
|
+
const { resolveCreds } = require('../pipelineSteps');
|
|
6
|
+
const {
|
|
7
|
+
FetchClient,
|
|
8
|
+
appendUrlToBase,
|
|
9
|
+
fetchClientCommonSteps,
|
|
10
|
+
} = require('../utils');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_API_VERSION = '3.22';
|
|
13
|
+
const PAGE_SIZE = 1000; // Tableau's max page size
|
|
14
|
+
|
|
15
|
+
const tokenCache = new Map();
|
|
16
|
+
|
|
17
|
+
const tableauSignIn = async ({
|
|
18
|
+
SERVER_URL,
|
|
19
|
+
PAT_NAME,
|
|
20
|
+
PAT_SECRET,
|
|
21
|
+
SITE_CONTENT_URL = '',
|
|
22
|
+
}) => {
|
|
23
|
+
const cacheKey = `${ SERVER_URL }::${ SITE_CONTENT_URL }`;
|
|
24
|
+
const cached = tokenCache.get(cacheKey);
|
|
25
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
26
|
+
return cached;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const response = await fetch(`${ SERVER_URL }/api/${ DEFAULT_API_VERSION }/auth/signin`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
Accept: 'application/json',
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
credentials: {
|
|
37
|
+
personalAccessTokenName: PAT_NAME,
|
|
38
|
+
personalAccessTokenSecret: PAT_SECRET,
|
|
39
|
+
site: { contentUrl: SITE_CONTENT_URL },
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(`Tableau sign-in failed: ${ response.status } ${ await response.text() }`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { credentials } = await response.json();
|
|
49
|
+
|
|
50
|
+
const session = {
|
|
51
|
+
token: credentials.token,
|
|
52
|
+
siteId: credentials.site.id,
|
|
53
|
+
expiresAt: Date.now() + 1000 * 60 * 60 * 2,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
tokenCache.set(cacheKey, session);
|
|
57
|
+
return session;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const useUrlAndAuthHeaders = async (state) => {
|
|
61
|
+
const { requestPayload, context } = state;
|
|
62
|
+
const { creds } = context;
|
|
63
|
+
|
|
64
|
+
const { token, siteId } = await tableauSignIn(creds);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
requestPayload: {
|
|
68
|
+
...requestPayload,
|
|
69
|
+
url: appendUrlToBase(
|
|
70
|
+
`${ creds.SERVER_URL }/api/${ DEFAULT_API_VERSION }`,
|
|
71
|
+
requestPayload.url.replace('{siteId}', siteId),
|
|
72
|
+
),
|
|
73
|
+
headers: {
|
|
74
|
+
'X-Tableau-Auth': token,
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
Accept: 'application/json',
|
|
77
|
+
...requestPayload.headers,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const tableauClient = new FetchClient({
|
|
84
|
+
pipeline: [
|
|
85
|
+
resolveCreds,
|
|
86
|
+
useUrlAndAuthHeaders,
|
|
87
|
+
'fetch',
|
|
88
|
+
fetchClientCommonSteps.exitEarlyOnNotOk,
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const argsWarden = new ArgsWarden([]);
|
|
93
|
+
|
|
94
|
+
// Optional filter, e.g. { filter: "siteRole:eq:Explorer" } or
|
|
95
|
+
// { filter: "name:has:jane" } per Tableau's filter-expression syntax.
|
|
96
|
+
const tableauUsersGet = async ({ filter } = {}) => {
|
|
97
|
+
const rejectResponse = await argsWarden.responseIfRejectingArgs({});
|
|
98
|
+
if (rejectResponse) {
|
|
99
|
+
return rejectResponse;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const users = [];
|
|
103
|
+
let pageNumber = 1;
|
|
104
|
+
|
|
105
|
+
while (true) {
|
|
106
|
+
const query = new URLSearchParams({
|
|
107
|
+
pageSize: String(PAGE_SIZE),
|
|
108
|
+
pageNumber: String(pageNumber),
|
|
109
|
+
...(filter ? { filter } : {}),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const response = await tableauClient.fetch({
|
|
113
|
+
context: {
|
|
114
|
+
credsPayload: { credsPath: ['tableau'] },
|
|
115
|
+
},
|
|
116
|
+
requestPayload: {
|
|
117
|
+
url: `/sites/{siteId}/users?${ query.toString() }`,
|
|
118
|
+
method: 'get',
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const { ok, data, error } = response;
|
|
123
|
+
if (!ok) {
|
|
124
|
+
logDeep({ error });
|
|
125
|
+
return { ok: false, error };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pageUsers = data?.users?.user ?? [];
|
|
129
|
+
users.push(...pageUsers);
|
|
130
|
+
|
|
131
|
+
const totalAvailable = Number(data?.pagination?.totalAvailable ?? pageUsers.length);
|
|
132
|
+
if (users.length >= totalAvailable || pageUsers.length === 0) {
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
pageNumber += 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
data: users,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
tableauUsersGet,
|
|
147
|
+
tableauClient,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
curl -X POST "http://localhost:8000/tableauUsersGet"
|
|
152
|
+
|
|
153
|
+
curl -X POST "http://localhost:8000/tableauUsersGet"
|
|
154
|
+
-d '{ "filter": "siteRole:eq:Creator" }'
|
|
155
|
+
*/
|
package/api/tagalys/docs.md
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
- [Storefront API (v2)](https://tagalys.notion.site/Storefront-API-v2-20eacdd38c2080d58d3fd0cf6f24e435)
|
|
4
4
|
|
|
5
|
-
Region-specific HTTPS origins (`https://api-r{n}.tagalys.com`) with REST-style JSON under `/v2`. Storefront reads use GET; analytics ingest uses `POST /v2/analytics/events` with a JSON body. Browser requests authenticate with the `shop_id` query param (the shop’s `*.myshopify.com` domain). Server-side integrations should
|
|
5
|
+
Region-specific HTTPS origins (`https://api-r{n}.tagalys.com`) with REST-style JSON under `/v2`. Storefront reads use GET; analytics ingest uses `POST /v2/analytics/events` with a JSON body. Browser requests authenticate with the `shop_id` query param (the shop’s `*.myshopify.com` domain). Server-side integrations should pass `api_key`.
|
|
6
6
|
|
|
7
7
|
Endpoints: `GET /v2/collections/:collection_id`, `GET /v2/search`, `GET /v2/search_suggestions`, `GET /v2/popular_searches`, `GET /v2/recommendations/:recommendation_id`, `POST /v2/analytics/events`. Collections and search support pagination (`page`, `per_page`, capped at 10,000 products), sorting (`sort`, `include[]=sort_options`), filtering (`filter[...]`, `include[]=filters`), and scope (`scope[...]`). Use `include[]=products` or `include[]=product_ids` to control product payloads. Optional request context on collections, search, and recommendations: `country`, `language`, `segment_tag`. Errors return JSON with `error.type`, `error.code`, and `error.message`.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// https://tagalys.notion.site/Storefront-API-v2-20eacdd38c2080d58d3fd0cf6f24e435
|
|
2
|
+
|
|
3
|
+
const { credsValidator } = require('../validators');
|
|
4
|
+
const { ArgsWarden, logDeep } = require('../utils');
|
|
5
|
+
const { resolveCreds } = require('../pipelineSteps');
|
|
6
|
+
const {
|
|
7
|
+
FetchClient,
|
|
8
|
+
fetchClientCommonSteps,
|
|
9
|
+
} = require('../utils');
|
|
10
|
+
|
|
11
|
+
// NOTE: the Storefront API v2 docs authenticate with `shop_id` (the
|
|
12
|
+
// Shopify myshopify.com domain) + an optional `storefront_api_key`, and
|
|
13
|
+
// don't mention a client_code param on this endpoint at all -- that's a
|
|
14
|
+
// holdover from Tagalys' older v1 API. Mapped below as best guess:
|
|
15
|
+
// BASE_URL -> used directly (docs say it's region-specific,
|
|
16
|
+
// e.g. https://api-r1.tagalys.com -- assumed already
|
|
17
|
+
// resolved to the right region in the creds store)
|
|
18
|
+
// STORE_HANDLE -> expanded to the `shop_id` myshopify domain
|
|
19
|
+
// STOREFRONT_API_KEY -> sent as `api_key`
|
|
20
|
+
// CLIENT_CODE -> not used by /v2/search per the docs; left unused below.
|
|
21
|
+
// Flag if your account actually needs it sent somewhere.
|
|
22
|
+
const DEFAULT_INCLUDE = ['products', 'total_count'];
|
|
23
|
+
|
|
24
|
+
// Recursively flattens nested params into Rails/PHP-style bracket query
|
|
25
|
+
// params, e.g. { filter: { color: ['red'] } } -> filter[color][]=red
|
|
26
|
+
// and { filter: { price: { selected_min: 100 } } } -> filter[price][selected_min]=100
|
|
27
|
+
const appendParams = (searchParams, key, value) => {
|
|
28
|
+
if (value === undefined || value === null) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
value.forEach((item) => appendParams(searchParams, `${ key }[]`, item));
|
|
33
|
+
} else if (typeof value === 'object') {
|
|
34
|
+
Object.entries(value).forEach(([subKey, subValue]) => {
|
|
35
|
+
appendParams(searchParams, `${ key }[${ subKey }]`, subValue);
|
|
36
|
+
});
|
|
37
|
+
} else {
|
|
38
|
+
searchParams.append(key, value);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const useUrlAndQuery = async (state) => {
|
|
43
|
+
const { requestPayload, context } = state;
|
|
44
|
+
const { creds } = context;
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
BASE_URL,
|
|
48
|
+
STORE_HANDLE,
|
|
49
|
+
STOREFRONT_API_KEY,
|
|
50
|
+
} = creds;
|
|
51
|
+
const shopId = `${ STORE_HANDLE }.myshopify.com`;
|
|
52
|
+
const storefrontApiKey = STOREFRONT_API_KEY;
|
|
53
|
+
|
|
54
|
+
const searchParams = new URLSearchParams();
|
|
55
|
+
searchParams.append('shop_id', shopId);
|
|
56
|
+
if (storefrontApiKey) {
|
|
57
|
+
searchParams.append('api_key', storefrontApiKey);
|
|
58
|
+
}
|
|
59
|
+
Object.entries(requestPayload.query || {}).forEach(([key, value]) => {
|
|
60
|
+
appendParams(searchParams, key, value);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
requestPayload: {
|
|
65
|
+
...requestPayload,
|
|
66
|
+
url: `${ BASE_URL }${ requestPayload.url }?${ searchParams.toString() }`,
|
|
67
|
+
headers: {
|
|
68
|
+
Accept: 'application/json',
|
|
69
|
+
...requestPayload.headers,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const tagalysClient = new FetchClient({
|
|
76
|
+
pipeline: [
|
|
77
|
+
resolveCreds,
|
|
78
|
+
useUrlAndQuery,
|
|
79
|
+
'fetch',
|
|
80
|
+
fetchClientCommonSteps.exitEarlyOnNotOk,
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const argsWarden = new ArgsWarden([
|
|
85
|
+
['credsPayload', credsValidator],
|
|
86
|
+
['query'],
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const tagalysSearch = async (
|
|
90
|
+
credsPayload,
|
|
91
|
+
query,
|
|
92
|
+
{
|
|
93
|
+
include = DEFAULT_INCLUDE, // e.g. ['products', 'filters', 'sort_options', 'total_count']
|
|
94
|
+
filter, // e.g. { color: ['red'], price: { selected_min: 100, selected_max: 200 } }
|
|
95
|
+
scope, // e.g. { gender: ['female'] }
|
|
96
|
+
sort, // e.g. 'price-asc'
|
|
97
|
+
page,
|
|
98
|
+
perPage,
|
|
99
|
+
} = {},
|
|
100
|
+
) => {
|
|
101
|
+
const rejectResponse = await argsWarden.responseIfRejectingArgs({
|
|
102
|
+
credsPayload,
|
|
103
|
+
query,
|
|
104
|
+
});
|
|
105
|
+
if (rejectResponse) {
|
|
106
|
+
return rejectResponse;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const response = await tagalysClient.fetch({
|
|
110
|
+
context: {
|
|
111
|
+
credsPayload,
|
|
112
|
+
},
|
|
113
|
+
requestPayload: {
|
|
114
|
+
url: '/v2/search',
|
|
115
|
+
method: 'get',
|
|
116
|
+
query: {
|
|
117
|
+
query,
|
|
118
|
+
include,
|
|
119
|
+
...(filter ? { filter } : {}),
|
|
120
|
+
...(scope ? { scope } : {}),
|
|
121
|
+
...(sort ? { sort } : {}),
|
|
122
|
+
...(page ? { page } : {}),
|
|
123
|
+
...(perPage ? { per_page: perPage } : {}),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const { ok, data, error } = response;
|
|
129
|
+
if (!ok) {
|
|
130
|
+
logDeep({ error });
|
|
131
|
+
return { ok: false, error };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A configured redirect takes precedence over the rest of the response --
|
|
135
|
+
// surface it plainly rather than making callers dig for it.
|
|
136
|
+
if (data.redirect_url) {
|
|
137
|
+
return { ok: true, redirected: true, redirectUrl: data.redirect_url, data };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { ok: true, data };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const funcApiConfig = {
|
|
144
|
+
argsWarden,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
tagalysSearch,
|
|
149
|
+
tagalysClient,
|
|
150
|
+
funcApiConfig,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
curl -X POST "http://localhost:8000/tagalysSearch" \
|
|
155
|
+
-d '{ "credsPayload": { "credsPath": "tagalys.au" }, "query": "gold" }'
|
|
156
|
+
*/
|
package/api/utils.js
CHANGED
|
@@ -1319,6 +1319,98 @@ const objectMatchesPartial = (object, partial) => {
|
|
|
1319
1319
|
));
|
|
1320
1320
|
};
|
|
1321
1321
|
|
|
1322
|
+
const oneFromManyInResponse = (
|
|
1323
|
+
response,
|
|
1324
|
+
idProp,
|
|
1325
|
+
idValue,
|
|
1326
|
+
) => {
|
|
1327
|
+
const {
|
|
1328
|
+
ok,
|
|
1329
|
+
data,
|
|
1330
|
+
} = response;
|
|
1331
|
+
|
|
1332
|
+
if (!ok || !data) {
|
|
1333
|
+
return response;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const candidates = data.filter(item => item[idProp] === idValue);
|
|
1337
|
+
|
|
1338
|
+
if (candidates.length === 0) {
|
|
1339
|
+
return {
|
|
1340
|
+
ok: true,
|
|
1341
|
+
data: null,
|
|
1342
|
+
meta: {
|
|
1343
|
+
message: `No item found with ${ idProp } ${ idValue }`,
|
|
1344
|
+
},
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// TODO: Consider whether to return ok: false as the user's id is not sufficient
|
|
1349
|
+
if (candidates.length > 1) {
|
|
1350
|
+
return {
|
|
1351
|
+
ok: true,
|
|
1352
|
+
data: null,
|
|
1353
|
+
meta: {
|
|
1354
|
+
message: `Multiple items found with ${ idProp } ${ idValue }`,
|
|
1355
|
+
},
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
return {
|
|
1360
|
+
ok: true,
|
|
1361
|
+
data: candidates[0],
|
|
1362
|
+
};
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
const surveyObject = (
|
|
1366
|
+
object,
|
|
1367
|
+
options = {},
|
|
1368
|
+
) => {
|
|
1369
|
+
|
|
1370
|
+
const {
|
|
1371
|
+
includeSamples,
|
|
1372
|
+
} = options;
|
|
1373
|
+
|
|
1374
|
+
return Object.fromEntries(
|
|
1375
|
+
Object.entries(object).map(([key, value]) => {
|
|
1376
|
+
|
|
1377
|
+
// Arrays
|
|
1378
|
+
if (Array.isArray(value)) {
|
|
1379
|
+
|
|
1380
|
+
if (includeSamples) {
|
|
1381
|
+
return [key, {
|
|
1382
|
+
samples: value.slice(0, 5),
|
|
1383
|
+
length: value.length,
|
|
1384
|
+
}];
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
return [key, value.length];
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Sets
|
|
1391
|
+
if (value instanceof Set) {
|
|
1392
|
+
|
|
1393
|
+
if (includeSamples) {
|
|
1394
|
+
return [key, {
|
|
1395
|
+
samples: new Set(Array.from(value).slice(0, 5)),
|
|
1396
|
+
size: value.size,
|
|
1397
|
+
}];
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
return [key, value.size];
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// Objects
|
|
1404
|
+
if (isObject(value)) {
|
|
1405
|
+
return [key, surveyObject(value, options)];
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Anything else
|
|
1409
|
+
return [key, value];
|
|
1410
|
+
})
|
|
1411
|
+
);
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1322
1414
|
module.exports = {
|
|
1323
1415
|
wait,
|
|
1324
1416
|
timeMs,
|
|
@@ -1358,4 +1450,6 @@ module.exports = {
|
|
|
1358
1450
|
valueProvided,
|
|
1359
1451
|
objectToArray,
|
|
1360
1452
|
objectMatchesPartial,
|
|
1453
|
+
oneFromManyInResponse,
|
|
1454
|
+
surveyObject,
|
|
1361
1455
|
};
|
package/package.json
CHANGED
package/tagalys/.gitkeep
DELETED
|
File without changes
|