I want to select all locales from my API (api/locales).
My problem is that the yield call() return the function as a plan string (yes yes plain string) and I don't have a clue why!
Maybe I missed something with yield + call + restClient response?? =\
My Saga file look like this:
import types from './types';
import actions from './actions';
import { call, put, takeEvery } from 'redux-saga/effects';
import restClient from './../../restClient';
function* getLocalesSaga() {
try {
yield put({type: types.GET_LOCALES_LOADING});
let locales;
locales = yield call(restClient, 'GET', 'locales');
console.log(locales); // this show the function string!!!
if (!locales.data) {
throw new Error('REST response must contain a data key');
}
yield put( {type: types.LOCALES_RECEIVED, locales } )
} catch (error) {
console.log(error);
yield put({type: types.GET_LOCALES_FAILURE, error})
}
}
export default function* localesSaga() {
yield [
takeEvery(types.GET_LOCALES, getLocalesSaga),
takeEvery(types.GET_LOCALES_LOADING, actions.loadingLocales),
takeEvery(types.LOCALES_RECEIVED, actions.localesReceived),
takeEvery(types.GET_LOCALES_FAILURE, actions.failedLocales),
];
}
The console.log output is:
ƒ (type, resource, params) {
if (type === __WEBPACK_IMPORTED_MODULE_0_admin_on_rest__["GET_MANY"]) {
return Promise.all(params.ids.map(function (id) {
return httpClient(apiUrl + '/' +…
There is no request in the browser networks tab.
No JS errors in console apart the console.log.
I registered the saga with customSagas={customsSagas} on the Admin component.
When I use fetch() function it works!
I want to use my restClient which include the authentication token and all logic of request and response.
The restClient is custom and this is the code:
import {
GET_LIST,
GET_ONE,
GET_MANY,
GET_MANY_REFERENCE,
CREATE,
UPDATE,
DELETE,
fetchUtils
} from 'admin-on-rest';
const { queryParameters, fetchJson } = fetchUtils;
const apiUrl = process.env.REACT_APP_API_PATH;
const httpClient = (url, options = {}) => {
if (!options.headers) {
options.headers = new Headers({ Accept: 'application/json' });
}
const token = localStorage.getItem('token');
options.headers.set('Authorization', `Bearer ${token}`);
return fetchJson(url, options);
}
/**
* Maps admin-on-rest queries to a json-server powered REST API
*
* #see https://github.com/typicode/json-server
* #example
* GET_LIST => GET http://my.api.url/posts?_sort=title&_order=ASC&_start=0&_end=24
* GET_ONE => GET http://my.api.url/posts/123
* GET_MANY => GET http://my.api.url/posts/123, GET http://my.api.url/posts/456, GET http://my.api.url/posts/789
* UPDATE => PUT http://my.api.url/posts/123
* CREATE => POST http://my.api.url/posts/123
* DELETE => DELETE http://my.api.url/posts/123
*/
export default () => {
/**
* #param {String} type One of the constants appearing at the top if this file, e.g. 'UPDATE'
* #param {String} resource Name of the resource to fetch, e.g. 'posts'
* #param {Object} params The REST request params, depending on the type
* #returns {Object} { url, options } The HTTP request parameters
*/
const convertRESTRequestToHTTP = (type, resource, params) => {
let url = '';
const options = {};
switch (type) {
case GET_LIST: {
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const query = {
...params.filter,
sort: field,
order: order,
page: page,
per_page: perPage,
};
url = `${apiUrl}/${resource}?${queryParameters(query)}`;
break;
}
case GET_ONE:
url = `${apiUrl}/${resource}/${params.id}`;
break;
case GET_MANY_REFERENCE: {
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const query = {
...params.filter,
[params.target]: params.id,
_sort: field,
_order: order,
_start: (page - 1) * perPage,
_end: page * perPage,
};
url = `${apiUrl}/${resource}?${queryParameters(query)}`;
break;
}
case UPDATE:
url = `${apiUrl}/${resource}/${params.id}`;
options.method = 'PUT';
options.body = JSON.stringify(params.data);
break;
case CREATE:
url = `${apiUrl}/${resource}`;
options.method = 'POST';
options.body = JSON.stringify(params.data);
break;
case DELETE:
url = `${apiUrl}/${resource}/${params.id}`;
options.method = 'DELETE';
break;
default:
throw new Error(`Unsupported fetch action type ${type}`);
}
return { url, options };
};
/**
* #param {Object} response HTTP response from fetch()
* #param {String} type One of the constants appearing at the top if this file, e.g. 'UPDATE'
* #param {String} resource Name of the resource to fetch, e.g. 'posts'
* #param {Object} params The REST request params, depending on the type
* #returns {Object} REST response
*/
const convertHTTPResponseToREST = (response, type, resource, params) => {
const { headers, json } = response;
switch (type) {
case GET_LIST:
case GET_MANY_REFERENCE:
if (!headers.has('x-total-count')) {
throw new Error('The X-Total-Count header is missing in the HTTP Response. The jsonServer REST client expects responses for lists of resources to contain this header with the total number of results to build the pagination. If you are using CORS, did you declare X-Total-Count in the Access-Control-Expose-Headers header?');
}
return {
// change the primary key to uuid
data: json.data.map(resource => resource = { ...resource, id: resource.uuid }),
total: parseInt(headers.get('x-total-count').split('/').pop(), 10),
};
case UPDATE:
case DELETE:
case GET_ONE:
return { data: json, id: json.uuid };
case CREATE:
return { data: { ...params.data, id: json.uuid } };
default:
return { data: json };
}
};
/**
* #param {string} type Request type, e.g GET_LIST
* #param {string} resource Resource name, e.g. "posts"
* #param {Object} payload Request parameters. Depends on the request type
* #returns {Promise} the Promise for a REST response
*/
return (type, resource, params) => {
if (type === GET_MANY) {
return Promise.all(params.ids.map(id => httpClient(`${apiUrl}/${resource}/${id}`)))
.then(responses => ({ data: responses.map(response => response.json) }));
}
const { url, options } = convertRESTRequestToHTTP(type, resource, params);
return httpClient(url, options)
.then(response => convertHTTPResponseToREST(response, type, resource, params));
};
};
Any one can help here and tell me why the restClient is returned as a string instead of returning the json?
It is not a GET_LIST nor GET_ONE request. It is just a normal GET request.
I tried to use GET_ONE and GET_LIST but I still get the response as the function plain string.
Edit & Solution:
Thanks to #Gildas it is clearer to use fetch instead of restClient for a GET request. restClient is used just for <resource /> and it is not so clear by the docs.
Moreover, the actions creators were useless when I use put in my main saga function.
My fetch looks like this and it works:
function getLocales() {
return fetch(process.env.REACT_APP_API_PATH + '/locales', { method: 'GET' })
.then(response => (
Promise.resolve(response)
))
.then(response => (
response.json()
))
.catch((e) => {
console.error(e);
});
}
When I called it like this:
const { languages, currentLocale } = yield call(getLocales);
Thank you in advanced.
Leo.
This part is really strange, it seems you're binding action creators as if they were sagas, though you do put them in the getLocalesSaga:
export default function* localesSaga() {
yield [
takeEvery(types.GET_LOCALES, getLocalesSaga),
takeEvery(types.GET_LOCALES_LOADING, actions.loadingLocales),
takeEvery(types.LOCALES_RECEIVED, actions.localesReceived),
takeEvery(types.GET_LOCALES_FAILURE, actions.failedLocales),
];
}
Moreover, the restClient is not fetch. GET is not a type recognized by it (see documentation). You should not use the restClient for anything that's not a resource in admin-on-rest terms. Here, you should indeed use fetch.
This should probably be rewritten like:
import types from './types';
import actions from './actions';
import { call, put, takeEvery } from 'redux-saga/effects';
import restClient from './../../restClient';
function fetchLocales() {
return fetch(...);
}
function* getLocalesSaga() {
try {
yield put(actions.loadingLocales());
let locales;
locales = yield call(fetchLocales);
console.log(locales); // this show the function string!!!
if (!locales.data) {
throw new Error('REST response must contain a data key');
}
yield put(actions.localesReceived(locales))
} catch (error) {
console.log(error);
yield put(actions.failedLocales(error))
}
}
export default function* localesSaga() {
yield takeEvery(types.GET_LOCALES, getLocalesSaga);
}
Related
I am quite new in redux world and have not yet had a project structured the ducks way. I am trying to understand it and use it to make a mock api, since I don't have the backend ready yet. I am working with the legacy code, that I am trying to figure out. There is a folder called data, that has a duck and a backendApi file. Duck file looks like this.
data/duck.jsx
import { createSelector } from 'reselect';
import { createReduxApi } from './backendApi';
const getDataContext = state => state.default.dataContext;
const backendReduxApi = createBackendReduxApi(getDataContext);
// Action creators
export const makeRestApiRequest = endpointName => backendReduxApi .makeRequestActionCreator(endpointName);
export const resetRestApi = endpointName => backendReduxApi .makeResetActionCreator(endpointName);
// Reducers
export const dataReducer = backendReduxApi .createReducer();
// Selectors
const getRestApiState = endpointName => backendReduxApi .getEndpointState(endpointName);
export const getRestApiData = endpointName => createSelector([getRestApiState(endpointName)], apiState => apiState.data);
export const getRestApiMeta = endpointName => createSelector([getRestApiState(endpointName)], apiState => apiState.meta);
export const getRestApiError = endpointName => createSelector([getRestApiState(endpointName)], apiState => apiState.error);
export const getRestApiStarted = endpointName => createSelector([getRestApiState(endpointName)], apiState => apiState.started);
export const getRestApiFinished = endpointName => createSelector([getRestApiState(endpointName)], apiState => apiState.finished);
The backendApi.jsx file looks like this:
data/backendApi.jsx
import ReduxRestApi from './rest/ReduxRestApi';
export const BackendApi = { // NOSONAR
LANGUAGE_FILE: 'languageFile',
EMPLOYEE: 'employee',
};
const backendReduxApiBuilder = ReduxRestApi.build()
/* /api */
/* /api/employee */
.withGet('/myproject/api/employee', BackendApi.EMPLOYEE)
/* /language*/
.withGet('/myproject/language/nb_NO.json', BackendApi.LANGUAGE_FILE)
export const createBackendReduxApi = restApiSelector => backendReduxApiBuilder
.withRestApiSelector(restApiSelector)
.create();
Then in the data/rest folder I have 4 files: ReduxRestApi, restConfig, RestDuck and restMethods.
data/rest/ReduxRestApi.jsx
import { combineReducers } from 'redux';
import { get, post, postAndOpenBlob } from './restMethods';
import RestDuck from './RestDuck';
class ReduxRestApi {
constructor(endpoints, getRestApiState) {
this.createReducer = this.createReducer.bind(this);
this.getEndpoint = this.getEndpoint.bind(this);
this.makeRequestActionCreator = this.makeRequestActionCreator.bind(this);
this.makeResetActionCreator = this.makeResetActionCreator.bind(this);
this.getEndpointState = this.getEndpointState.bind(this);
this.ducks = endpoints.map(({ name, path, restMethod }) => new RestDuck(name, path, restMethod, getRestApiState));
}
createReducer() {
const reducers = this.ducks
.map(duck => ({ [duck.name]: duck.reducer }))
.reduce((a, b) => ({ ...a, ...b }), {});
return combineReducers(reducers);
}
getEndpoint(endpointName) {
return this.ducks.find(duck => duck.name === endpointName)
|| { actionCreators: {} };
}
makeRequestActionCreator(endpointName) {
return this.getEndpoint(endpointName).actionCreators.execRequest;
}
makeResetActionCreator(endpointName) {
return this.getEndpoint(endpointName).actionCreators.reset;
}
getEndpointState(endpointName) {
return this.getEndpoint(endpointName).stateSelector;
}
static build() {
class RestApiBuilder {
constructor() {
this.withGet = this.withGet.bind(this);
this.withPost = this.withPost.bind(this);
this.withPostAndOpenBlob = this.withPostAndOpenBlob.bind(this);
this.withRestApiSelector = this.withRestApiSelector.bind(this);
this.endpoints = [];
}
withGet(path, name) {
this.endpoints.push({ path, name, restMethod: get });
return this;
}
withPost(path, name) {
this.endpoints.push({ path, name, restMethod: post });
return this;
}
withPostAndOpenBlob(path, name) {
this.endpoints.push({ path, name, restMethod: postAndOpenBlob });
return this;
}
withRestApiSelector(restApiSelector) {
this.restApiSelector = restApiSelector;
return this;
}
create() {
return new ReduxRestApi(
this.endpoints,
this.restApiSelector
);
}
}
return new RestApiBuilder();
}
}
export default ReduxRestApi;
restConfig.jsx
import axios from 'axios';
import { removeErrorMessage, showErrorMessage } from '../../app/duck';
import { is401Error, isHandledError } from '../../app/ErrorTypes';
const isDevelopment = process.env.NODE_ENV === 'development';
const configureRequestInterceptors = (store) => {
const onRequestAccepted = (config) => {
store.dispatch(removeErrorMessage());
return config;
};
const onRequestRejected = error => Promise.reject(error);
axios.interceptors.request.use(onRequestAccepted, onRequestRejected);
};
const configureResponseInterceptors = (store) => {
const onSuccessResponse = response => response;
const onErrorResponse = (error) => {
if (is401Error(error) && !isDevelopment) {
window.location.reload();
}
if (!isHandledError(error)) {
store.dispatch(showErrorMessage(error));
}
return Promise.reject(error);
};
axios.interceptors.response.use(onSuccessResponse, onErrorResponse);
};
const configureRestInterceptors = (store) => {
configureRequestInterceptors(store);
configureResponseInterceptors(store);
};
export default configureRestInterceptors;
data/rest/RestDuck.jsx
import { createSelector } from 'reselect';
import { get, getBlob, post, postAndOpenBlob, postBlob } from './restMethods';
/**
* getMethodName
* Helper function that maps given AJAX-method to a name
*
* Ex. getMethodName(getBlob) -> 'GET'
*/
const getMethodName = (restMethod) => {
switch (restMethod) {
case get:
case getBlob:
return 'GET';
case post:
case postBlob:
case postAndOpenBlob:
return 'POST';
default:
return '';
}
};
/**
* createRequestActionType
* Helper function to generate actionType for actions related to AJAX calls
*
* Ex: createRequestActionType('fetchEmployee', 'ERROR', get, '/myproject/api/employee') -> '##REST/fetchEmployee GET /myproject/api/employeeERROR'
*/
const createRequestActionType = (name, qualifier, restMethod = '', path = '') => [`##REST/${name}`, getMethodName(restMethod), path, qualifier]
.filter(s => s !== '')
.join(' ');
/**
* createRequestActionTypes
* Helper function to generate ActionTypes for a given AJAX method and resource.
*
* Ex. createRequestActionType(fetchEmployee, get, '/myproject/api/employee') -> {
* reset: '##REST GET /myproject/api/employee RESET',
* requestStarted: '##REST GET /myproject/api/employee STARTED',
* requestError: '##REST GET /myproject/api/employee ERROR',
* requestFinished: '##REST GET /myproject/api/employee FINISHED',
* }
*/
const createRequestActionTypes = (name, restMethod, path) => ({
reset: createRequestActionType(name, 'RESET'),
requestStarted: createRequestActionType(name, 'STARTED', restMethod, path),
requestError: createRequestActionType(name, 'ERROR', restMethod, path),
requestFinished: createRequestActionType(name, 'FINISHED', restMethod, path)
});
/**
* createRequestThunk
* Helper function that generates a thunk that performs an AJAX call specified by 'restMethod' and 'restEndpoint'
*
* When the thunk is running, the action 'requestStarted' will be dispatched immediately.
* Then, it performs the AJAX call that returns a promise.
* If the call goes well, the action 'requestFinished' will be dispatched with data from the call.
* If the call fails, the action 'requestError' is dispatched with the contents of the error.
*/
const createRequestThunk = (restMethod, restEndpoint, requestStarted, requestFinished, requestError) => (
(params, options = {}) => (dispatch) => {
dispatch(requestStarted(params, options));
return restMethod(restEndpoint, params)
.catch((error) => {
const data = error.response && error.response.data ? error.response.data : error;
dispatch(requestError(data));
return Promise.reject(error);
})
.then((response) => {
dispatch(requestFinished(response.data));
return response;
});
}
);
/**
* createRequestActionCreators
* Helper function that creates action creators 'requestStarted', 'requestFinished' and 'requestError',
* #see createRequestThunkCreator
*/
const createRequestActionCreators = (restMethod, restEndpoint, actionTypes) => {
const reset = () => ({ type: actionTypes.reset });
const requestStarted = (params, options = {}) => ({ type: actionTypes.requestStarted, payload: { params, timestamp: Date.now() }, meta: { options } });
const requestFinished = data => ({ type: actionTypes.requestFinished, payload: data });
const requestError = error => ({ type: actionTypes.requestError, payload: error });
const execRequest = createRequestThunk(restMethod, restEndpoint, requestStarted, requestFinished, requestError);
return {
reset, requestStarted, requestFinished, requestError, execRequest
};
};
/**
* createRequestReducer
*
* Helper function that creates a reducer for an AJAX call.
* Reducer alters the state of the actions with the name defined by
* actionTypes.requestStarted
* actionTypes.requestFinished
* actionTypes.requestError
*/
const createRequestReducer = (restMethod, resourceName, actionTypes) => {
const initialState = {
data: undefined,
meta: undefined,
error: undefined,
started: false,
finished: false
};
return (state = initialState, action = {}) => {
switch (action.type) {
case actionTypes.requestStarted:
return {
...initialState,
data: action.meta.options.keepData ? state.data : initialState.data,
started: true,
meta: action.payload
};
case actionTypes.requestFinished:
return {
...state,
started: false,
finished: true,
data: action.payload
};
case actionTypes.requestError:
return {
...state,
started: false,
error: action.payload
};
case actionTypes.reset:
return {
...initialState
};
default:
return state;
}
};
};
/**
* RestDuck
* Class that offers action types, action creators, reducers and selectors for an AJAX call.
* #see createRequestActionTypes
* #see createRequestActionCreators
* #see createRequestReducer
*
* Ex.
* const getEmployeeDuck = new RestDuck(execGetRequest, 'employee', GET_EMPLOYEE_SERVER_URL);
* // Action creators
* export const fetchEmployee = getEmployeeDuck.actionCreators.execRequest;
* // Reducer
* export const dataReducer = combineReducers(
* ...,
* getEmployeeDuck.reducer,
* }
* // Selectors
* export const getDataContext = state => state.default.dataContext;
* export const getEmployeeData = getEmployeeDuck.selectors.getRequestData(getDataContext);
* export const getEmployeeStarted = getEmployeeDuck.selectors.getRequestStarted(getDataContext);
* ...
*/
class RestDuck {
constructor(name, path, restMethod, getApiContext) {
this.restMethod = restMethod;
this.name = name;
this.path = path;
this.getApiContext = getApiContext;
this.$$duck = {}; // for class internal use
}
get actionTypes() {
if (!this.$$duck.actionTypes) {
this.$$duck.actionTypes = createRequestActionTypes(this.name, this.restMethod, this.path);
}
return this.$$duck.actionTypes;
}
get actionCreators() {
if (!this.$$duck.actionCreators) {
this.$$duck.actionCreators = createRequestActionCreators(this.restMethod, this.path, this.actionTypes);
}
return this.$$duck.actionCreators;
}
get reducer() {
if (!this.$$duck.reducer) {
this.$$duck.reducer = createRequestReducer(this.restMethod, this.name, this.actionTypes);
}
return this.$$duck.reducer;
}
get stateSelector() {
return createSelector([this.getApiContext], restApiContext => restApiContext[this.name]);
}
}
export default RestDuck;
data/rest/restMethods.jsx
import axios, { CancelToken } from 'axios';
const openPreview = (data) => {
if (window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveOrOpenBlob(data);
} else {
window.open(URL.createObjectURL(data));
}
};
const cancellable = (config) => {
let cancel;
const request = axios({
...config,
cancelToken: new CancelToken((c) => { cancel = c; })
});
request.cancel = cancel;
return request.catch(error => (axios.isCancel(error) ? Promise.reject(new Error(null)) : Promise.reject(error)));
};
const defaultHeaders = {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
Expires: 0
};
const defaultPostHeaders = {
'Content-Type': 'application/json'
};
export const get = (url, params, responseType = 'json') => cancellable({
url,
params,
responseType,
method: 'get',
headers: {
...defaultHeaders
}
});
export const post = (url, data, responseType = 'json') => cancellable({
url,
responseType,
data: JSON.stringify(data),
method: 'post',
headers: {
...defaultHeaders,
...defaultPostHeaders
},
cache: false
});
export const getBlob = (url, params) => get(url, params, 'blob');
export const postBlob = (url, data) => post(url, data, 'blob');
export const postAndOpenBlob = (url, data) => postBlob(url, data)
.then((response) => {
openPreview(response.data);
return {
...response,
data: 'blob opened as preview' // Don't waste memory by storing blob in state
};
});
I am not sure where to place and how to do mock api calls in this structure. I was thinking of making a mock api similiar to this one, where I would mimick the ajax calls and store them in the redux, but just not sure how to do this in this kind of setup?
I have tried with making the mockApi folder and instead of using the restMethods, to use the file where I would write promises that would resolve the mockData. This is my attempt:
mockRestMethods
const employee = {
name: 'Joe Doe'
}
const data = {
employee
};
export const get = item => new Promise((resolve) => {
setTimeout(() => {
resolve({ data: data[item] });
}, 1000);
});
But, if I inspect what is returned as the response.data inside the createRequestThunk function in the RestDuck file I get data: undefined there. Why is that, what am I doing wrong?
I may well have this wrong, but it seems like you are replacing
export const get = (url, params, responseType = 'json') => cancellable({
with export const get = item => new Promise((resolve) => { which has a different API.
Regardless, have you tried logging the value of item in the mock get function. I'm guessing it isn't "employee" which is the only property in data.
Yes, that was my goal, to replace the call that was pointing to the backend API, with the call where I would return the mock data. I have tried to log the value of the item, but I get undefined
ok, so there's an awful lot of abstraction going on there. Id suggest starting by replacing get in data/rest/restMethods.jsx directly with a version that returns a promise, get it working, and then break it out. That way you're not dealing with too many unknowns at once.
I had done similar using redux-saga. After debugging, I had found that there must be data property as root key. Here's how you should do:
const employee = {
data: { // root key of employee
items: [
{ name: 'Bhojendra' },
{ name: 'Rauniyar' }
]
}
}
// and no need to use setTimeout, we're just resolving some constant data
export const getItems = item => new Promise(resolve => resolve(employee))
Now, I hope you know why data is undefined with your code.
Still not clear?
Response looks for the data property. That's it.
I have the following code:
export const myEpic = (action$, store) =>
action$.ofType("SOME_ACTION")
.switchMap(action => {
const {siteId, selectedProgramId} = action;
const state = store.getState();
const siteProgram$ = Observable.fromPromise(axios.get(`/url/${siteId}/programs`))
.catch(error =>{
return Observable.of({
type: 'PROGRAM_FAILURE'
error
});
});
const programType$ = Observable.fromPromise(axios.get('url2'))
.catch(error =>{
return Observable.of({
type: "OTHER_FAILURE",
error
});
});
so far so good, when there is an error I catch it, and (maybe this is wrong) map it to an action (indicating something failed).
now the question begins, I have another observable which is the result of the zip operator of the two observables from above:
const siteProgram$result$ = Observable.zip(siteProgram$, programType$)
.map(siteProgramsAndProgramTypes => siteProgramsAndProgramTypesToFinalSiteProgramsActionMapper(siteProgramsAndProgramTypes, siteId));
the problem is that I still get to this observable as if everything is fine.
is there a way to "understand" that one of the "zipped" observables errored and then not get to the "next" of siteProgram$result$.
I think I am missing something trivial...
I don't want to have to perform this check:
const siteProgramsAndProgramTypesToFinalSiteProgramsActionMapper = (siteProgramsAndProgramTypesArray, siteId) => {
const [programsResponse, programTypesResponse] = siteProgramsAndProgramTypesArray;
if (programsResponse.error || programTypesResponse.error){
return {
type: 'GENERAL_ERROR',
};
}
everytime I have an observable which is a result of an operator on other observable that might have errored.
in pure rxjs (not in redux observable) I think I could subscribe to it passing it an object
{
next: val => some logic,
error: err => do what ever I want :) //this is what I am missing in redux observable,
complete: () => some logic
}
// some more logic
return Observable.concat(programType$Result$, selectedProgramId$, siteProgram$result$);
What is the right way to attack this in redux observable?
Thanks.
Here is a detailed example with an API wrapper to facilitate what you're trying to achieve.
The gist is available on GitHub here
Here is the API wrapper which wraps Observable.ajax and lets you dispatch single actions or array of actions and handles both XHR and Application level generated errors that stem from the requests made with Observable.ajax
import * as Rx from 'rxjs';
import queryString from 'query-string';
/**
* This function simply transforms any actions into an array of actions
* This enables us to use the synthax Observable.of(...actions)
* If an array is passed to this function it will be returned automatically instead
* Example: mapObservables({ type: ACTION_1 }) -> will return: [{ type: ACTION_1 }]
* Example2: mapObservables([{ type: ACTION_1 }, { type: ACTION_2 }]) -> will return: [{ type: ACTION_1 }, { type: ACTION_2 }]
*/
function mapObservables(observables) {
if (observables === null) {
return null;
} else if (Array.isArray(observables)) {
return observables;
}
return [observables];
}
/**
* Possible Options:
* params (optional): Object of parameters to be appended to query string of the uri e.g: { foo: bar } (Used with GET requests)
* headers (optional): Object of headers to be appended to the request headers
* data (optional): Any type of data you want to be passed to the body of the request (Used for POST, PUT, PATCH, DELETE requests)
* uri (required): Uri to be appended to our API base url
*/
function makeRequest(method, options) {
let uri = options.uri;
if (method === 'get' && options.params) {
uri += `?${queryString.stringify(options.params)}`;
}
return Rx.Observable.ajax({
headers: {
'Content-Type': 'application/json;charset=utf-8',
...options.headers,
},
responseType: 'json',
timeout: 60000,
body: options.data || null,
method,
url: `http://www.website.com/api/v1/${uri}`,
// Most often you have a fixed API url so we just append a URI here to our fixed URL instead of repeating the API URL everywhere.
})
.flatMap(({ response }) => {
/**
* Here we handle our success callback, anyt actions returned from it will be dispatched.
* You can return a single action or an array of actions to be dispatched eg. [{ type: ACTION_1 }, { type: ACTION_2 }].
*/
if (options.onSuccess) {
const observables = mapObservables(options.onSuccess(response));
if (observables) {
// This is only being called if our onSuccess callback returns any actions in which case we have to dispatch them
return Rx.Observable.of(...observables);
}
}
return Rx.Observable.of();
})
.catch((error) => {
/**
* This if case is to handle non-XHR errors gracefully that may be coming from elsewhere in our application when we fire
* an Observable.ajax request
*/
if (!error.xhr) {
if (options.onError) {
const observables = mapObservables(options.onError(null)); // Note we pass null to our onError callback because it's not an XHR error
if (observables) {
// This is only being called if our onError callback returns any actions in which case we have to dispatch them
return Rx.Observable.of(...observables);
}
}
// You always have to ensure that you return an Observable, even if it's empty from all your Observables.
return Rx.Observable.of();
}
const { xhr } = error;
const { response } = error.xhr;
const actions = [];
const resArg = response || null;
let message = null;
if (xhr.status === 0) {
message = 'Server is not responding.';
} else if (xhr.status === 401) {
// For instance we handle a 401 here, if you use react-router-redux you can simply push actions here to your router
actions.push(
replace('/login'),
);
} else if (
response
&& response.errorMessage
) {
/*
* In this case the errorMessage parameter would refer to SampleApiResponse.json 400 example
* { "errorMessage": "Invalid parameter." }
*/
message = response.errorMessage;
}
if (options.onError) {
// Here if our options contain an onError callback we can map the returned Actions and push them into our action payload
mapObservables(options.onError(resArg)).forEach(o => actions.push(o));
}
if (message) {
actions.push(showMessageAction(message));
}
/**
* You can return multiple actions in one observable by adding arguments Rx.Observable.of(action1, action2, ...)
* The actions always have to have a type { type: 'ACTION_1' }
*/
return Rx.Observable.of(...actions);
});
}
const API = {
get: options => makeRequest('get', options),
post: options => makeRequest('post', options),
put: options => makeRequest('put', options),
patch: options => makeRequest('patch', options),
delete: options => makeRequest('delete', options),
};
export default API;
Here are the actions, action creators and epics:
import API from 'API';
const FETCH_PROFILE = 'FETCH_PROFILE';
const FETCH_PROFILE_SUCCESS = 'FETCH_PROFILE_SUCCESS';
const FETCH_PROFILE_ERROR = 'FETCH_PROFILE_ERROR';
const FETCH_OTHER_THING = 'FETCH_OTHER_THING';
const FETCH_OTHER_THING_SUCCESS = 'FETCH_OTHER_THING_SUCCESS';
const FETCH_OTHER_THING_ERROR = 'FETCH_OTHER_THING_ERROR';
function fetchProfile(id) {
return {
type: FETCH_PROFILE,
id,
};
}
function fetchProfileSuccess(data) {
return {
type: FETCH_PROFILE_SUCCESS,
data,
};
}
function fetchProfileError(error) {
return {
type: FETCH_PROFILE_ERROR,
error,
};
}
function fetchOtherThing(id) {
return {
type: FETCH_OTHER_THING,
id,
};
}
const fetchProfileEpic = action$ => action$.
ofType(FETCH_PROFILE)
.switchMap(({ id }) => API.get({
uri: 'profile',
params: {
id,
},
/*
* We could also dispatch multiple actions using an array here you could dispatch another API request if needed
* We can redispatch another action to fire another epic if we want also.
* In both onSuccess and onError you can return a single action, an array of actions or null
* Note here we fire fetchOtherThing(data.someOtherThingId) which will trigger our fetchOtherThingEpic!
* In this case the data parameter would refer to SampleApiResponse.json 200 example
* { firstName: "John", lastName: "Doe" }
*/
onSuccess: ({ data }) => [fetchProfileSuccess(data), fetchOtherThing(data.someOtherThingId)],
onError: error => fetchProfileError(error),
})
const fetchOtherThingEpic = action$ => action$.
ofType(FETCH_OTHER_THING)
.switchMap(({ id }) => API.get({
uri: 'other-thing',
params: {
id,
},
onSuccess: ...
onError: ...
});
Here is a data sample that works with the examples shown above:
/*
* If possible, you should standardize your API response which will make error/data handling a lot easier on the client side
* Note, this data format is to work with the example code above
*/
/**
* Status code: 401
* This error would be caught in the .catch() method of our API wrapper
*/
{
"errorMessage": "Please login to perform this operation",
"data": null,
}
/**
* Status code: 400
* This error would be caught in the .catch() method of our API wrapper
*
*/
{
"errorMessage": "Invalid parameter.",
"data": null
}
/**
* Status code: 200
* This would be passed to our onSuccess function specified in our API options
*/
{
"errorMessage": 'Please login to perform this operation',
"data": {
"firstName": "John",
"lastName": "Doe"
}
}
I'm having a really difficult time connecting admin-on-rest with my rails api. I've been following the tutorial walk through at https://marmelab.com/admin-on-rest/Tutorial.html but when I point to my localhost is where all the trouble starts.
Response
{
"events": [
{
"id": 13,
"name": "Event 1",
"description": "test"
},
{
"id": 16,
"name": "Event 2",
"description": "dsadfa adf asd"
},
{
"id": 17,
"name": "Event 3",
"description": "Hey this is a test"
},
{
"id": 18,
"name": "Some Stuff",
"description": "Yay, it work"
},
{
"id": 20,
"name": "Test",
"description": "asdfs"
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 5
}
}
App.js
import React from 'react';
import { jsonServerRestClient, fetchUtils, Admin, Resource } from 'admin-on-rest';
import apiClient from './apiClient';
import { EventList } from './events';
import { PostList } from './posts';
const httpClient = (url, options = {}) => {
if (!options.headers) {
options.headers = new Headers({ Accept: 'application/json' });
}
// add your own headers here
// options.headers.set('X-Custom-Header', 'foobar');
return fetchUtils.fetchJson(url, options);
}
const restClient = apiClient('http://localhost:5000/admin', httpClient);
const App = () => (
<Admin title="My Admin" restClient={restClient}>
<Resource name="events" list={EventList} />
</Admin>
);
export default App;
apiClient.js
import { stringify } from 'query-string';
import {
GET_LIST,
GET_ONE,
GET_MANY,
GET_MANY_REFERENCE,
CREATE,
UPDATE,
DELETE,
fetchJson
} from 'admin-on-rest';
/**
* Maps admin-on-rest queries to a simple REST API
*
* The REST dialect is similar to the one of FakeRest
* #see https://github.com/marmelab/FakeRest
* #example
* GET_LIST => GET http://my.api.url/posts?sort=['title','ASC']&range=[0, 24]
* GET_ONE => GET http://my.api.url/posts/123
* GET_MANY => GET http://my.api.url/posts?filter={ids:[123,456,789]}
* UPDATE => PUT http://my.api.url/posts/123
* CREATE => POST http://my.api.url/posts/123
* DELETE => DELETE http://my.api.url/posts/123
*/
export default (apiUrl, httpClient = fetchJson) => {
/**
* #param {String} type One of the constants appearing at the top if this file, e.g. 'UPDATE'
* #param {String} resource Name of the resource to fetch, e.g. 'posts'
* #param {Object} params The REST request params, depending on the type
* #returns {Object} { url, options } The HTTP request parameters
*/
const convertRESTRequestToHTTP = (type, resource, params) => {
console.log(type)
console.log(params)
console.log(params.filter)
console.log(resource)
console.log(fetchJson)
let url = '';
const options = {};
switch (type) {
case GET_LIST: {
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const query = {
sort: JSON.stringify([field, order]),
range: JSON.stringify([
(page - 1) * perPage,
page * perPage - 1,
]),
filter: JSON.stringify(params.filter),
};
url = `${apiUrl}/${resource}?${stringify(query)}`;
break;
}
case GET_ONE:
url = `${apiUrl}/${resource}/${params.id}`;
break;
case GET_MANY: {
const query = {
filter: JSON.stringify({ id: params.ids }),
};
url = `${apiUrl}/${resource}?${stringify(query)}`;
break;
}
case GET_MANY_REFERENCE: {
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const query = {
sort: JSON.stringify([field, order]),
range: JSON.stringify([
(page - 1) * perPage,
page * perPage - 1,
]),
filter: JSON.stringify({
...params.filter,
[params.target]: params.id,
}),
};
url = `${apiUrl}/${resource}?${stringify(query)}`;
break;
}
case UPDATE:
url = `${apiUrl}/${resource}/${params.id}`;
options.method = 'PUT';
options.body = JSON.stringify(params.data);
break;
case CREATE:
url = `${apiUrl}/${resource}`;
options.method = 'POST';
options.body = JSON.stringify(params.data);
break;
case DELETE:
url = `${apiUrl}/${resource}/${params.id}`;
options.method = 'DELETE';
break;
default:
throw new Error(`Unsupported fetch action type ${type}`);
}
return { url, options };
};
/**
* #param {Object} response HTTP response from fetch()
* #param {String} type One of the constants appearing at the top if this file, e.g. 'UPDATE'
* #param {String} resource Name of the resource to fetch, e.g. 'posts'
* #param {Object} params The REST request params, depending on the type
* #returns {Object} REST response
*/
const convertHTTPResponseToREST = (response, type, resource, params) => {
const { headers, json } = response;
switch (type) {
case GET_LIST:
case GET_MANY_REFERENCE:
// if (!headers.has('content-range')) {
// throw new Error(
// 'The Content-Range header is missing in the HTTP Response. The simple REST client expects responses for lists of resources to contain this header with the total number of results to build the pagination. If you are using CORS, did you declare Content-Range in the Access-Control-Expose-Headers header?'
// );
// }
console.log("DATA", json[resource])
return {
data: json[resource],
total: json.meta.total_count
};
case CREATE:
return { data: { ...params.data, id: json.id } };
default:
return { data: json };
}
};
/**
* #param {string} type Request type, e.g GET_LIST
* #param {string} resource Resource name, e.g. "posts"
* #param {Object} payload Request parameters. Depends on the request type
* #returns {Promise} the Promise for a REST response
*/
return (type, resource, params) => {
const { url, options } = convertRESTRequestToHTTP(
type,
resource,
params
);
return httpClient(url, options).then(response =>{
console.log("RESPONSE", response);
convertHTTPResponseToREST(response, type, resource, params)}
);
};
};
events.js
import React from 'react';
import { List, Datagrid, Edit, Create, SimpleForm, DateField, ImageField, ReferenceField, translate,
TextField, EditButton, DisabledInput, TextInput, LongTextInput, DateInput, Show, Tab, TabbedShowLayout } from 'admin-on-rest';
export EventIcon from 'material-ui/svg-icons/action/today';
const EventTitle = translate(({ record, translate }) => (
<span>
{record ? translate('event.edit.name', { title: record.name }) : ''}
</span>
));
export const EventList = (props) => (
<List {...props}>
<Datagrid>
<TextField source="id" />
<TextField source="name" />
<TextField source="description" />
<DateField source="date" />
<ImageField source="flyer" />
<EditButton basePath="/events" />
</Datagrid>
</List>
);
The error i receive current is Cannot read property 'data' of undefined but I can verify through my logs that the data is being received correctly.
Try using data as the root level key in your json response. Change events to data, for example:
{
"data": [
{
"id": 13,
"name": "Event 1",
"description": "test"
},
{
"id": 16,
"name": "Event 2",
"description": "dsadfa adf asd"
}
],
"meta": {...}
}
I'm trying to update the Angular2 Forms Validation example to handle an Async Validation response. This way I can hit an HTTP endpoint to validate a username.
Looking at their code they currently aren't currently using a Promise and it's working just fine:
/** A hero's name can't match the given regular expression */
export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
return (control: AbstractControl): {[key: string]: any} => {
const name = control.value;
const no = nameRe.test(name);
return no ? {'forbiddenName': {name}} : null;
};
}
I'm trying to update to return a Promise. Something like:
/** A hero's name can't match the given regular expression */
export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
return (control: AbstractControl) => {
const name = control.value;
return new Promise( resolve => {
resolve({'forbiddenName': {name}});
});
};
}
However, the result I get doesn't display the error message, it's showing undefined.
My thought is it has something to do with the way they are handling displaying the errors:
onValueChanged(data?: any) {
if (!this.heroForm) { return; }
const form = this.heroForm;
for (const field in this.formErrors) {
// clear previous error message (if any)
this.formErrors[field] = '';
const control = form.get(field);
if (control && control.dirty && !control.valid) {
const messages = this.validationMessages[field];
for (const key in control.errors) {
this.formErrors[field] += messages[key] + ' ';
}
}
}
}
However I'm not sure of a better way of doing this.
Angular2 example:
https://angular.io/docs/ts/latest/cookbook/form-validation.html#!#live-example
Link to my example attempting to return Promise:
https://plnkr.co/edit/sDs9pNQ1Bs2knp6tasgI?p=preview
The problem is that you add the AsyncValidator to the SyncValidator Array. AsyncValidators are added in a separate array after the SyncValidators:
this.heroForm = this.fb.group({
'name': [this.hero.name, [
Validators.required,
Validators.minLength(4),
Validators.maxLength(24)
],
[forbiddenNameValidator(/bob/i)] // << separate array
],
'alterEgo': [this.hero.alterEgo],
'power': [this.hero.power, Validators.required]
});
I was trying to use the default template in a rest query using this:
/company/somecompanyid?template=default
But I still get all the data from my mongodb including fields and collections of related tables that are not in the templates.
This is also defined as the defaultTemplate in the model but it doesn't seems to impact the results in any way.
Can someone explain what I am doing wrong and how to apply the template?
If I want to include just the object Id rather than a whole related object in the reply, how do I specify it in the template?
company.js:
'use strict';
/**
* Module dependencies
*/
// Node.js core.
const path = require('path');
// Public node modules.
const _ = require('lodash');
const anchor = require('anchor');
// Local dependencies.
const WLValidationError = require('../../../node_modules/waterline/lib/waterline/error/WLValidationError');
// Settings for the Company model.
const settings = require('./Company.settings.json');
/**
* Export the Company model
*/
module.exports = {
/**
* Basic settings
*/
// The identity to use.
identity: settings.identity,
// The connection to use.
connection: settings.connection,
// Do you want to respect schema?
schema: settings.schema,
// Limit for a get request on the list.
limit: settings.limit,
// Merge simple attributes from settings with those ones.
attributes: _.merge(settings.attributes, {
}),
// Do you automatically want to have time data?
autoCreatedAt: settings.autoCreatedAt,
autoUpdatedAt: settings.autoUpdatedAt,
/**
* Lifecycle callbacks on validate
*/
// Before validating value
beforeValidate: function (values, next) {
// WARNING: Don't remove this part of code if you don't know what you are doing
const api = path.basename(__filename, '.js').toLowerCase();
if (strapi.api.hasOwnProperty(api) && _.size(strapi.api[api].templates)) {
const template = strapi.api[api].templates.hasOwnProperty(values.template) ? values.template : strapi.models[api].defaultTemplate;
const templateAttributes = _.merge(_.pick(strapi.models[api].attributes, 'lang'), strapi.api[api].templates[template].attributes);
// Handle Waterline double validate
if (_.size(_.keys(values)) === 1 && !_.includes(_.keys(templateAttributes), _.keys(values)[0])) {
next();
} else {
const errors = {};
// Set template with correct value
values.template = template;
values.lang = _.includes(strapi.config.i18n.locales, values.lang) ? values.lang : strapi.config.i18n.defaultLocale;
_.forEach(templateAttributes, function (rules, key) {
if (values.hasOwnProperty(key)) {
// Check rules
const test = anchor(values[key]).to(rules);
if (test) {
errors[key] = test;
}
} else if (rules.required) {
errors[key] = [{
rule: 'required',
message: 'Missing attributes ' + key
}];
}
});
// Go next step or not
_.isEmpty(errors) ? next() : next(new WLValidationError({
invalidAttributes: errors,
model: api
}));
}
} else if (strapi.api.hasOwnProperty(api) && !_.size(strapi.api[api].templates)) {
next();
} else {
next(new Error('Unknow API or no template detected'));
}
}
/**
* Lifecycle callbacks on create
*/
// Before creating a value.
// beforeCreate: function (values, next) {
// next();
// },
// After creating a value.
// afterCreate: function (newlyInsertedRecord, next) {
// next();
// },
/**
* Lifecycle callbacks on update
*/
// Before updating a value.
// beforeUpdate: function (valuesToUpdate, next) {
// next();
// },
// After updating a value.
// afterUpdate: function (updatedRecord, next) {
// next();
// },
/**
* Lifecycle callbacks on destroy
*/
// Before updating a value.
// beforeDestroy: function (criteria, next) {
// next();
// },
// After updating a value.
// afterDestroy: function (destroyedRecords, next) {
// next();
// }
};
the template which is a subset of the company attributes (CompanyDefault.template.json):
{
"default": {
"attributes": {
"name": {
"required": true
},
"address": {},
"phone": {},
"email": {}
},
"displayedAttribute": "name"
}
}
Can you show us your default template file? Then, to use the template system, you have to generate your model from the Studio or the CLI. The template logic is located in the "/api/company/models/Company.js" file in your case. It will be interesting to show us this file too.
You're using the right way to apply the template. Your URL seems good! However, this is not possible to include only the object ID rather than the whole related object for now. To do this, I advise you to use GraphQL, or an RAW query...
In the V2, we will support the JSON API specifications in Strapi, this will fit with your problematics for sure!
PS: I'm one of the authors of Strapi.