[UI] API Service (#29965)

* adds api service

* adds missing copyright headers

* Update ui/app/services/api.ts

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* removes response cache and comments from api service

* removes hide warnings condition from showWarnings middleware in api service

* splits out setHeaders test

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Jordan Reimer 2025-03-20 16:28:02 -06:00 committed by GitHub
parent 32f74c1016
commit bbcd0e0465
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 490 additions and 12 deletions

View File

@ -13,7 +13,16 @@ declare const config: {
podModulePrefix: string;
locationType: 'history' | 'hash' | 'none';
rootURL: string;
APP: Record<string, unknown>;
APP: {
POLLING_URLS: string[];
NAMESPACE_ROOT_URLS: string[];
DEFAULT_PAGE_SIZE: number;
LOG_TRANSITIONS?: boolean;
LOG_ACTIVE_GENERATION?: boolean;
LOG_VIEW_LOOKUPS?: boolean;
rootElement?: string;
autoboot?: boolean;
};
};
export default config;

View File

@ -3,31 +3,34 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import type { ApiError } from 'vault/api';
export default {
isLocalStorageSupported() {
try {
const key = `__storage__test`;
window.localStorage.setItem(key, null);
window.localStorage.setItem(key, '');
window.localStorage.removeItem(key);
return true;
} catch (e) {
const error = e as ApiError;
// modify the e object so we can customize the error message.
// e.message is readOnly.
e.errors = [`This is likely due to your browser's cookie settings.`];
error.errors = [`This is likely due to your browser's cookie settings.`];
throw e;
}
},
getItem(key) {
getItem(key: string) {
const item = window.localStorage.getItem(key);
return item && JSON.parse(item);
},
setItem(key, val) {
setItem(key: string, val: unknown) {
window.localStorage.setItem(key, JSON.stringify(val));
},
removeItem(key) {
removeItem(key: string) {
return window.localStorage.removeItem(key);
},
@ -35,7 +38,7 @@ export default {
return Object.keys(window.localStorage);
},
cleanupStorage(string, keyToKeep) {
cleanupStorage(string: string, keyToKeep: string) {
if (!string) return;
const relevantKeys = this.keys().filter((str) => str.startsWith(string));
relevantKeys?.forEach((key) => {

View File

@ -3,19 +3,19 @@
* SPDX-License-Identifier: BUSL-1.1
*/
const cache = {};
const cache: { [key: string]: string } = {};
export default {
getItem(key) {
var item = cache[key];
getItem(key: string) {
const item = cache[key];
return item && JSON.parse(item);
},
setItem(key, val) {
setItem(key: string, val: unknown) {
cache[key] = JSON.stringify(val);
},
removeItem(key) {
removeItem(key: string) {
delete cache[key];
},

175
ui/app/services/api.ts Normal file
View File

@ -0,0 +1,175 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Service, { service } from '@ember/service';
import {
Configuration,
RequestContext,
ResponseContext,
AuthApi,
IdentityApi,
SecretsApi,
SystemApi,
} from '@hashicorp/vault-client-typescript';
import config from '../config/environment';
import type AuthService from 'vault/services/auth';
import type NamespaceService from 'vault/services/namespace';
import type ControlGroupService from 'vault/services/control-group';
import type FlashMessageService from 'vault/services/flash-messages';
import type { ApiError, HeaderMap, XVaultHeaders } from 'vault/api';
export default class ApiService extends Service {
@service('auth') declare readonly authService: AuthService;
@service('namespace') declare readonly namespaceService: NamespaceService;
@service declare readonly controlGroup: ControlGroupService;
@service declare readonly flashMessages: FlashMessageService;
// -- Pre Request Middleware --
setLastFetch = async (context: RequestContext) => {
const { url } = context;
const isPolling = config.APP.POLLING_URLS.some((str) => url.includes(str));
if (!isPolling) {
this.authService.setLastFetch(Date.now());
}
};
getControlGroupToken = async (context: RequestContext) => {
const { url, init } = context;
const controlGroupToken = this.controlGroup.tokenForUrl(url);
let newUrl = url;
// if we have a Control Group token that matches the url,
// unwrap it and return the unwrapped response as if it were the initial request
// to do this, we rewrite the request
if (controlGroupToken) {
const { token } = controlGroupToken;
const { headers } = this.buildHeaders({ token });
newUrl = '/v1/sys/wrapping/unwrap';
init.method = 'POST';
init.headers = headers;
init.body = JSON.stringify({ token });
}
return { url: newUrl, init };
};
setHeaders = async (context: RequestContext) => {
const { url, init } = context;
const headers = new Headers(init.headers);
// unauthenticated or clientToken requests should set the header in initOverrides
// unauthenticated value should be empty string, not undefined or null
if (!headers.has('X-Vault-Token')) {
headers.set('X-Vault-Token', this.authService.currentToken);
}
if (init.method === 'PATCH') {
headers.set('Content-Type', 'application/merge-patch+json');
}
// use initOverrides to set the namespace header to something other than path set in the namespace service
// for requests that must be made to root namespace pass empty string as value
const namespace = this.namespaceService.path;
if (!headers.has('X-Vault-Namespace') && namespace) {
headers.set('X-Vault-Namespace', namespace);
}
init.headers = headers;
return { url, init };
};
// -- Post Request Middleware --
showWarnings = async (context: ResponseContext) => {
const response = context.response.clone();
const json = await response?.json();
if (json?.warnings) {
json.warnings.forEach((message: string) => {
this.flashMessages.info(message);
});
}
};
deleteControlGroupToken = async (context: ResponseContext) => {
const { url } = context;
const controlGroupToken = this.controlGroup.tokenForUrl(url);
if (controlGroupToken) {
this.controlGroup.deleteControlGroupToken(controlGroupToken.accessor);
}
};
formatErrorResponse = async (context: ResponseContext) => {
const response = context.response.clone();
const { headers, status, statusText } = response;
// backwards compatibility with Ember Data
if (status >= 400) {
const error: ApiError = (await response?.json()) || {};
error.httpStatus = response?.status;
error.path = context.url;
// typically the Vault API error response looks like { errors: ['some error message'] }
// but sometimes (eg RespondWithStatusCode) it's { data: { error: 'some error message' } }
if (error?.data?.error && !error.errors) {
// normalize the errors from RespondWithStatusCode
error.errors = [error.data.error];
}
return new Response(JSON.stringify(error), { headers, status, statusText });
}
return;
};
// the responses in the OpenAPI spec don't account for the return values to be under the 'data' key
// return the data rather than the entire response
// if there is no data then return the full response
extractData = async (context: ResponseContext) => {
const response = context.response.clone();
const { headers, ok, status, statusText } = response;
if (ok) {
const json = await response?.json();
if (json.data) {
return new Response(JSON.stringify(json.data), { headers, status, statusText });
}
}
return;
};
// --- End Middleware ---
configuration = new Configuration({
basePath: '/v1',
middleware: [
{ pre: this.setLastFetch },
{ pre: this.getControlGroupToken },
{ pre: this.setHeaders },
{ post: this.showWarnings },
{ post: this.deleteControlGroupToken },
{ post: this.formatErrorResponse },
{ post: this.extractData },
],
});
auth = new AuthApi(this.configuration);
identity = new IdentityApi(this.configuration);
secrets = new SecretsApi(this.configuration);
sys = new SystemApi(this.configuration);
// convenience method for overriding headers for given requests to ensure consistency
// eg. this.api.sys.wrap(data, { headers: { 'X-Vault-Wrap-TTL': wrap } });
// -> this.api.sys.wrap(data, this.api.buildHeaders({ wrap }));
buildHeaders(headerMap: HeaderMap) {
const headers = {} as XVaultHeaders;
for (const key in headerMap) {
const headerKey = {
namespace: 'X-Vault-Namespace',
token: 'X-Vault-Token',
wrap: 'X-Vault-Wrap-TTL',
}[key] as keyof XVaultHeaders;
headers[headerKey] = headerMap[key as keyof HeaderMap];
}
return { headers };
}
}

View File

@ -0,0 +1,190 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import sinon from 'sinon';
module('Unit | Service | api', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.apiService = this.owner.lookup('service:api');
const authService = this.owner.lookup('service:auth');
this.setLastFetch = sinon.spy(authService, 'setLastFetch');
this.currentToken = sinon.stub(authService, 'currentToken').value('foobar');
const namespaceService = this.owner.lookup('service:namespace');
this.namespace = sinon.stub(namespaceService, 'path').value('another-ns');
const controlGroupService = this.owner.lookup('service:control-group');
this.wrapInfo = { token: 'ctrl-group', accessor: '84tfdfd5pQ5vOOEMxC2o3Ymt' };
this.tokenForUrl = sinon.stub(controlGroupService, 'tokenForUrl').returns(this.wrapInfo);
this.deleteControlGroupToken = sinon.spy(controlGroupService, 'deleteControlGroupToken');
const flashMessageService = this.owner.lookup('service:flash-messages');
this.info = sinon.spy(flashMessageService, 'info');
this.url = '/v1/sys/capabilities-self';
});
test('it should set last fetch time', async function (assert) {
await this.apiService.setLastFetch({ url: '/v1/sys/health' });
assert.true(this.setLastFetch.notCalled, 'Last fetch is not set for polling url');
await this.apiService.setLastFetch({ url: '/v1/auth/token/lookup-self' });
assert.true(this.setLastFetch.calledOnce, 'Last fetch is set for non polling url');
});
test('it should get control group token', async function (assert) {
const context = {
url: this.url,
init: {
method: 'GET',
headers: { 'X-Vault-Token': 'root' },
},
};
this.tokenForUrl.returns(undefined);
const noTokenContext = await this.apiService.getControlGroupToken(context);
assert.true(this.tokenForUrl.calledWith(context.url), 'Url is passed to tokenForUrl method');
assert.deepEqual(context, noTokenContext, 'Original context is returned when no token is present');
this.tokenForUrl.returns(this.wrapInfo);
const { token } = this.wrapInfo;
const tokenContext = await this.apiService.getControlGroupToken(context);
const newContext = {
url: '/v1/sys/wrapping/unwrap',
init: {
method: 'POST',
headers: { 'X-Vault-Token': token },
body: JSON.stringify({ token }),
},
};
assert.deepEqual(tokenContext, newContext, 'New context is returned when token is present');
});
test('it should set default headers', async function (assert) {
const {
init: { headers },
} = await this.apiService.setHeaders({ init: { method: 'PATCH' } });
assert.strictEqual(
headers.get('X-Vault-Token'),
'foobar',
'Token header is set with value from auth service'
);
assert.strictEqual(
headers.get('X-Vault-Namespace'),
'another-ns',
'Namespace header is set with value from namespace service'
);
assert.strictEqual(
headers.get('Content-Type'),
'application/merge-patch+json',
'Content type header is set for PATCH method'
);
});
test('it should override default headers when set on request init', async function (assert) {
const initHeaders = {
'X-Vault-Token': 'root',
'X-Vault-Namespace': 'ns1',
};
const {
init: { headers },
} = await this.apiService.setHeaders({ init: { headers: initHeaders } });
assert.strictEqual(headers.get('X-Vault-Token'), 'root', 'Token header set on request init is preserved');
assert.strictEqual(
headers.get('X-Vault-Namespace'),
'ns1',
'Namespace header set on request init is preserved'
);
});
test('it should show warnings', async function (assert) {
const warnings = ['warning1', 'warning2'];
const response = new Response(JSON.stringify({ warnings }));
await this.apiService.showWarnings({ response });
assert.true(this.info.firstCall.calledWith(warnings[0]), 'First warning message is shown');
assert.true(this.info.secondCall.calledWith(warnings[1]), 'Second warning message is shown');
});
test('it should delete control group token', async function (assert) {
await this.apiService.deleteControlGroupToken({ url: this.url });
assert.true(this.tokenForUrl.calledWith(this.url), 'Url is passed to tokenForUrl method');
assert.true(
this.deleteControlGroupToken.calledWith(this.wrapInfo.accessor),
'Control group token is deleted'
);
});
test('it should format error response', async function (assert) {
const e = { data: { error: 'Something went wrong' } };
const response = new Response(JSON.stringify(e), { status: 400 });
const errorResponse = await this.apiService.formatErrorResponse({ response, url: this.url });
const error = await errorResponse.json();
const expectedError = {
...e,
httpStatus: 400,
path: this.url,
errors: ['Something went wrong'],
};
assert.deepEqual(error, expectedError, 'Error is reformated and returned');
});
test('it should extract data from response', async function (assert) {
const data = { data: { foo: 'bar' } };
const response = new Response(JSON.stringify(data), { status: 200 });
const extractedDataResponse = await this.apiService.extractData({ response, url: this.url });
const json = await extractedDataResponse.json();
assert.deepEqual(json, { foo: 'bar' }, 'Data is extracted and returned');
});
test('it should build headers', async function (assert) {
const headerMap = {
token: 'foobar',
namespace: 'ns1',
wrap: '10s',
};
const token = await this.apiService.buildHeaders({ token: headerMap.token });
assert.deepEqual(token.headers, { 'X-Vault-Token': headerMap.token }, 'Token header is set');
const namespace = await this.apiService.buildHeaders({ namespace: headerMap.namespace });
assert.deepEqual(
namespace.headers,
{ 'X-Vault-Namespace': headerMap.namespace },
'Namespace header is set'
);
const wrapTTL = await this.apiService.buildHeaders({ wrap: headerMap.wrap });
assert.deepEqual(wrapTTL.headers, { 'X-Vault-Wrap-TTL': '10s' }, 'Wrap TTL header is set');
const multi = await this.apiService.buildHeaders(headerMap);
assert.deepEqual(
multi.headers,
{
'X-Vault-Token': 'foobar',
'X-Vault-Namespace': 'ns1',
'X-Vault-Wrap-TTL': '10s',
},
'All supported headers are set'
);
});
});

58
ui/types/vault/api.d.ts vendored Normal file
View File

@ -0,0 +1,58 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export interface ApiError {
httpStatus: number;
path: string;
message: string;
errors: Array<string | { [key: string]: unknown; title?: string; message?: string }>;
data?: {
[key: string]: unknown;
error?: string;
};
}
export interface WrapInfo {
accessor: string;
creation_path: string;
creation_time: string;
wrapped_accessor: string;
token: string;
ttl: number;
}
export interface ApiResponse {
auth: unknown;
data: unknown;
lease_duration: number;
lease_id: string;
mount_type: string;
renewable: boolean;
request_id: string;
warnings: Array<string> | null;
wrap_info: WrapInfo | null;
}
export type HeaderMap =
| {
namespace: string;
}
| {
token: string;
}
| {
wrap: string;
};
export type XVaultHeaders =
| {
'X-Vault-Namespace': string;
}
| {
'X-Vault-Token': string;
}
| {
'X-Vault-Wrap-TTL': string;
};

View File

@ -19,4 +19,6 @@ export interface AuthData {
export default class AuthService extends Service {
authData: AuthData;
currentToken: string;
setLastFetch: (time: number) => void;
}

View File

@ -0,0 +1,40 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Service from '@ember/service';
import type localStorage from 'vault/lib/local-storage';
import type memoryStorage from 'vault/lib/memory-storage';
import type { ApiResponse, WrapInfo } from 'vault/api';
import type Transition from '@ember/routing/transition';
export interface ControlGroupErrorLog {
type: string;
content: string;
href: string;
token: string;
accessor: string;
creation_path: string;
}
export default class ControlGroupService extends Service {
tokenToUnwrap: WrapInfo | null;
storage(): localStorage | memoryStorage;
keyFromAccessor(accessor: string): string | null;
storeControlGroupToken(info: WrapInfo): void;
deleteControlGroupToken(accessor: string): void;
deleteTokens(): void;
wrapInfoForAccessor(accessor: string): string | null;
markTokenForUnwrap(accessor: string): void;
unmarkTokenForUnwrap(): void;
tokenForUrl(url: string): { token: string; accessor: string; creationTime: string } | null;
checkForControlGroup(
callbackArgs: unknown,
response: ApiResponse,
wasWrapTTLRequested: boolean
): Promise<unknown>;
saveTokenFromError(error: WrapInfo): void;
logFromError(error: WrapInfo): ControlGroupErrorLog;
}

View File

@ -15,6 +15,7 @@ export default class NamespaceService extends Service {
inRootNamespace: boolean;
currentNamespace: string;
relativeNamespace: string;
path: string;
setNamespace: () => void;
findNamespacesForUser: () => void;
reset: () => void;