@stackend/api
Version:
JS bindings to api.stackend.com
1,705 lines (1,508 loc) • 42.6 kB
text/typescript
import { appendQueryString, LoadJson, LoadJsonResult, urlEncodeParameters } from './LoadJson';
import get from 'lodash/get';
import forIn from 'lodash/forIn';
import { applyReferenceHandlers, ReceiveReferences, receiveReferences } from './referenceActions';
import { getRequest, Request } from '../request';
import { Community, Module, STACKEND_COMMUNITY } from '../stackend';
import { User } from '../user';
import { setLoadingThrobberVisible } from '../throbber/throbberActions';
import { Content, Page, SubSite } from '../cms';
import { Privilege } from '../user/privileges';
import { XCAP_SET_CONFIG } from './configReducer';
import { Dispatch } from 'redux';
import { ListProductsQuery, ShopDataResult } from '../shop';
import Logger, { ConsoleLogger } from '../util/Logger';
import { appendAccessToken, handleAccessToken } from './AccessToken';
import { signalApiAccessFailed } from './actions';
import XcapObject from './XcapObject';
import { applyExtraObjectHandlers } from './extraObjectActions';
function createDefaultLogger(): Logger {
return new ConsoleLogger('stackend');
}
/**
* Stackend logger
*/
export let logger: Logger = createDefaultLogger();
/**
* Get the stackend logger
*/
export function getLogger(): Logger {
if (!logger) {
logger = createDefaultLogger();
}
return logger;
}
/**
* Set the default logger
* @param newLogger
*/
export function setLogger(newLogger: Logger): void {
logger = newLogger;
}
export const STACKEND_DEFAULT_SERVER = 'https://api.stackend.com';
export const STACKEND_DEFAULT_CONTEXT_PATH = '';
/**
* Xcap API configuration
*/
export interface Config {
/** Absolute url to the api server*/
server: string;
/** Context path on the api server */
contextPath: string;
/** Absolute url to the api server including context path and /api */
apiUrl: string;
/** Deploy profile */
deployProfile: DeployProfile;
/** Recaptcha site key */
recaptchaSiteKey: string | null;
/** Google Analytics key */
gaKey: string | null;
/** Other configuration properties */
[propName: string]: any;
}
/**
* Known deploy profiles
*/
export enum DeployProfile {
STACKEND = 'stackend'
}
export let configDefaults: Partial<Config> = {
server: STACKEND_DEFAULT_SERVER,
contextPath: '',
apiUrl: STACKEND_DEFAULT_SERVER + STACKEND_DEFAULT_CONTEXT_PATH + '/api',
recaptchaSiteKey: null,
gaKey: null,
deployProfile: DeployProfile.STACKEND
};
/**
* Set default configuration options
* @param defaults
*/
export function setConfigDefaults(defaults: Partial<Config>): void {
configDefaults = defaults;
}
let __xcapRunningServerSide = false;
/**
* Is the app running server side?
*/
export function isRunningServerSide(): boolean {
return __xcapRunningServerSide;
}
/**
* Is the app running in the browser
*/
export function isRunningInBrowser(): boolean {
return !__xcapRunningServerSide && typeof window !== 'undefined';
}
export function setRunningServerSide(ssr: boolean): void {
__xcapRunningServerSide = ssr;
}
/**
* The default community ("stackend")
* @type {string}
*/
export const DEFAULT_COMMUNITY = 'stackend';
/**
* Key holding related objects in the json response.
* @type {string}
*/
export const RELATED_OBJECTS = '__relatedObjects';
/**
* Key holding related objects that are not IdAware in the json response.
* @type {string}
*/
export const EXTRA_OBJECTS = '__extraObjects';
/**
* Key holding related likes in the json response.
* @type {string}
*/
export const LIKES = 'likes';
/**
* Key holding related votes in the json response.
* @type {string}
*/
export const VOTES = 'votes';
/**
* Parameter name holding the community
* @type {string}
*/
export const COMMUNITY_PARAMETER = '__community';
/**
* Parameter specifying an alternative rich content chain used to serialize the result
* @type {string}
*/
export const RICH_CONTENT_CHAIN_PARAMETER = 'xcap.rich-content-chain';
/**
* API errors
*/
export interface XcapJsonErrors {
/**
* Error messages from the API
*/
actionErrors: Array<string>;
/**
* Field errors. Maps from a field (parameter) name to validation error messages for that parameter.
*/
fieldErrors: {
[fieldName: string]: Array<string>;
};
}
/**
* Extra objects that are not IdAware.
* The key and id is implementation dependant.
*/
export type ExtraObjects = {
[key: string]: {
[context: string]: {
[id: string]: any;
};
};
};
/**
* Base type for API results
*/
export interface XcapJsonResult {
/**
* Action specific result code.
* Common codes includes: "success", "input", "notfound" etc.
*/
__resultCode: string;
/**
* Error messages. Not present if the API call was successful
*/
error?: XcapJsonErrors;
/**
* Additional debug messages (non errors) from the API
*/
__messages?: Array<string>;
/**
* Related objects mapped from a hash string to the actual object.
* Present only when a call is successful.
*/
__relatedObjects?: { [ref: string]: XcapObject };
/**
* Related objects that are not IdAware
* Present only when a call is successful and has extra objects.
*/
__extraObjects?: ExtraObjects;
/** Additional properties specific to the called API method */
[propName: string]: any;
}
/**
* Redux store state
*/
export type State = { [key: string]: any };
/**
* Function that dispatches actions against the store
*/
//export type Thunk<A> = (dispatch: Dispatch, getState: () => State) => Promise<A> | A | any;
export type Thunk<A> = (dispatch: Dispatch, getState: () => State) => A;
/**
* Xcap types and their names
*/
const typeNames: { [type: string]: string } = {
'se.josh.xcap.comment.Comment': 'Comment',
'se.josh.xcap.comment.impl.CommentImpl': 'Comment',
'net.josh.community.user.User': 'User',
'net.josh.community.group.Group': 'Group',
'net.josh.community.user.backend.xcap.XcapUser': 'User',
'net.josh.community.blog.BlogEntry': 'Post',
'net.josh.community.abuse.ReferencedAbuse': 'Abuse report',
'net.josh.community.forum.ForumThread': 'Question',
'net.josh.community.forum.impl.ForumThreadImpl': 'Question',
'net.josh.community.forum.ForumThreadEntry': 'Answer',
'net.josh.community.forum.impl.ForumThreadEntryImpl': 'Answer',
'net.josh.community.forum.Forum': 'Forum',
'net.josh.community.forum.impl.ForumImpl': 'Forum',
'net.josh.community.media.Media': 'Media',
'net.josh.community.media.Image': 'Image',
'net.josh.community.media.Document': 'Document',
'net.josh.community.media.Audio': 'Audio',
'net.josh.community.media.Video': 'Video',
'se.josh.xcap.cms.Content': 'CMS Content',
'se.josh.xcap.cms.impl.ContentImpl': 'CMS Content',
'se.josh.xcap.community.Community': 'Site'
};
/**
* Construct basic configuration from the environment.
*/
export function _constructConfig(): Config {
const c: Config = Object.assign(
{
server: STACKEND_DEFAULT_SERVER,
contextPath: STACKEND_DEFAULT_CONTEXT_PATH,
deployProfile: DeployProfile.STACKEND,
apiUrl: null,
gaKey: null,
recaptchaSiteKey: null
},
configDefaults
);
if (!c.apiUrl) {
c.apiUrl = c.server + c.contextPath + '/api';
}
return c;
}
/**
* Get the API configuration object
* @type {Thunk<Config>}
*/
export function getConfiguration(): Thunk<Config> {
return (dispatch, getState): Config => {
const c = get(getState(), 'config');
if (c) {
return c;
}
// FIXME: Push to store?
return _constructConfig();
};
}
/**
* Set the API configuration
* @param config
*/
export function setConfiguration(config: Partial<Config>): Thunk<any> {
return (dispatch, _getState): any => {
return dispatch({
type: XCAP_SET_CONFIG,
config
});
};
}
/**
* Reset the API configuration to the defaults
*/
export function resetConfiguration(): Thunk<any> {
return setConfiguration({
server: STACKEND_DEFAULT_SERVER,
contextPath: STACKEND_DEFAULT_CONTEXT_PATH,
deployProfile: DeployProfile.STACKEND,
gaKey: null,
recaptchaSiteKey: null
});
}
/**
* Server domain enabling CORS calls
* @type {string}
*/
export function getServer(): Thunk<string> {
return (dispatch, getState): string => {
let s = undefined;
if (typeof getState !== 'function') {
logger.error('getServer: Wrong invocation');
} else {
s = get(getState(), 'config.server');
if (s) {
return s;
}
}
return _constructConfig().server;
};
}
/**
* Server domain enabling CORS calls
* @type {string}
*/
export function _getServer(config: Config | null): string {
const s = get(config, 'server');
if (s) {
return s;
}
return _constructConfig().server;
}
/**
* @deprecated bad practise to dispatch getters which doesn't set any state, use api._getDeployProfile instead
* Get the deploy profile name. Allows customized styling for different deployments
* @return a profile name, or the empty string.
*/
export function getDeployProfile(): Thunk<string> {
return (dispatch, getState): string => {
if (typeof getState !== 'function') {
logger.error('getDeployProfile: Wrong invocation');
} else {
const s = get(getState(), 'config.deployProfile');
if (s) {
return s;
}
}
return _constructConfig().deployProfile;
};
}
/**
* Get the deploy profile name. Allows customized styling for different deployments
* @return a profile name, or the empty string.
*/
export function _getDeployProfile(config: Config | null): string {
const s = get(config, 'deployProfile');
if (s) {
return s;
}
return _constructConfig().deployProfile;
}
/**
* ContextPath of Api server
* @return {Thunk<string>}
*/
export function getContextPath(): Thunk<string> {
return (dispatch, getState): string => {
if (typeof getState !== 'function') {
logger.error('getContextPath: Wrong invocation');
} else {
const s = get(getState(), 'config.contextPath');
if (s) {
return s;
}
}
return _constructConfig().contextPath;
};
}
/**
* ContextPath of Api server
* @type {string}
*/
export function _getContextPath(config: Config | null): string {
const s = get(config, 'contextPath');
if (s) {
return s;
}
return _constructConfig().contextPath;
}
/**
* Server domain address with ContextPath
* @return {Thunk<string>}
*/
export function getServerWithContextPath(): Thunk<string> {
return (dispatch, getState): string => {
let server, contextPath;
if (typeof getState !== 'function') {
logger.error('getServerWithContextPath: Wrong invocation');
} else {
const state = getState();
server = get(state, 'config.server');
contextPath = get(state, 'config.contextPath');
}
if (!server || !contextPath) {
const c = _constructConfig();
server = server || c.server;
contextPath = contextPath || c.contextPath;
}
return server + contextPath;
};
}
/**
* Server domain address with ContextPath from redux store
*/
export function getServerWithContextPathFromStore(config: Config): string {
// FIXME: Duplicate of nex function
let c = config;
if (!config.server || !config.contextPath) {
c = _constructConfig();
}
return c.server + c.contextPath;
}
/**
* Server domain address with ContextPath
*/
export function _getServerWithContextPath(config: Config): string {
let c = config;
if (!config.server || !config.contextPath) {
c = _constructConfig();
}
return c.server + c.contextPath;
}
/**
* Get the path to the current community.
* For example "/stackend/test"
* @return never null
*/
export function getCommunityPath(): Thunk<string> {
return (dispatch, getState): string => {
if (typeof getState !== 'function') {
throw Error('getCommunityPath : Wrong invocation');
}
return get(getState(), 'request.communityUrl', '');
};
}
/**
* Get the path to the current community.
* For example "/stackend/test"
* @return never null
*/
export function getCommunityPathFromStore({ request }: { request: Request }): string {
return get(request, 'communityUrl', '');
}
/**
* Get the absolute path to the current community, including host name.
* For example "stackend.com/stackend/test"
* Same as request.absoluteCommunityUrl.
* @return {Thunk<string>}
*/
export function getAbsoluteCommunityPath(): Thunk<string> {
return (dispatch, getState): string => {
if (typeof getState !== 'function') {
throw Error('getAbsoluteCommunityPath: Wrong invocation');
}
return get(getState(), 'request.absoluteCommunityUrl', '');
};
}
/**
* Get the community path. In stackend /stacks/, return the context path, not the current community path.
* @return {Thunk<string>}
*/
export function getEffectiveCommunityPath(): Thunk<string> {
return (dispatch, getState): string => {
const state = getState();
if (/\/stacks\//.exec(get(state, 'request.location.pathname', ''))) {
// Ignore /stacks/xxx
return _getContextPath(state.config);
}
// Outside stackend
const p = get(state, 'request.communityPath', null);
if (p !== null) {
return p;
}
return _getContextPath(state.config);
};
}
/**
* Api url containing server and ContextPath if necessary.
* @param community Optional community
* @return {Thunk<string>}
*/
export function getAbsoluteApiBaseUrl(community: string): Thunk<string> {
return (dispatch, getState): string => {
const state = getState();
const server = _getServer(state.config);
const contextPath = _getContextPath(state.config);
const pfx = server + contextPath;
// The default community does not use a prefix
if (typeof community === 'undefined' || community === null || community === DEFAULT_COMMUNITY || community === '') {
return pfx + '/api';
}
return pfx + '/' + community + '/api';
};
}
/**
* Api url containing server and ContextPath if necessary.
* @param config Xcap config
* @param communityPermalink Optional community permalink
* @type {string}
*/
export function _getAbsoluteApiBaseUrl({
config,
communityPermalink
}: {
config: Config;
communityPermalink?: string;
}): string {
const server = _getServer(config);
const contextPath = _getContextPath(config);
const pfx = server + contextPath;
// The default community does not use a prefix
if (
typeof communityPermalink === 'undefined' ||
communityPermalink === null ||
communityPermalink === DEFAULT_COMMUNITY ||
communityPermalink === ''
) {
return pfx + '/api';
}
return pfx + '/' + communityPermalink + '/api';
}
/**
* Get the current community name (For example "c123")
* @return may return null
*/
export function getCurrentCommunity(): Thunk<Community | null> {
return (dispatch, getState): Community | null => {
return get(getState(), 'communities.community', null);
};
}
/**
* Get the current community permalink as used in name (For example "test").
*
* @return May return null
*/
export function getCurrentCommunityPermalink(): Thunk<string | null> {
return (dispatch, getState): string | null => {
return get(getState(), 'communities.community.permalink', null);
};
}
/**
* Get the base url to the api server.
* Typically '/APP/api/endpoint'
* @param state Store state
* @param url extra url
* @param parameters extra parameters (optional)
* @param notFromApi boolean if the url is not in the api
* @param community community name
* @param componentName Component name used to look up config
* @param context Context name used to look up config
* @returns {String} the api url
* @see COMMUNITY_PARAMETER
*/
export function _getApiUrl({
state,
url,
parameters,
notFromApi = false,
community,
componentName,
context
}: {
state: State;
url: string; //extra url
parameters?: any; //extra parameters
notFromApi?: boolean; //if the url is not in the api
community?: string | null; //community name
componentName?: string | null; //Component name used to look up config
context?: string | null; //Context name used to look up config
}): string {
//the api url
const params = argsToObject(parameters);
if (typeof community === 'undefined') {
if (params) {
community = (params as any)[COMMUNITY_PARAMETER];
delete (params as any)[COMMUNITY_PARAMETER];
}
if (typeof community === 'undefined') {
community = get(state, 'communities.community.permalink', DEFAULT_COMMUNITY);
}
}
let path = '';
if (notFromApi) {
path = url;
} else {
/* Calls to /api/* */
const server = _getConfig({
config: state.config || {},
componentName,
context,
key: 'server',
defaultValue: _getServer(state.config)
});
const contextPath = _getConfig({
config: state.config || {},
componentName,
context,
key: 'contextPath',
defaultValue: _getContextPath(state.config)
});
const apiUrlOverride = _getConfig({
config: state.config || {},
componentName,
context,
key: 'api-url'
});
let pfx = server + contextPath;
if (apiUrlOverride) {
pfx = apiUrlOverride;
}
// The default community does not use a prefix
else if (
typeof community === 'undefined' ||
community === null ||
community === DEFAULT_COMMUNITY ||
community === ''
) {
pfx += '/api';
} else {
pfx += '/' + community + '/api';
}
path = pfx + url;
path = appendAccessToken(path);
}
const args = urlEncodeParameters(params);
return appendQueryString(path, args);
}
/**
* Get the base url to the api server.
* Typically '/APP/api/endpoint'
* @param url extra url
* @param parameters extra parameters (optional)
* @param notFromApi boolean if the url is not in the api
* @param community community name
* @param componentName Component name used to look up config
* @param context Context name used to look up config
* @returns {Thunk} the api url
* @see COMMUNITY_PARAMETER
*/
export function getApiUrl({
url,
parameters,
notFromApi = false,
community,
componentName,
context
}: {
url: string;
parameters?: any;
notFromApi?: boolean;
community?: string | null;
componentName?: string | null;
context?: string | null;
}): Thunk<string> {
return (dispatch, getState): string => {
return _getApiUrl({
state: getState(),
url,
parameters,
notFromApi,
community,
componentName,
context
});
};
}
/**
* Add any related objects received to the store
* @param dispatch
* @param json
*/
export function addRelatedObjectsToStore(dispatch: Dispatch, json: any): void {
if (!!json[RELATED_OBJECTS] && Object.keys(json[RELATED_OBJECTS]).length > 0) {
const relatedObjects = json[RELATED_OBJECTS];
const rr: ReceiveReferences = { entries: relatedObjects };
dispatch(receiveReferences(rr) as any);
dispatch(applyReferenceHandlers(rr) as any);
}
const extraObjects = json[EXTRA_OBJECTS];
if (extraObjects && Object.keys(extraObjects).length > 0) {
dispatch(applyExtraObjectHandlers(extraObjects) as any);
}
}
export type XcapOptionalParameters = {
/** Set the community parameter to target a specific community (typically from admin) */
[COMMUNITY_PARAMETER]?: string | null | undefined;
};
/**
* Parameters for requests requiring an appid and api key
*/
export type StackendApiKeyParameters = {
/** Set this if the request requires a stackend app id and api key */
stackend_appid?: string | null | undefined;
/** Set this if the request requires a stackend app id and api key */
stackend_apikey?: string | null | undefined;
};
export type ParameterValue = string | number | boolean | null | undefined | Array<string | number | boolean | null>;
export type Parameters =
| (XcapOptionalParameters & StackendApiKeyParameters & { [name: string]: ParameterValue })
| string;
export interface XcapJsonRequest {
/** Path on the api server */
url: string;
/** Parameters as a js object */
parameters?: Parameters | IArguments;
/** Community permalink */
community?: string | null;
/** Component name used for config (for example "like") */
componentName?: string | null;
/** Community context used for config (for example "forum") */
context?: string | null;
/** Optional cookie string to pass on */
cookie?: string | null;
/** if the url is not in the api
* @deprecated
*/
notFromApi?: boolean;
}
/**
* Get json from the api.
*
* @param url
* @param parameters
* @param notFromApi boolean if the url is not in the api
* @param community Current community name
* @param componentName Component name used for config (for example "like")
* @param context Community context used for config (for example "forum")
* @param cookie Optional cookie string to pass on. Typically used for SSR only
* @returns {Thunk}
*/
export function getJson<T extends XcapJsonResult>({
url,
parameters,
notFromApi = false,
community,
componentName,
context,
cookie
}: XcapJsonRequest): Thunk<Promise<T>> {
return async (dispatch: any): Promise<T> => {
let p = url;
try {
dispatch(setLoadingThrobberVisible(true));
p = await dispatch(
getApiUrl({
url,
parameters: argsToObject(parameters),
notFromApi,
community,
componentName,
context
})
);
let c: string | undefined | null = cookie;
const runningServerSide = isRunningServerSide();
// The client will supply the cookie automatically. Server side will not, so pass it along
if ((typeof c === 'undefined' || c == null) && runningServerSide) {
const request: Request = await dispatch(getRequest());
c = request.cookie;
}
const requestStartTime = Date.now();
logger.debug('GET ' + p);
const result: LoadJsonResult = await LoadJson({ url: p, cookie: c });
const t = Date.now() - requestStartTime;
if (t > 500 && runningServerSide) {
logger.warn('Slow API request: ' + t + 'ms:' + p);
}
if (result.json) {
handleAccessToken(result.json);
if (result.error) {
logger.error(getJsonErrorText(result.json) + ' ' + p);
dispatch(setLoadingThrobberVisible(false));
dispatch(signalApiAccessFailed(p, result));
if (result.status === 403) {
// Unauthorized
logger.warn('Session has expired: ' + p);
/* FIXME: At this point the user should be prompted to login again. Preferably using a popup
dispatch(refreshLoginData({ force : true })); // Breaks because of circular dependencies
*/
}
return result.json as T;
}
const r = postProcessApiResult(result.json);
addRelatedObjectsToStore(dispatch, r);
dispatch(setLoadingThrobberVisible(false));
return r as T;
}
logger.error('No result received: ' + p);
dispatch(setLoadingThrobberVisible(false));
return newXcapJsonErrorResult('No result received') as T;
} catch (e) {
// 404, connection refused etc
logger.error("Couldn't getJson: " + p, e);
dispatch(setLoadingThrobberVisible(false));
return newXcapJsonErrorResult("Couldn't getJson: " + e) as T;
}
};
}
/**
* Get json from the api.
*
* @param url
* @param parameters
* @returns {Promise}
*/
export function getJsonOutsideApi({
url,
parameters
}: {
url: string;
parameters?: any;
}): Thunk<Promise<XcapJsonResult>> {
return async (dispatch): Promise<XcapJsonResult> => {
const p = appendQueryString(url, urlEncodeParameters(argsToObject(parameters)));
const result = await LoadJson({ url: p });
if (result) {
if (result.error) {
logger.warn(result.error + p);
return newXcapJsonErrorResult(result.error);
}
if (result.json) {
if (result.json.error) {
logger.warn(getJsonErrorText(result.json) + p);
return result.json;
}
const r = postProcessApiResult(result.json);
addRelatedObjectsToStore(dispatch, r);
return r as XcapJsonResult;
}
}
return newXcapJsonErrorResult('No result received');
// TODO: Used to return empty
};
}
/**
* Post using the json api.
* @param url
* @param parameters
* @param community Current community name
* @param componentName Component name used for config (for example "like")
* @param context Community context used for config (for example "forum")
* @returns {Thunk}
*/
export function post<T extends XcapJsonResult>({
url,
parameters,
community,
componentName,
context
}: {
url: string;
parameters?: Parameters | IArguments;
community?: string | null;
componentName?: string | null;
context?: string | null;
}): Thunk<Promise<T>> {
return async (dispatch: any): Promise<T> => {
// Must get token before constructing the url, because session may be established when requesting the token
const xpressToken = await dispatch(getXpressToken({ community, componentName, context }));
const params = argsToObject(parameters);
if (typeof community === 'undefined' && params && typeof (params as any)[COMMUNITY_PARAMETER] !== 'undefined') {
community = (params as any)[COMMUNITY_PARAMETER];
}
const p = dispatch(
getApiUrl({
url,
notFromApi: false,
community,
componentName,
context
})
);
const result = await LoadJson({
url: p,
method: 'POST',
parameters: params,
xpressToken: xpressToken.xpressToken
});
if (result) {
if (result.error) {
logger.warn(result.error + ': ' + p);
return newXcapJsonErrorResult('Post failed: ' + result.error) as T;
}
if (result.json) {
handleAccessToken(result.json);
if (result.json.error) {
logger.warn(getJsonErrorText(result.json) + ': ' + p);
}
const r = postProcessApiResult(result.json);
addRelatedObjectsToStore(dispatch, r);
return r as T;
}
}
return newXcapJsonErrorResult('Post failed: no response') as T;
};
}
export interface GetExpressTokenResult extends XcapJsonResult {
xpressToken: string;
xcapAjaxToken: string;
}
/**
* Get a token used for CSRF prevention.
*/
export function getXpressToken({
community,
componentName,
context
}: {
community?: string | null;
componentName?: string | null;
context?: string | null;
}): Thunk<Promise<GetExpressTokenResult>> {
return getJson({
url: '/xpresstoken',
community,
componentName,
context
});
}
/**
* Get a configuration variable.
*
* <p>When looking up a key, the following order is used:</p>
* <ol>
* <li>COMPONENT.CONTEXT.KEY</li>
* <li>COMPONENT.KEY</li>
* <li>KEY</li>
* <li>Default value</li>
* </ol>
*
* @param key configuration key
* @param componentName Component name (Optional)
* @param context Community context(Optional)
* @param defaultValue Default value (Optional)
*/
export function getConfig({
key,
componentName,
context,
defaultValue = ''
}: {
key: string;
componentName?: string;
context?: string;
defaultValue?: any;
}): Thunk<any> {
return (dispatch: any, getState: any): any => {
const config = get(getState(), 'config');
return _getConfig({ config, key, componentName, context, defaultValue });
};
}
export function _getConfig({
config,
key,
componentName,
context,
defaultValue = ''
}: {
config: Config;
key: string;
componentName?: string | null;
context?: string | null;
defaultValue?: any;
}): any {
if (typeof config === 'undefined') {
logger.warn('getConfig: config is not present in store');
return defaultValue;
}
let v = undefined;
if (!componentName) {
v = config[key];
if (typeof v !== 'undefined') {
return v;
}
} else {
if (!context) {
v = config[componentName + '.' + key];
if (typeof v !== 'undefined') {
return v;
}
return _getConfig({ config, key, defaultValue });
} else {
v = config[componentName + '.' + context + '.' + key];
if (typeof v !== 'undefined') {
return v;
}
return _getConfig({ config, key, componentName, defaultValue });
}
}
return defaultValue;
}
/**
* Construct an url to the UI.
*
* @param path Path
* @param parameters Parameters map
* @param hash
*/
export function createUrl({ path, params, hash }: { path: string; params?: any; hash?: string }): string {
let loc = path;
if (params) {
let hasQ = loc.indexOf('?') !== -1;
for (const p in params) {
if (Object.prototype.hasOwnProperty.call(params, p)) {
loc += hasQ ? '&' : '?';
const v = params[p];
if (!v) {
loc += encodeURIComponent(p);
} else {
if (typeof v === 'object') {
for (let i = 0; i < v.length; i++) {
const w = v[i];
loc += (i > 0 ? '&' : '') + encodeURIComponent(p) + '=' + encodeURIComponent(w);
}
} else {
loc += encodeURIComponent(p) + '=' + encodeURIComponent(v);
}
}
hasQ = true;
}
}
}
if (hash) {
loc += hash.startsWith('#') ? hash : '#' + hash;
}
return loc;
}
/**
* Construct an url to a community in the UI.
*
* @param request Request object from requestReducers.ts
* @param path Path
* @param parameters Parameters map
* @param hash
* @param absolute Should the url be absolute (boolean)
*/
export function createCommunityUrl({
request,
path,
params,
hash,
absolute
}: {
request?: Request;
path: string;
params?: any;
hash?: string;
absolute?: boolean;
}): string {
let pfx = '';
if (!!absolute && absolute) {
if (request) {
pfx = request.absoluteCommunityUrl;
} else {
//pfx = getAbsoluteCommunityPath();
pfx = 'FIXME';
}
} else {
if (request) {
pfx = request.communityUrl;
} else {
//pfx = getCommunityPath();
pfx = 'FIXME';
}
}
return createUrl({ path: pfx + path, params, hash });
}
/**
* Convert an Arguments, Array or Object to an object
* @param args
* @return {Object}
*/
export function argsToObject(args: Parameters | IArguments | undefined | null): null | Parameters {
if (typeof args === 'string') {
return {
[args]: args
};
}
if (!args) {
return null;
}
let r: Parameters = {};
if (typeof args.length === 'undefined') {
r = args as any; // Plain object
} else {
// Arguments or Arguments object
for (let i = 0; i < (args as IArguments).length; i++) {
Object.assign(r, (args as IArguments)[i]);
}
}
// Remove undefined values
const o = r as any;
for (const k in o) {
if (Object.prototype.hasOwnProperty.call(o, k) && typeof o[k] === 'undefined') {
delete o[k];
}
}
return r;
}
/**
* Post process data from the XCAP json api.
*
* - Turns timestamps into Date objects
* - Resolves references to objects
*
* The method modifies data in place to avoid copying.
*
* @param result
* @return {Object}
*/
export function postProcessApiResult(result: XcapJsonResult): XcapJsonResult | null {
const likes = result[LIKES] ? result[LIKES] : undefined;
const votes = result[VOTES] ? result[VOTES] : undefined;
return _postProcessApiResult(result, result[RELATED_OBJECTS] || {}, likes, votes);
}
function _postProcessApiResult(
result: XcapJsonResult | null,
relatedObjects: any,
likes?: any,
votes?: any
): XcapJsonResult | null {
if (result === null) {
return null;
}
if (!relatedObjects) {
logger.error('No related objects in result: ' + JSON.stringify(result));
}
const d = result;
for (const n in result) {
if (!Object.prototype.hasOwnProperty.call(result, n) || n === RELATED_OBJECTS) {
// Skip
} else if (n.endsWith('Ref')) {
/*This disables ssr due to wrong-formating in json-response
if (n === "createdDate" || n === "modifiedDate" || n === "publishDate" || n === "expiresDate")
{
var v = result[n];
if (typeof v === "number")
{
result[n] = new Date(v);
}
}*/
const v = result[n];
if (typeof v === 'string') {
const r = relatedObjects[v];
result[n] = r;
if (r === null) {
logger.error('Could not resolve related object ' + n + '=' + v);
} else {
result[n] = _postProcessApiResult(r, relatedObjects, likes);
}
} else if (typeof v === 'object' && v !== null && v.constructor === Array) {
for (let i = 0; i < v.length; i++) {
const ref = v[i];
const r = relatedObjects[ref] || ref;
v[i] = r;
if (r === null) {
logger.error('Could not resolve related object ' + ref);
}
}
}
} else if (n === 'obfuscatedReference') {
//Check for likes
const likeObject = get(likes, `[${result[n]}]`, undefined);
if (likeObject) {
result.likedByCurrentUser = likeObject;
}
//Check for votes
const voteObject = get(votes, `[${result[n]}].voteByCurrentUser`, undefined);
if (voteObject) {
result.voteByCurrentUser = voteObject;
}
} // Objects, arrays
else {
const v = result[n];
if (v !== null && typeof v === 'object') {
if (v.constructor === Array) {
for (let i = 0; i < v.length; i++) {
v[i] = _postProcessApiResult(v[i], relatedObjects, likes, votes);
}
} else {
result[n] = _postProcessApiResult(v, relatedObjects, likes, votes);
}
}
}
}
return d;
}
/**
* Format the response action and field errors object to a string.
* @return {String}
*/
export function getJsonErrorText(response?: XcapJsonResult): string {
if (typeof response === 'undefined') {
return 'No JSON response received';
}
const t = typeof response.error;
if (t === 'undefined') {
return 'No JSON response received';
}
if (t === 'string') {
return t;
}
let m = '';
if (!response.error) {
return m;
}
if (response.error.actionErrors) {
for (let i = 0; i < response.error.actionErrors.length; i++) {
if (i > 0) {
m += '\n';
}
m += response.error.actionErrors[i];
}
}
forIn(response.error.fieldErrors, (value, key) => {
if (m.length > 0) {
m += '\n';
}
m += key + ': ' + value;
});
return m;
}
/**
* Construct a new XcapJsonResult
* @param resultCode
* @param actionErrors
* @param fieldErrors
* @param data
*/
export function _newXcapJsonResult<T extends XcapJsonResult>(
resultCode: string,
actionErrors: undefined | string | Array<string>,
fieldErrors: undefined | { [fieldName: string]: string },
data?: any
): T {
const x = {
__resultCode: resultCode,
...data
};
if (actionErrors || fieldErrors) {
let ae: Array<string>;
if (typeof actionErrors === 'undefined') {
ae = [];
} else if (typeof actionErrors === 'string') {
ae = [actionErrors];
} else {
ae = actionErrors;
}
x.error = {
actionErrors: ae,
fieldErrors: fieldErrors || {}
};
} else if (resultCode === 'error') {
x.error = {
actionErrors: ['error']
};
}
return x;
}
/**
* Construct a new API result
* @param resultCode
* @param data
*/
export function newXcapJsonResult<T extends XcapJsonResult>(resultCode: string, data?: any): T {
return _newXcapJsonResult(resultCode, undefined, undefined, data);
}
/**
* Construct a new API result
* @param actionErrors
* @param fieldErrors
*/
export function newXcapJsonErrorResult<T extends XcapJsonResult>(
actionErrors: string | Array<string>,
fieldErrors?: { [fieldName: string]: string }
): T {
return _newXcapJsonResult('error', actionErrors, fieldErrors);
}
/**
* Get a human readable type of an xcap object
* @param objectOrClassName
* @return {String}
*/
export function getTypeName(objectOrClassName: string | XcapObject): string {
if (typeof objectOrClassName === 'string') {
return typeNames[objectOrClassName];
} else {
const tn = objectOrClassName['__type'];
if (typeof tn === 'string') {
const n = typeNames[tn];
if (n) {
return n;
}
const i = tn.lastIndexOf('.');
return i === -1 ? tn : tn.substring(i + 1);
}
}
return 'Unknown type';
}
/**
* Translations
*/
export interface Translations {
/** Language code */
lang: string;
messages: {
[key: string]: string;
};
}
/**
* Extra data required by some modules, like for example comment listings for comment modules
*/
export interface ModuleExtraData {
[moduleType: string]: {
[referenceOrModuleId: number]: any;
};
}
export interface GetInitialStoreValuesResult extends XcapJsonResult {
/** Was the community determined from the domain rather that from the permalink? */
communityFromDomain: boolean;
/** Permalink of community */
permalink: string;
/** Current community. may be null */
stackendCommunity: Community | null;
/** Privilege of current community (when running in /stacks) */
communityPrivilegeType: Privilege;
domain: string | null;
/** Current user. Stackend user when running in /stacks */
user: User | null;
xcapApiConfiguration: { [key: string]: any };
numberOfUnseen: number;
modules: { [id: string]: Module };
/** Maps from id to content */
cmsContents: { [id: string]: Content };
/** Maps from id to Page */
cmsPages: { [id: string]: Page };
/** Maps from id to SubSite */
subSites: { [id: string]: SubSite };
/** Maps the referenceUrl parameter to an id */
referenceUrlId: number;
/** Maps the shopify domain to an id */
shopifyDomainReferenceUrlId: number;
/** Shop data, if requested */
shopData: ShopDataResult | null;
/** Translation data, if not stackend.com */
translations: Translations | null;
/** Extra data for modules */
data: ModuleExtraData;
}
/**
* Extra module specific parameters
*/
export interface ModuleExtraParameters {
/**
* Key given by module handler, for example comments
*/
[moduleHandlerKey: string]: {
/**
* Key: moduleId + "_" + referenceId
*/
[moduleId_referenceId: string]: {
/**
* Parameter names and values
*/
[name: string]: any;
};
};
}
/**
* Add all extra parameters
* @param params
* @param moduleKey
* @param moduleId (may be 0 if not used)
* @param referenceId (may be 0 if not used)
* @param values
*/
export function addModuleExtraParameters(
params: ModuleExtraParameters,
moduleKey: string,
moduleId: number,
referenceId: number,
values: { [name: string]: any }
): void {
let mk = moduleKey;
if (mk.startsWith('stackend-')) {
mk = mk.substring('stackend-'.length);
}
let x = params[mk];
if (!x) {
x = {};
params[mk] = x;
}
const key = moduleId + '_' + referenceId;
let y = x[key];
if (!y) {
y = {};
x[key] = y;
}
Object.assign(y, values);
}
export interface GetInitialStoreValuesRequest {
permalink?: string;
domain?: string;
communityId?: number;
moduleIds?: Array<number>;
contentIds?: Array<number>;
pageIds?: Array<number>;
subSiteIds?: Array<number>;
cookie?: string;
referenceUrl?: string;
stackendMode?: boolean;
productHandles?: Array<string>;
productCollectionHandles?: Array<string>;
productListings?: Array<ListProductsQuery>;
shopImageMaxWidth?: number;
shopListingImageMaxWidth?: number;
/**
* Module specific parameters
*/
moduleExtraParameters?: ModuleExtraParameters;
}
/**
* Load the initial store values
*/
export function getInitialStoreValues(
params: GetInitialStoreValuesRequest
): Thunk<Promise<GetInitialStoreValuesResult>> {
let pl: Array<string> | undefined = undefined;
if (params.productListings && params.productListings.length !== 0) {
pl = params.productListings.map(q => JSON.stringify(q));
}
let d = undefined;
if (params.moduleExtraParameters) {
d = JSON.stringify(params.moduleExtraParameters);
}
const q = Object.assign({}, params, {
productListings: pl,
d
});
delete q.moduleExtraParameters;
const p = q as unknown as Parameters;
return getJson({
url: '/init',
parameters: p,
community: DEFAULT_COMMUNITY,
cookie: params.cookie
});
}
/**
* Log a javascript error
* @param error Browser Error object
* @param store
*/
export async function logJsError(error: any /* Error */, store?: any): Promise<any> {
if (!error) {
return;
}
const communityId = 0;
/* FIXME: Re add this
let api = getClientSideApi();
if (api && api.reduxStore) {
let state = api.reduxStore.getState();
if (state) {
if (state.communities && state.communities.community) {
communityId = state.communities.community.id;
}
store = JSON.stringify(state);
}
}
*/
// Produces the best error message in all browsers.
// Safari, however does not include the stacktrace
const message = error.toString() + (error.stack ? '\n' + error.stack : '');
const params = {
communityId,
store,
error: error.name + (error.number ? ' (' + error.number + ')' : ''),
message,
line: error.lineNumber || -1,
column: error.columnNumber || -1,
url: error.fileName || '',
pageUrl: document.location.href
};
let url = null;
if (store) {
url = await store.dispatch(
getApiUrl({
url: '/js-log',
community: STACKEND_COMMUNITY
})
);
} else {
url = _getServer(null) + _getContextPath(null) + '/api/js-log';
url = appendAccessToken(url);
}
let r = null;
try {
r = await LoadJson({
url,
method: 'POST',
parameters: params
});
} catch (e) {
logger.error('Failed to log: ' + JSON.stringify(params), '\nCause: ' + JSON.stringify(e));
}
return r;
}
/**
* Create a hash code of a string. Roughly the same impl as java.
* @param str
* @returns {number}
*/
export function getHashCode(str: string): number {
if (!str) {
return 0;
}
return str.split('').reduce((prevHash, currVal) => ((prevHash << 5) - prevHash + currVal.charCodeAt(0)) | 0, 0);
}