HTTPCustomRequestPanel.js (14916B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { 8 Component, 9 createFactory, 10 } = require("resource://devtools/client/shared/vendor/react.mjs"); 11 const asyncStorage = require("resource://devtools/shared/async-storage.js"); 12 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 13 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 14 const { 15 connect, 16 } = require("resource://devtools/client/shared/vendor/react-redux.js"); 17 const { 18 L10N, 19 } = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); 20 const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js"); 21 const { 22 getClickedRequest, 23 } = require("resource://devtools/client/netmonitor/src/selectors/index.js"); 24 const { 25 getUrlQuery, 26 parseQueryString, 27 } = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); 28 const InputMap = createFactory( 29 require("resource://devtools/client/netmonitor/src/components/new-request/InputMap.js") 30 ); 31 const { button, div, footer, label, textarea, select, option } = dom; 32 33 const CUSTOM_HEADERS = L10N.getStr("netmonitor.custom.newRequestHeaders"); 34 const CUSTOM_NEW_REQUEST_URL_LABEL = L10N.getStr( 35 "netmonitor.custom.newRequestUrlLabel" 36 ); 37 const CUSTOM_POSTDATA = L10N.getStr("netmonitor.custom.postBody"); 38 const CUSTOM_POSTDATA_PLACEHOLDER = L10N.getStr( 39 "netmonitor.custom.postBody.placeholder" 40 ); 41 const CUSTOM_QUERY = L10N.getStr("netmonitor.custom.urlParameters"); 42 const CUSTOM_SEND = L10N.getStr("netmonitor.custom.send"); 43 const CUSTOM_CLEAR = L10N.getStr("netmonitor.custom.clear"); 44 45 const FIREFOX_DEFAULT_HEADERS = [ 46 "Accept-Charset", 47 "Accept-Encoding", 48 "Access-Control-Request-Headers", 49 "Access-Control-Request-Method", 50 "Connection", 51 "Content-Length", 52 "Cookie", 53 "Cookie2", 54 "Date", 55 "DNT", 56 "Expect", 57 "Feature-Policy", 58 "Host", 59 "Keep-Alive", 60 "Origin", 61 "Proxy-", 62 "Sec-", 63 "Referer", 64 "TE", 65 "Trailer", 66 "Transfer-Encoding", 67 "Upgrade", 68 "Via", 69 ]; 70 // This does not include the CONNECT method as it is restricted and special. 71 // See https://bugzilla.mozilla.org/show_bug.cgi?id=1769572#c2 for details 72 const HTTP_METHODS = [ 73 "GET", 74 "HEAD", 75 "POST", 76 "DELETE", 77 "PUT", 78 "OPTIONS", 79 "TRACE", 80 "PATCH", 81 ]; 82 83 /* 84 * HTTP Custom request panel component 85 * A network request panel which enables creating and sending new requests 86 * or selecting, editing and re-sending current requests. 87 */ 88 class HTTPCustomRequestPanel extends Component { 89 static get propTypes() { 90 return { 91 connector: PropTypes.object.isRequired, 92 request: PropTypes.object, 93 sendCustomRequest: PropTypes.func.isRequired, 94 }; 95 } 96 97 constructor(props) { 98 super(props); 99 100 this.state = { 101 method: HTTP_METHODS[0], 102 url: "", 103 urlQueryParams: [], 104 headers: [], 105 postBody: "", 106 // Flag to know the data from either the request or the async storage has 107 // been loaded in componentDidMount 108 _isStateDataReady: false, 109 }; 110 111 this.handleInputChange = this.handleInputChange.bind(this); 112 this.handleChangeURL = this.handleChangeURL.bind(this); 113 this.updateInputMapItem = this.updateInputMapItem.bind(this); 114 this.addInputMapItem = this.addInputMapItem.bind(this); 115 this.deleteInputMapItem = this.deleteInputMapItem.bind(this); 116 this.checkInputMapItem = this.checkInputMapItem.bind(this); 117 this.handleClear = this.handleClear.bind(this); 118 this.createQueryParamsListFromURL = 119 this.createQueryParamsListFromURL.bind(this); 120 this.onUpdateQueryParams = this.onUpdateQueryParams.bind(this); 121 } 122 123 async componentDidMount() { 124 let { connector, request } = this.props; 125 if (!connector.currentTarget?.targetForm?.isPrivate) { 126 const persistedCustomRequest = await asyncStorage.getItem( 127 "devtools.netmonitor.customRequest" 128 ); 129 request = request || persistedCustomRequest; 130 } 131 132 if (!request) { 133 this.setState({ _isStateDataReady: true }); 134 return; 135 } 136 137 // We need this part because in the asyncStorage we are saving the request in one format 138 // and from the edit and resend it comes in a different form with different properties, 139 // so we need this to nomalize the request. 140 if (request.requestHeaders) { 141 request.headers = request.requestHeaders.headers; 142 } 143 144 if (request.requestPostData?.postData?.text) { 145 request.postBody = request.requestPostData.postData.text; 146 } 147 148 const headers = request.headers 149 .map(({ name, value }) => { 150 return { 151 name, 152 value, 153 checked: true, 154 disabled: FIREFOX_DEFAULT_HEADERS.some(i => name.startsWith(i)), 155 }; 156 }) 157 .sort((a, b) => { 158 if (a.disabled && !b.disabled) { 159 return -1; 160 } 161 if (!a.disabled && b.disabled) { 162 return 1; 163 } 164 return 0; 165 }); 166 167 if (request.requestPostDataAvailable && !request.postBody) { 168 const requestData = await connector.requestData( 169 request.id, 170 "requestPostData" 171 ); 172 request.postBody = requestData.postData.text; 173 } 174 175 this.setState({ 176 method: request.method, 177 url: request.url, 178 urlQueryParams: this.createQueryParamsListFromURL(request.url), 179 headers, 180 postBody: request.postBody, 181 _isStateDataReady: true, 182 }); 183 } 184 185 componentDidUpdate(prevProps, prevState) { 186 // This is when the query params change in the url params input map 187 if ( 188 prevState.urlQueryParams !== this.state.urlQueryParams && 189 prevState.url === this.state.url 190 ) { 191 this.onUpdateQueryParams(); 192 } 193 } 194 195 componentWillUnmount() { 196 if (!this.props.connector.currentTarget?.targetForm?.isPrivate) { 197 asyncStorage.setItem("devtools.netmonitor.customRequest", this.state); 198 } 199 } 200 201 handleChangeURL(event) { 202 const { value } = event.target; 203 204 this.setState({ 205 url: value, 206 urlQueryParams: this.createQueryParamsListFromURL(value), 207 }); 208 } 209 210 handleInputChange(event) { 211 const { name, value } = event.target; 212 const newState = { 213 [name]: value, 214 }; 215 216 // If the message body changes lets make sure we 217 // keep the content-length up to date. 218 if (name == "postBody") { 219 newState.headers = this.state.headers.map(header => { 220 if (header.name == "Content-Length") { 221 header.value = value.length; 222 } 223 return header; 224 }); 225 } 226 227 this.setState(newState); 228 } 229 230 updateInputMapItem(stateName, event) { 231 const { name, value } = event.target; 232 const [prop, index] = name.split("-"); 233 const updatedList = [...this.state[stateName]]; 234 updatedList[Number(index)][prop] = value; 235 236 this.setState({ 237 [stateName]: updatedList, 238 }); 239 } 240 241 addInputMapItem(stateName, name, value) { 242 this.setState({ 243 [stateName]: [ 244 ...this.state[stateName], 245 { name, value, checked: true, disabled: false }, 246 ], 247 }); 248 } 249 250 deleteInputMapItem(stateName, index) { 251 this.setState({ 252 [stateName]: this.state[stateName].filter((_, i) => i !== index), 253 }); 254 } 255 256 checkInputMapItem(stateName, index, checked) { 257 this.setState({ 258 [stateName]: this.state[stateName].map((item, i) => { 259 if (index === i) { 260 return { 261 ...item, 262 checked, 263 }; 264 } 265 return item; 266 }), 267 }); 268 } 269 270 onUpdateQueryParams() { 271 const { urlQueryParams, url } = this.state; 272 273 const searchParams = new URLSearchParams(); 274 for (const { name, value, checked } of urlQueryParams) { 275 // We only want to add checked parameters with a non-empty name 276 if (checked && name) { 277 searchParams.append(name, value); 278 } 279 } 280 281 // We can't use `getUrl` here as `url` is the value of the input and might not be a 282 // valid URL. We still want to try to set the params in the URL input in such case, 283 // so append them after the first "?" char we find. 284 let finalURL = url.split("?")[0]; 285 if (searchParams.size) { 286 finalURL += `?${searchParams}`; 287 } 288 289 this.setState({ 290 url: finalURL, 291 }); 292 } 293 294 createQueryParamsListFromURL(url = "") { 295 const parsedQuery = parseQueryString(getUrlQuery(url) || url.split("?")[1]); 296 const queryArray = parsedQuery || []; 297 return queryArray.map(({ name, value }) => { 298 return { 299 checked: true, 300 name, 301 value, 302 }; 303 }); 304 } 305 306 handleClear() { 307 this.setState({ 308 method: HTTP_METHODS[0], 309 url: "", 310 urlQueryParams: [], 311 headers: [], 312 postBody: "", 313 }); 314 } 315 316 render() { 317 return div( 318 { className: "http-custom-request-panel" }, 319 div( 320 { className: "http-custom-request-panel-content" }, 321 div( 322 { 323 className: "tabpanel-summary-container http-custom-method-and-url", 324 id: "http-custom-method-and-url", 325 }, 326 select( 327 { 328 className: "http-custom-method-value", 329 id: "http-custom-method-value", 330 name: "method", 331 onChange: this.handleInputChange, 332 onBlur: this.handleInputChange, 333 value: this.state.method, 334 }, 335 336 HTTP_METHODS.map(item => 337 option( 338 { 339 value: item, 340 key: item, 341 }, 342 item 343 ) 344 ) 345 ), 346 div( 347 { 348 className: "auto-growing-textarea", 349 "data-replicated-value": this.state.url, 350 title: this.state.url, 351 }, 352 textarea({ 353 className: "http-custom-url-value", 354 id: "http-custom-url-value", 355 name: "url", 356 placeholder: CUSTOM_NEW_REQUEST_URL_LABEL, 357 onChange: event => { 358 this.handleChangeURL(event); 359 }, 360 onBlur: this.handleTextareaChange, 361 value: this.state.url, 362 rows: 1, 363 }) 364 ) 365 ), 366 div( 367 { 368 className: "tabpanel-summary-container http-custom-section", 369 id: "http-custom-query", 370 }, 371 label( 372 { 373 className: "http-custom-request-label", 374 htmlFor: "http-custom-query-value", 375 }, 376 CUSTOM_QUERY 377 ), 378 // This is the input map for the Url Parameters Component 379 InputMap({ 380 list: this.state.urlQueryParams, 381 onUpdate: event => { 382 this.updateInputMapItem( 383 "urlQueryParams", 384 event, 385 this.onUpdateQueryParams 386 ); 387 }, 388 onAdd: (name, value) => 389 this.addInputMapItem( 390 "urlQueryParams", 391 name, 392 value, 393 this.onUpdateQueryParams 394 ), 395 onDelete: index => 396 this.deleteInputMapItem( 397 "urlQueryParams", 398 index, 399 this.onUpdateQueryParams 400 ), 401 onChecked: (index, checked) => { 402 this.checkInputMapItem( 403 "urlQueryParams", 404 index, 405 checked, 406 this.onUpdateQueryParams 407 ); 408 }, 409 }) 410 ), 411 div( 412 { 413 id: "http-custom-headers", 414 className: "tabpanel-summary-container http-custom-section", 415 }, 416 label( 417 { 418 className: "http-custom-request-label", 419 htmlFor: "custom-headers-value", 420 }, 421 CUSTOM_HEADERS 422 ), 423 // This is the input map for the Headers Component 424 InputMap({ 425 ref: this.headersListRef, 426 list: this.state.headers, 427 onUpdate: event => { 428 this.updateInputMapItem("headers", event); 429 }, 430 onAdd: (name, value) => 431 this.addInputMapItem("headers", name, value), 432 onDelete: index => this.deleteInputMapItem("headers", index), 433 onChecked: (index, checked) => { 434 this.checkInputMapItem("headers", index, checked); 435 }, 436 }) 437 ), 438 div( 439 { 440 id: "http-custom-postdata", 441 className: "tabpanel-summary-container http-custom-section", 442 }, 443 label( 444 { 445 className: "http-custom-request-label", 446 htmlFor: "http-custom-postdata-value", 447 }, 448 CUSTOM_POSTDATA 449 ), 450 textarea({ 451 className: "tabpanel-summary-input", 452 id: "http-custom-postdata-value", 453 name: "postBody", 454 placeholder: CUSTOM_POSTDATA_PLACEHOLDER, 455 onChange: this.handleInputChange, 456 rows: 6, 457 value: this.state.postBody, 458 wrap: "off", 459 }) 460 ) 461 ), 462 footer( 463 { className: "http-custom-request-button-container" }, 464 button( 465 { 466 className: "devtools-button", 467 id: "http-custom-request-clear-button", 468 onClick: this.handleClear, 469 }, 470 CUSTOM_CLEAR 471 ), 472 button( 473 { 474 className: "devtools-button", 475 id: "http-custom-request-send-button", 476 disabled: 477 !this.state._isStateDataReady || 478 !this.state.url || 479 !this.state.method, 480 onClick: () => { 481 const newRequest = { 482 method: this.state.method, 483 url: this.state.url, 484 cause: this.props.request?.cause, 485 urlQueryParams: this.state.urlQueryParams.map( 486 ({ ...params }) => params 487 ), 488 requestHeaders: { 489 headers: this.state.headers 490 .filter(({ checked }) => checked) 491 .map(({ ...headersValues }) => headersValues), 492 }, 493 }; 494 495 if (this.state.postBody) { 496 newRequest.requestPostData = { 497 postData: { 498 text: this.state.postBody, 499 }, 500 }; 501 } 502 this.props.sendCustomRequest(newRequest); 503 }, 504 }, 505 CUSTOM_SEND 506 ) 507 ) 508 ); 509 } 510 } 511 512 module.exports = connect( 513 state => ({ request: getClickedRequest(state) }), 514 dispatch => ({ 515 sendCustomRequest: request => 516 dispatch(Actions.sendHTTPCustomRequest(request)), 517 }) 518 )(HTTPCustomRequestPanel);