UNPKG

@lagoshny/ngx-hateoas-client

Version:

This client used to develop `Angular 12+` applications working with RESTfulll server API with HAL/JSON response type (supports server implementation by Spring HATEOAS)

1,303 lines (1,287 loc) 128 kB
import * as i0 from '@angular/core'; import { Injectable, NgModule } from '@angular/core'; import { isEmpty, capitalize, camelCase, isObject, isString, isNil, isFunction, isPlainObject, isArray, toString, includes, isBoolean, last, split, result, isNumber, isNull, isUndefined } from 'lodash-es'; import * as i1 from '@angular/common/http'; import { HttpParams, HttpHeaders } from '@angular/common/http'; import { UriTemplate } from 'uri-templates-es'; import { isMatch, parse } from 'date-fns'; import { of, throwError } from 'rxjs'; import { tap, map, catchError } from 'rxjs/operators'; /** * Holds dependency injector to allow use ше in internal the lib classes. */ /* tslint:disable:variable-name */ class DependencyInjector { static { this._injector = null; } static get(type) { if (this._injector) { return this._injector.get(type); } throw new Error('You need initialize Injector'); } static set injector(value) { this._injector = value; } } const DEFAULT_ROUTE_NAME = 'defaultRoute'; const RESOURCE_NAME_PROP = '__resourceName__'; const RESOURCE_OPTIONS_PROP = '__options__'; var Include; (function (Include) { /** * Allows to include null values to request body */ Include["NULL_VALUES"] = "NULL_VALUES"; /** * Not replace related resources with their self links, instead pass them as JSON objects. */ Include["REL_RESOURCES_AS_OBJECTS"] = "REL_RESOURCES_AS_OBJECTS"; })(Include || (Include = {})); /** * Supported http methods for custom query. */ var HttpMethod; (function (HttpMethod) { HttpMethod["GET"] = "GET"; HttpMethod["POST"] = "POST"; HttpMethod["PUT"] = "PUT"; HttpMethod["PATCH"] = "PATCH"; })(HttpMethod || (HttpMethod = {})); /** * Additional cache modes. */ var CacheMode; (function (CacheMode) { /** * Default mode. * When cache enable, then all HTTP GET methods will use cache. Except methods where explicitly passed {useCache : false}. */ CacheMode["ALWAYS"] = "ALWAYS"; /** * This is opposite option for ALWAYS mode. * When cache enable, that mode will NOT use cache by default on all HTTP GET methods. * Except methods where explicitly passed {useCache : true}. */ CacheMode["ON_DEMAND"] = "ON_DEMAND"; })(CacheMode || (CacheMode = {})); /** * Contains all configuration lib params. */ // tslint:disable:no-string-literal class LibConfig { static { this.DEFAULT_CONFIG = { http: { [DEFAULT_ROUTE_NAME]: { rootUrl: 'http://localhost:8080/api/v1' }, }, logs: { verboseLogs: false }, cache: { enabled: true, mode: CacheMode.ALWAYS, lifeTime: 5 * 60 * 1000 }, useTypes: { resources: [] }, pagination: { defaultPage: { size: 20, page: 0 } }, halFormat: { json: { convertEmptyObjectToNull: true }, collections: { embeddedOptional: false } }, isProduction: false }; } static { this.config = LibConfig.DEFAULT_CONFIG; } static setConfig(hateoasConfiguration) { LibConfig.config = LibConfig.mergeConfigs(hateoasConfiguration); } static getConfig() { return LibConfig.config; } static mergeConfigs(config) { return { ...LibConfig.DEFAULT_CONFIG, ...config, halFormat: { json: { ...LibConfig.DEFAULT_CONFIG.halFormat.json, ...config?.halFormat?.json }, collections: { ...LibConfig.DEFAULT_CONFIG.halFormat.collections, ...config?.halFormat?.collections } }, pagination: { defaultPage: { ...LibConfig.DEFAULT_CONFIG.pagination.defaultPage, ...config?.pagination?.defaultPage } }, cache: { ...LibConfig.DEFAULT_CONFIG.cache, ...config?.cache }, }; } } /* tslint:disable:variable-name no-console */ class ConsoleLogger { static info(message, ...optionalParams) { if (!this.isLoggingEnabled()) { return; } console.info(message, ...optionalParams); } static warn(message, ...optionalParams) { if (!this.isLoggingEnabled()) { return; } console.warn(message, ...optionalParams); } static error(message, ...optionalParams) { if (!this.isLoggingEnabled()) { return; } console.error(message, ...optionalParams); } /** * Log info messages in pretty format. * * @param message log message * @param params additional params for verbose log */ static prettyInfo(message, params) { if (!this.isLoggingEnabled()) { return; } let msg = `%c${message}\n`; const color = [ 'color: #201AB3;' ]; if (!isEmpty(params)) { for (const [key, value] of Object.entries(params)) { if (key.toLowerCase() === 'result') { msg += `%c${capitalize(key)}: %c${value}\n`; color.push('color: #3AA6D0;', 'color: #00BA45;'); } else { msg += `%c${camelCase(key)}: %c${value}\n`; color.push('color: #3AA6D0;', 'color: default;'); } } } ConsoleLogger.info(msg, ...color); } static objectPrettyInfo(message, params) { if (!this.isLoggingEnabled()) { return; } const msg = `%c${message}\n`; const color = [ 'color: #201AB3;' ]; ConsoleLogger.info(msg, ...color, params); } /** * Log resource info messages in pretty format. * * @param message log message * @param resourceName resource name * @param params additional params for verbose log */ static resourcePrettyInfo(resourceName, message, params) { if (!this.isLoggingEnabled()) { return; } let msg = `%c${resourceName} %c${message}\n`; const color = [ 'color: #DA005C;', 'color: #201AB3;' ]; if (!isEmpty(params)) { for (const [key, value] of Object.entries(params)) { if (key.toLowerCase() === 'result') { msg += `%c${capitalize(key)}: %c${value}\n`; color.push('color: #3AA6D0;', 'color: #00BA45;'); } else { msg += `%c${camelCase(key)}: %c${value}\n`; color.push('color: #3AA6D0;', 'color: default;'); } } } ConsoleLogger.info(msg, ...color); } /** * Log error messages in pretty format. * * @param message log message * @param params additional params for verbose log */ static prettyError(message, params) { if (!this.isLoggingEnabled()) { return; } let msg = `%c${message}\n`; const color = [ 'color: #df004f;' ]; if (!isEmpty(params)) { for (const [key, value] of Object.entries(params)) { if (key.toLowerCase() === 'error') { msg += `%c${capitalize(key)}: %c${value}\n`; color.push('color: #df004f;', 'color: #ff0000;'); } else { msg += `%c${capitalize(key)}: %c${value}\n`; color.push('color: #3AA6D0;', 'color: #000;'); } } } ConsoleLogger.error(msg, ...color); } /** * Log warn messages in pretty format. * * @param message log message * @param params additional params for verbose log */ static prettyWarn(message, params) { if (!this.isLoggingEnabled()) { return; } let msg = `%c${message}\n`; const color = [ 'color: #ffbe00;' ]; if (!isEmpty(params)) { for (const [key, value] of Object.entries(params)) { msg += `%c${capitalize(key)}: %c${value}\n`; color.push('color: #3AA6D0;', 'color: #000;'); } } ConsoleLogger.warn(msg, ...color); } static isLoggingEnabled() { return (!LibConfig.getConfig().isProduction && LibConfig.getConfig().logs.verboseLogs) || LibConfig.getConfig().logs.verboseLogs; } } function isEmbeddedResource(object) { // Embedded resource doesn't have self link in _links object return !isPagedResourceCollection(object) && !isResourceCollection(object) && isResourceObject(object) && !('self' in object._links); } function isResource(object) { return !isPagedResourceCollection(object) && !isResourceCollection(object) && isResourceObject(object) && ('self' in object._links); } function isResourceCollection(object) { const baseCondition = isObject(object) && ('_links' in object) && !('page' in object); if (!baseCondition) { return false; } if (LibConfig.getConfig().halFormat.collections.embeddedOptional) { return baseCondition && (Object.keys(object).length === 1 || '_embedded' in object && Object.keys(object).length === 2); } else { return baseCondition && '_embedded' in object && Object.keys(object).length === 2; } } function isPagedResourceCollection(object) { const baseCondition = isObject(object) && ('_links' in object) && ('page' in object); if (!baseCondition) { return false; } if (LibConfig.getConfig().halFormat.collections.embeddedOptional) { return baseCondition && (Object.keys(object).length === 2 || '_embedded' in object && Object.keys(object).length === 3); } else { return baseCondition && '_embedded' in object && Object.keys(object).length === 3; } } /** * Check that passed object has links property. * * @param object which need to check links property */ function isResourceObject(object) { return isObject(object) && ('_links' in object); } /** * Defining resource type bypassed object. * * @param object that presumably is one of resource type */ function getResourceType(object) { if (isEmbeddedResource(object)) { return 'EmbeddedResource'; } else if (isResource(object)) { return 'Resource'; } else if (isResourceCollection(object)) { return 'ResourceCollection'; } else if (isPagedResourceCollection(object)) { return 'PagedResourceCollection'; } else { return 'Unknown'; } } var Stage; (function (Stage) { Stage["BEGIN"] = "BEGIN"; Stage["PREPARE_URL"] = "PREPARE_URL"; Stage["CHECK_PARAMS"] = "CHECK_PARAMS"; Stage["PREPARE_PARAMS"] = "PREPARE_PARAMS"; Stage["INIT_RESOURCE"] = "INIT_RESOURCE"; Stage["RESOLVE_VALUES"] = "RESOLVE_VALUES"; Stage["CACHE_PUT"] = "CACHE_PUT"; Stage["CACHE_GET"] = "CACHE_GET"; Stage["CACHE_EVICT"] = "CACHE_EVICT"; Stage["CACHE_EVICT_ALL"] = "CACHE_EVICT_ALL"; Stage["HTTP_REQUEST"] = "HTTP_REQUEST"; Stage["HTTP_RESPONSE"] = "HTTP_RESPONSE"; Stage["END"] = "END"; })(Stage || (Stage = {})); /** * Simplify logger calls. */ /* tslint:disable:no-string-literal */ class StageLogger { static resourceBeginLog(resource, method, params) { if (!LibConfig.getConfig().logs.verboseLogs && !LibConfig.getConfig().isProduction) { return; } const paramToLog = this.prepareParams(params); let resourceName; if (isString(resource)) { resourceName = resource; } else if (!isNil(resource)) { resourceName = RESOURCE_NAME_PROP in resource ? resource[RESOURCE_NAME_PROP] : 'EmbeddedResource'; } else { resourceName = 'NOT_DEFINED_RESOURCE_NAME'; } ConsoleLogger.resourcePrettyInfo(`${capitalize(resourceName)} ${method}`, `STAGE ${Stage.BEGIN}`, paramToLog); } static resourceEndLog(resource, method, params) { if (!LibConfig.getConfig().logs.verboseLogs && !LibConfig.getConfig().isProduction) { return; } const paramToLog = this.prepareParams(params); let resourceName; if (isString(resource)) { resourceName = resource; } else { resourceName = RESOURCE_NAME_PROP in resource ? resource[RESOURCE_NAME_PROP] : 'EmbeddedResource'; } ConsoleLogger.resourcePrettyInfo(`${capitalize(resourceName)} ${method}`, `STAGE ${Stage.END}`, paramToLog); } static stageLog(stage, params) { if (!LibConfig.getConfig().logs.verboseLogs && !LibConfig.getConfig().isProduction) { return; } const paramToLog = this.prepareParams(params); ConsoleLogger.prettyInfo(`STAGE ${stage}`, paramToLog); } static stageErrorLog(stage, params) { if (LibConfig.getConfig().isProduction) { return; } const paramToLog = this.prepareParams(params); ConsoleLogger.prettyError(`STAGE ${stage}`, paramToLog); } static stageWarnLog(stage, params) { if (LibConfig.getConfig().isProduction) { return; } const paramToLog = this.prepareParams(params); ConsoleLogger.prettyWarn(`STAGE ${stage}`, paramToLog); } static prepareParams(params) { const paramToLog = {}; if (isEmpty(params)) { return paramToLog; } for (const [key, value] of Object.entries(params)) { if (!params.hasOwnProperty(key)) { continue; } if (isObject(value)) { paramToLog[key] = JSON.stringify(value, null, 2); } else { paramToLog[key] = value; } } return paramToLog; } } class ValidationUtils { /** * Checks that passed object with params has all valid params. * Params should not has null, undefined, empty object, empty string values. * * @param params object with params to check * @throws error if any params are not defined */ static validateInputParams(params) { if (isNil(params)) { const errMsg = 'Passed params object is not valid'; StageLogger.stageErrorLog(Stage.CHECK_PARAMS, { error: errMsg }); throw new Error(errMsg); } const notValidParams = []; for (const [key, value] of Object.entries(params)) { // tslint:disable-next-line:no-string-literal if (isFunction(value) && isFunction(value.constructor) && !value[RESOURCE_NAME_PROP]) { throw new Error(`Resource '${value.name}' has not 'resourceName' value. Set it with @HateoasResource decorator on '${value.name}' class.`); } if (isNil(value) || (isString(value) && !value) || (isPlainObject(value) && isEmpty(value)) || (isArray(value) && value.length === 0)) { let formattedValue = value; if (isObject(value)) { formattedValue = JSON.stringify(value, null, 2); } notValidParams.push(`'${key} = ${formattedValue}'`); } } if (notValidParams.length > 0) { const errMsg = `Passed param(s) ${notValidParams.join(', ')} ${notValidParams.length > 1 ? 'are' : 'is'} not valid`; StageLogger.stageErrorLog(Stage.CHECK_PARAMS, { error: errMsg }); throw new Error(errMsg); } } } class UrlUtils { /** * Convert passed params to the {@link HttpParams}. * * @param options which need to convert * @param httpParams (optional) if passed then will be applied to this one, otherwise created a new one */ static convertToHttpParams(options, httpParams) { let resultParams = httpParams ? httpParams : new HttpParams(); if (isEmpty(options) || isNil(options)) { return resultParams; } UrlUtils.checkDuplicateParams(options); if (isObject(options.params) && !isEmpty(options.params)) { for (const [key, value] of Object.entries(options.params)) { if (options.params.hasOwnProperty(key)) { if (isResource(value)) { // Append resource as resource link resultParams = resultParams.append(key, value.getSelfLinkHref()); } else if (isArray(options.params[key])) { // Append arrays params as repeated key with each value from array options.params[key].forEach((item) => { resultParams = resultParams.append(`${key.toString()}`, item); }); } else { // Else append simple param as is resultParams = resultParams.append(key, value.toString()); } } } } if (!isEmpty(options.pageParams)) { resultParams = resultParams.append('page', toString(options.pageParams.page)); resultParams = resultParams.append('size', toString(options.pageParams.size)); } if (!isEmpty(options.sort)) { resultParams = UrlUtils.generateSortParams(options.sort, resultParams); } return resultParams; } /** * Convert ngx-hateoas-client option to Angular HttpClient. * @param options ngx-hateoas-client options */ static convertToHttpOptions(options) { if (isEmpty(options) || isNil(options)) { return {}; } return { params: UrlUtils.convertToHttpParams(options), headers: options.headers, observe: options.observe, reportProgress: options.reportProgress, withCredentials: options.withCredentials, }; } /** * Generate link url. * If proxyUrl is not empty then relation url will be use proxy. * * @param relationLink resource link to which need to generate the url * @param options (optional) additional options that should be applied to the request * @throws error when required params are not valid */ static generateLinkUrl(relationLink, options) { ValidationUtils.validateInputParams({ relationLink, linkUrl: relationLink?.href }); let url; if (options && !isEmpty(options)) { url = relationLink.templated ? UrlUtils.fillTemplateParams(relationLink.href, options) : relationLink.href; } else { url = relationLink.templated ? UrlUtils.removeTemplateParams(relationLink.href) : relationLink.href; } const route = UrlUtils.guessResourceRoute(url); if (route.proxyUrl) { return url.replace(route.rootUrl, route.proxyUrl); } return url; } /** * Return server api url based on proxy url when it is not empty or root url otherwise. * * @param routeName resource route name that configured in {@link MultipleResourceRoutes}. */ static getApiUrl(routeName) { const route = UrlUtils.getRouteByName(routeName); if (route.proxyUrl) { return route.proxyUrl; } else { return route.rootUrl; } } /** * Try to determine resource route by passed resource url. * @param url resource url */ static guessResourceRoute(url) { let resourceRoute; for (const [routeName] of Object.entries(UrlUtils.getRoutes())) { const route = UrlUtils.getRouteByName(routeName); const lowerCaseUrl = url.toLowerCase(); if (lowerCaseUrl.includes(route.rootUrl.toLowerCase()) || (!isEmpty(route.proxyUrl) && lowerCaseUrl.includes(route.proxyUrl.toLowerCase()))) { resourceRoute = route; break; } } if (isEmpty(resourceRoute)) { throw new Error(`Failed to determine resource route by url: ${url}`); } return resourceRoute; } /** * Generate url use base and the resource name. * * @param baseUrl will be as first part as a result url * @param resourceName added to the base url through slash * @param query (optional) if passed then adds to end of the url * @throws error when required params are not valid */ static generateResourceUrl(baseUrl, resourceName, query) { ValidationUtils.validateInputParams({ baseUrl, resourceName }); let url = baseUrl; if (!url.endsWith('/')) { url = url.concat('/'); } return url.concat(resourceName).concat(query ? `${query.startsWith('/') ? query : '/' + query}` : ''); } /** * Retrieve a resource name from resource url. * * @param url resource url */ static getResourceNameFromUrl(url) { ValidationUtils.validateInputParams({ url }); const lowerCaseUrl = url.toLowerCase(); const resourceRoute = UrlUtils.guessResourceRoute(url); let baseUrl; if (lowerCaseUrl.includes(resourceRoute.rootUrl)) { baseUrl = resourceRoute.rootUrl; } else if (!isEmpty(resourceRoute.proxyUrl) && lowerCaseUrl.includes(resourceRoute.proxyUrl)) { baseUrl = resourceRoute.proxyUrl; } else { throw new Error(`Failed to determine resource name from url: ${url}, found resource route ${JSON.stringify(resourceRoute)}`); } if (!baseUrl.endsWith('/')) { baseUrl = baseUrl.concat('/'); } return url.toLowerCase().replace(`${baseUrl}`, '').split('/')[0]; } /** * Clear url from template params. * * @param url to be cleaned * @throws error when required params are not valid */ static removeTemplateParams(url) { ValidationUtils.validateInputParams({ url }); return UrlUtils.fillTemplateParams(url, {}); } /** * Clear all url params. * * @param url to clear params * @throws error when required params are not valid */ static clearUrlParams(url) { ValidationUtils.validateInputParams({ url }); const srcUrl = new URL(url); return srcUrl.origin + srcUrl.pathname; } /** * Fill url template params. * * @param url to be filled * @param options contains params to apply to result url, if empty then template params will be cleared * @throws error when required params are not valid */ static fillTemplateParams(url, options) { ValidationUtils.validateInputParams({ url }); UrlUtils.checkDuplicateParams(options); /* Sort params will be applied through Http request params not with template params because often sort params is not present in template params then sort params need to put through Http request params. But when sort params present in template params we need to avoid duplication sort params from Http request params and template params therefore we need to add it in one place. */ const paramsWithoutSortParam = { ...options, ...options?.params, ...options?.pageParams, }; return new UriTemplate(url).fill(isNil(paramsWithoutSortParam) ? {} : paramsWithoutSortParam); } static fillDefaultPageDataIfNoPresent(options) { const pagedOptions = !isEmpty(options) ? options : {}; if (isEmpty(pagedOptions.pageParams)) { pagedOptions.pageParams = LibConfig.getConfig().pagination.defaultPage; } else if (!pagedOptions.pageParams.size) { pagedOptions.pageParams.size = LibConfig.getConfig().pagination.defaultPage.size; } else if (!pagedOptions.pageParams.page) { pagedOptions.pageParams.page = LibConfig.getConfig().pagination.defaultPage.page; } return pagedOptions; } static generateSortParams(sort, httpParams) { let resultParams = httpParams ? httpParams : new HttpParams(); if (!isEmpty(sort)) { for (const [sortPath, sortOrder] of Object.entries(sort)) { resultParams = resultParams.append('sort', `${sortPath},${sortOrder}`); } } return resultParams; } static checkDuplicateParams(options) { if (isEmpty(options) || isEmpty(options.params)) { return; } if ('page' in options.params || 'size' in options.params) { throw Error('Please, pass page params in page object key, not with params object!'); } } static getRouteByName(routeName) { const route = LibConfig.getConfig().http[routeName]; if (isEmpty(route)) { ConsoleLogger.error(`No Resource route found by name: '${routeName}'. Check you configuration. Read more about this ...`, { availableRoutes: UrlUtils.getRoutes() }); throw Error(`No Resource route found by name: '${routeName}'.`); } return route; } static getRoutes() { return LibConfig.getConfig().http; } } /* tslint:disable:no-string-literal */ class ResourceUtils { static { this.RESOURCE_NAME_TYPE_MAP = new Map(); } static { this.RESOURCE_NAME_PROJECTION_TYPE_MAP = new Map(); } static { this.RESOURCE_PROJECTION_REL_NAME_TYPE_MAP = new Map(); } static { this.EMBEDDED_RESOURCE_TYPE_MAP = new Map(); } static useResourceType(type) { this.resourceType = type; } static useResourceCollectionType(type) { this.resourceCollectionType = type; } static usePagedResourceCollectionType(type) { this.pagedResourceCollectionType = type; } static useEmbeddedResourceType(type) { this.embeddedResourceType = type; } static instantiateResource(payload, isProjection) { // @ts-ignore if (isEmpty(payload) || (!isObject(payload['_links']) || isEmpty(payload['_links']))) { ConsoleLogger.warn('Incorrect resource object! Returned \'null\' value, because it has not \'_links\' array. Check that server send right resource object.', { incorrectResource: payload }); return null; } return this.createResource(this.resolvePayloadProperties(payload, isProjection), isProjection); } static resolvePayloadProperties(payload, isProjection) { for (const key of Object.keys(payload)) { if (key === 'hibernateLazyInitializer') { delete payload[key]; continue; } if (key === '_links') { payload[key] = payload[key]; continue; } if (key === '_embedded' && isObject(payload[key])) { payload = { ...payload, ...this.resolvePayloadProperties(payload[key], isProjection) }; delete payload['_embedded']; continue; } if (LibConfig.getConfig()?.typesFormat?.date?.patterns && !isEmpty(LibConfig.getConfig().typesFormat.date.patterns)) { for (const pattern of LibConfig.getConfig().typesFormat.date.patterns) { if (isMatch(payload[key], pattern)) { const valueAsDate = parse(payload[key], pattern, new Date()); if (valueAsDate) { payload[key] = valueAsDate; break; } } } if (payload[key] instanceof Date) { continue; } } payload[key] = this.resolvePayloadType(key, payload[key], isProjection); } return payload; } static resolvePayloadType(key, payload, isProjection) { if (isNil(payload)) { return payload; } else if (isArray(payload)) { for (let i = 0; i < payload.length; i++) { payload[i] = this.resolvePayloadType(key, payload[i], isProjection); } } else if (isProjection && isPlainObject(payload)) { // Need to check resource projection relation props because some inner props can be objects that can be also resources payload = this.resolvePayloadProperties(this.createResourceProjectionRel(key, payload), isProjection); } else if (isEmbeddedResource(payload) || ResourceUtils.EMBEDDED_RESOURCE_TYPE_MAP.get(key)) { // Need to check embedded resource props because some inner props can be objects that can be also resources payload = this.resolvePayloadProperties(this.createEmbeddedResource(key, payload), isProjection); } else if (isResource(payload)) { // Need to check resource props because some inner props can be objects that can be also resources payload = this.resolvePayloadProperties(this.createResource(payload), isProjection); } return payload; } static createResource(payload, isProjection) { const resourceName = this.findResourceName(payload); let resourceClass; if (isProjection && !ResourceUtils.RESOURCE_NAME_PROJECTION_TYPE_MAP.get(resourceName)) { resourceClass = ResourceUtils.RESOURCE_NAME_TYPE_MAP.get(resourceName); ConsoleLogger.prettyWarn('Not found projection resource type when create resource projection: \'' + resourceName + '\' so used resource type: \'' + (resourceClass ? resourceClass?.name : ' default Resource') + '\'. \n\r' + 'It can be when you pass projection param as http request directly instead use projection type with @HateoasProjection.\n\r' + '\n\rSee more how to use @HateoasProjection here https://github.com/lagoshny/ngx-hateoas-client#resource-projection-support.'); } else { resourceClass = isProjection ? ResourceUtils.RESOURCE_NAME_PROJECTION_TYPE_MAP.get(resourceName) : ResourceUtils.RESOURCE_NAME_TYPE_MAP.get(resourceName); } if (resourceClass) { return Object.assign(new (resourceClass)(), payload); } else { ConsoleLogger.prettyWarn('Not found resource type when create resource: \'' + resourceName + '\' so used default Resource type, for this can be some reasons: \n\r' + '1) You did not pass resource property name as \'' + resourceName + '\' with @HateoasResource decorator. \n\r' + '2) You did not declare resource type in configuration "configuration.useTypes.resources". \n\r' + '\n\rSee more about declare resource types here: https://github.com/lagoshny/ngx-hateoas-client#usetypes-params..'); return Object.assign(new this.resourceType(), payload); } } static createResourceProjectionRel(relationName, payload) { const relationClass = ResourceUtils.RESOURCE_PROJECTION_REL_NAME_TYPE_MAP.get(relationName); if (relationClass) { return Object.assign(new (relationClass)(), payload); } else { ConsoleLogger.prettyWarn('Not found resource relation type when create relation: \'' + relationName + '\' so used default Resource type, for this can be some reasons: \n\r' + 'You did not pass relation type property with @ProjectionRel decorator on relation property \'' + relationName + '\'. \n\r' + '\n\rSee more how to use @ProjectionRel here https://github.com/lagoshny/ngx-hateoas-client#resource-projection-support.'); return Object.assign(new this.resourceType(), payload); } } static createEmbeddedResource(key, payload) { const resourceClass = ResourceUtils.EMBEDDED_RESOURCE_TYPE_MAP.get(key); if (resourceClass) { return Object.assign(new (resourceClass)(), payload); } else { ConsoleLogger.prettyWarn('Not found embedded resource type when create resource: \'' + key + '\' so used default EmbeddedResource type, for this can be some reasons:. \n\r' + '1) You did not pass embedded resource property name as \'' + key + '\' with @HateoasEmbeddedResource decorator. \n\r' + '2) You did not declare embedded resource type in configuration "configuration.useTypes.embeddedResources". \n\r' + '\n\r See more about declare resource types here: https://github.com/lagoshny/ngx-hateoas-client#usetypes-params.'); return Object.assign(new this.embeddedResourceType(), payload); } } static instantiateResourceCollection(payload, isProjection) { if (isEmpty(payload) || (!isObject(payload['_links']) || isEmpty(payload['_links'])) || (!LibConfig.getConfig().halFormat.collections.embeddedOptional && (!('_embedded' in payload) || !isObject(payload['_embedded']) || isEmpty(payload['_embedded'])))) { return null; } const result = new this.resourceCollectionType(); if ('_embedded' in payload && isObject(payload['_embedded']) && !isEmpty(payload['_embedded'])) { for (const resourceName of Object.keys(payload['_embedded'])) { payload['_embedded'][resourceName].forEach((resource) => { result.resources.push(this.instantiateResource(resource, isProjection)); }); } } result['_links'] = { ...payload['_links'] }; return result; } static instantiatePagedResourceCollection(payload, isProjection) { const resourceCollection = this.instantiateResourceCollection(payload, isProjection); if (resourceCollection == null) { return null; } let result; if (payload['page']) { result = new this.pagedResourceCollectionType(resourceCollection, payload); } else { result = new this.pagedResourceCollectionType(resourceCollection); } return result; } /** * Resolve request body relations. * If request body has {@link Resource} value then this value will be replaced by resource self link. * If request body has {@link ValuesOption} it will be applied to body values. * * @param requestBody that contains the body directly and optional body values option {@link ValuesOption} */ static resolveValues(requestBody) { if (isEmpty(requestBody) || isNil(requestBody.body) || (LibConfig.getConfig().halFormat.json.convertEmptyObjectToNull && !isArray(requestBody.body) && isObject(requestBody.body) && isEmpty(requestBody.body))) { StageLogger.stageLog(Stage.RESOLVE_VALUES, { result: 'body is empty return null' }); return null; } const body = requestBody.body; if (!isObject(body) || isArray(body)) { StageLogger.stageLog(Stage.RESOLVE_VALUES, { result: 'body is not object or array return as is' }); return body; } let includeOptions = requestBody?.valuesOption?.include; if (!isArray(includeOptions)) { includeOptions = [includeOptions]; } const result = {}; for (const key in body) { if (!body.hasOwnProperty(key)) { continue; } if (body[key] == null && includes(includeOptions, Include.NULL_VALUES)) { result[key] = null; continue; } if (isNil(body[key])) { continue; } if (isArray(body[key])) { const array = body[key]; result[key] = []; array.forEach((element) => { if (isResource(element) && !includes(includeOptions, Include.REL_RESOURCES_AS_OBJECTS)) { result[key].push(element?._links?.self?.href); } else { result[key].push(this.resolveValues({ body: element, valuesOption: requestBody?.valuesOption })); } }); } else if (isResource(body[key]) && !includes(includeOptions, Include.REL_RESOURCES_AS_OBJECTS)) { result[key] = body[key]._links?.self?.href; } else if (isPlainObject(body[key])) { result[key] = this.resolveValues({ body: body[key], valuesOption: requestBody?.valuesOption }); } else { result[key] = body[key]; } } StageLogger.stageLog(Stage.RESOLVE_VALUES, { result }); return result; } /** * Assign {@link Resource} or {@link EmbeddedResource} properties to passed entity. * * @param entity to be converter to resource */ static initResource(entity) { if (isResource(entity)) { return Object.assign(new this.resourceType(), entity); } else if (isEmbeddedResource(entity)) { return Object.assign(new this.embeddedResourceType(), entity); } else { return entity; } } /** * Define resource name based on resource links. * It will get link name that href equals to self href resource link. * * @param payload that can be a resource for which to find the name */ static findResourceName(payload) { if (!payload || !payload['_links'] || !payload['_links'].self) { return ''; } const resourceLinks = payload['_links']; if (isEmpty(resourceLinks) || isEmpty(resourceLinks.self) || isNil(resourceLinks.self.href)) { return ''; } return UrlUtils.getResourceNameFromUrl(UrlUtils.removeTemplateParams(resourceLinks.self.href)); } /** * Checks is a resource projection or not. * * @param payload object that can be resource or resource projection */ static isResourceProjection(payload) { if (!payload || !payload['_links'] || !payload['_links'].self) { return false; } const selfLink = payload['_links'].self; const resourceLinks = payload['_links']; for (const key of Object.keys(resourceLinks)) { if (key !== 'self' && resourceLinks[key].href.includes(selfLink.href)) { return new URL(resourceLinks[key].href).search.includes('projection'); } } return false; } /** * Try to get projectionName from resource type and set it to options. If resourceType has not projectionName then return options as is. * * @param resourceType from get projectionName * @param options to set projectionName */ static fillProjectionNameFromResourceType(resourceType, options) { if (!resourceType) { return; } const projectionName = resourceType['__projectionName__']; if (projectionName) { options = options ? options : { params: {} }; options = { ...options, params: { ...options.params, projection: projectionName } }; } return options; } } /** * Contains all needed information about a resource. * It generates a string cache key to hold in a cache map from information about a resource. */ class CacheKey { constructor(url, options) { this.url = url; this.options = options; this.value = `url=${this.url}`; if (options) { if (options.params && options.params.keys().length > 0) { this.value += `${this.value.includes('?') ? '&' : '?'}${this.options?.params?.toString()}`; } if (options.observe) { this.value += `&observe=${this.options?.observe}`; } } } /** * Create cache key from resource url and request params. * * @param url resource url * @param params request params */ static of(url, params) { return new CacheKey(url, params); } } class CacheUtils { /** * Checks that cache is enabled and applying used cached mode. * * When mode is CacheMode.ALWAYS then only when passed param useCache === false will not be using cache. * When mode is CacheMode.ON_DEMAND then only when passed param useCache === true be using cache. * * @param useCache desired param can be undefined when caller has not passed it */ static shouldUseCache(useCache) { return LibConfig.getConfig().cache.enabled && ((CacheMode.ALWAYS === LibConfig.getConfig().cache.mode && (!isBoolean(useCache) || useCache)) || (CacheMode.ON_DEMAND === LibConfig.getConfig().cache.mode && isBoolean(useCache) && useCache)); } } /** * Base class with common logics to perform HTTP requests. */ /* tslint:disable:no-string-literal */ class HttpExecutor { constructor(httpClient, cacheService) { this.httpClient = httpClient; this.cacheService = cacheService; } static logRequest(method, url, options, body) { const params = { method, url, options: { ...options, params: options?.params?.keys().length > 0 ? options?.params.toString() : '', } }; if (body) { params['body'] = body; } StageLogger.stageLog(Stage.HTTP_REQUEST, params); } static logResponse(method, url, options, data) { StageLogger.stageLog(Stage.HTTP_RESPONSE, { method, url, options: { ...options, params: options?.params?.keys().length > 0 ? options?.params.toString() : '', }, result: data }); } /** * Perform GET request. * * @param url to perform request * @param options (optional) options that applied to the request * @param useCache value {@code true} if need to use cache, {@code false} otherwise * @throws error when required params are not valid */ getHttp(url, options, useCache) { ValidationUtils.validateInputParams({ url }); if (CacheUtils.shouldUseCache(useCache)) { const cachedValue = this.cacheService.getResource(CacheKey.of(url, options)); if (cachedValue != null) { return of(cachedValue); } } HttpExecutor.logRequest('GET', url, options); let response; if (options?.observe === 'response') { response = this.httpClient.get(url, { ...options, observe: 'response' }); } else { response = this.httpClient.get(url, { ...options, observe: 'body' }); } return response.pipe(tap((data) => { HttpExecutor.logResponse('GET', url, options, data); if (CacheUtils.shouldUseCache(useCache) && isResourceObject(data)) { this.cacheService.putResource(CacheKey.of(url, options), data); } })); } /** * Perform POST request. * * @param url to perform request * @param body to send with request * @param options (optional) options that applied to the request * @throws error when required params are not valid */ postHttp(url, body, options) { HttpExecutor.logRequest('POST', url, options, body); ValidationUtils.validateInputParams({ url }); let response; if (options?.observe === 'response') { response = this.httpClient.post(url, body, { ...options, observe: 'response' }); } else { response = this.httpClient.post(url, body, { ...options, observe: 'body' }); } return response.pipe(tap((data) => { HttpExecutor.logResponse('POST', url, options, data); if (LibConfig.getConfig().cache.enabled) { this.cacheService.evictResource(CacheKey.of(url, options)); } })); } /** * Perform PUT request. * * @param url to perform request * @param body to send with request * @param options (optional) options that applied to the request * @throws error when required params are not valid */ putHttp(url, body, options) { HttpExecutor.logRequest('PUT', url, options, body); ValidationUtils.validateInputParams({ url }); let response; if (options?.observe === 'response') { response = this.httpClient.put(url, body, { ...options, observe: 'response' }); } else { response = this.httpClient.put(url, body, { ...options, observe: 'body' }); } return response.pipe(tap((data) => { HttpExecutor.logResponse('PUT', url, options, data); if (LibConfig.getConfig().cache.enabled) { this.cacheService.evictResource(CacheKey.of(url, options)); } })); } /** * Perform PATCH request. * * @param url to perform request * @param body to send with request * @param options (optional) options that applied to the request * @throws error when required params are not valid */ patchHttp(url, body, options) { HttpExecutor.logRequest('PATCH', url, options, body); ValidationUtils.validateInputParams({ url }); let response; if (options?.observe === 'response') { response = this.httpClient.patch(url, body, { ...options, observe: 'response' }); } else { response = this.httpClient.patch(url, body, { ...options, observe: 'body' }); } return response.pipe(tap((data) => { HttpExecutor.logResponse('PATCH', url, options, data); if (LibConfig.getConfig().cache.enabled) { this.cacheService.evictResource(CacheKey.of(url, options)); } })); } /** * Perform DELETE request. * * @param url to perform request * @param options (optional) options that applied to the request * @throws error when required params are not valid */ deleteHttp(url, options) { HttpExecutor.logRequest('DELETE', url, options); ValidationUtils.validateInputParams({ url }); let response; if (options?.observe === 'response') { response = this.httpClient.delete(url, { ...options, observe: 'response' }); } else { response = this.httpClient.delete(url, { ...options, observe: 'body' }); } return response.pipe(tap((data) => { HttpExecutor.logResponse('DELETE', url, options, data); if (LibConfig.getConfig().cache.enabled) { this.cacheService.evictResource(CacheKey.of(url, options)); } })); } } class CachedResource { constructor(value, cachedTime) { this.value = value; this.cachedTime = cachedTime; } } class ResourceCacheService { constructor() { this.cacheMap = new Map(); } /** * Get cached resource value. * * @param key cache key * @return cached value or {@code null} when cached value is not exist or expired */ getResource(key) { ValidationUtils.validateInputParams({ key }); const cacheValue = this.cacheMap.get(key.value); if (isNil(cacheValue)) { StageLogger.stageLog(Stage.CACHE_GET, { cacheKey: key.value, result: null }); return null; } const cacheExpiredTime = new Date(cacheValue.cachedTime); cacheExpiredTime.setMilliseconds(cacheExpiredTime.getMilliseconds() + LibConfig.getConfig().cache.lifeTime); if (cacheExpiredTime.getTime() < new Date().getTime()) { this.evictResource(key); StageLogger.stageLog(Stage.CACHE_GET, { cacheKey: key.value, message: 'cache was expired', result: null }); return null; } StageLogger.stageLog(Stage.CACHE_GET, { cacheKey: key.value, result: cacheValue.value }); return cacheValue.value; } /** * Add resource value to the cache. * Before add new value, previous will be deleted if it was exist. * * @param key cache key * @param value cache value */ putResource(key, value) { ValidationUtils.validateInputParams({ key, value }); this.cacheMap.set(key.value, new CachedResource(value, new Date())); StageLogger.stageLog(Stage.CACHE_PUT, { cacheKey: key.value, value }); } /** * Delete cached resource value by passed key. * * @param key cache key */ evictResource(key) { ValidationUtils.validateInputParams({ key }); // Get resource name by url to evict all resource cache with collection/paged collection data const