2016-08-10 21:36:11 +02:00
|
|
|
/**
|
|
|
|
* Redux middleware to perform API queries.
|
|
|
|
*
|
|
|
|
* This middleware catches the API requests and replaces them with API
|
|
|
|
* responses.
|
|
|
|
*/
|
2016-07-07 23:23:18 +02:00
|
|
|
import fetch from "isomorphic-fetch";
|
|
|
|
import humps from "humps";
|
|
|
|
import X2JS from "x2js";
|
|
|
|
|
|
|
|
import { assembleURLAndParams } from "../utils";
|
2016-08-01 00:26:52 +02:00
|
|
|
import { i18nRecord } from "../models/i18n";
|
2016-07-07 23:23:18 +02:00
|
|
|
|
2016-08-06 17:20:02 +02:00
|
|
|
import { loginUserExpired } from "../actions/auth";
|
|
|
|
|
2016-07-07 23:23:18 +02:00
|
|
|
export const API_VERSION = 350001; /** API version to use. */
|
|
|
|
export const BASE_API_PATH = "/server/xml.server.php"; /** Base API path after endpoint. */
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
// Action key that carries API call info interpreted by this Redux middleware.
|
|
|
|
export const CALL_API = "CALL_API";
|
|
|
|
|
2016-07-07 23:23:18 +02:00
|
|
|
// Error class to represents errors from these actions.
|
|
|
|
class APIError extends Error {}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Check the HTTP status of the response.
|
|
|
|
*
|
|
|
|
* @param response A XHR response object.
|
|
|
|
* @return The response or a rejected Promise if the check failed.
|
|
|
|
*/
|
2016-08-10 23:50:23 +02:00
|
|
|
function _checkHTTPStatus(response) {
|
2016-07-07 23:23:18 +02:00
|
|
|
if (response.status >= 200 && response.status < 300) {
|
|
|
|
return response;
|
|
|
|
} else {
|
|
|
|
return Promise.reject(response.statusText);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Parse the XML resulting from the API to JS object.
|
|
|
|
*
|
|
|
|
* @param responseText The text from the API response.
|
|
|
|
* @return The response as a JS object or a rejected Promise on error.
|
|
|
|
*/
|
2016-08-10 23:50:23 +02:00
|
|
|
function _parseToJSON(responseText) {
|
2016-08-05 00:00:25 +02:00
|
|
|
let x2js = new X2JS({
|
2016-08-10 21:36:11 +02:00
|
|
|
attributePrefix: "", // No prefix for attributes
|
2016-08-10 23:50:23 +02:00
|
|
|
keepCData: false, // Do not store __cdata and toString functions
|
2016-07-07 23:23:18 +02:00
|
|
|
});
|
|
|
|
if (responseText) {
|
|
|
|
return x2js.xml_str2json(responseText).root;
|
|
|
|
}
|
2016-08-01 00:26:52 +02:00
|
|
|
return Promise.reject(new i18nRecord({
|
|
|
|
id: "app.api.invalidResponse",
|
2016-08-10 23:50:23 +02:00
|
|
|
values: {},
|
2016-08-01 00:26:52 +02:00
|
|
|
}));
|
2016-07-07 23:23:18 +02:00
|
|
|
}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Check the errors returned by the API itself, in its response.
|
|
|
|
*
|
|
|
|
* @param jsonData A JS object representing the API response.
|
|
|
|
* @return The input data or a rejected Promise if errors are present.
|
|
|
|
*/
|
2016-08-10 23:50:23 +02:00
|
|
|
function _checkAPIErrors(jsonData) {
|
2016-07-07 23:23:18 +02:00
|
|
|
if (jsonData.error) {
|
2016-08-06 17:20:02 +02:00
|
|
|
return Promise.reject(jsonData.error);
|
2016-07-07 23:23:18 +02:00
|
|
|
} else if (!jsonData) {
|
|
|
|
// No data returned
|
2016-08-01 00:26:52 +02:00
|
|
|
return Promise.reject(new i18nRecord({
|
|
|
|
id: "app.api.emptyResponse",
|
2016-08-10 23:50:23 +02:00
|
|
|
values: {},
|
2016-08-01 00:26:52 +02:00
|
|
|
}));
|
2016-07-07 23:23:18 +02:00
|
|
|
}
|
|
|
|
return jsonData;
|
|
|
|
}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Apply some fixes on the API data.
|
|
|
|
*
|
|
|
|
* @param jsonData A JS object representing the API response.
|
|
|
|
* @return A fixed JS object.
|
|
|
|
*/
|
2016-08-10 23:50:23 +02:00
|
|
|
function _uglyFixes(jsonData) {
|
2016-08-10 21:36:11 +02:00
|
|
|
// Fix songs array
|
2016-08-05 00:00:25 +02:00
|
|
|
let _uglyFixesSongs = function (songs) {
|
2016-08-02 13:07:12 +02:00
|
|
|
return songs.map(function (song) {
|
2016-07-07 23:23:18 +02:00
|
|
|
// Fix for cdata left in artist and album
|
2016-08-02 13:07:12 +02:00
|
|
|
song.artist.name = song.artist.cdata;
|
2016-08-12 13:57:53 +02:00
|
|
|
delete(song.artist.cdata);
|
|
|
|
delete(song.artist.toString);
|
2016-08-02 13:07:12 +02:00
|
|
|
song.album.name = song.album.cdata;
|
2016-08-12 13:57:53 +02:00
|
|
|
delete(song.album.cdata);
|
|
|
|
delete(song.album.toString);
|
2016-08-02 13:07:12 +02:00
|
|
|
return song;
|
|
|
|
});
|
2016-07-07 23:23:18 +02:00
|
|
|
};
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
// Fix albums array
|
2016-08-05 00:00:25 +02:00
|
|
|
let _uglyFixesAlbums = function (albums) {
|
2016-08-02 13:07:12 +02:00
|
|
|
return albums.map(function (album) {
|
2016-08-10 21:36:11 +02:00
|
|
|
// TODO: Should go in Ampache core
|
2016-07-07 23:23:18 +02:00
|
|
|
// Fix for absence of distinction between disks in the same album
|
2016-08-02 13:07:12 +02:00
|
|
|
if (album.disk > 1) {
|
|
|
|
album.name = album.name + " [Disk " + album.disk + "]";
|
2016-07-07 23:23:18 +02:00
|
|
|
}
|
|
|
|
|
2016-08-12 13:57:53 +02:00
|
|
|
// Fix for cdata left in artist
|
|
|
|
album.artist.name = album.artist.cdata;
|
|
|
|
delete(album.artist.cdata);
|
|
|
|
delete(album.artist.toString);
|
|
|
|
|
2016-07-07 23:23:18 +02:00
|
|
|
// Move songs one node top
|
2016-08-02 13:07:12 +02:00
|
|
|
if (album.tracks.song) {
|
|
|
|
album.tracks = album.tracks.song;
|
2016-07-07 23:23:18 +02:00
|
|
|
|
|
|
|
// Ensure tracks is an array
|
2016-08-02 13:07:12 +02:00
|
|
|
if (!Array.isArray(album.tracks)) {
|
|
|
|
album.tracks = [album.tracks];
|
2016-07-07 23:23:18 +02:00
|
|
|
}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
// Fix tracks array
|
2016-08-02 13:07:12 +02:00
|
|
|
album.tracks = _uglyFixesSongs(album.tracks);
|
2016-07-07 23:23:18 +02:00
|
|
|
}
|
2016-08-02 13:07:12 +02:00
|
|
|
return album;
|
|
|
|
});
|
2016-07-07 23:23:18 +02:00
|
|
|
};
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
// Fix artists array
|
2016-08-05 00:00:25 +02:00
|
|
|
let _uglyFixesArtists = function (artists) {
|
2016-08-02 13:07:12 +02:00
|
|
|
return artists.map(function (artist) {
|
|
|
|
// Move albums one node top
|
|
|
|
if (artist.albums.album) {
|
|
|
|
artist.albums = artist.albums.album;
|
|
|
|
|
|
|
|
// Ensure albums are an array
|
|
|
|
if (!Array.isArray(artist.albums)) {
|
|
|
|
artist.albums = [artist.albums];
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fix albums
|
|
|
|
artist.albums = _uglyFixesAlbums(artist.albums);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Move songs one node top
|
|
|
|
if (artist.songs.song) {
|
|
|
|
artist.songs = artist.songs.song;
|
|
|
|
|
|
|
|
// Ensure songs are an array
|
|
|
|
if (!Array.isArray(artist.songs)) {
|
|
|
|
artist.songs = [artist.songs];
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fix songs
|
|
|
|
artist.songs = _uglyFixesSongs(artist.songs);
|
|
|
|
}
|
|
|
|
return artist;
|
|
|
|
});
|
|
|
|
};
|
2016-07-07 23:23:18 +02:00
|
|
|
|
2016-08-03 15:44:29 +02:00
|
|
|
// Ensure items are always wrapped in an array
|
|
|
|
if (jsonData.artist && !Array.isArray(jsonData.artist)) {
|
|
|
|
jsonData.artist = [jsonData.artist];
|
|
|
|
}
|
|
|
|
if (jsonData.album && !Array.isArray(jsonData.album)) {
|
|
|
|
jsonData.album = [jsonData.album];
|
|
|
|
}
|
|
|
|
if (jsonData.song && !Array.isArray(jsonData.song)) {
|
|
|
|
jsonData.song = [jsonData.song];
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fix artists
|
|
|
|
if (jsonData.artist) {
|
|
|
|
jsonData.artist = _uglyFixesArtists(jsonData.artist);
|
|
|
|
}
|
2016-08-02 13:07:12 +02:00
|
|
|
|
2016-08-03 15:44:29 +02:00
|
|
|
// Fix albums
|
|
|
|
if (jsonData.album) {
|
|
|
|
jsonData.album = _uglyFixesAlbums(jsonData.album);
|
|
|
|
}
|
2016-08-02 13:07:12 +02:00
|
|
|
|
2016-08-03 15:44:29 +02:00
|
|
|
// Fix songs
|
|
|
|
if (jsonData.song) {
|
|
|
|
jsonData.song = _uglyFixesSongs(jsonData.song);
|
|
|
|
}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
// TODO: Should go in Ampache core
|
2016-08-03 15:44:29 +02:00
|
|
|
// Add sessionExpire information
|
|
|
|
if (!jsonData.sessionExpire) {
|
|
|
|
// Fix for Ampache not returning updated sessionExpire
|
|
|
|
jsonData.sessionExpire = (new Date(Date.now() + 3600 * 1000)).toJSON();
|
|
|
|
}
|
|
|
|
|
|
|
|
return jsonData;
|
2016-07-07 23:23:18 +02:00
|
|
|
}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Fetches an API response and normalizes the result.
|
|
|
|
*
|
|
|
|
* @param endpoint Base URL of your Ampache server.
|
|
|
|
* @param action API action name.
|
|
|
|
* @param auth API token to use.
|
|
|
|
* @param username Username to use in the API.
|
|
|
|
* @param extraParams An object of extra parameters to pass to the API.
|
|
|
|
*
|
|
|
|
* @return A fetching Promise.
|
|
|
|
*/
|
2016-08-10 23:50:23 +02:00
|
|
|
function doAPICall(endpoint, action, auth, username, extraParams) {
|
2016-08-10 21:36:11 +02:00
|
|
|
// Translate the API action to real API action
|
2016-07-07 23:23:18 +02:00
|
|
|
const APIAction = extraParams.filter ? action.rstrip("s") : action;
|
2016-08-10 21:36:11 +02:00
|
|
|
// Set base params
|
2016-07-07 23:23:18 +02:00
|
|
|
const baseParams = {
|
|
|
|
version: API_VERSION,
|
|
|
|
action: APIAction,
|
|
|
|
auth: auth,
|
2016-08-10 23:50:23 +02:00
|
|
|
user: username,
|
2016-07-07 23:23:18 +02:00
|
|
|
};
|
2016-08-10 21:36:11 +02:00
|
|
|
// Extend with extraParams
|
2016-07-07 23:23:18 +02:00
|
|
|
const params = Object.assign({}, baseParams, extraParams);
|
2016-08-10 21:36:11 +02:00
|
|
|
// Assemble the full URL with endpoint, API path and GET params
|
2016-07-07 23:23:18 +02:00
|
|
|
const fullURL = assembleURLAndParams(endpoint + BASE_API_PATH, params);
|
|
|
|
|
|
|
|
return fetch(fullURL, {
|
|
|
|
method: "get",
|
|
|
|
})
|
|
|
|
.then(_checkHTTPStatus)
|
|
|
|
.then (response => response.text())
|
|
|
|
.then(_parseToJSON)
|
2016-08-01 00:26:52 +02:00
|
|
|
.then(_checkAPIErrors)
|
2016-08-02 13:07:12 +02:00
|
|
|
.then(jsonData => humps.camelizeKeys(jsonData)) // Camelize
|
2016-08-05 00:00:25 +02:00
|
|
|
.then(_uglyFixes);
|
2016-07-07 23:23:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
/**
|
|
|
|
* A Redux middleware that interprets actions with CALL_API info specified.
|
|
|
|
* Performs the call and promises when such actions are dispatched.
|
|
|
|
*/
|
2016-07-07 23:23:18 +02:00
|
|
|
export default store => next => reduxAction => {
|
|
|
|
if (reduxAction.type !== CALL_API) {
|
2016-08-10 21:36:11 +02:00
|
|
|
// Do not apply on other actions
|
2016-07-07 23:23:18 +02:00
|
|
|
return next(reduxAction);
|
|
|
|
}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
// Check payload
|
2016-07-07 23:23:18 +02:00
|
|
|
const { endpoint, action, auth, username, dispatch, extraParams } = reduxAction.payload;
|
|
|
|
if (!endpoint || typeof endpoint !== "string") {
|
|
|
|
throw new APIError("Specify a string endpoint URL.");
|
|
|
|
}
|
|
|
|
if (!action) {
|
|
|
|
throw new APIError("Specify one of the supported API actions.");
|
|
|
|
}
|
|
|
|
if (!auth) {
|
|
|
|
throw new APIError("Specify an auth token.");
|
|
|
|
}
|
|
|
|
if (!username) {
|
|
|
|
throw new APIError("Specify a username.");
|
|
|
|
}
|
|
|
|
if (!Array.isArray(dispatch) || dispatch.length !== 3) {
|
|
|
|
throw new APIError("Expected an array of three action dispatch.");
|
|
|
|
}
|
|
|
|
if (!dispatch.every(type => typeof type === "function" || type === null)) {
|
|
|
|
throw new APIError("Expected action to dispatch to be functions or null.");
|
|
|
|
}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
// Get the actions to dispatch
|
2016-07-07 23:23:18 +02:00
|
|
|
const [ requestDispatch, successDispatch, failureDispatch ] = dispatch;
|
|
|
|
if (requestDispatch) {
|
2016-08-10 21:36:11 +02:00
|
|
|
// Dispatch request action if needed
|
2016-07-07 23:23:18 +02:00
|
|
|
store.dispatch(requestDispatch());
|
|
|
|
}
|
|
|
|
|
2016-08-10 21:36:11 +02:00
|
|
|
// Run the API call
|
2016-07-07 23:23:18 +02:00
|
|
|
return doAPICall(endpoint, action, auth, username, extraParams).then(
|
|
|
|
response => {
|
|
|
|
if (successDispatch) {
|
2016-08-10 21:36:11 +02:00
|
|
|
// Dispatch success if needed
|
2016-07-07 23:23:18 +02:00
|
|
|
store.dispatch(successDispatch(response));
|
|
|
|
}
|
|
|
|
},
|
|
|
|
error => {
|
|
|
|
if (failureDispatch) {
|
2016-08-10 21:36:11 +02:00
|
|
|
// Error object from the API (in the JS object)
|
2016-08-06 17:20:02 +02:00
|
|
|
if (error._code && error.__cdata) {
|
2016-08-10 21:36:11 +02:00
|
|
|
// Format the error message
|
|
|
|
const errorMessage = error.__cdata + " (" + error._code + ")";
|
2016-08-06 17:20:02 +02:00
|
|
|
if (401 == error._code) {
|
|
|
|
// This is an error meaning no valid session was
|
|
|
|
// passed. We must perform a new handshake.
|
|
|
|
store.dispatch(loginUserExpired(errorMessage));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// Else, form error message and continue
|
|
|
|
error = errorMessage;
|
|
|
|
}
|
|
|
|
// Else if exception was thrown
|
|
|
|
else if (error instanceof Error) {
|
|
|
|
// Form error message and continue
|
2016-07-27 13:51:09 +02:00
|
|
|
error = error.message;
|
|
|
|
}
|
2016-08-06 17:20:02 +02:00
|
|
|
// Dispatch a failure event
|
2016-07-27 13:51:09 +02:00
|
|
|
store.dispatch(failureDispatch(error));
|
2016-07-07 23:23:18 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
};
|