diff --git a/README.md b/README.md index 7a99122..607c29c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![RESTED](https://github.com/esphen/RESTED/raw/master/images/rested-logo-full.png) +![RESTED](https://github.com/esphen/RESTED/raw/master/doc/images/rested-logo-full.png) A REST client for the rest of us. @@ -8,7 +8,8 @@ _Note: This is the source code, the extension download is [here](https://addons. [![Coverage Status](https://coveralls.io/repos/github/esphen/RESTED/badge.svg?branch=next)](https://coveralls.io/github/esphen/RESTED?branch=next) [![Join the chat at https://gitter.im/RESTEDclient](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/RESTEDclient?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -**RESTED** is a new take on rest clients for browsers. +**RESTED** is a new take on REST clients for browsers. + It is designed to be easy to use to let you work as effective as possible. It features all the most commonly used HTTP methods, setting headers, beautiful themes, saving requests in your browser, and more. @@ -21,7 +22,7 @@ it is also awesomely easy to contribute to the project! Everything is javascript and html using React.js and Redux, so come join! All contributions welcome. -![Image of RESTED](https://github.com/esphen/RESTED/raw/master/images/rested-app.png) +![Image of RESTED](https://github.com/esphen/RESTED/raw/master/doc/images/rested-app.png) ## How to contribute In order to work on this project, you're going to need a few things: @@ -57,8 +58,7 @@ If you need to empty and reset the database, enter the entire database and force a clean install on refresh. **Please develop on the next branch.** -This is where all the magic happens, and all the development on the react -rewrite takes place. +This is where all the magic happens. ### Tests diff --git a/firefox/manifest.json b/firefox/manifest.json index f811655..328a69c 100644 --- a/firefox/manifest.json +++ b/firefox/manifest.json @@ -1,6 +1,6 @@ { "name": "RESTED", - "version": "2.0.0", + "version": "2.0.4", "description": "A REST client for the rest of us", "homepage_url": "https://github.com/esphen/RESTED", "contributors": [ @@ -38,6 +38,7 @@ }, "permissions": [ "", + "webRequest", "storage" ] } diff --git a/google-chrome/manifest.json b/google-chrome/manifest.json index af2bdc2..ebcb88e 100644 --- a/google-chrome/manifest.json +++ b/google-chrome/manifest.json @@ -1,6 +1,6 @@ { "name": "RESTED", - "version": "2.0.0", + "version": "2.0.4", "description": "A REST client for the rest of us", "homepage_url": "https://github.com/esphen/RESTED", "manifest_version": 2, @@ -26,6 +26,7 @@ }, "permissions": [ "", + "webRequest", "storage" ] } diff --git a/package.json b/package.json index 34ff538..9f2e4b7 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "description": "A REST client for the rest of us", "author": "Espen Henriksen", "license": "MIT", - "version": "2.0.0", + "version": "2.0.4", "main": "./main.js", "permissions": { "multiprocess": true diff --git a/scripts/build-source-archive.sh b/scripts/build-source-archive.sh new file mode 100755 index 0000000..0cc4836 --- /dev/null +++ b/scripts/build-source-archive.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Execute from the root of the git repo + +rm -rf node_modules RESTED.* dist/rested* coverage +cd .. +zip -r -9 RESTED/RESTED.src.zip RESTED +cd RESTED + diff --git a/src/components/App/index.js b/src/components/App/index.js index b99f83c..1062a9b 100644 --- a/src/components/App/index.js +++ b/src/components/App/index.js @@ -20,6 +20,7 @@ import './GlobalStyles'; import { LeftCol, RightCol } from './StyledComponents'; + /* * This must be a React.Component because DragDropContext * attaches a ref to the component, which as we know will diff --git a/src/components/Request/HeaderNameAutosuggest.js b/src/components/Request/HeaderNameAutosuggest.js index 22930f0..c86f016 100644 --- a/src/components/Request/HeaderNameAutosuggest.js +++ b/src/components/Request/HeaderNameAutosuggest.js @@ -1,5 +1,5 @@ import React, { PropTypes } from 'react'; -import { FormControl } from 'react-bootstrap'; +import classNames from 'classnames'; import Autosuggest from 'react-autosuggest'; import headers from 'constants/commonHeaders'; @@ -38,10 +38,14 @@ const renderSuggestion = suggestion => ( ); -const renderInputComponent = inputProps => ( - +const renderInputComponent = ({ className, ...rest }) => ( + ); +renderInputComponent.propTypes = { + className: PropTypes.string, +}; + export default class HeaderNameAutosuggest extends React.PureComponent { static propTypes = { input: PropTypes.shape({}).isRequired, diff --git a/src/components/Request/Titlebar.js b/src/components/Request/Titlebar.js index 28a6e0a..cc97b06 100644 --- a/src/components/Request/Titlebar.js +++ b/src/components/Request/Titlebar.js @@ -53,6 +53,15 @@ function Titlebar(props) { { if (formPristine || formInvalid) { + /* eslint-disable */ + // Debugging for #98 + console.log( + 'Not adding request because ' + + `formPristine=${formPristine} || formInvalid=${formInvalid}`, + props, + ); + /* eslint-enable */ + // Set URL as touched to give feedback to user props.touch('request', 'url'); return; diff --git a/src/components/Request/index.js b/src/components/Request/index.js index ceba64e..fb4b34a 100644 --- a/src/components/Request/index.js +++ b/src/components/Request/index.js @@ -7,7 +7,6 @@ import flow from 'lodash.flow'; import * as requestActions from 'store/request/actions'; import * as collectionsActions from 'store/collections/actions'; import { isEditMode } from 'store/config/selectors'; -import requestValidation from 'utils/requestValidation'; import { DEFAULT_REQUEST } from 'constants/constants'; import Titlebar from './Titlebar'; @@ -52,7 +51,7 @@ function Request(props) { names={['basicAuth.username', 'basicAuth.password']} component={BasicAuthField} /> - {['POST', 'PUT', 'PATCH', 'CUSTOM'].includes(formValues.method) && ( + {!['GET', 'HEAD'].includes(formValues.method) && ( )} @@ -72,7 +71,6 @@ Request.propTypes = { const formOptions = { form: requestForm, - validate: requestValidation, }; const mapStateToProps = state => ({ diff --git a/src/components/Response/Headers.js b/src/components/Response/Headers.js index 886498a..cd06e35 100644 --- a/src/components/Response/Headers.js +++ b/src/components/Response/Headers.js @@ -1,10 +1,23 @@ -import React from 'react'; +import React, { PropTypes } from 'react'; import Highlight from 'react-highlight'; import Collapse from 'components/Collapsable'; import { responseShape } from 'propTypes/response'; -function Headers({ headers }) { +function Headers({ headers, expanded }) { + if (expanded) { + return ( +
+

Headers

+ + {headers.reduce((prev, header) => ( + `${prev ? `${prev}\n` : ''}${header.name}: ${header.value}` + ), '')} + +
+ ); + } + return ( +

+ Redirect ({(time / 1000).toFixed(3)}s) - {url} +

+ + ); +} + +Titlebar.propTypes = { + url: responseShape.url, + time: responseShape.time, + onClick: PropTypes.func.isRequired, +}; + +function Redirect(props) { + const { + response, + headers, + isExpanded, + setExpanded, + } = props; + + if (!response || !headers) return null; + + const { method, url, time } = response; + + return ( + } + > +

+ = 200 && response.statusCode < 300} + red={response.statusCode >= 400 && response.statusCode < 600} + > + {response.statusCode} + + {response.statusLine.replace(/.*\d{3} /, '')} +

+ + +
+ ); +} + +Redirect.propTypes = { + response: responsePropTypes, + headers: responseShape.headers, + isExpanded: PropTypes.bool.isRequired, + setExpanded: PropTypes.func.isRequired, +}; + +export default Redirect; + diff --git a/src/components/Response/Response.js b/src/components/Response/Response.js new file mode 100644 index 0000000..1de627b --- /dev/null +++ b/src/components/Response/Response.js @@ -0,0 +1,133 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { Alert } from 'react-bootstrap'; +import Highlight from 'react-highlight'; +import formatXml from 'xml-formatter'; + +import * as Actions from 'store/request/actions'; +import { isDisabledHighlighting, isWrapResponse } from 'store/options/selectors'; +import responsePropTypes, { responseShape } from 'propTypes/response'; +import getContentType from 'utils/contentType'; +import approximateSizeFromLength from 'utils/approximateSizeFromLength'; + +import { StyledResponse, StyledHeader, Status } from './StyledComponents'; +import Headers from './Headers'; +import RenderedResponse from './RenderedResponse'; + +function Titlebar({ url, time }) { + return ( + +

+ Response ({time}) - {url} +

+
+ ); +} + +Titlebar.propTypes = { + url: responseShape.url, + time: responseShape.time, +}; + +export function Response(props) { + const { + response, + highlightingDisabled, + wrapResponse, + redirectChain, + interceptedResponse, + } = props; + + if (!response || !interceptedResponse) return null; + + const { method, url, totalTime } = response; + let { body } = response; + + let time; + if (redirectChain.length > 0) { + time = `${(interceptedResponse.time / 1000).toFixed(3)}s, total time ${totalTime / 1000}s`; + } else { + time = `${totalTime / 1000}s`; + } + + const contentLength = interceptedResponse.responseHeaders.find(header => ( + header.name.toLowerCase() === 'content-length' + )); + const contentType = interceptedResponse.responseHeaders.find(header => ( + header.name.toLowerCase() === 'content-type' + )); + + const contentSize = contentLength + ? Number(contentLength.value) + : approximateSizeFromLength(body); + const type = getContentType(contentType && contentType.value); + + try { + if (type.json) { + body = JSON.stringify(JSON.parse(body), null, 2); + } else if (type.xml) { + body = formatXml(body); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Encountered an error while formatting response as ' + + `${contentType && contentType.value}. Falling back to plain text`, e); + } + + return ( + } + > +

+ = 200 && response.status < 300} + red={response.status >= 400 && response.status < 600} + > + {response.status} + + {response.statusText} +

+ + + {type.html && } + + {!highlightingDisabled && contentSize < 20000 + ? ( + + {body} + + ) : ( + + {contentSize >= 20000 && ( + + The size of the response is greater than 20KB, syntax + highlighting has been disabled for performance reasons. + + )} + +
+              {body}
+            
+
+ ) + } +
+ ); +} + +Response.propTypes = { + response: responsePropTypes, + highlightingDisabled: PropTypes.bool.isRequired, + wrapResponse: PropTypes.bool.isRequired, + redirectChain: PropTypes.arrayOf().isRequired, + interceptedResponse: PropTypes.shape({}), +}; + +const mapStateToProps = state => ({ + highlightingDisabled: isDisabledHighlighting(state), + wrapResponse: isWrapResponse(state), +}); + +export default connect(mapStateToProps, Actions)(Response); + diff --git a/src/components/Response/StyledComponents.js b/src/components/Response/StyledComponents.js index 9834050..0259365 100644 --- a/src/components/Response/StyledComponents.js +++ b/src/components/Response/StyledComponents.js @@ -23,6 +23,8 @@ export const LoadingSpinner = styled(Fonticon)` `; export const StyledHeader = styled.div` + ${props => props.expandable && 'cursor: pointer;'} + h3 { font-size: 15px; margin: 4px 0; diff --git a/src/components/Response/index.js b/src/components/Response/index.js index 7e210e2..04ad85d 100644 --- a/src/components/Response/index.js +++ b/src/components/Response/index.js @@ -1,130 +1,91 @@ import React, { PropTypes } from 'react'; import { connect } from 'react-redux'; -import { Panel, Alert } from 'react-bootstrap'; -import Highlight from 'react-highlight'; -import formatXml from 'xml-formatter'; +import { Panel, Alert, Accordion } from 'react-bootstrap'; -import * as Actions from 'store/request/actions'; -import { getResponse, getLoading } from 'store/request/selectors'; -import { isDisabledHighlighting, isWrapResponse } from 'store/options/selectors'; -import responsePropTypes, { responseShape } from 'propTypes/response'; -import getContentType from 'utils/contentType'; -import approximateSizeFromLength from 'utils/approximateSizeFromLength'; +import { getResponse, getInterceptedResponse, getRedirectChain, getLoading } from 'store/request/selectors'; +import responsePropTypes from 'propTypes/response'; -import { StyledResponse, StyledHeader, Status } from './StyledComponents'; import Loading from './Loading'; -import Headers from './Headers'; -import RenderedResponse from './RenderedResponse'; +import Redirect from './Redirect'; +import Response from './Response'; + +export class ResponseAccordion extends React.Component { + // TODO How to reset when user sends a new request? + state = { + expanded: 'response', + }; + + setActivePanel = activeKey => this.setState({ activeKey }); + + toggleExpanded = index => { + this.setState({ expanded: index === this.state.expanded ? null : index }); + }; + + render() { + const { + response, + error, + loading, + redirectChain, + interceptedResponse, + } = this.props; + + if (error) { + return ( + + {`An error occured while fetching the resource: ${error}`} + + ); + } + + if (loading) { + return ( + + + + ); + } + + if (!response) return null; -function Titlebar({ url, time }) { - return ( - -

- Response ({time / 1000}s) - {url} -

-
- ); -} - -Titlebar.propTypes = { - url: responseShape.url, - time: responseShape.time, -}; - -export function Response(props) { - const { - response, - loading, - highlightingDisabled, - wrapResponse, - } = props; - - if (loading) { return ( - - - + + {redirectChain.map((redirectResponse, i) => ( + this.toggleExpanded(i)} + /> + ))} + + ); } - - if (!response) return null; - - const { method, url, headers, time } = response; - let { body } = response; - - const contentLength = headers.find(header => ( - header.name.toLowerCase() === 'content-length' - )); - const contentType = headers.find(header => ( - header.name.toLowerCase() === 'content-type' - )); - - const contentSize = contentLength - ? Number(contentLength.value) - : approximateSizeFromLength(body); - const type = getContentType(contentType && contentType.value); - - if (type.json) { - body = JSON.stringify(JSON.parse(body), null, 2); - } else if (type.xml) { - body = formatXml(body); - } - - return ( - } - > -

- = 200 && response.status < 300} - red={response.status >= 400 && response.status < 600} - > - {response.status} - - {response.statusText} -

- - - {type.html && } - - {!highlightingDisabled && contentSize < 20000 - ? ( - - {body} - - ) : ( - - {contentSize >= 20000 && ( - - The size of the response is greater than 20KB, syntax - highlighting has been disabled for performance reasons. - - )} - -
-              {body}
-            
-
- ) - } -
- ); } -Response.propTypes = { +ResponseAccordion.propTypes = { loading: PropTypes.bool.isRequired, + error: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.instanceOf(Error), + ]), response: responsePropTypes, - highlightingDisabled: PropTypes.bool.isRequired, - wrapResponse: PropTypes.bool.isRequired, + redirectChain: PropTypes.arrayOf(), + interceptedResponse: PropTypes.shape({}), }; const mapStateToProps = state => ({ response: getResponse(state), + interceptedResponse: getInterceptedResponse(state), + redirectChain: getRedirectChain(state), + error: state.request.error, loading: getLoading(state), - highlightingDisabled: isDisabledHighlighting(state), - wrapResponse: isWrapResponse(state), }); -export default connect(mapStateToProps, Actions)(Response); +export default connect(mapStateToProps)(ResponseAccordion); diff --git a/src/constants/constants.js b/src/constants/constants.js index 03938c2..289a457 100644 --- a/src/constants/constants.js +++ b/src/constants/constants.js @@ -149,14 +149,14 @@ export const HIGHLIGHT_STYLES = [ { title: 'GitHub', style: 'github' }, { title: 'Github Gist', style: 'github-gist' }, { title: 'Google Code', style: 'googlecode' }, - { title: 'Brown Paper', style: 'brown_paper' }, - { title: 'School Book', style: 'school_book' }, - { title: 'IR Black', style: 'ir_black' }, - { title: 'Solarized - Dark', style: 'solarized_dark' }, - { title: 'Solarized - Light', style: 'solarized_light' }, + { title: 'Brown Paper', style: 'brown-paper' }, + { title: 'School Book', style: 'school-book' }, + { title: 'IR Black', style: 'ir-black' }, + { title: 'Solarized - Dark', style: 'solarized-dark' }, + { title: 'Solarized - Light', style: 'solarized-light' }, { title: 'Arta', style: 'arta' }, { title: 'Monokai', style: 'monokai' }, - { title: 'Monokai Sublime', style: 'monokai_sublime' }, + { title: 'Monokai Sublime', style: 'monokai-sublime' }, { title: 'Agate', style: 'agate' }, { title: 'Androidstudio', style: 'androidstudio' }, { title: 'XCode', style: 'xcode' }, @@ -172,18 +172,18 @@ export const HIGHLIGHT_STYLES = [ { title: 'Docco', style: 'docco' }, { title: 'Mono Blue', style: 'mono-blue' }, { title: 'Foundation', style: 'foundation' }, - { title: 'Atelier Dun - Dark', style: 'atelier-dune.dark' }, - { title: 'Atelier Dun - Light', style: 'atelier-dune.light' }, - { title: 'Atelier Forest - Dark', style: 'atelier-forest.dark' }, - { title: 'Atelier Forest - Light', style: 'atelier-forest.light' }, - { title: 'Atelier Heath - Dark', style: 'atelier-heath.dark' }, - { title: 'Atelier Heath - Light', style: 'atelier-heath.light' }, - { title: 'Atelier Lakeside - Dark', style: 'atelier-lakeside.dark' }, - { title: 'Atelier Lakeside - Light', style: 'atelier-lakeside.light' }, - { title: 'Atelier Seaside - Dark', style: 'atelier-seaside.dark' }, - { title: 'Atelier Seaside - Light', style: 'atelier-seaside.light' }, - { title: 'Paraíso - Dark', style: 'paraiso.dark' }, - { title: 'Paraíso - Light', style: 'paraiso.light' }, + { title: 'Atelier Dun - Dark', style: 'atelier-dune-dark' }, + { title: 'Atelier Dun - Light', style: 'atelier-dune-light' }, + { title: 'Atelier Forest - Dark', style: 'atelier-forest-dark' }, + { title: 'Atelier Forest - Light', style: 'atelier-forest-light' }, + { title: 'Atelier Heath - Dark', style: 'atelier-heath-dark' }, + { title: 'Atelier Heath - Light', style: 'atelier-heath-light' }, + { title: 'Atelier Lakeside - Dark', style: 'atelier-lakeside-dark' }, + { title: 'Atelier Lakeside - Light', style: 'atelier-lakeside-light' }, + { title: 'Atelier Seaside - Dark', style: 'atelier-seaside-dark' }, + { title: 'Atelier Seaside - Light', style: 'atelier-seaside-light' }, + { title: 'Paraíso - Dark', style: 'paraiso-dark' }, + { title: 'Paraíso - Light', style: 'paraiso-light' }, { title: 'Colorbrewer', style: 'color-brewer' }, { title: 'Codepen.io Embed', style: 'codepen-embed' }, { title: 'Kimbie - Dark', style: 'kimbie.dark' }, diff --git a/src/index.js b/src/index.js index e309fe8..01e4ec7 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { Provider } from 'react-redux'; import localforage from 'localforage'; import localDriver from 'localforage-webextensionstorage-driver/local'; import syncDriver from 'localforage-webextensionstorage-driver/sync'; +import { initializeInterceptors } from 'utils/requestInterceptors'; const env = process.env.NODE_ENV === 'production' ? 'prod' : 'dev'; @@ -26,8 +27,11 @@ Promise.all([ return null; }) .then(() => { + const store = configureStore.default(); + initializeInterceptors(store); + ReactDOM.render( - + , document.getElementById('app'), diff --git a/src/store/collections/sagas.js b/src/store/collections/sagas.js index 0a32db9..76e27e9 100644 --- a/src/store/collections/sagas.js +++ b/src/store/collections/sagas.js @@ -67,6 +67,11 @@ function* toggleCollapsedSaga({ collectionIndex }) { } function* addRequestSaga({ request, collectionIndex }) { + /* eslint-disable */ + // Debugging for #98 + console.log('Adding request', request, 'to collection', collectionIndex); + /* eslint-enable */ + yield put({ type: ADD_REQUEST, request, collectionIndex }); yield call(updateLocalStorage); } diff --git a/src/store/request/actions.js b/src/store/request/actions.js index 6e1298f..ef945c1 100644 --- a/src/store/request/actions.js +++ b/src/store/request/actions.js @@ -2,19 +2,29 @@ import { SEND_REQUEST, EXECUTE_REQUEST, RECEIVE_RESPONSE, + RECEIVE_INTERCEPTED_RESPONSE, + PUSH_REDIRECT_CHAIN, CLEAR_RESPONSE, CHANGE_BODY_TYPE, SELECT_REQUESTED, } from './types'; export function executeRequest() { - return { type: EXECUTE_REQUEST }; + return { type: EXECUTE_REQUEST, lastRequestTime: Date.now() }; } export function receiveResponse(response) { return { type: RECEIVE_RESPONSE, response }; } +export function receiveInterceptedResponse(response) { + return { type: RECEIVE_INTERCEPTED_RESPONSE, response }; +} + +export function pushRedirectChain(response) { + return { type: PUSH_REDIRECT_CHAIN, response }; +} + export function clearRequest() { return { type: CLEAR_RESPONSE }; } diff --git a/src/store/request/reducer.js b/src/store/request/reducer.js index 017f0bc..e5c0c07 100644 --- a/src/store/request/reducer.js +++ b/src/store/request/reducer.js @@ -1,9 +1,12 @@ import { randomURL } from 'utils/requestUtils'; import { EXECUTE_REQUEST, + RECEIVE_INTERCEPTED_RESPONSE, + PUSH_REDIRECT_CHAIN, RECEIVE_RESPONSE, CLEAR_RESPONSE, CHANGE_BODY_TYPE, + REQUEST_FAILED, } from './types'; const initialState = { @@ -12,6 +15,9 @@ const initialState = { : 'https://example.com', request: null, response: null, + interceptedResponse: null, + redirectChain: [], + lastRequestTime: null, loading: false, useFormData: true, }; @@ -21,6 +27,11 @@ export default function (state = initialState, action) { case EXECUTE_REQUEST: return Object.assign({}, state, { loading: true, + response: null, + interceptedResponse: null, + redirectChain: [], + lastRequestTime: action.lastRequestTime, + error: undefined, }); case RECEIVE_RESPONSE: @@ -29,10 +40,24 @@ export default function (state = initialState, action) { loading: false, }); + case RECEIVE_INTERCEPTED_RESPONSE: + return Object.assign({}, state, { + interceptedResponse: action.response, + }); + + case PUSH_REDIRECT_CHAIN: + return Object.assign({}, state, { + lastRequestTime: new Date(), + redirectChain: !state.redirectChain + ? [action.response] + : [...state.redirectChain, action.response], + }); + case CLEAR_RESPONSE: return Object.assign({}, state, { response: null, loading: false, + error: undefined, }); case CHANGE_BODY_TYPE: @@ -42,6 +67,11 @@ export default function (state = initialState, action) { bodyType: action.bodyType, }); + case REQUEST_FAILED: + return Object.assign({}, state, { + error: action.error, + }); + default: return state; } diff --git a/src/store/request/sagas.js b/src/store/request/sagas.js index 41b26d7..aaf05a4 100644 --- a/src/store/request/sagas.js +++ b/src/store/request/sagas.js @@ -10,7 +10,7 @@ import { pushHistory } from 'store/history/actions'; import { getUrlVariables } from 'store/urlVariables/selectors'; import { requestForm } from 'components/Request'; -import { getPlaceholderUrl, getHeaders } from './selectors'; +import { getPlaceholderUrl, getUseFormData, getHeaders } from './selectors'; import { executeRequest, receiveResponse } from './actions'; import { SEND_REQUEST, REQUEST_FAILED, SELECT_REQUESTED, CHANGE_BODY_TYPE } from './types'; @@ -21,7 +21,7 @@ export function* getUrl(request) { return fallbackUrl; } - return request.url; + return request.url.trim(); } export function* getParameters() { @@ -133,10 +133,18 @@ function createUUID() { export function* fetchData({ request }) { try { yield put(executeRequest()); + const useFormData = yield select(getUseFormData); const resource = yield call(createResource, request); const headers = yield call(buildHeaders, request); - const body = buildRequestData(request); + + // Build body for requests that support it + let body; + if (!['GET', 'HEAD'].includes(request.method)) { + body = useFormData + ? buildRequestData(request) + : request.data; + } const historyEntry = Immutable.fromJS(request) .set('url', resource) @@ -166,7 +174,7 @@ export function* fetchData({ request }) { body: responseBody, headers: responseHeaders, method: request.method, - time: millisPassed, + totalTime: millisPassed, })); } catch (error) { yield put({ type: REQUEST_FAILED, error }); diff --git a/src/store/request/selectors.js b/src/store/request/selectors.js index 09880c5..a870774 100644 --- a/src/store/request/selectors.js +++ b/src/store/request/selectors.js @@ -2,7 +2,10 @@ import { getFormValues } from 'redux-form'; export const getPlaceholderUrl = state => state.request.placeholderUrl; export const getResponse = state => state.request.response; +export const getInterceptedResponse = state => state.request.interceptedResponse; +export const getRedirectChain = state => state.request.redirectChain; export const getLoading = state => state.request.loading; +export const getUseFormData = state => state.request.useFormData; const getValues = getFormValues('request'); export const getBodyType = state => getValues(state).bodyType; diff --git a/src/store/request/types.js b/src/store/request/types.js index fd48d29..22cba9c 100644 --- a/src/store/request/types.js +++ b/src/store/request/types.js @@ -1,5 +1,7 @@ export const SEND_REQUEST = 'request/SEND_REQUEST'; export const EXECUTE_REQUEST = 'request/EXECUTE_REQUEST'; +export const PUSH_REDIRECT_CHAIN = 'request/PUSH_REDIRECT_CHAIN'; +export const RECEIVE_INTERCEPTED_RESPONSE = 'request/RECEIVE_INTERCEPTED_RESPONSE'; export const RECEIVE_RESPONSE = 'request/RECEIVE_RESPONSE'; export const CLEAR_RESPONSE = 'request/CLEAR_RESPONSE'; export const CHANGE_BODY_TYPE = 'request/CHANGE_BODY_TYPE'; diff --git a/src/utils/requestInterceptors.js b/src/utils/requestInterceptors.js new file mode 100644 index 0000000..165d11f --- /dev/null +++ b/src/utils/requestInterceptors.js @@ -0,0 +1,56 @@ +import { pushRedirectChain, receiveInterceptedResponse } from 'store/request/actions'; + +const browser = window.browser || window.chrome; + +/** We need to ignore fonts and resources not initiated by the user */ +const blacklistedUrls = [ + 'https://fonts.gstatic.com/s/opensans/v14/PRmiXeptR36kaC0GEAetxvR_54zmj3SbGZQh3vCOwvY.woff', + 'https://fonts.gstatic.com/s/opensans/v14/k3k702ZOKiLJc3WVjuplzKRDOzjiPcYnFooOUGCOsRk.woff', + 'https://fonts.gstatic.com/s/opensans/v14/cJZKeOuBrn4kERxqtaUH3bO3LdcAZYWl9Si6vvxL-qU.woff', + 'https://fonts.gstatic.com/s/opensans/v14/DXI1ORHCpsQm3Vp6mXoaTaRDOzjiPcYnFooOUGCOsRk.woff', +]; + +const beforeRedirectInterceptor = ({ getState, dispatch }) => response => { + const state = getState(); + const lastRequestTime = state.request.lastRequestTime; + + dispatch(pushRedirectChain({ + ...response, + time: response.timeStamp - lastRequestTime, + })); +}; + +const completedInterceptor = ({ getState, dispatch }) => response => { + // Ignore fonts and other stuff not initiated by the user + if ((response.originUrl && !response.originUrl.includes('dist/index.html')) + || blacklistedUrls.includes(response.url)) { + return; + } + + const state = getState(); + const lastRequestTime = state.request.lastRequestTime; + + dispatch(receiveInterceptedResponse({ + ...response, + time: response.timeStamp - lastRequestTime, + })); +}; + +// eslint-disable-next-line import/prefer-default-export +export const initializeInterceptors = store => { + // Use chrome.tabs without promises for cross-browser support + chrome.tabs.getCurrent(currentTab => { + browser.webRequest.onBeforeRedirect.addListener( + beforeRedirectInterceptor(store), + { urls: [''], tabId: currentTab.id }, + ['responseHeaders'], + ); + + browser.webRequest.onCompleted.addListener( + completedInterceptor(store), + { urls: [''], tabId: currentTab.id }, + ['responseHeaders'], + ); + }); +}; + diff --git a/src/utils/requestValidation.js b/src/utils/requestValidation.js deleted file mode 100644 index dd12625..0000000 --- a/src/utils/requestValidation.js +++ /dev/null @@ -1,14 +0,0 @@ -function validateUrl({ url }) { - // Empty URL is OK, will be replaced with fallback - // Should at the very least be in the format of foo.bar - if (url && !/.+\..+/.test(url)) { - return 'Invalid URL'; - } - - return undefined; -} - -export default values => ({ - url: validateUrl(values), -}); - diff --git a/test/components/request/__snapshots__/HeadersField.test.js.snap b/test/components/request/__snapshots__/HeadersField.test.js.snap index b5333a1..227b385 100644 --- a/test/components/request/__snapshots__/HeadersField.test.js.snap +++ b/test/components/request/__snapshots__/HeadersField.test.js.snap @@ -45,7 +45,6 @@ exports[`HeadersField should match the previous snapshot 1`] = ` aria-owns="react-autowhatever-1" autoComplete="off" className="react-autosuggest__input form-control" - id="header.[object Object]" name="[object Object].name" onBlur={[Function]} onChange={[Function]} @@ -131,7 +130,6 @@ exports[`HeadersField should match the previous snapshot 1`] = ` aria-owns="react-autowhatever-1" autoComplete="off" className="react-autosuggest__input form-control" - id="header.[object Object]" name="[object Object].name" onBlur={[Function]} onChange={[Function]} @@ -217,7 +215,6 @@ exports[`HeadersField should match the previous snapshot 1`] = ` aria-owns="react-autowhatever-1" autoComplete="off" className="react-autosuggest__input form-control" - id="header.[object Object]" name="[object Object].name" onBlur={[Function]} onChange={[Function]} diff --git a/test/components/request/__snapshots__/request.test.js.snap b/test/components/request/__snapshots__/request.test.js.snap index 3c80e1d..5ec6ef4 100644 --- a/test/components/request/__snapshots__/request.test.js.snap +++ b/test/components/request/__snapshots__/request.test.js.snap @@ -356,6 +356,71 @@ exports[`should render correctly 1`] = ` +
+ +
+
+
+
+ +
+
+
+
+