@spotinst/spinnaker-deck
Version:
Spinnaker-Deck service, forked with support to Spotinst
281 lines (243 loc) • 10.3 kB
text/typescript
import { isNil } from 'lodash';
import { $http } from 'ngimport';
import { AuthenticationInitializer } from '../authentication/AuthenticationInitializer';
import { ICache } from '../cache/deckCacheFactory';
import { SETTINGS } from '../config/settings';
type IPrimitive = string | boolean | number;
type IParams = Record<string, IPrimitive | IPrimitive[]>;
/**
* A Builder API for making requests to Gate backend service
*/
export interface IRequestBuilder {
/**
* Appends one or more path segments to the URL, separated by slashes.
* Each path segment is uri encoded.
*/
path(...pathSegments: IPrimitive[]): this;
/** Adds query parameters to the URL */
query(queryParams: IParams): this;
/** Enables or disables caching of the response */
useCache(useCache?: boolean): this;
/** issues a GET request */
get<T = any>(): PromiseLike<T>;
/** issues a POST request */
post<T = any, P = any>(data?: P): PromiseLike<T>;
/** issues a PUT request */
put<T = any, P = any>(data?: P): PromiseLike<T>;
/** issues a PATCH request */
patch<T = any, P = any>(data?: P): PromiseLike<T>;
/** issues a DELETE request */
delete<T = any, P = any>(data?: P): PromiseLike<T>;
}
/**
* Internal interface to encapsulate a request
* Passed to the IHttpClientImplementation
*/
interface IRequestBuilderConfig {
url: string;
timeout?: number;
headers?: { [headerName: string]: string };
/** @deprecated used for AngularJS backwards compat */
data?: any;
params?: object;
cache?: boolean;
}
/**
* The old API interface
*/
export interface IDeprecatedRequestBuilder extends IRequestBuilder {
useCache(): this;
useCache(useCache: boolean): this;
useCache(useCache: ICache): this;
withParams(queryParams: IParams): this;
/** @deprecated do not use this config object */
config: IRequestBuilderConfig;
/** @deprecated use SETTINGS.gateUrl */
baseUrl: string;
/** @deprecated use path() instead (this is a passthrough to path) */
one(...urls: string[]): this;
/** @deprecated use one() instead (this is a passthrough to one) */
all(...urls: string[]): this;
/** @deprecated use put(data) or post(data) instead */
data(data: any): this;
// Add overload with params
get<T = any>(params?: IParams): PromiseLike<T>;
/** @deprecated use delete() instead (this is a passthrough to delete) */
remove(params?: IParams): PromiseLike<any>;
/** @deprecated use get() instead (this is a passthrough to get) */
getList<T = any>(params?: IParams): PromiseLike<T>;
}
/**
* An interface to support pluggable http clients
* In the future, we should have a TestingHttpClient and a FetchHttpClient (or whatever http client we go with)
*/
export interface IHttpClientImplementation {
get<T = any>(config: IRequestBuilderConfig): PromiseLike<T>;
post<T = any>(config: IRequestBuilderConfig): PromiseLike<T>;
put<T = any>(config: IRequestBuilderConfig): PromiseLike<T>;
patch<T = any>(config: IRequestBuilderConfig): PromiseLike<T>;
delete<T = any>(config: IRequestBuilderConfig): PromiseLike<T>;
}
export class InvalidAPIResponse extends Error {
public data: { message: string };
constructor(message: string, public originalResult: any) {
super(message);
this.data = { message };
}
}
/**
* An HTTP client that uses the AngularJS $http service
* This client also handles non-data responses from Gate which is used to indicate the user is not authenticated
* TODO: Can the re-authentication logic be moved somewhere else?
*/
class AngularJSHttpClient implements IHttpClientImplementation {
delete = <T = any>(requestConfig: IRequestBuilderConfig) => this.request<T>('DELETE', requestConfig);
get = <T = any>(requestConfig: IRequestBuilderConfig) => this.request<T>('GET', requestConfig);
post = <T = any>(requestConfig: IRequestBuilderConfig) => this.request<T>('POST', requestConfig);
put = <T = any>(requestConfig: IRequestBuilderConfig) => this.request<T>('PUT', requestConfig);
patch = <T = any>(requestConfig: IRequestBuilderConfig) => this.request<T>('PATCH', requestConfig);
private request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
requestConfig: IRequestBuilderConfig,
): PromiseLike<T> {
return $http<T>({ ...requestConfig, method }).then((response) => {
const contentType = response.headers('content-type');
if (contentType) {
// e.g application/json, application/hal+json
const isJson = contentType.match(/application\/(.+\+)?json/);
// e.g. application/yaml, application/x-yaml; it's regex, let's not get too fancy
const isYaml = contentType.match(/application\/(.+-)?yaml/);
const isZeroLengthHtml = contentType.includes('text/html') && (response as any).data === '';
const isZeroLengthText = contentType.includes('text/plain') && (response as any).data === '';
if (!(isJson || isYaml || isZeroLengthHtml || isZeroLengthText)) {
AuthenticationInitializer.reauthenticateUser();
throw new InvalidAPIResponse(invalidContentMessage, response);
}
}
return response.data;
});
}
}
function joinPaths(...paths: IPrimitive[]) {
// coerce paths toString() in case somebody sends in a url object
// according to https://github.com/spinnaker/deck/pull/6927
return paths
.filter((path) => !isNil(path) && path !== '')
.map((path) => path.toString())
.map((path) => path.replace(/^\/+/, '')) // strip leading slashes
.map((path) => path.replace(/\/+$/, '')) // strip trailing slashes
.join('/');
}
/** The base request builder implementation */
export class RequestBuilder implements IRequestBuilder {
static defaultHttpClient: IHttpClientImplementation = new AngularJSHttpClient();
public constructor(
protected config: IRequestBuilderConfig = makeRequestBuilderConfig(),
protected _httpClient?: IHttpClientImplementation,
protected _baseUrl?: string,
) {}
// Factory function to create a child builder of the appropriate type
protected builder(newRequest: IRequestBuilderConfig): this {
return new RequestBuilder(newRequest, this.httpClient, this._baseUrl) as this;
}
protected get httpClient(): IHttpClientImplementation {
return this._httpClient ?? RequestBuilder.defaultHttpClient;
}
protected get baseUrl(): string {
return joinPaths(this._baseUrl ?? SETTINGS.gateUrl);
}
path(...paths: IPrimitive[]) {
const url = joinPaths(this.config.url, ...paths.map((path) => encodeURIComponent(path)));
return this.builder({ ...this.config, url });
}
// queryParams argument for backwards compat
get<T>(queryParams: object = {}) {
// Merge with existing params
const params = { ...this.config.params, ...queryParams };
const url = joinPaths(this.baseUrl, this.config.url);
return this.httpClient.get<T>({ ...this.config, url, params });
}
post<T>(postData?: any) {
// Check this.config.data for backwards compat
const data = postData ?? this.config.data;
const url = joinPaths(this.baseUrl, this.config.url);
return this.httpClient.post<T>({ ...this.config, url, data });
}
put<T>(putData?: any) {
// Check this.config.data for backwards compat
const data = putData ?? this.config.data;
const url = joinPaths(this.baseUrl, this.config.url);
return this.httpClient.put<T>({ ...this.config, url, data });
}
patch<T>(putData?: any) {
// Check this.config.data for backwards compat
const data = putData ?? this.config.data;
const url = joinPaths(this.baseUrl, this.config.url);
return this.httpClient.patch<T>({ ...this.config, url, data });
}
delete<T>(deleteData?: any) {
const data = deleteData ?? this.config.data;
const url = joinPaths(this.baseUrl, this.config.url);
return this.httpClient.delete<T>({ ...this.config, url, data });
}
useCache(cache = true) {
return this.builder({ ...this.config, cache: cache as boolean });
}
query(queryParams: IParams) {
const params = { ...this.config.params, ...queryParams };
return this.builder({ ...this.config, params });
}
}
/**
* This class extends RequestBuilder and re-implements the deprecated API for backwards compat
* @deprecated
*/
export class DeprecatedRequestBuilder extends RequestBuilder implements IDeprecatedRequestBuilder {
protected builder = (newRequest: IRequestBuilderConfig): this => {
return new DeprecatedRequestBuilder(newRequest, this._httpClient, this._baseUrl) as this;
};
public config: IRequestBuilderConfig;
//////// deprecated apis
get baseUrl() {
return super.baseUrl;
}
getList = this.get.bind(this);
one = this.path.bind(this);
all = this.path.bind(this);
remove = this.delete.bind(this).bind(this);
data = (data: any) => this.builder({ ...this.config, data });
withParams = this.query.bind(this);
useCache = (cache: boolean | ICache = true) => this.builder({ ...this.config, cache: cache as boolean });
}
class DeprecatedRequestBuilderRoot extends DeprecatedRequestBuilder {
// Do not encode paths for the root API.one() call
one = (...paths: string[]) => {
const url = joinPaths(this.config.url, ...paths);
return this.builder({ ...this.config, url });
};
all = (...paths: string[]) => {
const url = joinPaths(this.config.url, ...paths);
return this.builder({ ...this.config, url });
};
}
export const invalidContentMessage = 'API response was neither JSON nor zero-length html or text';
export function makeRequestBuilderConfig(pathPrefix?: string): IRequestBuilderConfig {
return {
url: joinPaths(pathPrefix),
cache: false,
data: undefined,
params: {},
timeout: (SETTINGS.pollSchedule || 30000) * 2 + 5000,
headers: { 'X-RateLimit-App': 'deck' },
};
}
/** @deprecated use REST('/path/to/gate/endpoint') */
export const API: IDeprecatedRequestBuilder = new DeprecatedRequestBuilderRoot(makeRequestBuilderConfig());
/**
* A REST client used to access Gate endpoints
* @param staticPathPrefix a static string, i.e., '/proxies/foo/endpoint' --
* avoid dynamic strings like `/entity/${id}`, use .path('entity', id) instead
*/
export const REST = (staticPathPrefix?: string): IRequestBuilder => {
return new RequestBuilder(makeRequestBuilderConfig(staticPathPrefix));
};