1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-08 01:15:49 +02:00

Refactor: rename frontend api key (#1935)

* refactor: rename frontend api key

* fix: api token schema tests
This commit is contained in:
Tymoteusz Czech 2022-08-18 10:20:51 +02:00 committed by GitHub
parent 037b8eacd3
commit 3266e9c22a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 125 additions and 73 deletions

View File

@ -36,13 +36,13 @@ const apiAccessMiddleware = (
try { try {
const apiToken = req.header('authorization'); const apiToken = req.header('authorization');
const apiUser = apiTokenService.getUserForToken(apiToken); const apiUser = apiTokenService.getUserForToken(apiToken);
const { CLIENT, PROXY } = ApiTokenType; const { CLIENT, FRONTEND } = ApiTokenType;
if (apiUser) { if (apiUser) {
if ( if (
(apiUser.type === CLIENT && !isClientApi(req)) || (apiUser.type === CLIENT && !isClientApi(req)) ||
(apiUser.type === PROXY && !isProxyApi(req)) || (apiUser.type === FRONTEND && !isProxyApi(req)) ||
(apiUser.type === PROXY && !experimental.embedProxy) (apiUser.type === FRONTEND && !experimental.embedProxy)
) { ) {
res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE }); res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE });
return; return;

View File

@ -56,10 +56,10 @@ test('should set metadata', async () => {
expect(token.projects).toBeUndefined(); expect(token.projects).toBeUndefined();
}); });
test('should allow for embedded proxy (frontend) key', async () => { test('should allow for frontend key (embedded proxy)', async () => {
let token = await createApiToken.validateAsync({ let token = await createApiToken.validateAsync({
username: 'test', username: 'test',
type: 'proxy', type: 'frontend',
project: 'default', project: 'default',
metadata: { metadata: {
corsOrigins: ['*'], corsOrigins: ['*'],
@ -68,10 +68,10 @@ test('should allow for embedded proxy (frontend) key', async () => {
expect(token.error).toBeUndefined(); expect(token.error).toBeUndefined();
}); });
test('should set environment to default for proxy key', async () => { test('should set environment to default for frontend key', async () => {
let token = await createApiToken.validateAsync({ let token = await createApiToken.validateAsync({
username: 'test', username: 'test',
type: 'proxy', type: 'frontend',
project: 'default', project: 'default',
metadata: { metadata: {
corsOrigins: ['*'], corsOrigins: ['*'],

View File

@ -10,7 +10,11 @@ export const createApiToken = joi
.string() .string()
.lowercase() .lowercase()
.required() .required()
.valid(ApiTokenType.ADMIN, ApiTokenType.CLIENT, ApiTokenType.PROXY), .valid(
ApiTokenType.ADMIN,
ApiTokenType.CLIENT,
ApiTokenType.FRONTEND,
),
expiresAt: joi.date().optional(), expiresAt: joi.date().optional(),
project: joi.when('projects', { project: joi.when('projects', {
not: joi.required(), not: joi.required(),
@ -18,7 +22,7 @@ export const createApiToken = joi
}), }),
projects: joi.array().min(0).optional(), projects: joi.array().min(0).optional(),
environment: joi.when('type', { environment: joi.when('type', {
is: joi.string().valid(ApiTokenType.CLIENT, ApiTokenType.PROXY), is: joi.string().valid(ApiTokenType.CLIENT, ApiTokenType.FRONTEND),
then: joi.string().optional().default(DEFAULT_ENV), then: joi.string().optional().default(DEFAULT_ENV),
otherwise: joi.string().optional().default(ALL), otherwise: joi.string().optional().default(ALL),
}), }),

View File

@ -1,7 +1,7 @@
import { ApiTokenService } from './api-token-service'; import { ApiTokenService } from './api-token-service';
import { createTestConfig } from '../../test/config/test-config'; import { createTestConfig } from '../../test/config/test-config';
import { IUnleashConfig } from '../server-impl'; import { IUnleashConfig } from '../server-impl';
import { ApiTokenType } from '../types/models/api-token'; import { ApiTokenType, IApiTokenCreate } from '../types/models/api-token';
import FakeApiTokenStore from '../../test/fixtures/fake-api-token-store'; import FakeApiTokenStore from '../../test/fixtures/fake-api-token-store';
import FakeEnvironmentStore from '../../test/fixtures/fake-environment-store'; import FakeEnvironmentStore from '../../test/fixtures/fake-environment-store';
@ -33,3 +33,37 @@ test('Should init api token', async () => {
expect(tokens).toHaveLength(1); expect(tokens).toHaveLength(1);
}); });
test("Shouldn't return frontend token when secret is undefined", async () => {
const token: IApiTokenCreate = {
environment: 'default',
projects: ['*'],
secret: '*:*:some-random-string',
type: ApiTokenType.FRONTEND,
username: 'front',
expiresAt: null,
};
const config: IUnleashConfig = createTestConfig({});
const apiTokenStore = new FakeApiTokenStore();
const environmentStore = new FakeEnvironmentStore();
await environmentStore.create({
name: 'default',
enabled: true,
protected: true,
type: 'test',
sortOrder: 1,
});
const apiTokenService = new ApiTokenService(
{ apiTokenStore, environmentStore },
config,
);
await apiTokenService.createApiTokenWithProjects(token);
await apiTokenService.fetchActiveTokens();
expect(apiTokenService.getUserForToken(undefined)).toEqual(undefined);
expect(apiTokenService.getUserForToken('')).toEqual(undefined);
});

View File

@ -1,6 +1,6 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { Logger } from '../logger'; import { Logger } from '../logger';
import { ADMIN, CLIENT, PROXY } from '../types/permissions'; import { ADMIN, CLIENT, FRONTEND } from '../types/permissions';
import { IUnleashStores } from '../types/stores'; import { IUnleashStores } from '../types/stores';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
import ApiUser from '../types/api-user'; import ApiUser from '../types/api-user';
@ -29,8 +29,8 @@ const resolveTokenPermissions = (tokenType: string) => {
return [CLIENT]; return [CLIENT];
} }
if (tokenType === ApiTokenType.PROXY) { if (tokenType === ApiTokenType.FRONTEND) {
return [PROXY]; return [FRONTEND];
} }
return []; return [];
@ -69,7 +69,7 @@ export class ApiTokenService {
} }
} }
private async fetchActiveTokens(): Promise<void> { async fetchActiveTokens(): Promise<void> {
try { try {
this.activeTokens = await this.getAllActiveTokens(); this.activeTokens = await this.getAllActiveTokens();
} finally { } finally {
@ -102,12 +102,18 @@ export class ApiTokenService {
} }
public getUserForToken(secret: string): ApiUser | undefined { public getUserForToken(secret: string): ApiUser | undefined {
if (!secret) {
return undefined;
}
let token = this.activeTokens.find((t) => t.secret === secret); let token = this.activeTokens.find((t) => t.secret === secret);
// If the token is not found, try to find it in the legacy format with the metadata alias // If the token is not found, try to find it in the legacy format with the metadata alias
// This is to ensure that previous proxies we set up for our customers continue working // This is to ensure that previous proxies we set up for our customers continue working
if (!token) { if (!token && secret) {
token = this.activeTokens.find((t) => t.metadata.alias === secret); token = this.activeTokens.find(
(t) => t.metadata.alias && t.metadata.alias === secret,
);
} }
if (token) { if (token) {

View File

@ -115,6 +115,6 @@ export class ProxyService {
} }
private static assertExpectedTokenType({ type }: ApiUser) { private static assertExpectedTokenType({ type }: ApiUser) {
assert(type === ApiTokenType.PROXY || type === ApiTokenType.ADMIN); assert(type === ApiTokenType.FRONTEND || type === ApiTokenType.ADMIN);
} }
} }

View File

@ -6,7 +6,7 @@ export const ALL = '*';
export enum ApiTokenType { export enum ApiTokenType {
CLIENT = 'client', CLIENT = 'client',
ADMIN = 'admin', ADMIN = 'admin',
PROXY = 'proxy', FRONTEND = 'frontend',
} }
export interface ILegacyApiTokenCreate { export interface ILegacyApiTokenCreate {
@ -108,9 +108,9 @@ export const validateApiToken = ({
); );
} }
if (type === ApiTokenType.PROXY && environment === ALL) { if (type === ApiTokenType.FRONTEND && environment === ALL) {
throw new BadDataError( throw new BadDataError(
'Proxy token cannot be scoped to all environments', 'Frontend token cannot be scoped to all environments',
); );
} }
}; };

View File

@ -1,7 +1,7 @@
//Special //Special
export const ADMIN = 'ADMIN'; export const ADMIN = 'ADMIN';
export const CLIENT = 'CLIENT'; export const CLIENT = 'CLIENT';
export const PROXY = 'PROXY'; export const FRONTEND = 'FRONTEND';
export const NONE = 'NONE'; export const NONE = 'NONE';
export const CREATE_FEATURE = 'CREATE_FEATURE'; export const CREATE_FEATURE = 'CREATE_FEATURE';

View File

@ -246,7 +246,7 @@ Object {
"type": "string", "type": "string",
}, },
"type": Object { "type": Object {
"description": "client, admin, proxy.", "description": "client, admin, frontend.",
"type": "string", "type": "string",
}, },
"username": Object { "username": Object {
@ -683,7 +683,7 @@ Object {
"type": "string", "type": "string",
}, },
"type": Object { "type": Object {
"description": "client, admin, proxy.", "description": "client, admin, frontend.",
"type": "string", "type": "string",
}, },
"username": Object { "username": Object {

View File

@ -88,7 +88,7 @@ const createProject = async (id: string): Promise<void> => {
await app.services.projectService.createProject({ id, name: id }, user); await app.services.projectService.createProject({ id, name: id }, user);
}; };
test('should require a proxy token or an admin token', async () => { test('should require a frontend token or an admin token', async () => {
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -117,64 +117,64 @@ test('should allow requests with an admin token', async () => {
.expect((res) => expect(res.body).toEqual({ toggles: [] })); .expect((res) => expect(res.body).toEqual({ toggles: [] }));
}); });
test('should not allow admin requests with a proxy token', async () => { test('should not allow admin requests with a frontend token', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
await app.request await app.request
.get('/api/admin/features') .get('/api/admin/features')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(403); .expect(403);
}); });
test('should not allow client requests with a proxy token', async () => { test('should not allow client requests with a frontend token', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
await app.request await app.request
.get('/api/client/features') .get('/api/client/features')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(403); .expect(403);
}); });
test('should not allow requests with an invalid proxy token', async () => { test('should not allow requests with an invalid frontend token', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyToken.secret.slice(0, -1)) .set('Authorization', frontendToken.secret.slice(0, -1))
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(401); .expect(401);
}); });
test('should allow requests with a proxy token', async () => { test('should allow requests with a frontend token', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => expect(res.body).toEqual({ toggles: [] })); .expect((res) => expect(res.body).toEqual({ toggles: [] }));
}); });
test('should return 405 from unimplemented endpoints', async () => { test('should return 405 from unimplemented endpoints', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
await app.request await app.request
.post('/api/frontend') .post('/api/frontend')
.send({}) .send({})
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(405); .expect(405);
await app.request await app.request
.get('/api/frontend/client/features') .get('/api/frontend/client/features')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(405); .expect(405);
await app.request await app.request
.get('/api/frontend/health') .get('/api/frontend/health')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(405); .expect(405);
await app.request await app.request
.get('/api/frontend/internal-backstage/prometheus') .get('/api/frontend/internal-backstage/prometheus')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(405); .expect(405);
}); });
@ -183,16 +183,16 @@ test('should return 405 from unimplemented endpoints', async () => {
test.todo('should enforce token CORS settings'); test.todo('should enforce token CORS settings');
test('should accept client registration requests', async () => { test('should accept client registration requests', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
await app.request await app.request
.post('/api/frontend/client/register') .post('/api/frontend/client/register')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.send({}) .send({})
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(400); .expect(400);
await app.request await app.request
.post('/api/frontend/client/register') .post('/api/frontend/client/register')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.send({ .send({
appName: randomId(), appName: randomId(),
instanceId: randomId(), instanceId: randomId(),
@ -211,7 +211,7 @@ test('should store proxy client metrics', async () => {
const appName = randomId(); const appName = randomId();
const instanceId = randomId(); const instanceId = randomId();
const featureName = randomId(); const featureName = randomId();
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
const adminToken = await createApiToken(ApiTokenType.ADMIN, { const adminToken = await createApiToken(ApiTokenType.ADMIN, {
projects: ['*'], projects: ['*'],
environment: '*', environment: '*',
@ -232,7 +232,7 @@ test('should store proxy client metrics', async () => {
}); });
await app.request await app.request
.post('/api/frontend/client/metrics') .post('/api/frontend/client/metrics')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.send({ .send({
appName, appName,
instanceId, instanceId,
@ -246,7 +246,7 @@ test('should store proxy client metrics', async () => {
.expect((res) => expect(res.text).toEqual('OK')); .expect((res) => expect(res.text).toEqual('OK'));
await app.request await app.request
.post('/api/frontend/client/metrics') .post('/api/frontend/client/metrics')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.send({ .send({
appName, appName,
instanceId, instanceId,
@ -282,7 +282,7 @@ test('should store proxy client metrics', async () => {
}); });
test('should filter features by enabled/disabled', async () => { test('should filter features by enabled/disabled', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
await createFeatureToggle({ await createFeatureToggle({
name: 'enabledFeature1', name: 'enabledFeature1',
enabled: true, enabled: true,
@ -300,7 +300,7 @@ test('should filter features by enabled/disabled', async () => {
}); });
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
@ -324,7 +324,7 @@ test('should filter features by enabled/disabled', async () => {
}); });
test('should filter features by strategies', async () => { test('should filter features by strategies', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
await createFeatureToggle({ await createFeatureToggle({
name: 'featureWithoutStrategies', name: 'featureWithoutStrategies',
enabled: false, enabled: false,
@ -345,7 +345,7 @@ test('should filter features by strategies', async () => {
}); });
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
@ -363,7 +363,7 @@ test('should filter features by strategies', async () => {
}); });
test('should filter features by constraints', async () => { test('should filter features by constraints', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY); const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
await createFeatureToggle({ await createFeatureToggle({
name: 'featureWithAppNameA', name: 'featureWithAppNameA',
enabled: true, enabled: true,
@ -396,19 +396,19 @@ test('should filter features by constraints', async () => {
}); });
await app.request await app.request
.get('/api/frontend?appName=a') .get('/api/frontend?appName=a')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => expect(res.body.toggles).toHaveLength(2)); .expect((res) => expect(res.body.toggles).toHaveLength(2));
await app.request await app.request
.get('/api/frontend?appName=b') .get('/api/frontend?appName=b')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => expect(res.body.toggles).toHaveLength(1)); .expect((res) => expect(res.body.toggles).toHaveLength(1));
await app.request await app.request
.get('/api/frontend?appName=c') .get('/api/frontend?appName=c')
.set('Authorization', proxyToken.secret) .set('Authorization', frontendToken.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => expect(res.body.toggles).toHaveLength(0)); .expect((res) => expect(res.body.toggles).toHaveLength(0));
@ -419,11 +419,11 @@ test('should filter features by project', async () => {
const projectB = 'projectB'; const projectB = 'projectB';
await createProject(projectA); await createProject(projectA);
await createProject(projectB); await createProject(projectB);
const proxyTokenDefault = await createApiToken(ApiTokenType.PROXY); const frontendTokenDefault = await createApiToken(ApiTokenType.FRONTEND);
const proxyTokenProjectA = await createApiToken(ApiTokenType.PROXY, { const frontendTokenProjectA = await createApiToken(ApiTokenType.FRONTEND, {
projects: [projectA], projects: [projectA],
}); });
const proxyTokenProjectAB = await createApiToken(ApiTokenType.PROXY, { const frontendTokenProjectAB = await createApiToken(ApiTokenType.FRONTEND, {
projects: [projectA, projectB], projects: [projectA, projectB],
}); });
await createFeatureToggle({ await createFeatureToggle({
@ -445,7 +445,7 @@ test('should filter features by project', async () => {
}); });
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyTokenDefault.secret) .set('Authorization', frontendTokenDefault.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
@ -462,7 +462,7 @@ test('should filter features by project', async () => {
}); });
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyTokenProjectA.secret) .set('Authorization', frontendTokenProjectA.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
@ -479,7 +479,7 @@ test('should filter features by project', async () => {
}); });
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyTokenProjectAB.secret) .set('Authorization', frontendTokenProjectAB.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
@ -521,15 +521,21 @@ test('should filter features by environment', async () => {
environmentB, environmentB,
'default', 'default',
); );
const proxyTokenEnvironmentDefault = await createApiToken( const frontendTokenEnvironmentDefault = await createApiToken(
ApiTokenType.PROXY, ApiTokenType.FRONTEND,
);
const frontendTokenEnvironmentA = await createApiToken(
ApiTokenType.FRONTEND,
{
environment: environmentA,
},
);
const frontendTokenEnvironmentB = await createApiToken(
ApiTokenType.FRONTEND,
{
environment: environmentB,
},
); );
const proxyTokenEnvironmentA = await createApiToken(ApiTokenType.PROXY, {
environment: environmentA,
});
const proxyTokenEnvironmentB = await createApiToken(ApiTokenType.PROXY, {
environment: environmentB,
});
await createFeatureToggle({ await createFeatureToggle({
name: 'featureInEnvironmentDefault', name: 'featureInEnvironmentDefault',
enabled: true, enabled: true,
@ -549,7 +555,7 @@ test('should filter features by environment', async () => {
}); });
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyTokenEnvironmentDefault.secret) .set('Authorization', frontendTokenEnvironmentDefault.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
@ -566,7 +572,7 @@ test('should filter features by environment', async () => {
}); });
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyTokenEnvironmentA.secret) .set('Authorization', frontendTokenEnvironmentA.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
@ -583,7 +589,7 @@ test('should filter features by environment', async () => {
}); });
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', proxyTokenEnvironmentB.secret) .set('Authorization', frontendTokenEnvironmentB.secret)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {

View File

@ -44,7 +44,9 @@ export default class FakeApiTokenStore
} }
async getAllActive(): Promise<IApiToken[]> { async getAllActive(): Promise<IApiToken[]> {
return this.tokens.filter((token) => token.expiresAt > new Date()); return this.tokens.filter(
(token) => token.expiresAt === null || token.expiresAt > new Date(),
);
} }
async insert(newToken: IApiTokenCreate): Promise<IApiToken> { async insert(newToken: IApiTokenCreate): Promise<IApiToken> {