@foxtware/mineral 0.1.25 → 0.1.26

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 CHANGED
@@ -119,3 +119,9 @@ tagalys:
119
119
  CLIENT_CODE: _________________________
120
120
  API_KEY: _____________________________
121
121
  STORE_ID: ____________________________
122
+
123
+ tableau:
124
+ PAT_NAME: ______________________________
125
+ PAT_SECRET: ____________________________
126
+ SERVER_URL: ____________________________
127
+ 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
- ['id'],
16
+ ['teamIdentifier', teamIdentifierValidator],
10
17
  ]);
11
18
 
12
19
  const linearTeamGet = async (
13
20
  credsPayload,
14
- id,
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
- id,
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($id: String!) {
34
- team(id: $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
- id,
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
- "id": "f2387dcd-61ac-49aa-8d7a-7f62a0b5cca0"
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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxtware/mineral",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "bin": {
5
5
  "mineral": "bin/mineral.js"
6
6
  },
package/tagalys/.gitkeep DELETED
File without changes