@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 CHANGED
@@ -115,7 +115,14 @@ google:
115
115
 
116
116
  tagalys:
117
117
  store:
118
- BASE_URL: ____________________________ # Server URL
119
- CLIENT_CODE: _________________________
120
- API_KEY: _____________________________
121
- STORE_ID: ____________________________
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
- ['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
+ */
@@ -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 also pass `storefront_api_key`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxtware/mineral",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "bin": {
5
5
  "mineral": "bin/mineral.js"
6
6
  },
package/tagalys/.gitkeep DELETED
File without changes