├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── LICENSE ├── README.md ├── lib ├── backends │ ├── javascript-xhr.js │ ├── json.js │ └── python-requests.js ├── frontends │ ├── curl.js │ ├── http.js │ └── json.js ├── http-request.js └── transforms │ ├── drop-content-length-header.js │ ├── drop-host-header.js │ ├── parse-json-data.js │ └── split-form-data.js ├── package-lock.json ├── package.json ├── release.sh ├── src ├── app.js ├── index.html ├── index.js └── style.scss └── webpack.config.js /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [10.x, 12.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm ci 23 | - run: npm run build --if-present 24 | #- run: npm test 25 | # env: 26 | # CI: true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | dist/ 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Ryan Govostes 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build](https://github.com/rgov/http-translator/workflows/Node.js%20CI/badge.svg) 2 | 3 | # HTTP Request Translator 4 | 5 | This tool translates HTTP requests from a curl command-line invocation or raw HTTP request into Python or JavaScript code. 6 | 7 | You can generate curl commands from several tools: 8 | 9 | * **[Burp Suite](https://portswigger.net/burp):** Right-click on a request and select "Copy as curl command". 10 | 11 | * **[Charles](https://www.charlesproxy.com):** Right-click on a request and select "Copy cURL Request". 12 | 13 | * **Safari Web Inspector:** Right-click on a resource in the Network or Resources tab and select "Copy as cURL". 14 | 15 | Thee tool is not complete; it doesn’t know about some basic things right now (such as cookies) or less common curl options such as `--proxy-header`. Contributions are welcome. 16 | 17 | See also Matt Holt's [curl-to-Go](https://mholt.github.io/curl-to-go/), if that fits your use case better. 18 | 19 | 20 | ## Building from Source 21 | 22 | npm install && npm run build 23 | 24 | Build results will be put in `/dist`. You can also run a development webserver with `npm run-script start:dev`. 25 | 26 | 27 | ## Implementation 28 | 29 | This project is written in React. Please keep in mind this was an exercise in learning React, so the code may not be the best. 30 | 31 | **Frontends** (in `lib/frontends`) parse the input and populate a Request object (in `lib/http-request.js`). 32 | 33 | **Transforms** (in `lib/transforms`) perform some modification to the Request object, such as breaking up `application/x-www-form-urlencoded` content. 34 | 35 | **Backends** (in `lib/backends`) consume the Request and generate the output. 36 | -------------------------------------------------------------------------------- /lib/backends/javascript-xhr.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('url') 4 | 5 | // https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name 6 | const xhrForbiddenHeaders = [ 7 | "Accept-Charset", 8 | "Accept-Encoding", 9 | "Access-Control-Request-Headers", 10 | "Access-Control-Request-Method", 11 | "Connection", 12 | "Content-Length", 13 | "Cookie", 14 | "Cookie2", 15 | "Date", 16 | "DNT", 17 | "Expect", 18 | "Host", 19 | "Keep-Alive", 20 | "Origin", 21 | /^Proxy-/i, 22 | /^Sec-/i, 23 | "Referer", 24 | "TE", 25 | "Trailer", 26 | "Transfer-Encoding", 27 | "Upgrade", 28 | "Via" 29 | ] 30 | 31 | function javascript_escape(value, quote='"') { 32 | return value.replace(new RegExp('([' + quote + '\\\\])', 'g'), '\\$1') 33 | .replace(/\n/g, '\\n') 34 | .replace(/\t/g, '\\t') 35 | .replace(/\r/g, '\\r') 36 | .replace(/[^ -~]+/g, function (match) { 37 | var hex = new Buffer(match).toString('hex') 38 | return hex.replace(/(..)/g, '\\x$1') 39 | }) 40 | } 41 | 42 | function generateJavascriptXHR(request, logger=console) { 43 | // Emit the preamble 44 | var code = [] 45 | code.push('var xhr = new XMLHttpRequest()') 46 | 47 | // Parse the URI 48 | var parsedURI = new URL(request.uri) 49 | 50 | // Remove the username and password from the URL 51 | var username = parsedURI.username 52 | var password = parsedURI.password 53 | parsedURI.username = parsedURI.password = '' 54 | 55 | // Output the open() method 56 | var open = `xhr.open("${request.method}", "${parsedURI.href}"` 57 | open += ", false" // asynchronous 58 | if (!!username && !!password) { 59 | open += `, "${username}", "${password}"` 60 | } else if (!!username && !password) { 61 | open += `, "${username}"` 62 | } else if (!username && !!password) { 63 | open += `, "", "${password}"` 64 | } 65 | open += ')' 66 | code.push(open) 67 | 68 | // Emit code for the headers 69 | if (Object.keys(request.headers).length > 0) { 70 | for (const name in request.headers) { 71 | // Scan the list of forbidden headers 72 | var verboten = false 73 | for (const pattern of xhrForbiddenHeaders) { 74 | console.log(pattern) 75 | if (typeof pattern === 'string') { 76 | if (name.toLowerCase() === pattern.toLowerCase()) { 77 | verboten = true 78 | break 79 | } 80 | } else if (name.match(pattern)) { 81 | verboten = true 82 | break 83 | } 84 | } 85 | 86 | if (verboten) { 87 | logger.error(`Omitting forbidden ${name} header`) 88 | continue 89 | } 90 | 91 | const value = request.headers[name] 92 | code.push(`xhr.setRequestHeader("${name}", "${value}")`) 93 | } 94 | } 95 | 96 | // Add some placeholders for handlers 97 | code.push('xhr.onload = function (e) { /* ... */ }') 98 | code.push('xhr.onerror = function (e) { /* ... */ }') 99 | 100 | // Add the send command 101 | if (request.jsonData !== undefined) { 102 | var json = JSON.stringify(request.jsonData, null, 2) 103 | code.push(`xhr.send(JSON.stringify(${json}))`) 104 | } else if (typeof request.body === 'string' && request.body != '') { 105 | code.push(`xhr.send("${javascript_escape(request.body)}")`) 106 | } else { 107 | code.push(`xhr.send(null)`) 108 | } 109 | 110 | 111 | return code.join('\n') 112 | } 113 | 114 | 115 | exports.name = 'JavaScript (XMLHttpRequest)' 116 | exports.generate = generateJavascriptXHR 117 | exports.highlighter = 'javascript' 118 | -------------------------------------------------------------------------------- /lib/backends/json.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function generateJSON(request) { 4 | return JSON.stringify(request, null, 2) 5 | } 6 | 7 | 8 | module.exports.name = 'JSON' 9 | module.exports.generate = generateJSON 10 | module.exports.highlighter = 'javascript' 11 | -------------------------------------------------------------------------------- /lib/backends/python-requests.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('url') 4 | 5 | 6 | function python_escape(value, quote='\'') { 7 | return value.replace(new RegExp('([' + quote + '\\\\])', 'g'), '\\$1') 8 | .replace(/\n/g, '\\n') 9 | .replace(/\t/g, '\\t') 10 | .replace(/\r/g, '\\r') 11 | .replace(/[^ -~]+/g, function (match) { 12 | var hex = new Buffer(match).toString('hex') 13 | return hex.replace(/(..)/g, '\\x$1') 14 | }) 15 | } 16 | 17 | function pythonize(obj) { 18 | // TODO: Pretty printing 19 | if (typeof obj === 'string') { 20 | return '\'' + python_escape(obj) + '\'' 21 | } else if (typeof obj === 'number') { 22 | return obj 23 | } else if (typeof obj === 'boolean') { 24 | return obj ? 'True' : 'False' 25 | } else if (obj === null || obj === undefined) { 26 | return 'None' 27 | } else if (Array.isArray(obj)) { 28 | return '[ ' + obj.map(pythonize).join(', ') + ' ]' 29 | } else if (obj.constructor === Object) { 30 | var rows = [] 31 | for (let k in obj) { 32 | rows.push(pythonize(k) + ': ' + pythonize(obj[k])) 33 | } 34 | return '{ ' + rows.join(', ') + ' }' 35 | } else { 36 | throw 'unknown type' 37 | } 38 | } 39 | 40 | function generatePythonRequests(request, logger=console) { 41 | // Emit the preamble 42 | var code = [] 43 | code.push('import requests') 44 | code.push('') 45 | 46 | // Map the HTTP method to the function 47 | const methods = { 48 | DELETE: 'requests.delete', 49 | GET: 'requests.get', 50 | HEAD: 'requests.head', 51 | OPTIONS: 'requests.options', 52 | POST: 'requests.post', 53 | PUT: 'requests.put', 54 | } 55 | var func = methods[request.method] || 'requests.request' 56 | var isCustomMethod = !methods.hasOwnProperty(request.method) 57 | 58 | // Parse the URI 59 | var parsedURI = new URL(request.uri) 60 | 61 | // Try to break out URL parameters from the query string, if we can 62 | var emitQueryParams = (parsedURI.search !== '') 63 | if (emitQueryParams) { 64 | // Modify the URI we'll eventually emit so that it doesn't include the 65 | // query string 66 | request.uri = `${parsedURI.origin}${parsedURI.pathname}` 67 | } 68 | 69 | // Emit the function call, URL, and method 70 | code.push(`${func}(`) 71 | code.push(` ${pythonize(request.uri)},`) 72 | if (isCustomMethod) { code.push(` method=${pythonize(request.method)},`) } 73 | 74 | // Emit the URL params 75 | if (emitQueryParams) { 76 | // FIXME: We don't handle multiple parameters with the same name correctly 77 | code.push(' params={') 78 | for (const [name, value] of parsedURI.searchParams) { 79 | code.push(` ${pythonize(name)}: ${pythonize(value)},`) 80 | } 81 | code.push(' },') 82 | } 83 | 84 | // Emit code for the headers 85 | if (Object.keys(request.headers).length > 0) { 86 | code.push(' headers={') 87 | for (const name in request.headers) { 88 | const value = request.headers[name] 89 | code.push(` ${pythonize(name)}: ${pythonize(value)},`) 90 | } 91 | code.push(' },') 92 | } 93 | 94 | // Try to convert JSON to a Python object 95 | let json = undefined 96 | if (request.jsonData !== undefined) { 97 | try { 98 | json = pythonize(request.jsonData) 99 | } catch (err) { 100 | logger.log('Failed to represent JSON as Python') 101 | } 102 | } 103 | 104 | // Emit code for the data 105 | if (json) { 106 | code.push(` json=${json},`) 107 | } else if (request.formData !== undefined && Object.keys(request.formData).length > 0) { 108 | code.push(' data={') 109 | for (const name in request.formData) { 110 | const value = request.formData[name] 111 | code.push(` ${pythonize(name)}: ${pythonize(value)},`) 112 | } 113 | code.push(' },') 114 | } else if (typeof request.body === 'string' && request.body != '') { 115 | code.push(` data=${pythonize(request.body)},`) 116 | } 117 | 118 | // Finish the code and return it 119 | code.push(')') 120 | return code.join('\n') 121 | } 122 | 123 | 124 | exports.name = 'Python Requests' 125 | exports.generate = generatePythonRequests 126 | exports.highlighter = 'python' 127 | -------------------------------------------------------------------------------- /lib/frontends/curl.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const shlex = require('shlex') 4 | const argparse = require('argparse') 5 | 6 | const http = require('../http-request') 7 | 8 | 9 | // Patch argparse.ArgumentParser to log messages, rather than die 10 | argparse.ArgumentParser.prototype._printMessage = function (message) { 11 | this.logger.log(message.replace(/\s+$/, '')) 12 | } 13 | 14 | argparse.ArgumentParser.prototype.exit = function (status, message) { 15 | if (message !== undefined) { 16 | (status ? this.logger.error : this.logger.log)(message.replace(/\s+$/, '')) 17 | } 18 | this.logger.log('The argument parser exited with status', status) 19 | this.exit_status = status 20 | } 21 | 22 | 23 | function parseCurlCommandLine(command, logger=console) { 24 | var argv = shlex.split(command) 25 | argv.shift() // consume the leading `curl` 26 | 27 | var parser = new argparse.ArgumentParser({ prog: 'curl' }) 28 | parser.logger = logger // allow _printMessage patch to use our logger 29 | parser.addArgument(['url']) 30 | parser.addArgument(['--request', '-X'], { dest: 'method' }) 31 | parser.addArgument(['--header', '-H'], { dest: 'headers', action: 'append'}) 32 | parser.addArgument(['--referer', '-e']) 33 | parser.addArgument(['--user-agent', '-A']) 34 | parser.addArgument(['--data', '--data-ascii', '--data-binary', '--data-raw', 35 | /*'--data-urlencode',*/ '-d'], { action: 'append'}) 36 | var [args, extra] = (parser.parseKnownArgs(argv) || []) 37 | 38 | // If the parser died, don't continue 39 | if (parser.exit_status !== undefined) { return } 40 | 41 | // We don't parse all arguments. Be transparent about it. 42 | if (extra.length > 0) { 43 | logger.log('I skipped these unsupported arguments:', extra) 44 | } 45 | 46 | var req = new http.Request() 47 | req.uri = args.url 48 | 49 | for (const header of (args.headers || [])) { 50 | var match 51 | if (match = header.match(/^\s*([^:]+);\s*$/)) { 52 | req.headers[match[1]] = '' 53 | } else if (match = header.match(/^\s*([^:]+):\s*(.*?)\s*$/)) { 54 | req.headers[match[1]] = match[2] 55 | } else if (match = header.match(/^\s*([^:]+):\s*$/)) { 56 | // Tricky case, this syntax will unset a default header, but otherwise 57 | // won't unset a 58 | var name = match[1] 59 | logger.log('I don\'t support unsetting headers (like ' + name + ') yet.') 60 | } else { 61 | logger.log('I don\'t understand this header:', header) 62 | } 63 | } 64 | 65 | // TODO: curl supports many ways of specifying the body of the request. We 66 | // will need a custom argparse Action that keeps the data in order. 67 | // 68 | // * --data, --data-ascii, --data-raw -- possibly strip whitespace 69 | // * --data-binary -- do not strip whitespace 70 | // * --data-urlencode -- URL encode the *value* but not the key (if present) 71 | if (args.data) { 72 | if (!('Content-Type' in req.headers)) { 73 | req.headers['Content-Type'] = 'application/x-www-form-urlencoded' 74 | } 75 | 76 | req.method = 'POST' 77 | req.body = args.data.join('&') 78 | } 79 | 80 | // Allow overriding the method, or fall back to GET 81 | if (args.method) { 82 | req.method = args.method 83 | } else if (req.method === null) { 84 | req.method = 'GET' 85 | } 86 | 87 | return req 88 | } 89 | 90 | 91 | module.exports.name = 'curl' 92 | module.exports.parse = parseCurlCommandLine 93 | module.exports.highlighter = 'shell' 94 | 95 | module.exports.example = ` 96 | curl 'http://example.com/api/v2/defragment' \\ 97 | -H 'Host: example.com' \\ 98 | -H 'Content-Length: 19' \\ 99 | -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \\ 100 | -H 'Cookie: session=291419e390a3b67a3946e0854cc9e33e' \\ 101 | -H 'Referer: http://example.com/' \\ 102 | -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.1 Safari/605.1.15' \\ 103 | -H 'X-Requested-With: XMLHttpRequest' \\ 104 | --data 'drive=C&confirm=yes' 105 | `.trim() 106 | -------------------------------------------------------------------------------- /lib/frontends/http.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('../http-request') 4 | 5 | // This is a JavaScript reimplementation of Node's HTTP parser 6 | const HTTPParser = require('http-parser-js').HTTPParser 7 | 8 | 9 | function parseHTTP(input, logger=console) { 10 | // Since it may be hard to get CRLFs into the text area, we can try to be 11 | // smart about adding them back in. 12 | if (!input.includes('\r\n')) { 13 | let i = input.indexOf('\n\n') 14 | if (i !== -1) { 15 | let before = input.substring(0, i+2) 16 | let after = input.substring(i+2) 17 | input = before.replace(/\n/g, '\r\n') + after 18 | } 19 | } 20 | 21 | return new Promise((resolve, reject) => { 22 | var parser = new HTTPParser(HTTPParser.REQUEST) 23 | var request = new http.Request() 24 | 25 | parser[HTTPParser.kOnHeadersComplete] = (info) => { 26 | request.uri = info.url 27 | request.method = HTTPParser.methods[info.method] 28 | for (let i = 0; i < info.headers.length; i += 2) { 29 | request.headers[info.headers[i]] = info.headers[i+1] 30 | } 31 | } 32 | 33 | parser[HTTPParser.kOnBody] = (b, start, len) => { 34 | request.body = b.toString('utf8', start, start + len) 35 | } 36 | 37 | parser[HTTPParser.kOnMessageComplete] = () => { 38 | // If there is a Host header, we need to patch up the URL 39 | if (request.hasHeader('Host')) { 40 | request.uri = `http://${request.headers['Host']}${request.uri}` 41 | } else { 42 | request.uri = `http://localhost${request.uri}` 43 | } 44 | resolve(request) 45 | } 46 | 47 | // Execute the parser 48 | let buffer = Buffer.from(input) 49 | parser.execute(buffer, 0, buffer.length) 50 | parser.finish() 51 | }) 52 | } 53 | 54 | 55 | module.exports.name = 'HTTP' 56 | module.exports.parse = parseHTTP 57 | module.exports.highlighter = 'http' 58 | 59 | module.exports.example = ` 60 | POST / HTTP/1.1 61 | Host: example.com 62 | User-Agent: curl/7.54.0 63 | Accept: */* 64 | Content-Length: 11 65 | Content-Type: application/x-www-form-urlencoded 66 | 67 | hello=world 68 | `.trim() 69 | -------------------------------------------------------------------------------- /lib/frontends/json.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('../http-request') 4 | 5 | 6 | function parseJSON(input) { 7 | return Object.assign(new http.Request, JSON.parse(input)) 8 | } 9 | 10 | 11 | module.exports.name = 'JSON' 12 | module.exports.parse = parseJSON 13 | module.exports.highlighter = { name: 'javascript', json: true } 14 | -------------------------------------------------------------------------------- /lib/http-request.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class Request { 4 | constructor () { 5 | this.uri = null 6 | this.method = null 7 | this.headers = {} 8 | this.cookies = [] 9 | this.body = null 10 | } 11 | 12 | hasHeader(header) { 13 | return (header in this.headers) 14 | } 15 | } 16 | 17 | 18 | module.exports.Request = Request 19 | -------------------------------------------------------------------------------- /lib/transforms/drop-content-length-header.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function dropContentLength(request, logger=console) { 4 | if (!request.hasHeader('Content-Length')) { return } 5 | 6 | const contentLength = parseInt(request.headers['Content-Length']) 7 | if (typeof request.body === 'string' && request.body.length === contentLength) { 8 | logger.log(`The Content-Length header can be re-computed, so I dropped it`) 9 | delete request.headers['Content-Length'] 10 | } 11 | } 12 | 13 | 14 | exports.name = 'Drop unnecessary Content-Length header' 15 | exports.transform = dropContentLength 16 | -------------------------------------------------------------------------------- /lib/transforms/drop-host-header.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('url') 4 | 5 | 6 | function dropHost(request, logger=console) { 7 | if (!request.hasHeader('Host')) { return } 8 | 9 | let parsedURI = new URL(request.uri) 10 | var uselessHosts = [] 11 | if (parsedURI.port === '') { 12 | uselessHosts.push(parsedURI.hostname) 13 | if (parsedURI.protocol === 'http:') { uselessHosts.push(`${parsedURI.hostname}:80`) } 14 | else if (parsedURI.protocol === 'https:') { uselessHosts.push(`${parsedURI.hostname}:443`) } 15 | } else { 16 | uselessHosts.push(parsedURI.hostname + ':' + parsedURI.port) 17 | } 18 | 19 | if (uselessHosts.includes(request.headers['Host'])) { 20 | logger.log(`The Host header "${request.headers['Host']}" is implied by the URL, so I dropped it`) 21 | delete request.headers['Host'] 22 | } 23 | } 24 | 25 | 26 | exports.name = 'Drop unnecessary Host header' 27 | exports.transform = dropHost 28 | -------------------------------------------------------------------------------- /lib/transforms/parse-json-data.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | 4 | // Regular expression for matching a `token`, from RFC 2616 5 | const tokenRegex = /[^()<>@,;:\\"/\[\]?={} \t]+/ 6 | const mediaTypeRegex = new RegExp( 7 | '^(' + tokenRegex.source + ')/(' + tokenRegex.source + ')' 8 | ) 9 | 10 | 11 | function parseJsonData(request, logger=console) { 12 | if (!request.hasHeader('Content-Type')) { return } 13 | 14 | // Extract the type/subtype from the Content-Type header 15 | var match = request.headers['Content-Type'].match(mediaTypeRegex) 16 | if (!match) { 17 | logger.log('I can\'t parse this Content-Type:', 18 | request.headers['Content-Type']) 19 | return 20 | } 21 | 22 | // Stop if not application/json 23 | if (match[1] !== 'application' || match[2] !== 'json') { 24 | return 25 | } 26 | 27 | // If there's a trailer afterwards, for instance the charset, we ignore it 28 | if (match[0].length != request.headers['Content-Type'].length) { 29 | let trailer = request.headers['Content-Type'].substring(match[0].length) 30 | logger.log('Ignoring trailer after Content-Type:', trailer) 31 | } 32 | 33 | // Try to parse 34 | try { 35 | request.jsonData = JSON.parse(request.body || '') 36 | } catch (err) { 37 | logger.log(err.message) 38 | return 39 | } 40 | 41 | // If successful, drop the Content-Type header 42 | delete request.headers['Content-Type'] 43 | } 44 | 45 | 46 | exports.name = 'Parse JSON content' 47 | exports.transform = parseJsonData 48 | -------------------------------------------------------------------------------- /lib/transforms/split-form-data.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const querystring = require('querystring') 4 | 5 | 6 | // Regular expression for matching a `token`, from RFC 2616 7 | const tokenRegex = /[^()<>@,;:\\"/\[\]?={} \t]+/ 8 | const mediaTypeRegex = new RegExp( 9 | '^(' + tokenRegex.source + ')/(' + tokenRegex.source + ')' 10 | ) 11 | 12 | 13 | function splitFormData(request, logger=console) { 14 | if (!request.hasHeader('Content-Type')) { return } 15 | 16 | // Extract the type/subtype from the Content-Type header 17 | var match = request.headers['Content-Type'].match(mediaTypeRegex) 18 | if (!match) { 19 | logger.log('I can\'t parse this Content-Type:', 20 | request.headers['Content-Type']) 21 | return 22 | } 23 | 24 | // Stop if not application/x-www-form-urlencoded 25 | if (match[1] !== 'application' || match[2] !== 'x-www-form-urlencoded') { 26 | return 27 | } 28 | 29 | // If there's a trailer afterwards, for instance the charset, we ignore it 30 | if (match[0].length != request.headers['Content-Type'].length) { 31 | let trailer = request.headers['Content-Type'].substring(match[0].length) 32 | logger.log('Ignoring trailer after Content-Type:', trailer) 33 | } 34 | 35 | request.formData = querystring.parse(request.body || '') 36 | // delete request.body 37 | } 38 | 39 | 40 | exports.name = 'Split x-www-form-urlencoded content' 41 | exports.transform = splitFormData 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "http-translator", 4 | "version": "1.0.0", 5 | "description": "Translate curl commands to Python Requests code", 6 | "main": "main.js", 7 | "dependencies": { 8 | "argparse": "^1.0.10", 9 | "bootstrap": "^4.4.1", 10 | "codemirror": "^5.52.2", 11 | "http-parser-js": "^0.5.2", 12 | "preact": "^10.3.4", 13 | "react-codemirror2": "^7.1.0", 14 | "shlex": "^2.0.2" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.9.0", 18 | "@babel/preset-env": "^7.9.0", 19 | "@babel/preset-react": "^7.9.4", 20 | "babel-loader": "^8.1.0", 21 | "copy-webpack-plugin": "^5.1.1", 22 | "css-loader": "^3.4.2", 23 | "mini-css-extract-plugin": "^0.9.0", 24 | "node-sass": "^4.13.1", 25 | "sass-loader": "^8.0.2", 26 | "uglifyjs-webpack-plugin": "^2.2.0", 27 | "webpack": "^4.42.1", 28 | "webpack-cli": "^3.3.11", 29 | "webpack-dev-server": "^3.10.3" 30 | }, 31 | "scripts": { 32 | "test": "echo \"Error: no test specified\" && exit 1", 33 | "build": "npx webpack", 34 | "start:dev": "webpack-dev-server" 35 | }, 36 | "author": "Ryan Govostes", 37 | "license": "MIT", 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/rgov/http-translator.git" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | S3_BUCKET=ryan.govost.es 4 | # Should start with a slash, but not end with one 5 | S3_BUCKET_SUBDIR=/http-translator 6 | CF_DISTRIBUTION=E1PTO4RG80K1VB 7 | 8 | rm -Rf dist 9 | npm install 10 | npx webpack 11 | 12 | SYNC_COMMAND=( 13 | aws s3 sync 14 | --delete 15 | --exclude .DS_Store 16 | --acl public-read 17 | dist "s3://${S3_BUCKET}${S3_BUCKET_SUBDIR}" 18 | ) 19 | 20 | "${SYNC_COMMAND[@]}" --dryrun 21 | 22 | read -p "Are you sure? " -n 1 -r 23 | echo 24 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then 25 | exit 1 26 | fi 27 | 28 | # Actually run the sync command 29 | SYNC_OUTPUT=$("${SYNC_COMMAND[@]}") 30 | 31 | # Invalidate the Cloudfront cache 32 | echo "$SYNC_OUTPUT" \ 33 | | sed -Ee 's,^.*'"$S3_BUCKET"'('"$S3_BUCKET_SUBDIR"'/.*)$,\1,g' \ 34 | | xargs aws cloudfront create-invalidation \ 35 | --distribution-id "$CF_DISTRIBUTION" \ 36 | --paths 37 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const util = require('util') 4 | 5 | const React = require('react') 6 | const ReactDOM = require('react-dom') 7 | 8 | const CodeMirror = require('react-codemirror2') 9 | const CodeMirrorJS = require('codemirror') 10 | 11 | 12 | // This tells webpack to pull in these syntax highlighters. We cannot do this 13 | // dynamically, so make sure to update this if new frontends or backends are 14 | // added. 15 | require('codemirror/mode/http/http') 16 | require('codemirror/mode/javascript/javascript') 17 | require('codemirror/mode/python/python') 18 | require('codemirror/mode/shell/shell') 19 | 20 | 21 | class Logger extends React.Component { 22 | constructor() { 23 | super() 24 | this.state = { 25 | messages: [] 26 | } 27 | 28 | this.log = this.log.bind(this) 29 | this.error = this.error.bind(this) 30 | } 31 | 32 | clear() { 33 | this.setState({ messages: [] }) 34 | } 35 | 36 | addSection(name) { 37 | this._appendLog('section', [ name ]) 38 | } 39 | 40 | log() { 41 | this._appendLog('log', Array.from(arguments)) 42 | } 43 | 44 | error() { 45 | console.error.apply(console, Array.from(arguments)) 46 | this._appendLog('error', Array.from(arguments)) 47 | } 48 | 49 | hasError() { 50 | for (let message of this.state.messages) { 51 | if (message.type === 'error') 52 | return true; 53 | } 54 | return false; 55 | } 56 | 57 | _appendLog(type, message) { 58 | let strmsg = ''; 59 | for (let part of message) { 60 | if (typeof part === 'string') { 61 | strmsg += `${part} ` 62 | } else { 63 | strmsg += util.inspect(part) 64 | } 65 | } 66 | 67 | this.setState({ messages: [ 68 | ...this.state.messages, 69 | { type: type, content: strmsg } 70 | ]}) 71 | } 72 | 73 | render() { 74 | return ( 75 |
76 | 81 |
82 | ) 83 | } 84 | } 85 | 86 | 87 | class App extends React.Component { 88 | constructor() { 89 | super() 90 | this.state = {} 91 | 92 | // List the frontends, transforms, and backends we want to load 93 | this.state.frontends = [ 94 | require('../lib/frontends/curl'), 95 | require('../lib/frontends/http'), 96 | require('../lib/frontends/json') 97 | ] 98 | this.state.transforms = [ 99 | require('../lib/transforms/drop-content-length-header'), 100 | require('../lib/transforms/drop-host-header'), 101 | require('../lib/transforms/parse-json-data'), 102 | require('../lib/transforms/split-form-data'), 103 | ] 104 | this.state.backends = [ 105 | require('../lib/backends/javascript-xhr'), 106 | require('../lib/backends/python-requests'), 107 | require('../lib/backends/json') 108 | ] 109 | 110 | // Choose the default frontends 111 | this.state.frontend = this.state.frontends[0] 112 | this.state.backend = this.state.backends[1] 113 | 114 | // Set the default input and clear output 115 | this.state.input = this.state.frontend.example 116 | this.state.output = '' 117 | 118 | // Set the tab we want to show 119 | this.state.showLogTab = false 120 | 121 | // Bind `this` so that handlers for JavaScript events work 122 | this.handleFrontendChange = this.handleFrontendChange.bind(this) 123 | this.handleBackendChange = this.handleBackendChange.bind(this) 124 | 125 | // These will store our code editor instances, after they've mounted 126 | this.inputEditor = null 127 | this.outputEditor = null 128 | } 129 | 130 | componentDidMount() { 131 | // Wire up the renderLine handler on both of them 132 | for (let editor of [this.inputEditor, this.outputEditor]) { 133 | editor.on('renderLine', (cm, line, elt) => { 134 | let charWidth = editor.defaultCharWidth() 135 | let hangingIndent = charWidth * 2 136 | var off = CodeMirrorJS.countColumn(line.text, null, cm.getOption('tabSize')) * charWidth 137 | elt.style.textIndent = `-${hangingIndent + off}px` 138 | elt.style.paddingLeft = `${hangingIndent + off}px` 139 | }) 140 | editor.refresh() 141 | } 142 | 143 | // Kick off the initial generation 144 | this.handleCodeChange() 145 | } 146 | 147 | componentDidUpdate(prevProps, prevState, snapshot) { 148 | // Re-generate if any of the input parameters changed 149 | if ((this.state.input !== prevState.input || 150 | this.state.frontend !== prevState.frontend || 151 | this.state.backend !== prevState.backend) && 152 | this.state.output === prevState.output) { 153 | 154 | this.handleCodeChange() 155 | } 156 | } 157 | 158 | handleCodeChange() { 159 | this.logger.clear() 160 | var p = Promise.resolve(this.state.input) 161 | p.then((input) => { 162 | this.logger.addSection(`Frontend: ${this.state.frontend.name}`) 163 | return this.state.frontend.parse(input, this.logger) 164 | }).then((request) => { 165 | for (let transform of this.state.transforms) { 166 | this.logger.addSection(`Transform: ${transform.name}`) 167 | transform.transform(request, this.logger) 168 | } 169 | return request 170 | }).then((request) => { 171 | this.logger.addSection(`Backend: ${this.state.backend.name}`) 172 | return this.state.backend.generate(request, this.logger) 173 | }).then((output) => { 174 | this.setState({ output: output }) 175 | }).catch((error) => { 176 | this.logger.error(error) 177 | }) 178 | } 179 | 180 | handleFrontendChange(event) { 181 | for (var frontend of this.state.frontends) { 182 | if (frontend.name === event.target.value) { 183 | this.setState({ frontend: frontend }) 184 | return 185 | } 186 | } 187 | } 188 | 189 | handleBackendChange(event) { 190 | for (var backend of this.state.backends) { 191 | if (backend.name === event.target.value) { 192 | this.setState({ backend: backend }) 193 | return 194 | } 195 | } 196 | } 197 | 198 | render() { 199 | return ( 200 |
201 |
202 | 224 |
225 | { 234 | this.setState({ input: value }) 235 | }} 236 | editorDidMount={(editor) => { this.inputEditor = editor }} 237 | /> 238 |
239 |
240 | 241 |
242 | 271 |
272 |
273 | this.logger = c} /> 274 |
275 |
276 | { this.outputEditor = editor }} 286 | /> 287 |
288 |
289 |
290 |
291 | ) 292 | } 293 | } 294 | 295 | ReactDOM.render(, document.getElementById('root')) 296 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | HTTP Request Translator 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 | 23 | 24 | 25 |
26 | 32 | 33 |

HTTP Request Translator

34 |

brought to you by Ryan Govostes

35 |
36 | 37 |
38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './app.js' 2 | import './style.scss' 3 | -------------------------------------------------------------------------------- /src/style.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Crete+Round'); 2 | 3 | @import 'node_modules/codemirror/lib/codemirror'; 4 | 5 | %red-triangle { 6 | content: ""; 7 | 8 | /* useful: http://apps.eky.hk/css-triangle-generator/ */ 9 | width: 0; 10 | height: 0; 11 | border-style: solid; 12 | border-width: 0 4px 6.9px 4px; 13 | border-color: transparent transparent red transparent; 14 | } 15 | 16 | %gray-circle { 17 | content: ""; 18 | 19 | width: 6px; 20 | height: 6px; 21 | background-color: gray; 22 | border-radius: 50%; 23 | } 24 | 25 | body { 26 | font-family: sans-serif; 27 | margin: 0; 28 | } 29 | 30 | .hide { 31 | display: none; 32 | } 33 | 34 | h1, h2, h3, h4, h5, h6 { 35 | font-family: 'Crete Round', serif; 36 | margin: 0; 37 | } 38 | 39 | h1 { 40 | font-size: 48px; 41 | } 42 | 43 | a { 44 | text-decoration: none; 45 | } 46 | 47 | .github { 48 | float: right; 49 | margin-left: 20px; 50 | 51 | .icon { 52 | width: 48px; 53 | opacity: 0.4; 54 | transition: opacity .15s ease-in-out; 55 | 56 | &:hover { 57 | opacity: 1.0; 58 | } 59 | } 60 | } 61 | 62 | .top { 63 | padding: 20px; 64 | 65 | ul { 66 | list-style: none; 67 | padding: 0; 68 | } 69 | ul li { 70 | display: inline; 71 | padding-right: 40px; 72 | } 73 | p { 74 | margin-top: 10px; 75 | } 76 | } 77 | 78 | .CodeMirror { 79 | height: auto; 80 | } 81 | 82 | .parent { 83 | display: flex; 84 | flex-wrap: wrap; 85 | } 86 | 87 | .child { 88 | width: 50%; 89 | } 90 | 91 | button, select { 92 | padding:.375rem .75rem; 93 | color: #495057; 94 | vertical-align: middle; 95 | border: 1px solid #ced4da; 96 | border-radius: .25rem; 97 | -webkit-appearance: none; 98 | -moz-appearance: none; 99 | appearance: none; 100 | margin: 2px; 101 | } 102 | 103 | button { 104 | color: #007bff; 105 | background-color: transparent; 106 | background-image: none; 107 | border-color: #007bff; 108 | text-align: center; 109 | transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out; 110 | 111 | &:not(:disabled):hover, &:not(:disabled):active { 112 | color: #fff; 113 | background-color: #007bff; 114 | border-color: #007bff; 115 | } 116 | 117 | &:disabled { 118 | color: #007bff; 119 | background-color: transparent; 120 | opacity: .65; 121 | } 122 | 123 | &:not(:disabled) { 124 | cursor: pointer; 125 | } 126 | } 127 | 128 | 129 | 130 | select { 131 | padding-right: 1.75rem; 132 | background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") no-repeat right .75rem center; 133 | background-size: 8px 10px; 134 | } 135 | 136 | .nav { 137 | display: flex; 138 | justify-content: space-between; 139 | flex-wrap: wrap; 140 | 141 | border-bottom: 1px solid #dee2e6; 142 | padding: 0 20px; 143 | 144 | .nav-tabs { 145 | display: flex; 146 | list-style: none; 147 | padding: 0; 148 | margin-bottom: 0; 149 | 150 | .nav-item { 151 | margin-bottom: -1px; 152 | } 153 | 154 | .nav-link { 155 | display: block; 156 | padding: .45rem .9rem; 157 | 158 | font-family: 'Crete Round', serif; 159 | border: 1px solid transparent; 160 | border-top-left-radius: .25rem; 161 | border-top-right-radius: .25rem; 162 | 163 | color: #666; 164 | 165 | &.active { 166 | color: black; 167 | background-color: white; 168 | border-color: #dee2e6 #dee2e6 white; 169 | } 170 | 171 | &.error:after { 172 | @extend %red-triangle; 173 | 174 | left: 0.25em; 175 | position: relative; 176 | 177 | display: inline-block; 178 | } 179 | } 180 | } 181 | } 182 | 183 | 184 | .logger { 185 | font-family: monospace; 186 | padding-right: 1em; 187 | 188 | ul { 189 | list-style: none; 190 | padding: 0; 191 | 192 | li { 193 | 194 | &.section { 195 | font-weight: bold; 196 | padding-left: 1em; 197 | padding-top: 1em; 198 | 199 | &:first-child { 200 | padding-top: 0 201 | } 202 | } 203 | 204 | &:not(.section) { 205 | padding-left: 2em; 206 | } 207 | 208 | &.error:before { 209 | @extend %red-triangle; 210 | 211 | left: -0.9em; /* looks better */ 212 | top: 0.8em; 213 | position: relative; 214 | 215 | display: block; 216 | } 217 | 218 | &.log:before { 219 | @extend %gray-circle; 220 | 221 | left: -0.8em; 222 | top: 0.8em; 223 | position: relative; 224 | 225 | display: block; 226 | } 227 | } 228 | 229 | } 230 | } 231 | 232 | 233 | @media (max-width: 800px) { 234 | .child { 235 | width: 100%; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | 4 | const CopyWebpackPlugin = require('copy-webpack-plugin') 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin') 7 | 8 | 9 | module.exports = { 10 | mode: 'production', 11 | 12 | entry: './src/index.js', 13 | output: { 14 | filename: '[name].bundle.js', 15 | path: path.resolve(__dirname, 'dist') 16 | }, 17 | 18 | // Run JavaScript through Babel 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.js$/, 23 | loader: 'babel-loader', 24 | exclude: /node_modules/, 25 | query: { 26 | presets: ['@babel/env', '@babel/react'] 27 | } 28 | }, 29 | { 30 | test: /\.s?css$/, 31 | use: [ MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader' ] 32 | } 33 | ] 34 | }, 35 | 36 | // Use Preact compatibility layer in React's place 37 | resolve: { 38 | alias: { 39 | 'react': 'preact/compat', 40 | 'react-dom': 'preact/compat', 41 | } 42 | }, 43 | 44 | plugins: [ 45 | new CopyWebpackPlugin([ 46 | { from: 'src/index.html' } 47 | ]), 48 | new MiniCssExtractPlugin({ 49 | filename: '[name].css' 50 | }) 51 | ], 52 | 53 | // This is to fix the fact that argparse has some code that accesses the 54 | // filesystem, but we obviously don't use that in the browser 55 | node: { 56 | fs: "empty" 57 | } 58 | } 59 | --------------------------------------------------------------------------------