├── .gitignore
├── src
├── index.js
├── style.scss
└── app.jsx
├── lib
├── http-request.js
├── backends
│ ├── json.js
│ ├── javascript-xhr.js
│ ├── python-urllib.js
│ └── python-requests.js
├── frontends
│ ├── json.js
│ ├── http.js
│ └── curl.js
└── transforms
│ ├── drop-content-length-header.js
│ ├── drop-host-header.js
│ ├── split-form-data.js
│ └── parse-json-data.js
├── vite.config.js
├── .github
└── workflows
│ └── nodejs.yml
├── release.sh
├── package.json
├── LICENSE
├── README.md
└── index.html
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
3 | dist/
4 | node_modules/
5 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import './app.jsx'
2 | import './style.scss'
3 |
--------------------------------------------------------------------------------
/lib/http-request.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | export 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 |
--------------------------------------------------------------------------------
/lib/backends/json.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | function generateJSON(request) {
4 | return JSON.stringify(request, null, 2)
5 | }
6 |
7 |
8 | export const name = 'JSON'
9 | export const generate = generateJSON
10 | export const highlighter = 'javascript'
11 |
12 | export default {
13 | name,
14 | generate,
15 | highlighter,
16 | }
17 |
--------------------------------------------------------------------------------
/lib/frontends/json.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | import { Request } from '../http-request'
4 |
5 | function parseJSON(input) {
6 | return Object.assign(new Request(), JSON.parse(input))
7 | }
8 |
9 | export const name = 'JSON'
10 | export const parse = parseJSON
11 | export const highlighter = { name: 'javascript', json: true }
12 |
13 | export default {
14 | name,
15 | parse,
16 | highlighter,
17 | }
18 |
--------------------------------------------------------------------------------
/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 | export const name = 'Drop unnecessary Content-Length header'
15 | export const transform = dropContentLength
16 |
17 | export default {
18 | name,
19 | transform,
20 | }
21 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import preact from '@preact/preset-vite'
3 | import { nodePolyfills } from 'vite-plugin-node-polyfills'
4 |
5 | // TODO
6 | // Our argparse dependency relies on Node-only modules (assert, fs, path, etc.).
7 | // To make it work in the browser, we need polyfills for these modules.
8 | // It is also a CommonJS module, so we need to convert to ESM.
9 |
10 | // https://vite.dev/config/
11 | export default defineConfig({
12 | base: './',
13 | plugins: [
14 | preact(),
15 | nodePolyfills({
16 | include: ['fs', 'path', 'process', 'querystring', 'stream', 'util'],
17 | globals: { global: true, process: true },
18 | }),
19 | ],
20 | })
21 |
--------------------------------------------------------------------------------
/.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: [20.x, 22.x, 24.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 |
--------------------------------------------------------------------------------
/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 | npm run build
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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "http-translator",
3 | "private": true,
4 | "version": "1.0.1",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@codemirror/lang-javascript": "^6.2.4",
13 | "@codemirror/lang-json": "^6.0.2",
14 | "@codemirror/lang-python": "^6.2.1",
15 | "@uiw/react-codemirror": "^4.23.14",
16 | "argparse": "^2.0.1",
17 | "bootstrap": "^5.3.7",
18 | "http-parser-js": "^0.5.10",
19 | "preact": "^10.26.9",
20 | "shlex": "^3.0.0"
21 | },
22 | "devDependencies": {
23 | "@preact/preset-vite": "^2.10.1",
24 | "@rollup/plugin-commonjs": "^28.0.6",
25 | "@rollup/plugin-node-resolve": "^16.0.1",
26 | "browser-stdout": "^1.3.1",
27 | "sass": "^1.89.2",
28 | "vite": "^6.3.6",
29 | "vite-plugin-node-polyfills": "^0.23.0"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/lib/transforms/drop-host-header.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 |
4 |
5 | function dropHost(request, logger=console) {
6 | if (!request.hasHeader('Host')) { return }
7 |
8 | let parsedURI = new URL(request.uri)
9 | var uselessHosts = []
10 | if (parsedURI.port === '') {
11 | uselessHosts.push(parsedURI.hostname)
12 | if (parsedURI.protocol === 'http:') { uselessHosts.push(`${parsedURI.hostname}:80`) }
13 | else if (parsedURI.protocol === 'https:') { uselessHosts.push(`${parsedURI.hostname}:443`) }
14 | } else {
15 | uselessHosts.push(parsedURI.hostname + ':' + parsedURI.port)
16 | }
17 |
18 | if (uselessHosts.includes(request.headers['Host'])) {
19 | logger.log(`The Host header "${request.headers['Host']}" is implied by the URL, so I dropped it`)
20 | delete request.headers['Host']
21 | }
22 | }
23 |
24 |
25 | export const name = 'Drop unnecessary Host header'
26 | export const transform = dropHost
27 |
28 | export default {
29 | name,
30 | transform,
31 | }
32 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/transforms/split-form-data.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | import querystring from '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 | export 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 | export const name = 'Split x-www-form-urlencoded content'
41 | export const transform = splitFormData
42 |
43 | export default {
44 | name,
45 | transform,
46 | }
47 |
--------------------------------------------------------------------------------
/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 | export 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 | export const name = 'Parse JSON content'
47 | export const transform = parseJsonData
48 |
49 | export default {
50 | name,
51 | transform,
52 | }
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
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 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 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | HTTP Request Translator
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
HTTP Request Translator
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/lib/frontends/http.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | import { Request } from '../http-request'
4 | // This is a JavaScript reimplementation of Node's HTTP parser
5 | import * as httpParserJs from 'http-parser-js'
6 | const { HTTPParser } = httpParserJs
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 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 | export const name = 'HTTP'
56 | export const parse = parseHTTP
57 | export const highlighter = 'http'
58 |
59 | export const 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 |
70 | export default {
71 | name,
72 | parse,
73 | highlighter,
74 | example,
75 | }
76 |
--------------------------------------------------------------------------------
/lib/backends/javascript-xhr.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 |
4 | // https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name
5 | const xhrForbiddenHeaders = [
6 | "Accept-Charset",
7 | "Accept-Encoding",
8 | "Access-Control-Request-Headers",
9 | "Access-Control-Request-Method",
10 | "Connection",
11 | "Content-Length",
12 | "Cookie",
13 | "Cookie2",
14 | "Date",
15 | "DNT",
16 | "Expect",
17 | "Host",
18 | "Keep-Alive",
19 | "Origin",
20 | /^Proxy-/i,
21 | /^Sec-/i,
22 | "Referer",
23 | "TE",
24 | "Trailer",
25 | "Transfer-Encoding",
26 | "Upgrade",
27 | "Via"
28 | ]
29 |
30 | function javascript_escape(value, quote='"') {
31 | return value.replace(new RegExp('([' + quote + '\\\\])', 'g'), '\\$1')
32 | .replace(/\n/g, '\\n')
33 | .replace(/\t/g, '\\t')
34 | .replace(/\r/g, '\\r')
35 | .replace(/[^ -~]+/g, function (match) {
36 | var hex = new Buffer(match).toString('hex')
37 | return hex.replace(/(..)/g, '\\x$1')
38 | })
39 | }
40 |
41 | function generateJavascriptXHR(request, logger=console) {
42 | // Emit the preamble
43 | var code = []
44 | code.push('var xhr = new XMLHttpRequest()')
45 |
46 | // Parse the URI
47 | var parsedURI = new URL(request.uri)
48 |
49 | // Remove the username and password from the URL
50 | var username = parsedURI.username
51 | var password = parsedURI.password
52 | parsedURI.username = parsedURI.password = ''
53 |
54 | // Output the open() method
55 | var open = `xhr.open("${request.method}", "${parsedURI.href}"`
56 | open += ", false" // asynchronous
57 | if (!!username && !!password) {
58 | open += `, "${username}", "${password}"`
59 | } else if (!!username && !password) {
60 | open += `, "${username}"`
61 | } else if (!username && !!password) {
62 | open += `, "", "${password}"`
63 | }
64 | open += ')'
65 | code.push(open)
66 |
67 | // Emit code for the headers
68 | if (Object.keys(request.headers).length > 0) {
69 | for (const name in request.headers) {
70 | // Scan the list of forbidden headers
71 | var verboten = false
72 | for (const pattern of xhrForbiddenHeaders) {
73 | console.log(pattern)
74 | if (typeof pattern === 'string') {
75 | if (name.toLowerCase() === pattern.toLowerCase()) {
76 | verboten = true
77 | break
78 | }
79 | } else if (name.match(pattern)) {
80 | verboten = true
81 | break
82 | }
83 | }
84 |
85 | if (verboten) {
86 | logger.error(`Omitting forbidden ${name} header`)
87 | continue
88 | }
89 |
90 | const value = request.headers[name]
91 | code.push(`xhr.setRequestHeader("${name}", "${value}")`)
92 | }
93 | }
94 |
95 | // Add some placeholders for handlers
96 | code.push('xhr.onload = function (e) { /* ... */ }')
97 | code.push('xhr.onerror = function (e) { /* ... */ }')
98 |
99 | // Add the send command
100 | if (request.jsonData !== undefined) {
101 | var json = JSON.stringify(request.jsonData, null, 2)
102 | code.push(`xhr.send(JSON.stringify(${json}))`)
103 | } else if (typeof request.body === 'string' && request.body != '') {
104 | code.push(`xhr.send("${javascript_escape(request.body)}")`)
105 | } else {
106 | code.push(`xhr.send(null)`)
107 | }
108 |
109 |
110 | return code.join('\n')
111 | }
112 |
113 |
114 | export const name = 'JavaScript (XMLHttpRequest)'
115 | export const generate = generateJavascriptXHR
116 | export const highlighter = 'javascript'
117 |
118 | export default {
119 | name,
120 | generate,
121 | highlighter,
122 | }
123 |
--------------------------------------------------------------------------------
/lib/backends/python-urllib.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | function python_escape(value, quote='\'') {
4 | return value.replace(new RegExp('([' + quote + '\\\\])', 'g'), '\\$1')
5 | .replace(/\n/g, '\\n')
6 | .replace(/\t/g, '\\t')
7 | .replace(/\r/g, '\\r')
8 | .replace(/[^ -~]+/g, function (match) {
9 | var hex = Buffer.from(match).toString('hex')
10 | return hex.replace(/(..)/g, '\\x$1')
11 | })
12 | }
13 |
14 | function pythonize(obj) {
15 | if (typeof obj === 'string') {
16 | return '\'' + python_escape(obj) + '\''
17 | } else if (typeof obj === 'number') {
18 | return obj
19 | } else if (typeof obj === 'boolean') {
20 | return obj ? 'True' : 'False'
21 | } else if (obj === null || obj === undefined) {
22 | return 'None'
23 | } else if (Array.isArray(obj)) {
24 | return '[ ' + obj.map(pythonize).join(', ') + ' ]'
25 | } else if (obj.constructor === Object) {
26 | var rows = []
27 | for (let k in obj) {
28 | rows.push(pythonize(k) + ': ' + pythonize(obj[k]))
29 | }
30 | return '{ ' + rows.join(', ') + ' }'
31 | } else {
32 | throw 'unknown type'
33 | }
34 | }
35 |
36 | function generatePythonUrllib(request, logger=console) {
37 | // Determine required imports
38 | const imports = new Set()
39 | imports.add('urllib.request')
40 |
41 | // Emit preamble
42 | const code = []
43 | code.push('req = urllib.request.Request(')
44 |
45 | // Emit the URL
46 | const parsedURI = new URL(request.uri)
47 | const emitQueryParams = parsedURI.search !== ''
48 | if (emitQueryParams) {
49 | imports.add('urllib.parse')
50 | code.push(` ${pythonize(parsedURI.origin + parsedURI.pathname + '?')} + urllib.parse.urlencode({`)
51 | for (const [name, value] of parsedURI.searchParams) {
52 | code.push(` ${pythonize(name)}: ${pythonize(value)},`)
53 | }
54 | code.push(' }),')
55 | } else {
56 | code.push(` ${pythonize(request.uri)},`)
57 | }
58 |
59 | // Emit the headers
60 | if (Object.keys(request.headers).length > 0) {
61 | code.push(' headers={')
62 | for (const name in request.headers) {
63 | code.push(` ${pythonize(name)}: ${pythonize(request.headers[name])},`)
64 | }
65 | code.push(' },')
66 | }
67 |
68 | // Emit the body
69 | const hasFormData = request.formData && Object.keys(request.formData).length > 0
70 | if (request.jsonData !== undefined) {
71 | imports.add('json')
72 | code.push(` data=json.dumps(${pythonize(request.jsonData)}).encode(),`)
73 | } else if (hasFormData) {
74 | imports.add('urllib.parse')
75 | code.push(' data=urllib.parse.urlencode({')
76 | for (const name in request.formData) {
77 | code.push(` ${pythonize(name)}: ${pythonize(request.formData[name])},`)
78 | }
79 | code.push(` }).encode(),`)
80 | } else if (typeof request.body === 'string' && request.body !== '') {
81 | code.push(` data=b${pythonize(request.body)},`)
82 | }
83 |
84 | // Emit the method
85 | code.push(` method=${pythonize(request.method)}`)
86 |
87 | // Finalize the code
88 | code.push(')')
89 | code.push('')
90 | code.push('with urllib.request.urlopen(req) as response:')
91 | code.push(' body = response.read().decode()')
92 |
93 | // Insert the needed imports at the top
94 | code.splice(0, 0, '') // blank line before imports
95 | new Array(...imports)
96 | .sort((a, b) => b.localeCompare(a)) // reverse order
97 | .forEach(x => code.splice(0, 0, `import ${x}`));
98 |
99 |
100 | return code.join('\n')
101 | }
102 |
103 | export const name = 'Python urllib'
104 | export const generate = generatePythonUrllib
105 | export const highlighter = 'python'
106 |
107 | export default {
108 | name,
109 | generate,
110 | highlighter
111 | }
112 |
--------------------------------------------------------------------------------
/lib/frontends/curl.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | import * as argparse from 'argparse'
4 | import * as shlex from 'shlex'
5 | import BrowserStdout from 'browser-stdout'
6 | import { Request } from '../http-request'
7 |
8 |
9 | // Patch process.stdout to use browser-stdout
10 | process.stdout = BrowserStdout()
11 |
12 |
13 | // Patch argparse.ArgumentParser to log messages, rather than die
14 | class ArgumentParser extends argparse.ArgumentParser {
15 | _printMessage (message) {
16 | this.logger.log(message.replace(/\s+$/, ''))
17 | }
18 |
19 | exit (status, message) {
20 | if (message !== undefined) {
21 | (status ? this.logger.error : this.logger.log)(message.replace(/\s+$/, ''))
22 | }
23 | this.logger.log('The argument parser exited with status', status)
24 | this.exit_status = status
25 | }
26 | }
27 |
28 |
29 | function parseCurlCommandLine(command, logger=console) {
30 | var argv = shlex.split(command)
31 | argv.shift() // consume the leading `curl`
32 |
33 | var parser = new ArgumentParser({ prog: 'curl' })
34 | parser.logger = logger // allow _printMessage patch to use our logger
35 | parser.add_argument('url')
36 | parser.add_argument('--request', '-X', { dest: 'method' })
37 | parser.add_argument('--header', '-H', { dest: 'headers', action: 'append'})
38 | parser.add_argument('--referer', '-e')
39 | parser.add_argument('--user-agent', '-A')
40 | parser.add_argument('--data', '--data-ascii', '--data-binary', '--data-raw',
41 | /*'--data-urlencode',*/ '-d', { action: 'append'})
42 | var [args, extra] = (parser.parse_known_args(argv) || [])
43 |
44 | // If the parser died, don't continue
45 | if (parser.exit_status !== undefined) { return }
46 |
47 | // We don't parse all arguments. Be transparent about it.
48 | if (extra.length > 0) {
49 | logger.log('I skipped these unsupported arguments:', extra)
50 | }
51 |
52 | var req = new Request()
53 | req.uri = args.url
54 |
55 | for (const header of (args.headers || [])) {
56 | var match
57 | if (match = header.match(/^\s*([^:]+);\s*$/)) {
58 | req.headers[match[1]] = ''
59 | } else if (match = header.match(/^\s*([^:]+):\s*(.*?)\s*$/)) {
60 | req.headers[match[1]] = match[2]
61 | } else if (match = header.match(/^\s*([^:]+):\s*$/)) {
62 | // Tricky case, this syntax will unset a default header, but otherwise
63 | // won't unset a
64 | var name = match[1]
65 | logger.log('I don\'t support unsetting headers (like ' + name + ') yet.')
66 | } else {
67 | logger.log('I don\'t understand this header:', header)
68 | }
69 | }
70 |
71 | // TODO: curl supports many ways of specifying the body of the request. We
72 | // will need a custom argparse Action that keeps the data in order.
73 | //
74 | // * --data, --data-ascii, --data-raw -- possibly strip whitespace
75 | // * --data-binary -- do not strip whitespace
76 | // * --data-urlencode -- URL encode the *value* but not the key (if present)
77 | if (args.data) {
78 | if (!('Content-Type' in req.headers)) {
79 | req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
80 | }
81 |
82 | req.method = 'POST'
83 | req.body = args.data.join('&')
84 | }
85 |
86 | // Allow overriding the method, or fall back to GET
87 | if (args.method) {
88 | req.method = args.method
89 | } else if (req.method === null) {
90 | req.method = 'GET'
91 | }
92 |
93 | return req
94 | }
95 |
96 |
97 | export const name = 'curl'
98 | export const parse = parseCurlCommandLine
99 | export const highlighter = 'shell'
100 |
101 | export const example = `
102 | curl 'http://example.com/api/v2/defragment' \\
103 | -H 'Host: example.com' \\
104 | -H 'Content-Length: 19' \\
105 | -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \\
106 | -H 'Cookie: session=291419e390a3b67a3946e0854cc9e33e' \\
107 | -H 'Referer: http://example.com/' \\
108 | -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' \\
109 | -H 'X-Requested-With: XMLHttpRequest' \\
110 | --data 'drive=C&confirm=yes'
111 | `.trim()
112 |
113 | export default {
114 | name,
115 | parse,
116 | highlighter,
117 | example,
118 | }
119 |
--------------------------------------------------------------------------------
/lib/backends/python-requests.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 |
4 |
5 | function python_escape(value, quote='\'') {
6 | return value.replace(new RegExp('([' + quote + '\\\\])', 'g'), '\\$1')
7 | .replace(/\n/g, '\\n')
8 | .replace(/\t/g, '\\t')
9 | .replace(/\r/g, '\\r')
10 | .replace(/[^ -~]+/g, function (match) {
11 | var hex = new Buffer(match).toString('hex')
12 | return hex.replace(/(..)/g, '\\x$1')
13 | })
14 | }
15 |
16 | function pythonize(obj) {
17 | // TODO: Pretty printing
18 | if (typeof obj === 'string') {
19 | return '\'' + python_escape(obj) + '\''
20 | } else if (typeof obj === 'number') {
21 | return obj
22 | } else if (typeof obj === 'boolean') {
23 | return obj ? 'True' : 'False'
24 | } else if (obj === null || obj === undefined) {
25 | return 'None'
26 | } else if (Array.isArray(obj)) {
27 | return '[ ' + obj.map(pythonize).join(', ') + ' ]'
28 | } else if (obj.constructor === Object) {
29 | var rows = []
30 | for (let k in obj) {
31 | rows.push(pythonize(k) + ': ' + pythonize(obj[k]))
32 | }
33 | return '{ ' + rows.join(', ') + ' }'
34 | } else {
35 | throw 'unknown type'
36 | }
37 | }
38 |
39 | function generatePythonRequests(request, logger=console) {
40 | // Emit the preamble
41 | var code = []
42 | code.push('import requests')
43 | code.push('')
44 |
45 | // Map the HTTP method to the function
46 | const methods = {
47 | DELETE: 'requests.delete',
48 | GET: 'requests.get',
49 | HEAD: 'requests.head',
50 | OPTIONS: 'requests.options',
51 | POST: 'requests.post',
52 | PUT: 'requests.put',
53 | }
54 | var func = methods[request.method] || 'requests.request'
55 | var isCustomMethod = !methods.hasOwnProperty(request.method)
56 |
57 | // Parse the URI
58 | var parsedURI = new URL(request.uri)
59 |
60 | // Try to break out URL parameters from the query string, if we can
61 | var emitQueryParams = (parsedURI.search !== '')
62 | if (emitQueryParams) {
63 | // Modify the URI we'll eventually emit so that it doesn't include the
64 | // query string
65 | request.uri = `${parsedURI.origin}${parsedURI.pathname}`
66 | }
67 |
68 | // Emit the function call, URL, and method
69 | code.push(`${func}(`)
70 | code.push(` ${pythonize(request.uri)},`)
71 | if (isCustomMethod) { code.push(` method=${pythonize(request.method)},`) }
72 |
73 | // Emit the URL params
74 | if (emitQueryParams) {
75 | // FIXME: We don't handle multiple parameters with the same name correctly
76 | code.push(' params={')
77 | for (const [name, value] of parsedURI.searchParams) {
78 | code.push(` ${pythonize(name)}: ${pythonize(value)},`)
79 | }
80 | code.push(' },')
81 | }
82 |
83 | // Emit code for the headers
84 | if (Object.keys(request.headers).length > 0) {
85 | code.push(' headers={')
86 | for (const name in request.headers) {
87 | const value = request.headers[name]
88 | code.push(` ${pythonize(name)}: ${pythonize(value)},`)
89 | }
90 | code.push(' },')
91 | }
92 |
93 | // Try to convert JSON to a Python object
94 | let json = undefined
95 | if (request.jsonData !== undefined) {
96 | try {
97 | json = pythonize(request.jsonData)
98 | } catch (err) {
99 | logger.log('Failed to represent JSON as Python')
100 | }
101 | }
102 |
103 | // Emit code for the data
104 | if (json) {
105 | code.push(` json=${json},`)
106 | } else if (request.formData !== undefined && Object.keys(request.formData).length > 0) {
107 | code.push(' data={')
108 | for (const name in request.formData) {
109 | const value = request.formData[name]
110 | code.push(` ${pythonize(name)}: ${pythonize(value)},`)
111 | }
112 | code.push(' },')
113 | } else if (typeof request.body === 'string' && request.body != '') {
114 | code.push(` data=${pythonize(request.body)},`)
115 | }
116 |
117 | // Finish the code and return it
118 | code.push(')')
119 | return code.join('\n')
120 | }
121 |
122 |
123 | export const name = 'Python Requests'
124 | export const generate = generatePythonRequests
125 | export const highlighter = 'python'
126 |
127 | export default {
128 | name,
129 | generate,
130 | highlighter,
131 | }
132 |
--------------------------------------------------------------------------------
/src/style.scss:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=Crete+Round');
2 |
3 | %red-triangle {
4 | content: "";
5 |
6 | /* useful: http://apps.eky.hk/css-triangle-generator/ */
7 | width: 0;
8 | height: 0;
9 | border-style: solid;
10 | border-width: 0 4px 6.9px 4px;
11 | border-color: transparent transparent red transparent;
12 | }
13 |
14 | %gray-circle {
15 | content: "";
16 |
17 | width: 6px;
18 | height: 6px;
19 | background-color: gray;
20 | border-radius: 50%;
21 | }
22 |
23 | body {
24 | font-family: sans-serif;
25 | margin: 0;
26 | }
27 |
28 | .hide {
29 | display: none;
30 | }
31 |
32 | h1, h2, h3, h4, h5, h6 {
33 | font-family: 'Crete Round', serif;
34 | margin: 0;
35 | }
36 |
37 | h1 {
38 | font-size: 48px;
39 | }
40 |
41 | a {
42 | text-decoration: none;
43 | }
44 |
45 | .github {
46 | float: right;
47 | margin-left: 20px;
48 |
49 | .icon {
50 | width: 48px;
51 | opacity: 0.4;
52 | transition: opacity .15s ease-in-out;
53 |
54 | &:hover {
55 | opacity: 1.0;
56 | }
57 | }
58 | }
59 |
60 | .top {
61 | padding: 20px;
62 |
63 | ul {
64 | list-style: none;
65 | padding: 0;
66 | }
67 | ul li {
68 | display: inline;
69 | padding-right: 40px;
70 | }
71 | p {
72 | margin-top: 10px;
73 | }
74 | }
75 |
76 | .CodeMirror {
77 | height: auto;
78 | }
79 |
80 | .parent {
81 | display: flex;
82 | flex-wrap: wrap;
83 | }
84 |
85 | .child {
86 | width: 50%;
87 | }
88 |
89 | button, select {
90 | padding:.375rem .75rem;
91 | color: #495057;
92 | vertical-align: middle;
93 | border: 1px solid #ced4da;
94 | border-radius: .25rem;
95 | -webkit-appearance: none;
96 | -moz-appearance: none;
97 | appearance: none;
98 | margin: 2px;
99 | }
100 |
101 | button {
102 | color: #007bff;
103 | background-color: transparent;
104 | background-image: none;
105 | border-color: #007bff;
106 | text-align: center;
107 | transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
108 |
109 | &:not(:disabled):hover, &:not(:disabled):active {
110 | color: #fff;
111 | background-color: #007bff;
112 | border-color: #007bff;
113 | }
114 |
115 | &:disabled {
116 | color: #007bff;
117 | background-color: transparent;
118 | opacity: .65;
119 | }
120 |
121 | &:not(:disabled) {
122 | cursor: pointer;
123 | }
124 | }
125 |
126 |
127 |
128 | select {
129 | padding-right: 1.75rem;
130 | 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;
131 | background-size: 8px 10px;
132 | }
133 |
134 | .nav {
135 | display: flex;
136 | justify-content: space-between;
137 | flex-wrap: wrap;
138 |
139 | border-bottom: 1px solid #dee2e6;
140 | padding: 0 20px;
141 |
142 | .nav-tabs {
143 | display: flex;
144 | list-style: none;
145 | padding: 0;
146 | margin-bottom: 0;
147 |
148 | .nav-item {
149 | margin-bottom: -1px;
150 | }
151 |
152 | .nav-link {
153 | display: block;
154 | padding: .45rem .9rem;
155 |
156 | font-family: 'Crete Round', serif;
157 | border: 1px solid transparent;
158 | border-top-left-radius: .25rem;
159 | border-top-right-radius: .25rem;
160 |
161 | color: #666;
162 |
163 | &.active {
164 | color: black;
165 | background-color: white;
166 | border-color: #dee2e6 #dee2e6 white;
167 | }
168 |
169 | &.error:after {
170 | @extend %red-triangle;
171 |
172 | left: 0.25em;
173 | position: relative;
174 |
175 | display: inline-block;
176 | }
177 | }
178 | }
179 | }
180 |
181 |
182 | .logger {
183 | font-family: monospace;
184 | padding-right: 1em;
185 |
186 | ul {
187 | list-style: none;
188 | padding: 0;
189 |
190 | li {
191 |
192 | &.section {
193 | font-weight: bold;
194 | padding-left: 1em;
195 | padding-top: 1em;
196 |
197 | &:first-child {
198 | padding-top: 0
199 | }
200 | }
201 |
202 | &:not(.section) {
203 | padding-left: 2em;
204 | }
205 |
206 | &.error:before {
207 | @extend %red-triangle;
208 |
209 | left: -0.9em; /* looks better */
210 | top: 0.8em;
211 | position: relative;
212 |
213 | display: block;
214 | }
215 |
216 | &.log:before {
217 | @extend %gray-circle;
218 |
219 | left: -0.8em;
220 | top: 0.8em;
221 | position: relative;
222 |
223 | display: block;
224 | }
225 | }
226 |
227 | }
228 | }
229 |
230 |
231 | @media (max-width: 800px) {
232 | .child {
233 | width: 100%;
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/src/app.jsx:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | // Removed Node's util.inspect; using JSON.stringify for browser-friendly logging
4 |
5 | import React from 'react'
6 | import ReactDOM from 'react-dom'
7 |
8 | import CodeMirror from '@uiw/react-codemirror'
9 | import { javascript as langJavaScript } from '@codemirror/lang-javascript'
10 | import { json as langJson } from '@codemirror/lang-json'
11 | import { python as langPython } from '@codemirror/lang-python'
12 | import { EditorView, lineNumbers } from '@codemirror/view'
13 |
14 | import frontendCurl from '../lib/frontends/curl'
15 | import frontendHttp from '../lib/frontends/http'
16 | import frontendJson from '../lib/frontends/json'
17 |
18 | import transformDropContentLengthHeader from '../lib/transforms/drop-content-length-header'
19 | import transformDropHostHeader from '../lib/transforms/drop-host-header'
20 | import transformParseJsonData from '../lib/transforms/parse-json-data'
21 | import transformSplitFormData from '../lib/transforms/split-form-data'
22 |
23 | import backendJavascriptXhr from '../lib/backends/javascript-xhr'
24 | import backendJson from '../lib/backends/json'
25 | import backendPythonRequests from '../lib/backends/python-requests'
26 | import backendPythonUrllib from '../lib/backends/python-urllib'
27 |
28 |
29 | class Logger extends React.Component {
30 | constructor() {
31 | super()
32 | this.state = {
33 | messages: []
34 | }
35 |
36 | this.log = this.log.bind(this)
37 | this.error = this.error.bind(this)
38 | }
39 |
40 | clear() {
41 | this.setState({ messages: [] })
42 | }
43 |
44 | addSection(name) {
45 | this._appendLog('section', [ name ])
46 | }
47 |
48 | log() {
49 | this._appendLog('log', Array.from(arguments))
50 | }
51 |
52 | error() {
53 | console.error.apply(console, Array.from(arguments))
54 | this._appendLog('error', Array.from(arguments))
55 | }
56 |
57 | hasError() {
58 | for (let message of this.state.messages) {
59 | if (message.type === 'error')
60 | return true;
61 | }
62 | return false;
63 | }
64 |
65 | _appendLog(type, message) {
66 | let strmsg = '';
67 | for (let part of message) {
68 | if (typeof part === 'string') {
69 | strmsg += `${part} `
70 | } else {
71 | strmsg += JSON.stringify(part, null, 2)
72 | }
73 | }
74 |
75 | this.setState({ messages: [
76 | ...this.state.messages,
77 | { type: type, content: strmsg }
78 | ]})
79 | }
80 |
81 | render() {
82 | return (
83 |
84 |
85 | {this.state.messages.map((message, i) =>
86 | - {message.content}
87 | )}
88 |
89 |
90 | )
91 | }
92 | }
93 |
94 |
95 | class App extends React.Component {
96 | constructor() {
97 | super()
98 | this.state = {}
99 |
100 | // List the frontends, transforms, and backends we want to load
101 | this.state.frontends = [
102 | frontendCurl,
103 | frontendHttp,
104 | frontendJson,
105 | ]
106 | this.state.transforms = [
107 | transformDropContentLengthHeader,
108 | transformDropHostHeader,
109 | transformParseJsonData,
110 | transformSplitFormData,
111 | ]
112 | this.state.backends = [
113 | backendJavascriptXhr,
114 | backendJson,
115 | backendPythonRequests,
116 | backendPythonUrllib,
117 | ]
118 |
119 | // Choose the default frontends
120 | this.state.frontend = frontendCurl
121 | this.state.backend = backendPythonUrllib
122 |
123 | // Set the default input and clear output
124 | this.state.input = this.state.frontend.example
125 | this.state.output = ''
126 |
127 | // Set the tab we want to show
128 | this.state.showLogTab = false
129 |
130 | // Bind `this` so that handlers for JavaScript events work
131 | this.handleFrontendChange = this.handleFrontendChange.bind(this)
132 | this.handleBackendChange = this.handleBackendChange.bind(this)
133 |
134 | // These will store our code editor instances, after they've mounted
135 | this.inputEditor = null
136 | this.outputEditor = null
137 | }
138 |
139 | componentDidMount() {
140 | // Kick off the initial generation
141 | this.handleCodeChange()
142 | }
143 |
144 | componentDidUpdate(prevProps, prevState, snapshot) {
145 | // Re-generate if any of the input parameters changed
146 | if ((this.state.input !== prevState.input ||
147 | this.state.frontend !== prevState.frontend ||
148 | this.state.backend !== prevState.backend) &&
149 | this.state.output === prevState.output) {
150 |
151 | this.handleCodeChange()
152 | }
153 | }
154 |
155 | handleCodeChange() {
156 | this.logger.clear()
157 | var p = Promise.resolve(this.state.input)
158 | p.then((input) => {
159 | this.logger.addSection(`Frontend: ${this.state.frontend.name}`)
160 | return this.state.frontend.parse(input, this.logger)
161 | }).then((request) => {
162 | for (let transform of this.state.transforms) {
163 | this.logger.addSection(`Transform: ${transform.name}`)
164 | transform.transform(request, this.logger)
165 | }
166 | return request
167 | }).then((request) => {
168 | this.logger.addSection(`Backend: ${this.state.backend.name}`)
169 | return this.state.backend.generate(request, this.logger)
170 | }).then((output) => {
171 | this.setState({ output: output })
172 | }).catch((error) => {
173 | this.logger.error(error)
174 | })
175 | }
176 |
177 | handleFrontendChange(event) {
178 | for (var frontend of this.state.frontends) {
179 | if (frontend.name === event.target.value) {
180 | this.setState({ frontend: frontend })
181 | return
182 | }
183 | }
184 | }
185 |
186 | handleBackendChange(event) {
187 | for (var backend of this.state.backends) {
188 | if (backend.name === event.target.value) {
189 | this.setState({ backend: backend })
190 | return
191 | }
192 | }
193 | }
194 |
195 | getLangHighlighter(highlighter) {
196 | switch (highlighter) {
197 | case 'javascript': return langJavaScript();
198 | case 'json': return langJson();
199 | case 'python': return langPython();
200 | // no http or shell highlighter
201 | default: return langJavaScript();
202 | }
203 | }
204 |
205 | render() {
206 | return (
207 |
208 |
209 |
210 |
213 |
214 | -
215 |
221 |
222 | -
223 |
228 |
229 |
230 |
231 |
232 | { this.setState({ input: value }) }}
240 | onCreateEditor={(view) => { this.inputEditor = view }}
241 | />
242 |
243 |
244 |
245 |
246 |
247 |
265 |
266 | -
267 |
272 |
273 |
274 |
275 |
276 |
277 | this.logger = c} />
278 |
279 |
280 | { this.outputEditor = view }}
288 | editable={false}
289 | />
290 |
291 |
292 |
293 |
294 | )
295 | }
296 | }
297 |
298 | ReactDOM.render(, document.getElementById('root'))
299 |
--------------------------------------------------------------------------------