@slates/provider 1.0.0-rc.15 → 1.0.0-rc.17

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.
@@ -0,0 +1,114 @@
1
+ import { ServiceError } from '@lowerdeck/error';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildApiServiceError, collectApiErrorDetails, createApiServiceError } from './api';
4
+ import { SlateError } from './base';
5
+
6
+ describe('API service error helpers', () => {
7
+ it('collects unique nested API error details', () => {
8
+ let details: string[] = [];
9
+
10
+ collectApiErrorDetails(
11
+ {
12
+ message: 'Invalid request',
13
+ code: 'bad_request',
14
+ errors: [
15
+ {
16
+ detail: 'Missing name'
17
+ },
18
+ 'Missing name'
19
+ ]
20
+ },
21
+ details
22
+ );
23
+
24
+ expect(details).toEqual(['Invalid request', 'bad_request', 'Missing name']);
25
+ });
26
+
27
+ it('builds provider API ServiceErrors with upstream metadata', () => {
28
+ let parent = new Error('Request failed');
29
+ (parent as any).response = {
30
+ status: 429,
31
+ statusText: 'Too Many Requests',
32
+ data: {
33
+ title: 'Rate limit',
34
+ details: [{ message: 'Retry later' }]
35
+ }
36
+ };
37
+
38
+ let error = buildApiServiceError(parent, {
39
+ providerLabel: 'Example',
40
+ operation: 'list widgets',
41
+ reason: 'example_api_error',
42
+ detailKeys: ['title', 'message'],
43
+ nestedKeys: ['details'],
44
+ extractUpstreamCode: () => 'RATE_LIMITED'
45
+ });
46
+
47
+ expect(error).toBeInstanceOf(ServiceError);
48
+ expect(error.data.message).toBe(
49
+ 'Example API list widgets failed: HTTP 429 Too Many Requests: Rate limit - Retry later'
50
+ );
51
+ expect(error.data.reason).toBe('example_api_error');
52
+ expect(error.data.upstreamStatus).toBe(429);
53
+ expect(error.data.upstreamCode).toBe('RATE_LIMITED');
54
+ });
55
+
56
+ it('recovers the upstream status from interceptor-normalized SlateErrors', () => {
57
+ // Runtime axios instances reject with SlateError, not AxiosError: the
58
+ // response interceptor consumes error.response, so the status only
59
+ // survives at data.upstream.status / data.status.
60
+ let parent = new SlateError({
61
+ code: 'not_found',
62
+ message: 'Method not found.',
63
+ upstream: { status: 404 }
64
+ });
65
+
66
+ let error = buildApiServiceError(parent, {
67
+ providerLabel: 'Example',
68
+ operation: 'search messages',
69
+ reason: 'example_api_error'
70
+ });
71
+
72
+ expect(error.data.upstreamStatus).toBe(404);
73
+ expect(error.data.message).toBe(
74
+ 'Example API search messages failed: HTTP 404: Method not found.'
75
+ );
76
+ });
77
+
78
+ it('allows provider-specific status and message extraction', () => {
79
+ let error = buildApiServiceError(
80
+ {
81
+ status: 503,
82
+ data: {
83
+ message: 'Unavailable'
84
+ }
85
+ },
86
+ {
87
+ providerLabel: 'Example',
88
+ reason: 'example_api_error',
89
+ extractStatus: (input, _response, helpers) =>
90
+ helpers.isRecord(input) && typeof input.status === 'number'
91
+ ? input.status
92
+ : undefined,
93
+ extractMessage: (input, helpers) => {
94
+ if (!helpers.isRecord(input) || !helpers.isRecord(input.data)) return undefined;
95
+ return typeof input.data.message === 'string' ? input.data.message : undefined;
96
+ }
97
+ }
98
+ );
99
+
100
+ expect(error.data.message).toBe('Example API request failed: HTTP 503: Unavailable');
101
+ expect(error.data.upstreamStatus).toBe(503);
102
+ });
103
+
104
+ it('passes through existing ServiceErrors', () => {
105
+ let existing = createApiServiceError('Already normalized');
106
+
107
+ expect(
108
+ buildApiServiceError(existing, {
109
+ providerLabel: 'Example',
110
+ reason: 'example_api_error'
111
+ })
112
+ ).toBe(existing);
113
+ });
114
+ });
@@ -0,0 +1,236 @@
1
+ import { badRequestError, type ErrorRecord, ServiceError } from '@lowerdeck/error';
2
+
3
+ export type ApiErrorStatus = number | string;
4
+ export type ApiServiceError = ServiceError<ErrorRecord<any, any>>;
5
+
6
+ export type ApiErrorResponse = {
7
+ status?: ApiErrorStatus;
8
+ statusText?: string;
9
+ data?: unknown;
10
+ };
11
+
12
+ export type ApiErrorDetailOptions = {
13
+ detailKeys?: readonly string[];
14
+ nestedKeys?: readonly string[];
15
+ includeNumbers?: boolean;
16
+ separator?: string;
17
+ fallbackMessage?: string;
18
+ };
19
+
20
+ export type ApiErrorBuildHelpers = {
21
+ collectDetails: (value: unknown, details: string[], options?: ApiErrorDetailOptions) => void;
22
+ extractMessage: (error: unknown, options?: ApiErrorDetailOptions) => string;
23
+ getResponse: (error: unknown) => ApiErrorResponse | undefined;
24
+ getStatus: (error: unknown) => ApiErrorStatus | undefined;
25
+ isRecord: (value: unknown) => value is Record<string, unknown>;
26
+ statusLabelFor: (status?: ApiErrorStatus, statusText?: string) => string;
27
+ };
28
+
29
+ export type BuildApiServiceErrorOptions = ApiErrorDetailOptions & {
30
+ providerLabel: string;
31
+ reason: string;
32
+ operation?: string;
33
+ extractMessage?: (error: unknown, helpers: ApiErrorBuildHelpers) => string | undefined;
34
+ extractResponse?: (
35
+ error: unknown,
36
+ helpers: ApiErrorBuildHelpers
37
+ ) => ApiErrorResponse | undefined;
38
+ extractStatus?: (
39
+ error: unknown,
40
+ response: ApiErrorResponse | undefined,
41
+ helpers: ApiErrorBuildHelpers
42
+ ) => ApiErrorStatus | undefined;
43
+ extractUpstreamCode?: (
44
+ error: unknown,
45
+ response: ApiErrorResponse | undefined,
46
+ helpers: ApiErrorBuildHelpers
47
+ ) => string | undefined;
48
+ formatMessage?: (parts: {
49
+ providerLabel: string;
50
+ operation: string;
51
+ statusLabel: string;
52
+ message: string;
53
+ response?: ApiErrorResponse;
54
+ status?: ApiErrorStatus;
55
+ }) => string;
56
+ parent?: unknown;
57
+ };
58
+
59
+ let DEFAULT_DETAIL_KEYS = ['message', 'detail', 'error', 'error_description', 'code'];
60
+ let DEFAULT_NESTED_KEYS = ['errors'];
61
+
62
+ export let isApiErrorRecord = (value: unknown): value is Record<string, unknown> =>
63
+ typeof value === 'object' && value !== null && !Array.isArray(value);
64
+
65
+ let pushDetail = (
66
+ details: string[],
67
+ value: unknown,
68
+ options: Pick<ApiErrorDetailOptions, 'includeNumbers'> = {}
69
+ ) => {
70
+ if (
71
+ typeof value !== 'string' &&
72
+ (options.includeNumbers === false || typeof value !== 'number')
73
+ ) {
74
+ return;
75
+ }
76
+
77
+ let detail = String(value).trim();
78
+ if (detail && !details.includes(detail)) {
79
+ details.push(detail);
80
+ }
81
+ };
82
+
83
+ export let collectApiErrorDetails = (
84
+ value: unknown,
85
+ details: string[],
86
+ options: ApiErrorDetailOptions = {}
87
+ ) => {
88
+ if (Array.isArray(value)) {
89
+ for (let item of value) {
90
+ collectApiErrorDetails(item, details, options);
91
+ }
92
+ return;
93
+ }
94
+
95
+ if (!isApiErrorRecord(value)) {
96
+ pushDetail(details, value, options);
97
+ return;
98
+ }
99
+
100
+ for (let key of options.detailKeys ?? DEFAULT_DETAIL_KEYS) {
101
+ pushDetail(details, value[key], options);
102
+ }
103
+
104
+ for (let key of options.nestedKeys ?? DEFAULT_NESTED_KEYS) {
105
+ collectApiErrorDetails(value[key], details, options);
106
+ }
107
+ };
108
+
109
+ // Runtime axios instances from createAxios reject with SlateError (the raw
110
+ // AxiosError is consumed by the response interceptor), so the upstream HTTP
111
+ // status only survives inside SlateError.data — read it back from there or
112
+ // every buildApiServiceError caller loses status-based handling in production.
113
+ let getSlateErrorResponse = (error: Record<string, unknown>): ApiErrorResponse | undefined => {
114
+ if (error.name !== 'SlateError' || !isApiErrorRecord(error.data)) {
115
+ return undefined;
116
+ }
117
+
118
+ let data = error.data;
119
+ let upstream = isApiErrorRecord(data.upstream) ? data.upstream : undefined;
120
+ let status = upstream?.status ?? data.status;
121
+ return typeof status === 'number' || typeof status === 'string' ? { status } : undefined;
122
+ };
123
+
124
+ export let getApiErrorResponse = (error: unknown): ApiErrorResponse | undefined => {
125
+ if (!isApiErrorRecord(error)) {
126
+ return undefined;
127
+ }
128
+ if (!isApiErrorRecord(error.response)) {
129
+ return getSlateErrorResponse(error);
130
+ }
131
+
132
+ let response = error.response;
133
+ return {
134
+ status:
135
+ typeof response.status === 'number' || typeof response.status === 'string'
136
+ ? response.status
137
+ : undefined,
138
+ statusText: typeof response.statusText === 'string' ? response.statusText : undefined,
139
+ data: response.data
140
+ };
141
+ };
142
+
143
+ export let getApiErrorStatus = (error: unknown): ApiErrorStatus | undefined =>
144
+ getApiErrorResponse(error)?.status;
145
+
146
+ export let statusLabelForApiError = (status?: ApiErrorStatus, statusText?: string) =>
147
+ status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : '';
148
+
149
+ export let extractApiErrorMessage = (
150
+ error: unknown,
151
+ options: ApiErrorDetailOptions & { response?: ApiErrorResponse } = {}
152
+ ) => {
153
+ let details: string[] = [];
154
+ let response = options.response ?? getApiErrorResponse(error);
155
+
156
+ collectApiErrorDetails(response?.data, details, options);
157
+
158
+ if (details.length > 0) {
159
+ return details.join(options.separator ?? ' - ');
160
+ }
161
+
162
+ if (error instanceof Error && error.message) {
163
+ return error.message;
164
+ }
165
+
166
+ return options.fallbackMessage ?? 'Unknown error';
167
+ };
168
+
169
+ export let createApiServiceError = (
170
+ message: string,
171
+ options: {
172
+ reason?: string;
173
+ upstreamStatus?: ApiErrorStatus;
174
+ upstreamCode?: string;
175
+ parent?: unknown;
176
+ } = {}
177
+ ) => {
178
+ let error = new ServiceError(badRequestError({ message }));
179
+
180
+ if (options.reason !== undefined) {
181
+ error.data.reason = options.reason;
182
+ }
183
+
184
+ if ('upstreamStatus' in options) {
185
+ error.data.upstreamStatus = options.upstreamStatus;
186
+ }
187
+
188
+ if ('upstreamCode' in options) {
189
+ error.data.upstreamCode = options.upstreamCode;
190
+ }
191
+
192
+ if (options.parent instanceof Error) {
193
+ error.setParent(options.parent);
194
+ }
195
+
196
+ return error;
197
+ };
198
+
199
+ let helpers: ApiErrorBuildHelpers = {
200
+ collectDetails: collectApiErrorDetails,
201
+ extractMessage: extractApiErrorMessage,
202
+ getResponse: getApiErrorResponse,
203
+ getStatus: getApiErrorStatus,
204
+ isRecord: isApiErrorRecord,
205
+ statusLabelFor: statusLabelForApiError
206
+ };
207
+
208
+ export let buildApiServiceError = (error: unknown, options: BuildApiServiceErrorOptions) => {
209
+ if (error instanceof ServiceError) {
210
+ return error;
211
+ }
212
+
213
+ let operation = options.operation ?? 'request';
214
+ let response = options.extractResponse?.(error, helpers) ?? getApiErrorResponse(error);
215
+ let status = options.extractStatus?.(error, response, helpers) ?? response?.status;
216
+ let message =
217
+ options.extractMessage?.(error, helpers) ??
218
+ extractApiErrorMessage(error, { ...options, response });
219
+ let statusLabel = statusLabelForApiError(status, response?.statusText);
220
+ let fullMessage =
221
+ options.formatMessage?.({
222
+ providerLabel: options.providerLabel,
223
+ operation,
224
+ statusLabel,
225
+ message,
226
+ response,
227
+ status
228
+ }) ?? `${options.providerLabel} API ${operation} failed: ${statusLabel}${message}`;
229
+
230
+ return createApiServiceError(fullMessage, {
231
+ reason: options.reason,
232
+ upstreamStatus: status,
233
+ upstreamCode: options.extractUpstreamCode?.(error, response, helpers),
234
+ parent: options.parent ?? error
235
+ });
236
+ };
@@ -1,3 +1,4 @@
1
+ export * from './api';
1
2
  export * from './axios';
2
3
  export * from './base';
3
4
  export * from './declaration';
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getBase64ByteLength, getResponseHeaderValue } from './index';
3
+
4
+ describe('file helpers', () => {
5
+ it('finds response headers case-insensitively from plain objects', () => {
6
+ expect(
7
+ getResponseHeaderValue(
8
+ {
9
+ 'Content-Type': 'application/pdf',
10
+ 'x-empty': ''
11
+ },
12
+ 'content-type'
13
+ )
14
+ ).toBe('application/pdf');
15
+ expect(getResponseHeaderValue({ 'x-empty': '' }, 'X-Empty')).toBe('');
16
+ });
17
+
18
+ it('returns the first string value from array headers', () => {
19
+ expect(
20
+ getResponseHeaderValue({ 'content-type': ['text/plain', 'text/html'] }, 'content-type')
21
+ ).toBe('text/plain');
22
+ expect(
23
+ getResponseHeaderValue({ 'content-type': [123, 'text/html'] }, 'content-type')
24
+ ).toBe('text/html');
25
+ });
26
+
27
+ it('supports Headers-like objects', () => {
28
+ let headers = new Headers({ 'content-type': 'image/png' });
29
+
30
+ expect(getResponseHeaderValue(headers, 'Content-Type')).toBe('image/png');
31
+ });
32
+
33
+ it('computes decoded base64 byte length', () => {
34
+ expect(getBase64ByteLength(Buffer.from('hello').toString('base64'))).toBe(5);
35
+ });
36
+ });
@@ -0,0 +1,27 @@
1
+ export let getResponseHeaderValue = (headers: unknown, name: string): string | undefined => {
2
+ if (!headers) return undefined;
3
+
4
+ if (typeof (headers as { get?: unknown }).get === 'function') {
5
+ let value = (headers as { get(headerName: string): unknown }).get(name);
6
+ return typeof value === 'string' ? value : undefined;
7
+ }
8
+
9
+ if (typeof headers !== 'object') return undefined;
10
+
11
+ let lowerName = name.toLowerCase();
12
+ for (let [headerName, value] of Object.entries(headers as Record<string, unknown>)) {
13
+ if (headerName.toLowerCase() !== lowerName) continue;
14
+
15
+ if (Array.isArray(value)) {
16
+ let first = value.find(item => typeof item === 'string');
17
+ return typeof first === 'string' ? first : undefined;
18
+ }
19
+
20
+ return typeof value === 'string' ? value : undefined;
21
+ }
22
+
23
+ return undefined;
24
+ };
25
+
26
+ export let getBase64ByteLength = (contentBase64: string) =>
27
+ Buffer.from(contentBase64, 'base64').byteLength;
package/src/index.ts CHANGED
@@ -4,7 +4,11 @@ export * from './auth';
4
4
  export * from './axios';
5
5
  export * from './config';
6
6
  export * from './context';
7
+ export * from './crypto';
7
8
  export * from './docs';
8
9
  export * from './error';
10
+ export * from './file';
9
11
  export * from './logger';
12
+ export * from './object';
10
13
  export * from './specification';
14
+ export * from './webhook';
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { pickDefined, setIfDefined } from './index';
3
+
4
+ describe('object helpers', () => {
5
+ it('omits only undefined values', () => {
6
+ expect(
7
+ pickDefined({
8
+ keepNull: null,
9
+ keepFalse: false,
10
+ keepZero: 0,
11
+ keepEmpty: '',
12
+ dropUndefined: undefined
13
+ })
14
+ ).toEqual({
15
+ keepNull: null,
16
+ keepFalse: false,
17
+ keepZero: 0,
18
+ keepEmpty: ''
19
+ });
20
+ });
21
+
22
+ it('sets only defined values on an existing target', () => {
23
+ let target: Record<string, unknown> = {};
24
+
25
+ setIfDefined(target, 'nullValue', null);
26
+ setIfDefined(target, 'falseValue', false);
27
+ setIfDefined(target, 'zeroValue', 0);
28
+ setIfDefined(target, 'emptyValue', '');
29
+ setIfDefined(target, 'undefinedValue', undefined);
30
+
31
+ expect(target).toEqual({
32
+ nullValue: null,
33
+ falseValue: false,
34
+ zeroValue: 0,
35
+ emptyValue: ''
36
+ });
37
+ });
38
+ });
@@ -0,0 +1,12 @@
1
+ export let pickDefined = <T extends object>(value: T) =>
2
+ Object.fromEntries(
3
+ Object.entries(value).filter(([, child]) => child !== undefined)
4
+ ) as Partial<T> & Record<string, unknown>;
5
+
6
+ export let setIfDefined = (target: Record<string, unknown>, key: string, value: unknown) => {
7
+ if (value !== undefined) {
8
+ target[key] = value;
9
+ }
10
+
11
+ return target;
12
+ };
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getGraphWebhookValidationResponse, graphWebhookHttp } from './graph';
3
+
4
+ describe('Microsoft Graph webhook validation', () => {
5
+ it('makes only validation requests synchronous', () => {
6
+ expect(graphWebhookHttp).toEqual({
7
+ methods: ['POST'],
8
+ sync: {
9
+ mode: 'match',
10
+ match: [{ hasQueryParam: 'validationToken' }]
11
+ }
12
+ });
13
+ });
14
+
15
+ it('echoes the URL-decoded validation token as plain text', () => {
16
+ let response = getGraphWebhookValidationResponse(
17
+ new Request(
18
+ 'https://example.com/webhook?validationToken=Validation%3a+Testing+client+application+reachability',
19
+ { method: 'POST' }
20
+ )
21
+ );
22
+
23
+ expect(response).toEqual({
24
+ status: 200,
25
+ headers: { 'content-type': 'text/plain' },
26
+ body: 'Validation: Testing client application reachability'
27
+ });
28
+ });
29
+
30
+ it('ignores ordinary change notifications', () => {
31
+ expect(
32
+ getGraphWebhookValidationResponse(
33
+ new Request('https://example.com/webhook', { method: 'POST' })
34
+ )
35
+ ).toBeNull();
36
+ });
37
+ });
@@ -0,0 +1,22 @@
1
+ import type { SlateWebhookHttpOptions, SlateWebhookHttpResponseInit } from '../action';
2
+
3
+ export const graphWebhookHttp = {
4
+ methods: ['POST'],
5
+ sync: {
6
+ mode: 'match',
7
+ match: [{ hasQueryParam: 'validationToken' }]
8
+ }
9
+ } satisfies SlateWebhookHttpOptions;
10
+
11
+ export let getGraphWebhookValidationResponse = (
12
+ request: Request
13
+ ): SlateWebhookHttpResponseInit | null => {
14
+ let validationToken = new URL(request.url).searchParams.get('validationToken');
15
+ if (validationToken === null) return null;
16
+
17
+ return {
18
+ status: 200,
19
+ headers: { 'content-type': 'text/plain' },
20
+ body: validationToken
21
+ };
22
+ };
@@ -0,0 +1,2 @@
1
+ export * from './graph';
2
+ export * from './meta';
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getMetaWebhookVerificationResponse, metaWebhookHttp } from './meta';
3
+
4
+ let verificationRequest = (verifyToken = 'verify-token', challenge = 'challenge-value') =>
5
+ new Request(
6
+ `https://example.com/webhook?hub.mode=subscribe&hub.verify_token=${verifyToken}&hub.challenge=${challenge}`
7
+ );
8
+
9
+ describe('Meta webhook verification', () => {
10
+ it('makes only verification GET requests synchronous', () => {
11
+ expect(metaWebhookHttp).toEqual({
12
+ methods: ['GET', 'POST'],
13
+ sync: {
14
+ mode: 'match',
15
+ match: [{ method: 'GET', hasQueryParam: 'hub.mode' }]
16
+ }
17
+ });
18
+ });
19
+
20
+ it('returns the challenge for a matching configured token', () => {
21
+ expect(getMetaWebhookVerificationResponse(verificationRequest(), 'verify-token')).toEqual({
22
+ status: 200,
23
+ headers: { 'content-type': 'text/plain' },
24
+ body: 'challenge-value'
25
+ });
26
+ });
27
+
28
+ it('rejects a mismatched configured token', () => {
29
+ expect(
30
+ getMetaWebhookVerificationResponse(verificationRequest('wrong-token'), 'verify-token')
31
+ ).toMatchObject({ status: 403 });
32
+ expect(
33
+ getMetaWebhookVerificationResponse(verificationRequest('wrong-token'), '')
34
+ ).toMatchObject({ status: 403 });
35
+ });
36
+
37
+ it('preserves legacy verification when no token is configured', () => {
38
+ expect(
39
+ getMetaWebhookVerificationResponse(verificationRequest('legacy-token'), undefined)
40
+ ).toMatchObject({ status: 200, body: 'challenge-value' });
41
+ });
42
+
43
+ it('rejects malformed verification and ignores POST events', () => {
44
+ let malformed = new Request('https://example.com/webhook?hub.mode=subscribe');
45
+ let post = new Request('https://example.com/webhook', { method: 'POST' });
46
+
47
+ expect(getMetaWebhookVerificationResponse(malformed, undefined)).toMatchObject({
48
+ status: 400
49
+ });
50
+ expect(getMetaWebhookVerificationResponse(post, undefined)).toBeNull();
51
+ });
52
+ });
@@ -0,0 +1,43 @@
1
+ import type { SlateWebhookHttpOptions, SlateWebhookHttpResponseInit } from '../action';
2
+
3
+ export const metaWebhookHttp = {
4
+ methods: ['GET', 'POST'],
5
+ sync: {
6
+ mode: 'match',
7
+ match: [{ method: 'GET', hasQueryParam: 'hub.mode' }]
8
+ }
9
+ } satisfies SlateWebhookHttpOptions;
10
+
11
+ export let getMetaWebhookVerificationResponse = (
12
+ request: Request,
13
+ expectedVerifyToken: string | undefined
14
+ ): SlateWebhookHttpResponseInit | null => {
15
+ if (request.method !== 'GET') return null;
16
+
17
+ let url = new URL(request.url);
18
+ let mode = url.searchParams.get('hub.mode');
19
+ let verifyToken = url.searchParams.get('hub.verify_token');
20
+ let challenge = url.searchParams.get('hub.challenge');
21
+
22
+ if (mode !== 'subscribe' || challenge === null) {
23
+ return {
24
+ status: 400,
25
+ headers: { 'content-type': 'text/plain' },
26
+ body: 'invalid webhook verification request'
27
+ };
28
+ }
29
+
30
+ if (expectedVerifyToken !== undefined && verifyToken !== expectedVerifyToken) {
31
+ return {
32
+ status: 403,
33
+ headers: { 'content-type': 'text/plain' },
34
+ body: 'invalid verify token'
35
+ };
36
+ }
37
+
38
+ return {
39
+ status: 200,
40
+ headers: { 'content-type': 'text/plain' },
41
+ body: challenge
42
+ };
43
+ };