-
Notifications
You must be signed in to change notification settings - Fork 1
feat: json flag #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: json flag #112
Changes from all commits
6b278fc
abf2014
756ce6f
6ef2bbc
7904332
a2ac8f5
3e95b41
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,150 +6,189 @@ require('update-notifier')({ pkg: require('../package.json') }).notify() | |
|
|
||
| const getContentType = require('@kikobeats/content-type') | ||
| const { URLSearchParams } = require('url') | ||
| const clipboardy = require('clipboardy') | ||
| const mql = require('@microlink/mql') | ||
| const prettyMs = require('pretty-ms') | ||
| const temp = require('temperment') | ||
| const fs = require('fs') | ||
| const os = require('os') | ||
|
|
||
| const { toClipboard, toPlainHeaders } = require('./util') | ||
| const { gray, green } = require('./colors') | ||
| const print = require('./print') | ||
| const printJson = require('./print-json') | ||
| const printText = require('./print-text') | ||
| const exit = require('./exit') | ||
|
|
||
| const microlinkUrl = () => | ||
| /^https?:\/\/((?!fonts|geolocation\.)[a-z0-9-]+\.)+microlink\.io/ | ||
|
|
||
| const normalizeInput = input => { | ||
| if (!input) return input | ||
| ;[ | ||
| let normalized = input | ||
| const sanitizers = [ | ||
| microlinkUrl, | ||
| () => require('is-local-address/ipv4').regex, | ||
| () => require('is-local-address/ipv6').regex | ||
| ].forEach(regex => { | ||
| return (input = input.replace(regex(), '')) | ||
| }) | ||
| return input.replace(/^\??url=/, '') | ||
| ] | ||
|
|
||
| for (const createRegex of sanitizers) { | ||
| normalized = normalized.replace(createRegex(), '') | ||
| } | ||
|
|
||
| return normalized.replace(/^\??url=/, '') | ||
| } | ||
|
|
||
| const getInput = input => { | ||
| const collection = input.length === 1 ? input[0].split(os.EOL) : input | ||
| return collection.reduce((acc, item) => acc + item.trim(), '') | ||
| return collection.map(item => item.trim()).join('') | ||
| } | ||
|
|
||
| const toPlainObject = input => Object.fromEntries(new URLSearchParams(input)) | ||
|
|
||
| const fetch = async (cli, gotOpts) => { | ||
| const { pretty, color, copy, endpoint, ...flags } = cli.flags | ||
| const { | ||
| pretty, | ||
| copy, | ||
| json, | ||
| 'json-full': jsonFull, | ||
| endpoint, | ||
| ...flags | ||
| } = cli.flags | ||
| const isJson = json || jsonFull | ||
| const input = getInput(cli.input, endpoint) | ||
| const { url, ...queryParams } = toPlainObject(`url=${normalizeInput(input)}`) | ||
| const mqlOpts = { endpoint, ...queryParams, ...flags } | ||
| const spinner = print.spinner() | ||
| const spinner = printText.spinner() | ||
| const shouldSpin = !isJson && pretty | ||
|
|
||
| let mergedGotOpts = gotOpts | ||
| if (Object.keys(cli.headers).length > 0) { | ||
| mergedGotOpts = { | ||
| ...gotOpts, | ||
| headers: { ...gotOpts.headers, ...cli.headers } | ||
| } | ||
| } | ||
|
|
||
| const mergedGotOpts = Object.keys(cli.headers).length > 0 | ||
| ? { ...gotOpts, headers: { ...gotOpts.headers, ...cli.headers } } | ||
| : gotOpts | ||
| const [requestUrl, requestOptions] = mql.getApiUrl( | ||
| url, | ||
| mqlOpts, | ||
| mergedGotOpts | ||
| ) | ||
|
|
||
| try { | ||
| spinner.start() | ||
| const response = await mql.buffer(url, mqlOpts, mergedGotOpts) | ||
| spinner.stop() | ||
| return { response, flags: { copy, pretty } } | ||
| if (shouldSpin) spinner.start() | ||
|
|
||
| const start = Date.now() | ||
| const request = isJson ? mql : mql.buffer | ||
| const mqlResponse = await request(url, mqlOpts, mergedGotOpts) | ||
| const duration = Date.now() - start | ||
|
|
||
| let response = mqlResponse | ||
| if (isJson) { | ||
| response = printJson({ | ||
| requestUrl, | ||
| requestOptions, | ||
| response: mqlResponse.response, | ||
| full: jsonFull, | ||
| pretty | ||
| }) | ||
| } | ||
|
|
||
| return { response, duration, flags: { copy, pretty, json: isJson } } | ||
| } catch (error) { | ||
| spinner.stop() | ||
| error.flags = cli.flags | ||
| throw error | ||
| } finally { | ||
| if (shouldSpin) spinner.stop() | ||
| } | ||
| } | ||
|
|
||
| const render = ({ response, flags }) => { | ||
| const { headers, timings, requestUrl: uri, body } = response | ||
| if (!flags.pretty) return console.log(body.toString()) | ||
|
|
||
| const contentType = getContentType(headers['content-type']) | ||
| const time = prettyMs(timings.phases.total) | ||
| const serverTiming = headers['server-timing'] | ||
| const id = headers['x-request-id'] | ||
|
|
||
| const printMode = (() => { | ||
| if (body.toString().startsWith('data:')) return 'base64' | ||
| if (contentType !== 'application/json') return 'image' | ||
| })() | ||
|
|
||
| switch (printMode) { | ||
| case 'base64': { | ||
| const extension = contentType.split('/')[1].split(';')[0] | ||
| const filepath = temp.file({ extension }) | ||
| fs.writeFileSync(filepath, body.toString().split(',')[1], 'base64') | ||
| print.image(filepath) | ||
| break | ||
| } | ||
| case 'image': | ||
| print.image(body) | ||
| console.log() | ||
| break | ||
| default: { | ||
| const isText = contentType === 'text/plain' | ||
| const isHtml = contentType === 'text/html' | ||
| const output = isText || isHtml ? body.toString() : JSON.parse(body) | ||
| print.json(output, flags) | ||
| break | ||
| } | ||
| const render = ({ response, duration, flags }) => { | ||
| const { headers, requestUrl, url: responseUrl, body } = response | ||
|
|
||
| if (flags.json) { | ||
| if (flags.copy) toClipboard(JSON.parse(response), flags) | ||
| if (!flags.pretty) return console.log(response) | ||
|
|
||
| return printText.json(JSON.parse(response), { color: true }) | ||
| } | ||
|
|
||
| const edgeCacheStatus = headers['cf-cache-status'] | ||
| const unifiedCacheStatus = headers['x-cache-status'] | ||
| const plainHeaders = toPlainHeaders(headers) | ||
|
|
||
| const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body) | ||
| const bodyText = bodyBuffer.toString() | ||
|
|
||
| if (!flags.pretty) return console.log(bodyText) | ||
|
|
||
| const contentType = getContentType(plainHeaders['content-type']) | ||
| const time = Number.isFinite(duration) ? prettyMs(duration) : 'unknown' | ||
| const serverTiming = plainHeaders['server-timing'] | ||
| const id = plainHeaders['x-request-id'] | ||
|
|
||
| if (bodyText.startsWith('data:')) { | ||
| const extension = contentType | ||
| ? contentType.split('/')[1].split(';')[0] | ||
| : 'png' | ||
| const filepath = temp.file({ extension }) | ||
| fs.writeFileSync(filepath, bodyText.split(',')[1], 'base64') | ||
| printText.image(filepath) | ||
| } else if (contentType !== 'application/json') { | ||
| printText.image(bodyBuffer) | ||
| console.log() | ||
| } else { | ||
| const isText = contentType === 'text/plain' | ||
| const isHtml = contentType === 'text/html' | ||
| const output = isText || isHtml ? bodyText : JSON.parse(bodyText) | ||
| printText.json(output, flags) | ||
| } | ||
|
|
||
| const edgeCacheStatus = plainHeaders['cf-cache-status'] | ||
| const unifiedCacheStatus = plainHeaders['x-cache-status'] | ||
|
|
||
| const cacheStatus = | ||
| unifiedCacheStatus === 'MISS' && edgeCacheStatus === 'HIT' | ||
| ? edgeCacheStatus | ||
| : unifiedCacheStatus | ||
|
|
||
| const timestamp = Number(headers['x-timestamp']) | ||
| const ttl = Number(headers['x-cache-ttl']) | ||
| const timestamp = Number(plainHeaders['x-timestamp']) | ||
| const ttl = Number(plainHeaders['x-cache-ttl']) | ||
| const expires = timestamp + ttl - Date.now() | ||
| const expiration = prettyMs(expires) | ||
| const expiredAt = cacheStatus === 'HIT' ? `(${expiration})` : '' | ||
| const fetchMode = headers['x-fetch-mode'] | ||
| const fetchTime = fetchMode && `(${headers['x-fetch-time']})` | ||
| const size = Number(headers['content-length'] || Buffer.byteLength(body)) | ||
| const fetchMode = plainHeaders['x-fetch-mode'] | ||
| const fetchTime = fetchMode && `(${plainHeaders['x-fetch-time']})` | ||
| const size = Number(plainHeaders['content-length'] || bodyBuffer.length) | ||
| const uri = requestUrl || responseUrl | ||
|
|
||
| console.error() | ||
| console.error( | ||
| print.label('success', 'green'), | ||
| gray(`${print.bytes(size)} in ${time}`) | ||
| printText.label('success', 'green'), | ||
| gray(`${printText.bytes(size)} in ${time}`) | ||
| ) | ||
| console.error() | ||
|
|
||
| if (serverTiming) { | ||
| console.error(' ', print.keyValue(green('timing'), serverTiming)) | ||
| console.error(' ', printText.keyValue(green('timing'), serverTiming)) | ||
| } | ||
|
|
||
| if (cacheStatus) { | ||
| console.error( | ||
| ' ', | ||
| print.keyValue(green('cache'), `${cacheStatus} ${gray(expiredAt)}`) | ||
| printText.keyValue(green('cache'), `${cacheStatus} ${gray(expiredAt)}`) | ||
| ) | ||
| } | ||
|
|
||
| if (fetchMode) { | ||
| console.error( | ||
| ' ', | ||
| print.keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`) | ||
| printText.keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`) | ||
| ) | ||
| } | ||
|
|
||
| console.error(' ', print.keyValue(green('uri'), uri)) | ||
| console.error(' ', print.keyValue(green('id'), id)) | ||
| console.error(' ', printText.keyValue(green('uri'), uri)) | ||
| console.error(' ', printText.keyValue(green('id'), id)) | ||
|
|
||
| if (flags.copy) { | ||
| let copiedValue | ||
| try { | ||
| copiedValue = JSON.parse(body) | ||
| } catch (err) { | ||
| copiedValue = body | ||
| } | ||
| clipboardy.writeSync(JSON.stringify(copiedValue, null, 2)) | ||
| toClipboard(JSON.parse(bodyText), flags) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing try-catch crashes copy for non-JSON responsesHigh Severity
Reviewed by Cursor Bugbot for commit 3e95b41. Configure here. |
||
| console.error(`\n ${gray('Copied to clipboard!')}`) | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| 'use strict' | ||
|
|
||
| const { humanizeApiKey, toPlainHeaders, stringify } = require('./util') | ||
|
|
||
| module.exports = ({ | ||
| requestUrl, | ||
| requestOptions, | ||
| response, | ||
| full = false, | ||
| pretty = true | ||
| }) => { | ||
| const { responseType, ...requestOptionsWithoutResponseType } = requestOptions | ||
| const headers = { ...requestOptionsWithoutResponseType.headers } | ||
|
|
||
| if (!full && headers['x-api-key']) { | ||
| headers['x-api-key'] = humanizeApiKey(headers['x-api-key']) | ||
| } | ||
|
|
||
| return stringify( | ||
| { | ||
| request: { | ||
| url: requestUrl, | ||
| ...requestOptionsWithoutResponseType, | ||
| headers | ||
| }, | ||
| response: { | ||
| ...response, | ||
| headers: toPlainHeaders(response.headers) | ||
| } | ||
| }, | ||
| { pretty } | ||
| ) | ||
| } |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copy with non-JSON body crashes without try-catch
Medium Severity
toClipboard(JSON.parse(bodyText), flags)will throw whenbodyTextisn't valid JSON (e.g., data URIs, image buffers, ortext/plainresponses) and--copyis used. The previous implementation wrappedJSON.parse(body)in a try-catch, falling back to the raw body on parse failure. That safety net was removed in this rewrite.Reviewed by Cursor Bugbot for commit a2ac8f5. Configure here.