const ParserRegistry = require('../parser/parser-registry');
const ConsoleLogger = require('../logging/console-logger');
const HttpClient = require('../http/http-client');
const RepositoryClientConfig =
require('../repository/repository-client-config');
const Iterable = require('../util/iterable');
const HttpResponse = require('../http/http-response');
const LoggingUtils = require('../logging/logging-utils');
const AuthenticationService = require('../service/authentication-service');
/**
* Set of HTTP status codes for which requests could be re-attempted.
*
* @type {number[]}
*/
const RETRIABLE_STATUSES = [
503 // Server busy
];
/**
* Implementation of the RDF repository operations.
*
* The repository will construct a list of HTTP clients for each supplied
* repository endpoint in the configuration. These clients will be used as
* fallback strategy.
*
* @abstract
* @class
* @author Mihail Radkov
* @author Svilen Velikov
*/
class BaseRepositoryClient {
/**
* Constructs a repository client with the provided configuration.
*
* @param {RepositoryClientConfig} repositoryClientConfig
*/
constructor(repositoryClientConfig) {
BaseRepositoryClient.validateConfig(repositoryClientConfig);
this.repositoryClientConfig = repositoryClientConfig;
this.httpClient = new HttpClient(
this.repositoryClientConfig.getEndpoint());
this.authenticationService = new AuthenticationService(this.httpClient);
this.initParsers();
this.initLogger();
this.initHttpClients();
}
/**
* Initializes the parser registry with default supported parsers.
* @private
*/
initParsers() {
this.parserRegistry = new ParserRegistry();
}
/**
* Initializes a logger instance.
* @private
*/
initLogger() {
this.logger = this.getLogger();
}
/**
* Gets a logger instance.
*
* @return {Logger} the logger instance
*/
getLogger() {
return new ConsoleLogger();
}
/**
* Initializes http clients depending on the provided endpoints.
* @private
*/
initHttpClients() {
const config = this.repositoryClientConfig;
// Constructs a http client for each endpoint
this.httpClients = config.getEndpoints().map((endpoint) => {
return new HttpClient(endpoint)
.setDefaultHeaders(config.getHeaders())
.setDefaultReadTimeout(config.getReadTimeout())
.setDefaultWriteTimeout(config.getWriteTimeout());
});
}
/**
* Register provided parser in the internal parser registry.
*
* @param {ContentParser} parser implementation wrapper.
*/
registerParser(parser) {
this.parserRegistry.register(parser);
}
/**
* Parses provided content with registered parser if there is one. Otherwise
* returns the content untouched. If <code>contentType</code> is provided it
* should be an instance of {@link RDFMimeType} enum and is used as a key
* for selecting appropriate parser from the parsers registry.
* Parsing is done synchronously!
*
* @protected
* @param {string} content
* @param {string} responseType
* @param {Object} [parserConfig] optional parser configuration
* @return {(string|Term|Term[])}
*/
parse(content, responseType, parserConfig = {}) {
if (!this.parserRegistry.get(responseType)) {
return content;
}
const parser = this.parserRegistry.get(responseType);
const startTime = Date.now();
const parsed = parser.parse(content, parserConfig);
const elapsedTime = Date.now() - startTime;
this.logger.debug({elapsedTime, responseType}, 'Parsed content');
return parsed;
}
/**
* Executor for http requests. It passes the provided HTTP request builder
* to a HTTP client for executing requests.
*
* If the request was unsuccessful it will be retried with another endpoint
* HTTP client in case the request's status is one of
* {@link RETRIABLE_STATUSES} or if the host is currently unreachable.
*
* If all of the endpoints are unsuccessful then the execution will fail
* with promise rejection.
*
* @protected
* @param {HttpRequestBuilder} requestBuilder the http request data to be
* passed to a http client
* @return {Promise<HttpResponse|Error>} a promise which resolves to response
* wrapper or rejects with error if thrown during execution.
*/
execute(requestBuilder) {
try {
const startTime = Date.now();
const httpClients = new Iterable(this.httpClients);
return this.retryExecution(httpClients, requestBuilder)
.then((executionResponse) => {
executionResponse.setElapsedTime(Date.now() - startTime);
return executionResponse;
});
} catch (err) {
return Promise.reject(err);
}
}
/**
* Retries HTTP request execution until successful or until no more clients
* are left if the status is allowed for retry.
*
* @private
* @param {Iterable} httpClients iterable collection of http clients
* @param {HttpRequestBuilder} requestBuilder the http request data to be
* passed to a http client
* @param {HttpClient} [currentHttpClient] current client is passed only if
* the retry is invoked directly in result of some error handler which may try
* to re-execute the request to the same server.
* @return {Promise<HttpResponse|Error>} a promise which resolves to response
* wrapper or rejects with error if thrown during execution.
*/
retryExecution(httpClients, requestBuilder, currentHttpClient) {
const httpClient = currentHttpClient || httpClients.next();
return this.authenticationService
.login(this.repositoryClientConfig, this.user)
.then((user) => {
this.setLoggedUser(user);
this.decorateRequestConfig(requestBuilder);
return httpClient.request(requestBuilder).then((response) => {
return new HttpResponse(response, httpClient);
}).catch((error) => {
const status = error?.response ? error.response.status : null;
const isUnauthorized = status && status === 401;
if (isUnauthorized && this.repositoryClientConfig.getKeepAlive()) {
this.user.clearToken();
// re-execute will try to re-login the user and update it
return this.retryExecution(httpClients, requestBuilder, httpClient);
}
const canRetry = BaseRepositoryClient.canRetryExecution(error);
const hasNext = httpClients.hasNext();
const loggerPayload = {repositoryUrl: httpClient.getBaseURL()};
// Try the next repo http client (if any)
if (canRetry && hasNext) {
this.logger.warn(loggerPayload, 'Retrying execution');
return this.retryExecution(httpClients, requestBuilder);
}
if (!canRetry) {
this.logger.error(loggerPayload, 'Cannot retry execution');
} else {
this.logger.error(loggerPayload, 'No more retries');
}
// Not retriable
return Promise.reject(error);
});
});
}
/**
* Allow request config to be altered before sending.
*
* @private
* @param {HttpRequestBuilder} requestBuilder
*/
decorateRequestConfig(requestBuilder) {
const token = this.authenticationService
.getAuthenticationToken(this.getLoggedUser());
if (token) {
requestBuilder.addAuthorizationHeader(token);
}
}
/**
* Creates an object from the provided HTTP response that is suitable for
* structured logging.
*
* Any additional key-value entries from <code>params</code> will be assigned
* in the created payload object.
*
* @protected
* @param {HttpResponse} response the HTTP response.
* Used to get the execution time and the base URL
* @param {object} [params] additional parameters to be appended
* @return {object} the constructed payload object for logging
*/
getLogPayload(response, params) {
return LoggingUtils.getLogPayload(response, params);
}
/**
* Checks if the request that produced the provided error can be re-attempted.
*
* @private
* @param {Object} error the error to check
* @return {boolean} <code>true</code> if it can be attempted again or
* <code>false</code> otherwise
*/
static canRetryExecution(error) {
// Not an error from the HTTP client, do not retry
if (!error) {
return false;
}
if (!error.request) {
return false;
}
// The current client couldn't get a response from the server, try again
if (!error.response) {
return true;
}
const status = error.response.status;
return RETRIABLE_STATUSES.indexOf(status) > -1;
}
/**
* Validates the provided repository client configuration.
*
* @private
* @param {RepositoryClientConfig} repositoryClientConfig the config to check
* @throws {Error} if the configuration is not an instance of
* {@link RepositoryClientConfig} or there are no configured endpoints
*/
static validateConfig(repositoryClientConfig) {
if (!(repositoryClientConfig instanceof RepositoryClientConfig)) {
throw new Error('Cannot instantiate repository with unsupported config ' +
'type!');
}
const endpoints = repositoryClientConfig.getEndpoints();
if (!endpoints || endpoints.length === 0) {
throw new Error('Cannot instantiate a repository without repository ' +
'endpoint configuration! At least one endpoint must be provided.');
}
}
/**
* Logged user getter.
* @return {User} user
*/
getLoggedUser() {
return this.user;
}
/**
* User setter
* @param {User} user
*
* @return {BaseRepositoryClient}
*/
setLoggedUser(user) {
this.user = user;
return this;
}
}
module.exports = BaseRepositoryClient;