diff --git a/public/components/integrations/components/__tests__/__snapshots__/added_integration_flyout.test.tsx.snap b/public/components/integrations/components/__tests__/__snapshots__/added_integration_flyout.test.tsx.snap index 9ae4ce75ec..3ba54c92b4 100644 --- a/public/components/integrations/components/__tests__/__snapshots__/added_integration_flyout.test.tsx.snap +++ b/public/components/integrations/components/__tests__/__snapshots__/added_integration_flyout.test.tsx.snap @@ -2,7 +2,14 @@ exports[`Add Integration Flyout Test Renders add integration flyout with dummy integration name 1`] = ` diff --git a/public/components/integrations/components/__tests__/added_integration_flyout.test.tsx b/public/components/integrations/components/__tests__/added_integration_flyout.test.tsx index 7f5280652a..ae3b609f87 100644 --- a/public/components/integrations/components/__tests__/added_integration_flyout.test.tsx +++ b/public/components/integrations/components/__tests__/added_integration_flyout.test.tsx @@ -6,15 +6,37 @@ import { configure, mount } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import { waitFor } from '@testing-library/react'; -import { AddIntegrationFlyout } from '../add_integration_flyout'; +import { + AddIntegrationFlyout, + checkDataSourceName, + doTypeValidation, + doNestedPropertyValidation, + doPropertyValidation, + fetchDataSourceMappings, + fetchIntegrationMappings, + doExistingDataSourceValidation, +} from '../add_integration_flyout'; +import * as add_integration_flyout from '../add_integration_flyout'; import React from 'react'; +import { HttpSetup } from '../../../../../../../src/core/public'; describe('Add Integration Flyout Test', () => { configure({ adapter: new Adapter() }); it('Renders add integration flyout with dummy integration name', async () => { const wrapper = mount( - + ) as HttpSetup + } + /> ); await waitFor(() => { @@ -22,3 +44,320 @@ describe('Add Integration Flyout Test', () => { }); }); }); + +describe('doTypeValidation', () => { + it('should return true if required type is not specified', () => { + const toCheck = { type: 'string' }; + const required = {}; + + const result = doTypeValidation(toCheck, required); + + expect(result.ok).toBe(true); + }); + + it('should return true if types match', () => { + const toCheck = { type: 'string' }; + const required = { type: 'string' }; + + const result = doTypeValidation(toCheck, required); + + expect(result.ok).toBe(true); + }); + + it('should return true if object has properties', () => { + const toCheck = { properties: { prop1: { type: 'string' } } }; + const required = { type: 'object' }; + + const result = doTypeValidation(toCheck, required); + + expect(result.ok).toBe(true); + }); + + it('should return false if types do not match', () => { + const toCheck = { type: 'string' }; + const required = { type: 'number' }; + + const result = doTypeValidation(toCheck, required); + + expect(result.ok).toBe(false); + }); +}); + +describe('doNestedPropertyValidation', () => { + it('should return true if type validation passes and no properties are required', () => { + const toCheck = { type: 'string' }; + const required = { type: 'string' }; + + const result = doNestedPropertyValidation(toCheck, required); + + expect(result.ok).toBe(true); + }); + + it('should return false if type validation fails', () => { + const toCheck = { type: 'string' }; + const required = { type: 'number' }; + + const result = doNestedPropertyValidation(toCheck, required); + + expect(result.ok).toBe(false); + }); + + it('should return false if a required property is missing', () => { + const toCheck = { type: 'object', properties: { prop1: { type: 'string' } } }; + const required = { + type: 'object', + properties: { prop1: { type: 'string' }, prop2: { type: 'number' } }, + }; + + const result = doNestedPropertyValidation(toCheck, required); + + expect(result.ok).toBe(false); + }); + + it('should return true if all required properties pass validation', () => { + const toCheck = { + type: 'object', + properties: { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }, + }; + const required = { + type: 'object', + properties: { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }, + }; + + const result = doNestedPropertyValidation(toCheck, required); + + expect(result.ok).toBe(true); + }); +}); + +describe('doPropertyValidation', () => { + it('should return true if all properties pass validation', () => { + const rootType = 'root'; + const dataSourceProps = { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }; + const requiredMappings = { + root: { + template: { + mappings: { + properties: { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }, + }, + }, + }, + }; + + const result = doPropertyValidation(rootType, dataSourceProps as any, requiredMappings); + + expect(result.ok).toBe(true); + }); + + it('should return false if a property fails validation', () => { + const rootType = 'root'; + const dataSourceProps = { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }; + const requiredMappings = { + root: { + template: { + mappings: { + properties: { + prop1: { type: 'string' }, + prop2: { type: 'boolean' }, + }, + }, + }, + }, + }; + + const result = doPropertyValidation(rootType, dataSourceProps as any, requiredMappings); + + expect(result.ok).toBe(false); + }); + + it('should return false if a required nested property is missing', () => { + const rootType = 'root'; + const dataSourceProps = { + prop1: { type: 'string' }, + }; + const requiredMappings = { + root: { + template: { + mappings: { + properties: { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }, + }, + }, + }, + }; + + const result = doPropertyValidation(rootType, dataSourceProps as any, requiredMappings); + + expect(result.ok).toBe(false); + }); +}); + +describe('checkDataSourceName', () => { + it('Filters out invalid index names', () => { + const result = checkDataSourceName('ss4o_logs-no-exclams!', 'logs'); + + expect(result.ok).toBe(false); + }); + + it('Filters out incorrectly typed indices', () => { + const result = checkDataSourceName('ss4o_metrics-test-test', 'logs'); + + expect(result.ok).toBe(false); + }); + + it('Accepts correct indices', () => { + const result = checkDataSourceName('ss4o_logs-test-test', 'logs'); + + expect(result.ok).toBe(true); + }); +}); + +describe('fetchDataSourceMappings', () => { + it('Retrieves mappings', async () => { + const mockHttp = { + post: jest.fn().mockResolvedValue({ + source1: { mappings: { properties: { test: true } } }, + source2: { mappings: { properties: { test: true } } }, + }), + } as Partial; + + const result = fetchDataSourceMappings('sample', mockHttp as HttpSetup); + + await expect(result).resolves.toMatchObject({ + source1: { properties: { test: true } }, + source2: { properties: { test: true } }, + }); + }); + + it('Catches errors', async () => { + const mockHttp = { + post: jest.fn().mockRejectedValue(new Error('Mock error')), + } as Partial; + + const result = fetchDataSourceMappings('sample', mockHttp as HttpSetup); + + await expect(result).resolves.toBeNull(); + }); +}); + +describe('fetchIntegrationMappings', () => { + it('Returns schema mappings', async () => { + const mockHttp = { + get: jest.fn().mockResolvedValue({ data: { mappings: { test: true } }, statusCode: 200 }), + } as Partial; + + const result = fetchIntegrationMappings('target', mockHttp as HttpSetup); + + await expect(result).resolves.toStrictEqual({ test: true }); + }); + + it('Returns null if response fails', async () => { + const mockHttp = { + get: jest.fn().mockResolvedValue({ statusCode: 404 }), + } as Partial; + + const result = fetchIntegrationMappings('target', mockHttp as HttpSetup); + + await expect(result).resolves.toBeNull(); + }); + + it('Catches request error', async () => { + const mockHttp = { + get: jest.fn().mockRejectedValue(new Error('mock error')), + } as Partial; + + const result = fetchIntegrationMappings('target', mockHttp as HttpSetup); + + await expect(result).resolves.toBeNull(); + }); +}); + +describe('doExistingDataSourceValidation', () => { + it('Catches and returns checkDataSourceName errors', async () => { + const mockHttp = {} as Partial; + jest + .spyOn(add_integration_flyout, 'checkDataSourceName') + .mockReturnValue({ ok: false, errors: ['mock'] }); + + const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); + + await expect(result).resolves.toHaveProperty('ok', false); + }); + + it('Catches data stream fetch errors', async () => { + const mockHttp = {} as Partial; + jest.spyOn(add_integration_flyout, 'checkDataSourceName').mockReturnValue({ ok: true }); + jest.spyOn(add_integration_flyout, 'fetchDataSourceMappings').mockResolvedValue(null); + jest + .spyOn(add_integration_flyout, 'fetchIntegrationMappings') + .mockResolvedValue({ test: { template: { mappings: {} } } }); + + const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); + + await expect(result).resolves.toHaveProperty('ok', false); + }); + + it('Catches integration fetch errors', async () => { + const mockHttp = {} as Partial; + jest.spyOn(add_integration_flyout, 'checkDataSourceName').mockReturnValue({ ok: true }); + jest + .spyOn(add_integration_flyout, 'fetchDataSourceMappings') + .mockResolvedValue({ test: { properties: {} } }); + jest.spyOn(add_integration_flyout, 'fetchIntegrationMappings').mockResolvedValue(null); + + const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); + + await expect(result).resolves.toHaveProperty('ok', false); + }); + + it('Catches type validation issues', async () => { + const mockHttp = {} as Partial; + jest.spyOn(add_integration_flyout, 'checkDataSourceName').mockReturnValue({ ok: true }); + jest + .spyOn(add_integration_flyout, 'fetchDataSourceMappings') + .mockResolvedValue({ test: { properties: {} } }); + jest + .spyOn(add_integration_flyout, 'fetchIntegrationMappings') + .mockResolvedValue({ test: { template: { mappings: {} } } }); + jest + .spyOn(add_integration_flyout, 'doPropertyValidation') + .mockReturnValue({ ok: false, errors: ['mock'] }); + + const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); + + await expect(result).resolves.toHaveProperty('ok', false); + }); + + it('Returns no errors if everything passes', async () => { + const mockHttp = {} as Partial; + jest.spyOn(add_integration_flyout, 'checkDataSourceName').mockReturnValue({ ok: true }); + jest + .spyOn(add_integration_flyout, 'fetchDataSourceMappings') + .mockResolvedValue({ test: { properties: {} } }); + jest + .spyOn(add_integration_flyout, 'fetchIntegrationMappings') + .mockResolvedValue({ test: { template: { mappings: {} } } }); + jest.spyOn(add_integration_flyout, 'doPropertyValidation').mockReturnValue({ ok: true }); + + const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); + + await expect(result).resolves.toHaveProperty('ok', true); + }); +}); diff --git a/public/components/integrations/components/__tests__/mapping_validation.test.ts b/public/components/integrations/components/__tests__/mapping_validation.test.ts deleted file mode 100644 index 4a02058cf4..0000000000 --- a/public/components/integrations/components/__tests__/mapping_validation.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - doTypeValidation, - doNestedPropertyValidation, - doPropertyValidation, -} from '../add_integration_flyout'; - -describe('Validation', () => { - describe('doTypeValidation', () => { - it('should return true if required type is not specified', () => { - const toCheck = { type: 'string' }; - const required = {}; - - const result = doTypeValidation(toCheck, required); - - expect(result).toBe(true); - }); - - it('should return true if types match', () => { - const toCheck = { type: 'string' }; - const required = { type: 'string' }; - - const result = doTypeValidation(toCheck, required); - - expect(result).toBe(true); - }); - - it('should return true if object has properties', () => { - const toCheck = { properties: { prop1: { type: 'string' } } }; - const required = { type: 'object' }; - - const result = doTypeValidation(toCheck, required); - - expect(result).toBe(true); - }); - - it('should return false if types do not match', () => { - const toCheck = { type: 'string' }; - const required = { type: 'number' }; - - const result = doTypeValidation(toCheck, required); - - expect(result).toBe(false); - }); - }); - - describe('doNestedPropertyValidation', () => { - it('should return true if type validation passes and no properties are required', () => { - const toCheck = { type: 'string' }; - const required = { type: 'string' }; - - const result = doNestedPropertyValidation(toCheck, required); - - expect(result).toBe(true); - }); - - it('should return false if type validation fails', () => { - const toCheck = { type: 'string' }; - const required = { type: 'number' }; - - const result = doNestedPropertyValidation(toCheck, required); - - expect(result).toBe(false); - }); - - it('should return false if a required property is missing', () => { - const toCheck = { type: 'object', properties: { prop1: { type: 'string' } } }; - const required = { - type: 'object', - properties: { prop1: { type: 'string' }, prop2: { type: 'number' } }, - }; - - const result = doNestedPropertyValidation(toCheck, required); - - expect(result).toBe(false); - }); - - it('should return true if all required properties pass validation', () => { - const toCheck = { - type: 'object', - properties: { - prop1: { type: 'string' }, - prop2: { type: 'number' }, - }, - }; - const required = { - type: 'object', - properties: { - prop1: { type: 'string' }, - prop2: { type: 'number' }, - }, - }; - - const result = doNestedPropertyValidation(toCheck, required); - - expect(result).toBe(true); - }); - }); - - describe('doPropertyValidation', () => { - it('should return true if all properties pass validation', () => { - const rootType = 'root'; - const dataSourceProps = { - prop1: { type: 'string' }, - prop2: { type: 'number' }, - }; - const requiredMappings = { - root: { - template: { - mappings: { - properties: { - prop1: { type: 'string' }, - prop2: { type: 'number' }, - }, - }, - }, - }, - }; - - const result = doPropertyValidation(rootType, dataSourceProps as any, requiredMappings); - - expect(result).toBe(true); - }); - - it('should return false if a property fails validation', () => { - const rootType = 'root'; - const dataSourceProps = { - prop1: { type: 'string' }, - prop2: { type: 'number' }, - }; - const requiredMappings = { - root: { - template: { - mappings: { - properties: { - prop1: { type: 'string' }, - prop2: { type: 'boolean' }, - }, - }, - }, - }, - }; - - const result = doPropertyValidation(rootType, dataSourceProps as any, requiredMappings); - - expect(result).toBe(false); - }); - - it('should return false if a required nested property is missing', () => { - const rootType = 'root'; - const dataSourceProps = { - prop1: { type: 'string' }, - }; - const requiredMappings = { - root: { - template: { - mappings: { - properties: { - prop1: { type: 'string' }, - prop2: { type: 'number' }, - }, - }, - }, - }, - }; - - const result = doPropertyValidation(rootType, dataSourceProps as any, requiredMappings); - - expect(result).toBe(false); - }); - }); -}); diff --git a/public/components/integrations/components/add_integration_flyout.tsx b/public/components/integrations/components/add_integration_flyout.tsx index 29cc8921e6..0e1e792610 100644 --- a/public/components/integrations/components/add_integration_flyout.tsx +++ b/public/components/integrations/components/add_integration_flyout.tsx @@ -20,7 +20,7 @@ import { EuiTitle, } from '@elastic/eui'; import React, { useState } from 'react'; -import { HttpStart } from '../../../../../../src/core/public'; +import { HttpSetup, HttpStart } from '../../../../../../src/core/public'; import { useToast } from '../../../../public/components/common/toast'; interface IntegrationFlyoutProps { @@ -31,48 +31,65 @@ interface IntegrationFlyoutProps { http: HttpStart; } -export const doTypeValidation = (toCheck: any, required: any): boolean => { +type ValidationResult = { ok: true } | { ok: false; errors: string[] }; + +export const doTypeValidation = ( + toCheck: { type?: string; properties?: object }, + required: { type?: string; properties?: object } +): ValidationResult => { if (!required.type) { - return true; + return { ok: true }; } if (required.type === 'object') { - return Boolean(toCheck.properties); + if (Boolean(toCheck.properties)) { + return { ok: true }; + } + return { ok: false, errors: ["'object' type must have properties."] }; } - return required.type === toCheck.type; + if (required.type !== toCheck.type) { + return { ok: false, errors: [`Type mismatch: '${required.type}' and '${toCheck.type}'`] }; + } + return { ok: true }; }; export const doNestedPropertyValidation = ( - toCheck: { type?: string; properties?: any }, - required: { type?: string; properties?: any } -): boolean => { - if (!doTypeValidation(toCheck, required)) { - return false; + toCheck: { type?: string; properties?: { [key: string]: object } }, + required: { type?: string; properties?: { [key: string]: object } } +): ValidationResult => { + const typeCheck = doTypeValidation(toCheck, required); + if (!typeCheck.ok) { + return typeCheck; } - if (required.properties) { - return Object.keys(required.properties).every((property: string) => { - if (!toCheck.properties[property]) { - return false; - } - return doNestedPropertyValidation( - toCheck.properties[property], - required.properties[property] - ); - }); + for (const property of Object.keys(required.properties ?? {})) { + if (!Object.hasOwn(toCheck.properties ?? {}, property)) { + return { ok: false, errors: [`Missing field '${property}'`] }; + } + // Both are safely non-null after above checks. + const nested = doNestedPropertyValidation( + toCheck.properties![property], + required.properties![property] + ); + if (!nested.ok) { + return nested; + } } - return true; + return { ok: true }; }; export const doPropertyValidation = ( rootType: string, dataSourceProps: { [key: string]: { properties?: any } }, requiredMappings: { [key: string]: { template: { mappings: { properties?: any } } } } -): boolean => { +): ValidationResult => { // Check root object type (without dependencies) for (const [key, value] of Object.entries( requiredMappings[rootType].template.mappings.properties )) { - if (!dataSourceProps[key] || !doNestedPropertyValidation(dataSourceProps[key], value as any)) { - return false; + if ( + !dataSourceProps[key] || + !doNestedPropertyValidation(dataSourceProps[key], value as any).ok + ) { + return { ok: false, errors: [`Data source is invalid at key '${key}'`] }; } } // Check nested dependencies @@ -82,12 +99,104 @@ export const doPropertyValidation = ( } if ( !dataSourceProps[key] || - !doNestedPropertyValidation(dataSourceProps[key], value.template.mappings.properties) + !doNestedPropertyValidation(dataSourceProps[key], value.template.mappings.properties).ok ) { - return false; + return { ok: false, errors: [`Data source is invalid at key '${key}'`] }; } } - return true; + return { ok: true }; +}; + +// Returns true if the data stream is a legal name. +// Appends any additional validation errors to the provided errors array. +export const checkDataSourceName = ( + targetDataSource: string, + integrationType: string +): ValidationResult => { + let errors: string[] = []; + if (!/^[a-z\d\.][a-z\d\._\-\*]*$/.test(targetDataSource)) { + errors = errors.concat('This is not a valid index name.'); + return { ok: false, errors }; + } + const nameValidity: boolean = new RegExp(`^ss4o_${integrationType}-[^\\-]+-[^\\-]+`).test( + targetDataSource + ); + if (!nameValidity) { + errors = errors.concat('This index does not match the suggested naming convention.'); + return { ok: false, errors }; + } + return { ok: true }; +}; + +export const fetchDataSourceMappings = async ( + targetDataSource: string, + http: HttpSetup +): Promise<{ [key: string]: { properties: any } } | null> => { + return http + .post('/api/console/proxy', { + query: { + path: `${targetDataSource}/_mapping`, + method: 'GET', + }, + }) + .then((response) => { + // Un-nest properties by a level for caller convenience + Object.keys(response).forEach((key) => { + response[key].properties = response[key].mappings.properties; + }); + return response; + }) + .catch((err: any) => { + console.error(err); + return null; + }); +}; + +export const fetchIntegrationMappings = async ( + targetName: string, + http: HttpSetup +): Promise<{ [key: string]: { template: { mappings: { properties?: any } } } } | null> => { + return http + .get(`/api/integrations/repository/${targetName}/schema`) + .then((response) => { + if (response.statusCode && response.statusCode !== 200) { + throw new Error('Failed to retrieve Integration schema', { cause: response }); + } + return response.data.mappings; + }) + .catch((err: any) => { + console.error(err); + return null; + }); +}; + +export const doExistingDataSourceValidation = async ( + targetDataSource: string, + integrationName: string, + integrationType: string, + http: HttpSetup +): Promise => { + const dataSourceNameCheck = checkDataSourceName(targetDataSource, integrationType); + if (!dataSourceNameCheck.ok) { + return dataSourceNameCheck; + } + const [dataSourceMappings, integrationMappings] = await Promise.all([ + fetchDataSourceMappings(targetDataSource, http), + fetchIntegrationMappings(integrationName, http), + ]); + if (!dataSourceMappings) { + return { ok: false, errors: ['Provided data stream could not be retrieved'] }; + } + if (!integrationMappings) { + return { ok: false, errors: ['Failed to retrieve integration schema information'] }; + } + const validationResult = Object.values(dataSourceMappings).every( + (value) => doPropertyValidation(integrationType, value.properties, integrationMappings).ok + ); + if (!validationResult) { + return { ok: false, errors: ['The provided index does not match the schema'] }; + } + return { ok: true }; }; export function AddIntegrationFlyout(props: IntegrationFlyoutProps) { @@ -110,93 +219,6 @@ export function AddIntegrationFlyout(props: IntegrationFlyoutProps) { setName(e.target.value); }; - // Returns true if the data stream is a legal name. - // Appends any additional validation errors to the provided errors array. - const checkDataSourceName = (targetDataSource: string, validationErrors: string[]): boolean => { - if (!Boolean(targetDataSource.match(/^[a-z\d\.][a-z\d\._\-\*]*$/))) { - validationErrors.push('This is not a valid index name.'); - setErrors(validationErrors); - return false; - } - const nameValidity: boolean = Boolean( - targetDataSource.match(new RegExp(`^ss4o_${integrationType}-[^\\-]+-[^\\-]+`)) - ); - if (!nameValidity) { - validationErrors.push('This index does not match the suggested naming convention.'); - setErrors(validationErrors); - } - return true; - }; - - const fetchDataSourceMappings = async ( - targetDataSource: string - ): Promise<{ [key: string]: { properties: any } } | null> => { - return http - .post('/api/console/proxy', { - query: { - path: `${targetDataSource}/_mapping`, - method: 'GET', - }, - }) - .then((response) => { - // Un-nest properties by a level for caller convenience - Object.keys(response).forEach((key) => { - response[key].properties = response[key].mappings.properties; - }); - return response; - }) - .catch((err: any) => { - console.error(err); - return null; - }); - }; - - const fetchIntegrationMappings = async ( - targetName: string - ): Promise<{ [key: string]: { template: { mappings: { properties?: any } } } } | null> => { - return http - .get(`/api/integrations/repository/${targetName}/schema`) - .then((response) => { - if (response.statusCode && response.statusCode !== 200) { - throw new Error('Failed to retrieve Integration schema', { cause: response }); - } - return response.data.mappings; - }) - .catch((err: any) => { - console.error(err); - return null; - }); - }; - - const doExistingDataSourceValidation = async (targetDataSource: string): Promise => { - const validationErrors: string[] = []; - if (!checkDataSourceName(targetDataSource, validationErrors)) { - return false; - } - const [dataSourceMappings, integrationMappings] = await Promise.all([ - fetchDataSourceMappings(targetDataSource), - fetchIntegrationMappings(integrationName), - ]); - if (!dataSourceMappings) { - validationErrors.push('Provided data stream could not be retrieved'); - setErrors(validationErrors); - return false; - } - if (!integrationMappings) { - validationErrors.push('Failed to retrieve integration schema information'); - setErrors(validationErrors); - return false; - } - const validationResult = Object.values(dataSourceMappings).every((value) => - doPropertyValidation(integrationType, value.properties, integrationMappings) - ); - if (!validationResult) { - validationErrors.push('The provided index does not match the schema'); - setErrors(validationErrors); - } - return validationResult; - }; - const formContent = () => { return (
@@ -227,11 +249,17 @@ export function AddIntegrationFlyout(props: IntegrationFlyoutProps) { { - const validationResult = await doExistingDataSourceValidation(dataSource); - if (validationResult) { + const validationResult = await doExistingDataSourceValidation( + dataSource, + integrationName, + integrationType, + http + ); + if (validationResult.ok) { setToast('Index name or wildcard pattern is valid', 'success'); } - setDataSourceValid(validationResult); + setDataSourceValid(validationResult.ok); + setErrors(!validationResult.ok ? validationResult.errors : []); }} disabled={dataSource.length === 0} >