@openstax/ts-utils 1.21.12 → 1.24.1

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.
Files changed (37) hide show
  1. package/dist/cjs/errors/index.d.ts +11 -0
  2. package/dist/cjs/errors/index.js +15 -1
  3. package/dist/cjs/middleware/apiErrorHandler.d.ts +4 -3
  4. package/dist/cjs/middleware/apiErrorHandler.js +4 -3
  5. package/dist/cjs/misc/helpers.js +1 -1
  6. package/dist/cjs/services/accountsGateway/index.js +3 -4
  7. package/dist/cjs/services/apiGateway/index.d.ts +6 -2
  8. package/dist/cjs/services/apiGateway/index.js +17 -5
  9. package/dist/cjs/services/fileServer/localFileServer.js +5 -4
  10. package/dist/cjs/services/fileServer/s3FileServer.js +1 -0
  11. package/dist/cjs/services/lrsGateway/index.js +1 -1
  12. package/dist/cjs/services/openSearch.d.ts +29 -0
  13. package/dist/cjs/services/openSearch.js +107 -0
  14. package/dist/cjs/services/searchProvider/index.d.ts +1 -0
  15. package/dist/cjs/services/searchProvider/memorySearchTheBadWay.js +11 -4
  16. package/dist/cjs/tsconfig.without-specs.cjs.tsbuildinfo +1 -1
  17. package/dist/esm/errors/index.d.ts +11 -0
  18. package/dist/esm/errors/index.js +13 -0
  19. package/dist/esm/middleware/apiErrorHandler.d.ts +4 -3
  20. package/dist/esm/middleware/apiErrorHandler.js +5 -4
  21. package/dist/esm/misc/helpers.js +1 -1
  22. package/dist/esm/services/accountsGateway/index.js +3 -4
  23. package/dist/esm/services/apiGateway/index.d.ts +6 -2
  24. package/dist/esm/services/apiGateway/index.js +18 -6
  25. package/dist/esm/services/fileServer/localFileServer.js +6 -5
  26. package/dist/esm/services/fileServer/s3FileServer.js +1 -0
  27. package/dist/esm/services/lrsGateway/index.js +1 -1
  28. package/dist/esm/services/openSearch.d.ts +29 -0
  29. package/dist/esm/services/openSearch.js +103 -0
  30. package/dist/esm/services/searchProvider/index.d.ts +1 -0
  31. package/dist/esm/services/searchProvider/memorySearchTheBadWay.js +11 -4
  32. package/dist/esm/tsconfig.without-specs.esm.tsbuildinfo +1 -1
  33. package/package.json +3 -1
  34. package/script/bin/deploy.bash +8 -0
  35. package/script/bin/get-env-param.bash +3 -3
  36. package/script/bin/init-params-script.bash +10 -1
  37. package/script/bin/upload-params.bash +3 -3
@@ -42,6 +42,17 @@ export declare class UnauthorizedError extends Error {
42
42
  static matches: (e: any) => e is typeof UnauthorizedError;
43
43
  constructor(message?: string);
44
44
  }
45
+ /**
46
+ * Forbidden error
47
+ *
48
+ * `ForbiddenError.matches(error)` is a reliable way to check if an error is a
49
+ * `ForbiddenError`; `instanceof` checks may not work if code is split into multiple bundles
50
+ */
51
+ export declare class ForbiddenError extends Error {
52
+ static readonly TYPE = "ForbiddenError";
53
+ static matches: (e: any) => e is typeof ForbiddenError;
54
+ constructor(message?: string);
55
+ }
45
56
  /**
46
57
  * Not found error
47
58
  *
@@ -58,6 +58,19 @@ export class UnauthorizedError extends Error {
58
58
  }
59
59
  UnauthorizedError.TYPE = 'UnauthorizedError';
60
60
  UnauthorizedError.matches = errorIsType(UnauthorizedError);
61
+ /**
62
+ * Forbidden error
63
+ *
64
+ * `ForbiddenError.matches(error)` is a reliable way to check if an error is a
65
+ * `ForbiddenError`; `instanceof` checks may not work if code is split into multiple bundles
66
+ */
67
+ export class ForbiddenError extends Error {
68
+ constructor(message) {
69
+ super(message || ForbiddenError.TYPE);
70
+ }
71
+ }
72
+ ForbiddenError.TYPE = 'ForbiddenError';
73
+ ForbiddenError.matches = errorIsType(ForbiddenError);
61
74
  /**
62
75
  * Not found error
63
76
  *
@@ -1,12 +1,13 @@
1
- import { InvalidRequestError, NotFoundError, SessionExpiredError, UnauthorizedError, ValidationError } from '../errors';
1
+ import { ForbiddenError, InvalidRequestError, NotFoundError, SessionExpiredError, UnauthorizedError, ValidationError } from '../errors';
2
2
  import type { ApiResponse } from '../routing';
3
3
  import type { Logger } from '../services/logger';
4
4
  export declare type DefaultErrors = {
5
+ InvalidRequestError: InvalidRequestError;
5
6
  UnauthorizedError: UnauthorizedError;
6
- SessionExpiredError: SessionExpiredError;
7
+ ForbiddenError: ForbiddenError;
7
8
  NotFoundError: NotFoundError;
8
- InvalidRequestError: InvalidRequestError;
9
9
  ValidationError: ValidationError;
10
+ SessionExpiredError: SessionExpiredError;
10
11
  };
11
12
  export declare type Handlers<E> = {
12
13
  [T in keyof E]: (e: E[T], logger: Logger) => ApiResponse<number, any>;
@@ -1,12 +1,13 @@
1
- import { isAppError } from '../errors';
1
+ import { isAppError, } from '../errors';
2
2
  import { apiJsonResponse, apiTextResponse } from '../routing';
3
3
  import { Level } from '../services/logger';
4
4
  export const defaultHandlers = {
5
- UnauthorizedError: () => apiTextResponse(401, '401 UnauthorizedError'),
6
- SessionExpiredError: () => apiTextResponse(440, '440 SessionExpiredError'),
7
- NotFoundError: (e) => apiTextResponse(404, `404 ${e.message}`),
8
5
  InvalidRequestError: (e) => apiTextResponse(400, `400 ${e.message}`),
6
+ UnauthorizedError: (e) => apiTextResponse(401, `401 ${e.message}`),
7
+ ForbiddenError: (e) => apiTextResponse(403, `403 ${e.message}`),
8
+ NotFoundError: (e) => apiTextResponse(404, `404 ${e.message}`),
9
9
  ValidationError: (e) => apiJsonResponse(422, e.getData()),
10
+ SessionExpiredError: (e) => apiTextResponse(440, `440 ${e.message}`),
10
11
  };
11
12
  /**
12
13
  * Creates an error handler. Provides default handlers for `UnauthorizedError`,
@@ -76,7 +76,7 @@ export const retryWithDelay = (fn, options) => {
76
76
  reject(e);
77
77
  }
78
78
  else {
79
- (_a = options === null || options === void 0 ? void 0 : options.logger) === null || _a === void 0 ? void 0 : _a.log(`failed try ${n} of ${retries}. ${e.message}`);
79
+ (_a = options === null || options === void 0 ? void 0 : options.logger) === null || _a === void 0 ? void 0 : _a.log(`failed try ${n + 1} of ${retries}. ${e.message}`);
80
80
  setTimeout(() => retryWithDelay(fn, { ...options, n: n + 1 }).then(resolve, reject), timeout);
81
81
  }
82
82
  });
@@ -17,11 +17,10 @@ export const accountsGateway = (initializer) => (configProvider) => {
17
17
  const request = async (method, path, options, statuses = [200, 201]) => {
18
18
  const host = (await accountsBase).replace(/\/+$/, '');
19
19
  const url = `${host}/api/${path}`;
20
- const token = options.token || await accountsAuthToken;
21
20
  const config = {
22
- headers: token
23
- ? { Authorization: `Bearer ${token}` }
24
- : {},
21
+ headers: {
22
+ Authorization: `Bearer ${options.token || await accountsAuthToken}`,
23
+ },
25
24
  method,
26
25
  };
27
26
  if (options.body) {
@@ -3,6 +3,7 @@ import { ConfigProviderForConfig } from '../../config';
3
3
  import { ConfigForFetch, GenericFetch, Response } from '../../fetch';
4
4
  import { AnyRoute, ApiResponse, OutputForRoute, ParamsForRoute, PayloadForRoute, QueryParams } from '../../routing';
5
5
  import { UnwrapPromise } from '../../types';
6
+ import { Logger } from '../logger';
6
7
  declare type TResponsePayload<R> = R extends ApiResponse<any, infer P> ? P : never;
7
8
  declare type TResponseStatus<R> = R extends ApiResponse<infer S, any> ? S : never;
8
9
  declare type RouteClient<R> = {
@@ -49,8 +50,11 @@ export declare const loadResponse: (response: Response) => () => Promise<any>;
49
50
  interface MakeApiGateway<F> {
50
51
  <Ru>(config: ConfigProviderForConfig<{
51
52
  apiBase: string;
52
- }>, routes: MapRoutesToConfig<Ru>, authProvider?: {
53
- getAuthorizedFetchConfig: () => Promise<ConfigForFetch<F>>;
53
+ }>, routes: MapRoutesToConfig<Ru>, appProvider?: {
54
+ authProvider?: {
55
+ getAuthorizedFetchConfig: () => Promise<ConfigForFetch<F>>;
56
+ };
57
+ logger?: Logger;
54
58
  }): MapRoutesToClient<Ru>;
55
59
  }
56
60
  export declare const createApiGateway: <F extends GenericFetch<import("../../fetch").FetchConfig, Response>>(initializer: {
@@ -1,9 +1,11 @@
1
1
  import * as pathToRegexp from 'path-to-regexp';
2
2
  import queryString from 'query-string';
3
+ import { v4 as uuid } from 'uuid';
3
4
  import { merge } from '../..';
4
5
  import { resolveConfigValue } from '../../config';
5
- import { SessionExpiredError, UnauthorizedError } from '../../errors';
6
+ import { ForbiddenError, SessionExpiredError, UnauthorizedError } from '../../errors';
6
7
  import { fetchStatusRetry } from '../../fetch/fetchStatusRetry';
8
+ import { Level } from '../logger';
7
9
  /** Pulls the content out of a response based on the content type */
8
10
  export const loadResponse = (response) => () => {
9
11
  const [contentType] = (response.headers.get('content-type') || '').split(';');
@@ -16,7 +18,7 @@ export const loadResponse = (response) => () => {
16
18
  throw new Error(`unknown content type ${contentType}`);
17
19
  }
18
20
  };
19
- const makeRouteClient = (initializer, config, route, authProvider) => {
21
+ const makeRouteClient = (initializer, config, route, appProvider) => {
20
22
  /* TODO this duplicates code with makeRenderRouteUrl, reuse that */
21
23
  const renderUrl = async ({ params, query }) => {
22
24
  const apiBase = await resolveConfigValue(config.apiBase);
@@ -25,21 +27,31 @@ const makeRouteClient = (initializer, config, route, authProvider) => {
25
27
  return apiBase.replace(/\/+$/, '') + getPathForParams(params || {}) + (search ? `?${search}` : '');
26
28
  };
27
29
  const routeClient = async ({ params, payload, query, fetchConfig }) => {
30
+ var _a, _b;
31
+ const { fetch } = initializer;
28
32
  const url = await renderUrl({ params, query });
29
33
  const body = payload ? JSON.stringify(payload) : undefined;
30
- const baseOptions = merge((await (authProvider === null || authProvider === void 0 ? void 0 : authProvider.getAuthorizedFetchConfig())) || {}, fetchConfig || {});
31
- const fetcher = fetchStatusRetry(initializer.fetch, { retries: 1, status: [502] });
34
+ const baseOptions = merge((await ((_a = appProvider === null || appProvider === void 0 ? void 0 : appProvider.authProvider) === null || _a === void 0 ? void 0 : _a.getAuthorizedFetchConfig())) || {}, fetchConfig || {});
35
+ const requestId = uuid();
36
+ const requestLogger = (_b = appProvider === null || appProvider === void 0 ? void 0 : appProvider.logger) === null || _b === void 0 ? void 0 : _b.createSubContext();
37
+ requestLogger === null || requestLogger === void 0 ? void 0 : requestLogger.setContext({ requestId, url, timeStamp: new Date().getTime() });
38
+ const fetcher = fetchStatusRetry(fetch, { retries: 1, status: [502], logger: requestLogger });
39
+ requestLogger === null || requestLogger === void 0 ? void 0 : requestLogger.log('Request Initiated', Level.Info);
32
40
  return fetcher(url, merge(baseOptions, {
33
41
  method: route.method,
34
42
  body,
35
43
  headers: {
36
44
  ...fetchConfig === null || fetchConfig === void 0 ? void 0 : fetchConfig.headers,
37
45
  ...(body ? { 'content-type': 'application/json' } : {}),
46
+ 'X-Request-ID': requestId,
38
47
  }
39
48
  })).then(response => {
40
49
  if (response.status === 401) {
41
50
  throw new UnauthorizedError();
42
51
  }
52
+ if (response.status === 403) {
53
+ throw new ForbiddenError();
54
+ }
43
55
  if (response.status === 440) {
44
56
  throw new SessionExpiredError();
45
57
  }
@@ -59,7 +71,7 @@ const makeRouteClient = (initializer, config, route, authProvider) => {
59
71
  routeClient.renderUrl = renderUrl;
60
72
  return routeClient;
61
73
  };
62
- export const createApiGateway = (initializer) => (config, routes, authProvider) => {
74
+ export const createApiGateway = (initializer) => (config, routes, appProvider) => {
63
75
  return Object.fromEntries(Object.entries(routes)
64
- .map(([key, routeConfig]) => ([key, makeRouteClient(initializer, config, routeConfig, authProvider)])));
76
+ .map(([key, routeConfig]) => ([key, makeRouteClient(initializer, config, routeConfig, appProvider)])));
65
77
  };
@@ -1,13 +1,14 @@
1
+ /* cspell:ignore originalname */
1
2
  import fs from 'fs';
3
+ import https from 'https';
2
4
  import path from 'path';
3
- import { resolveConfigValue } from '../../config';
4
- import { ifDefined } from '../../guards';
5
5
  import cors from 'cors';
6
6
  import express from 'express';
7
7
  import multer from 'multer';
8
- import https from 'https';
9
- import { once } from "../../misc/helpers";
10
- import { assertString } from "../../assertions";
8
+ import { assertString } from '../../assertions';
9
+ import { resolveConfigValue } from '../../config';
10
+ import { ifDefined } from '../../guards';
11
+ import { once } from '../../misc/helpers';
11
12
  /* istanbul ignore next */
12
13
  const startServer = once((port, uploadDir) => {
13
14
  // TODO - re-evaluate the `preservePath` behavior to match whatever s3 does
@@ -1,3 +1,4 @@
1
+ /* cspell:ignore presigner */
1
2
  import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
2
3
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3
4
  import { once } from '../..';
@@ -82,7 +82,7 @@ ${await response.text()}`);
82
82
  const response = await fetchXapiStatements(fetchParams).catch(abort);
83
83
  const consistentThrough = response.headers.get('X-Experience-API-Consistent-Through');
84
84
  if (!consistentThrough || new Date(consistentThrough) < date) {
85
- throw new Error(`xAPI consistent through ${consistentThrough}; not in sync with current date ${date}.`);
85
+ throw new Error(`xAPI consistent through ${consistentThrough}; not in sync with current date ${date.toISOString()}.`);
86
86
  }
87
87
  return formatGetXapiStatementsResponse(response);
88
88
  }, { retries: 4, logger });
@@ -0,0 +1,29 @@
1
+ import { RequestBody } from '@opensearch-project/opensearch/lib/Transport';
2
+ import { ConfigProviderForConfig } from '../config';
3
+ import { IndexOptions, SearchOptions } from './searchProvider';
4
+ export declare type Config = {
5
+ node: string;
6
+ region: string;
7
+ };
8
+ export interface Initializer<C> {
9
+ configSpace?: C;
10
+ }
11
+ export declare type IndexConfig = {
12
+ name: string;
13
+ mappings: Record<string, any>;
14
+ pageSize?: number;
15
+ };
16
+ export declare const openSearchService: <T extends RequestBody<Record<string, any>>, C extends string = "deployed">(initializer?: Initializer<C>) => (indexConfig: IndexConfig, configProvider: { [key in C]: {
17
+ node: import("../config").ConfigValueProvider<string>;
18
+ region: import("../config").ConfigValueProvider<string>;
19
+ }; }) => {
20
+ ensureIndexCreated: () => Promise<void>;
21
+ index: (params: IndexOptions<T>) => Promise<void>;
22
+ search: (options: SearchOptions) => Promise<{
23
+ items: Exclude<T, undefined>[];
24
+ pageSize: number;
25
+ currentPage: number;
26
+ totalItems: number;
27
+ totalPages: number;
28
+ }>;
29
+ };
@@ -0,0 +1,103 @@
1
+ // cspell:ignore opensearch, Sigv
2
+ import { defaultProvider } from '@aws-sdk/credential-provider-node';
3
+ import { Client } from '@opensearch-project/opensearch';
4
+ import { AwsSigv4Signer } from '@opensearch-project/opensearch/aws';
5
+ import { resolveConfigValue } from '../config';
6
+ import { ifDefined, isDefined } from '../guards';
7
+ import { once } from '../misc/helpers';
8
+ export const openSearchService = (initializer = {}) => (indexConfig, configProvider) => {
9
+ const config = configProvider[ifDefined(initializer.configSpace, 'deployed')];
10
+ const pageSize = indexConfig.pageSize || 10;
11
+ const client = once(async () => new Client({
12
+ ...AwsSigv4Signer({
13
+ getCredentials: () => defaultProvider()(),
14
+ region: await resolveConfigValue(config.region),
15
+ service: 'es',
16
+ }),
17
+ node: await resolveConfigValue(config.node),
18
+ }));
19
+ const createIndexIfNotExists = async (indices, params) => {
20
+ const { index } = params;
21
+ const { body } = await indices.exists({ index });
22
+ if (!body) {
23
+ await indices.create(params);
24
+ }
25
+ };
26
+ const ensureIndexCreated = async () => {
27
+ const { indices } = await client();
28
+ await createIndexIfNotExists(indices, {
29
+ index: indexConfig.name,
30
+ body: {
31
+ mappings: {
32
+ dynamic: false,
33
+ properties: indexConfig.mappings
34
+ }
35
+ }
36
+ });
37
+ };
38
+ const index = async (params) => {
39
+ const openSearchClient = await client();
40
+ await openSearchClient.index({
41
+ index: indexConfig.name,
42
+ body: params.body,
43
+ id: params.id,
44
+ refresh: true
45
+ });
46
+ };
47
+ const search = async (options) => {
48
+ const body = { query: { bool: {} } };
49
+ if (options.query) {
50
+ body.query.bool.must = {
51
+ multi_match: {
52
+ fields: options.fields.map((field) => 'weight' in field ? `${field.key}^${field.weight}` : field.key),
53
+ query: options.query
54
+ }
55
+ };
56
+ }
57
+ const { must_not } = options;
58
+ const must = 'filter' in options ? options.filter : options.must;
59
+ if (must && must.length > 0) {
60
+ body.query.bool.filter = [];
61
+ must.forEach((filter) => {
62
+ const { key } = filter;
63
+ const values = filter.value instanceof Array ? filter.value : [filter.value];
64
+ body.query.bool.filter.push({ terms: { [key]: values } });
65
+ });
66
+ }
67
+ if (must_not && must_not.length > 0) {
68
+ body.query.bool.must_not = [];
69
+ must_not.forEach((filter) => {
70
+ const { key } = filter;
71
+ const values = filter.value instanceof Array ? filter.value : [filter.value];
72
+ values.forEach((value) => body.query.bool.must_not.push({ term: { [key]: value } }));
73
+ });
74
+ }
75
+ if (options.should && options.should.length > 0) {
76
+ body.query.bool.should = options.should.map(term => {
77
+ const { key } = term;
78
+ const values = term.value instanceof Array ? term.value : [term.value];
79
+ return { terms: { [key]: values } };
80
+ });
81
+ body.query.bool.minimum_should_match = 1;
82
+ }
83
+ if (options.page) {
84
+ body.size = pageSize;
85
+ body.from = (options.page - 1) * pageSize;
86
+ }
87
+ const response = await (await client()).search({
88
+ body,
89
+ index: indexConfig.name
90
+ });
91
+ if (response.statusCode !== 200) {
92
+ throw new Error(`Unexpected status code: ${response.statusCode} from OpenSearch`);
93
+ }
94
+ const hits = response.body.hits;
95
+ const items = hits.hits.map((hit) => hit._source).filter(isDefined);
96
+ const currentPage = options.page || 1;
97
+ const { total } = hits;
98
+ const totalItems = typeof total === 'number' ? total : total.value;
99
+ const totalPages = Math.ceil(totalItems / pageSize) || 1;
100
+ return { items, pageSize, currentPage, totalItems, totalPages };
101
+ };
102
+ return { ensureIndexCreated, index, search };
103
+ };
@@ -27,5 +27,6 @@ export interface SearchOptions {
27
27
  filter?: Filter[];
28
28
  must?: Filter[];
29
29
  must_not?: Filter[];
30
+ should?: Filter[];
30
31
  }
31
32
  export {};
@@ -7,6 +7,7 @@ var MatchType;
7
7
  (function (MatchType) {
8
8
  MatchType[MatchType["Must"] = 0] = "Must";
9
9
  MatchType[MatchType["MustNot"] = 1] = "MustNot";
10
+ MatchType[MatchType["Should"] = 2] = "Should";
10
11
  })(MatchType || (MatchType = {}));
11
12
  const resolveField = (document, field) => field.key.toString().split('.').reduce((result, key) => result[key], document);
12
13
  export const memorySearchTheBadWay = ({ loadAllDocumentsTheBadWay }) => {
@@ -18,7 +19,7 @@ export const memorySearchTheBadWay = ({ loadAllDocumentsTheBadWay }) => {
18
19
  const results = (await loadAllDocumentsTheBadWay())
19
20
  .map(document => {
20
21
  let weight = 0;
21
- const matchFilters = (filters, mustMatch) => {
22
+ const matchFilters = (filters, matchType) => {
22
23
  for (const field of filters) {
23
24
  const docValues = coerceArray(resolveField(document, field));
24
25
  const coerceValue = getFieldType(field) === 'boolean'
@@ -33,11 +34,14 @@ export const memorySearchTheBadWay = ({ loadAllDocumentsTheBadWay }) => {
33
34
  }
34
35
  : (x) => x;
35
36
  const hasMatch = coerceArray(field.value).map(coerceValue).some(v => docValues.includes(v));
36
- if ((mustMatch === MatchType.Must && !hasMatch) || (mustMatch === MatchType.MustNot && hasMatch)) {
37
+ if ((matchType === MatchType.Must && !hasMatch) || (matchType === MatchType.MustNot && hasMatch)) {
37
38
  return false;
38
39
  }
40
+ else if (matchType === MatchType.Should && hasMatch) {
41
+ return true;
42
+ }
39
43
  }
40
- return true;
44
+ return matchType !== MatchType.Should;
41
45
  };
42
46
  if (options.query !== undefined) {
43
47
  for (const field of options.fields) {
@@ -58,7 +62,7 @@ export const memorySearchTheBadWay = ({ loadAllDocumentsTheBadWay }) => {
58
62
  }
59
63
  }
60
64
  }
61
- const { must_not } = options;
65
+ const { must_not, should } = options;
62
66
  const must = 'filter' in options ? options.filter : options.must;
63
67
  if ((must === null || must === void 0 ? void 0 : must.length) && !matchFilters(must, MatchType.Must)) {
64
68
  return undefined;
@@ -66,6 +70,9 @@ export const memorySearchTheBadWay = ({ loadAllDocumentsTheBadWay }) => {
66
70
  if ((must_not === null || must_not === void 0 ? void 0 : must_not.length) && !matchFilters(must_not, MatchType.MustNot)) {
67
71
  return undefined;
68
72
  }
73
+ if ((should === null || should === void 0 ? void 0 : should.length) && !matchFilters(should, MatchType.Should)) {
74
+ return undefined;
75
+ }
69
76
  return { document, weight };
70
77
  })
71
78
  .filter(isDefined)