lib/curly.ts
/**
* Copyright (c) Jonathan Cardoso Machado. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import { Readable } from 'stream'
import {
CurlOptionName,
CurlOptionCamelCaseMap,
CurlOptionValueType,
} from './generated/CurlOption'
import { HeaderInfo } from './parseHeaders'
import { Curl } from './Curl'
import { CurlFeature } from './enum/CurlFeature'
/**
* Object the curly call resolves to.
*
* @public
*/
export interface CurlyResult<ResultData = any> {
/**
* Data will be the body of the requested URL
*/
data: ResultData
/**
* Parsed headers
*
* See {@link HeaderInfo}
*/
headers: HeaderInfo[]
/**
* HTTP Status code for the last request
*/
statusCode: number
}
// This is basically http.METHODS
const methods = [
'acl',
'bind',
'checkout',
'connect',
'copy',
'delete',
'get',
'head',
'link',
'lock',
'm-search',
'merge',
'mkactivity',
'mkcalendar',
'mkcol',
'move',
'notify',
'options',
'patch',
'post',
'propfind',
'proppatch',
'purge',
'put',
'rebind',
'report',
'search',
'source',
'subscribe',
'trace',
'unbind',
'unlink',
'unlock',
'unsubscribe',
] as const
type HttpMethod = (typeof methods)[number]
export type CurlyResponseBodyParser = (
data: Buffer,
header: HeaderInfo[],
) => any
export type CurlyResponseBodyParsersProperty = {
[key: string]: CurlyResponseBodyParser
}
/**
* These are the options accepted by the {@link CurlyFunction | `CurlyFunction`} API.
*
* Most libcurl options are accepted as their specific name, like `PROXY_CAPATH`, or as a camel
* case version of that name, like `proxyCaPath`.
*
* Options specific to the `curly` API are prefixed with `curly`, like `curlyBaseUrl`.
*
* For quick navigation use the sidebar.
*/
export interface CurlyOptions extends CurlOptionValueType {
/**
* Set this to a callback function that should be used as the progress callback.
*
* This is the only reliable way to set the progress callback.
*
* @remarks
*
* This basically calls one of the following methods, depending on if any of the streams feature is being used or not:
* - If using streams: {@link "Curl".Curl.setStreamProgressCallback | `Curl#setStreamProgressCallback`}
* - else: {@link "Curl".Curl.setProgressCallback | `Curl#setProgressCallback`}
*/
curlyProgressCallback?: CurlOptionValueType['xferInfoFunction']
/**
* If set to a function this will always be called
* for all requests, ignoring other response body parsers.
*
* This can also be set to `false`, which will disable the response parsing and will make
* the raw `Buffer` of the response to be returned.
*/
curlyResponseBodyParser?: CurlyResponseBodyParser | false
/**
* Add more response body parsers, or overwrite existing ones.
*
* This object is merged with the {@link CurlyFunction.defaultResponseBodyParsers | `curly.defaultResponseBodyParsers`}
*/
curlyResponseBodyParsers?: CurlyResponseBodyParsersProperty
/**
* If set, this value will always prefix the `URL` of the request.
*
* No special handling is done, so make sure you set the url correctly later on.
*/
curlyBaseUrl?: string
/**
* If `true`, `curly` will lower case all headers before returning then.
*
* By default this is `false`.
*/
curlyLowerCaseHeaders?: boolean
/**
* If `true`, `curly` will return the response data as a stream.
*
* The `curly` call will resolve as soon as the stream is available.
*
* When using this option, if an error is thrown in the internal {@link "Curl".Curl | `Curl`} instance
* after the `curly` call has been resolved (it resolves as soon as the stream is available)
* it will cause the `error` event to be emitted on the stream itself, this way it's possible
* to handle these too, if necessary. The error object will have the property `isCurlError` set to `true`.
*
* Calling `destroy()` on the stream will always cause the `Curl` instance to emit the error event.
* Even if an error argument was not supplied to `stream.destroy()`.
*
* By default this is `false`.
*
* @remarks
*
* Make sure your libcurl version is greater than or equal 7.69.1.
* Versions older than that one are not reliable for streams usage.
*
* This basically enables the {@link CurlFeature.StreamResponse | `CurlFeature.StreamResponse`} feature
* flag in the internal {@link "Curl".Curl | `Curl`} instance.
*/
curlyStreamResponse?: boolean
/**
* This will set the `hightWaterMark` option in the response stream, if `curlyStreamResponse` is `true`.
*
* @remarks
*
* This basically calls {@link "Curl".Curl.setStreamResponseHighWaterMark | `Curl#setStreamResponseHighWaterMark`}
* method in the internal {@link "Curl".Curl | `Curl`} instance.
*/
curlyStreamResponseHighWaterMark?: number
/**
* If set, the contents of this stream will be uploaded to the server.
*
* Keep in mind that if you set this option you **SHOULD** not set
* `progressFunction` or `xferInfoFunction`, as these are used internally.
*
* If you need to set a progress callback, use the `curlyProgressCallback` option.
*
* If the stream set here is destroyed before libcurl finishes uploading it, the error
* `Curl upload stream was unexpectedly destroyed` (Code `42`) will be emitted in the
* internal {@link "Curl".Curl | `Curl`} instance, and so will cause the curly call to be rejected with that error.
*
* If the stream was destroyed with a specific error, this error will be passed instead.
*
* By default this is not set.
*
* @remarks
*
* Make sure your libcurl version is greater than or equal 7.69.1.
* Versions older than that one are not reliable for streams usage.
*
* This basically calls {@link "Curl".Curl.setUploadStream | `Curl#setUploadStream`}
* method in the internal {@link "Curl".Curl | `Curl`} instance.
*/
curlyStreamUpload?: Readable | null
}
interface CurlyHttpMethodCall {
/**
* **EXPERIMENTAL** This API can change between minor releases
*
* Async wrapper around the Curl class.
*
* The `curly.<field>` being used will be the HTTP verb sent.
*
* @typeParam ResultData You can use this to specify the type of the `data` property returned from this call.
*/
<ResultData = any>(
url: string,
options?: CurlyOptions,
): Promise<CurlyResult<ResultData>>
}
// type HttpMethodCalls = { readonly [K in HttpMethod]: CurlyHttpMethodCall }
type HttpMethodCalls = Record<HttpMethod, CurlyHttpMethodCall>
export interface CurlyFunction extends HttpMethodCalls {
/**
* **EXPERIMENTAL** This API can change between minor releases
*
* Async wrapper around the Curl class.
*
* It's also possible to request using a specific http verb
* directly by using `curl.<http-verb>(url: string, options?: CurlyOptions)`, like:
*
* ```js
* curly.get('https://www.google.com')
* ```
* @typeParam ResultData You can use this to specify the type of the `data` property returned from this call.
*/
<ResultData = any>(
url: string,
options?: CurlyOptions,
): Promise<CurlyResult<ResultData>>
/**
* **EXPERIMENTAL** This API can change between minor releases
*
* This returns a new `curly` with the specified options set by default.
*/
create: (defaultOptions?: CurlyOptions) => CurlyFunction
/**
* These are the default response body parsers to be used.
*
* By default there are parsers for the following:
*
* - application/json
* - text/*
* - *
*/
defaultResponseBodyParsers: CurlyResponseBodyParsersProperty
}
const create = (defaultOptions: CurlyOptions = {}): CurlyFunction => {
function curly<ResultData>(
url: string,
options: CurlyOptions = {},
): Promise<CurlyResult<ResultData>> {
const curlHandle = new Curl()
curlHandle.enable(CurlFeature.NoDataParsing)
curlHandle.setOpt('URL', `${options.curlyBaseUrl || ''}${url}`)
const finalOptions = {
...defaultOptions,
...options,
}
for (const key of Object.keys(finalOptions)) {
const keyTyped = key as keyof CurlyOptions
const optionName: CurlOptionName =
keyTyped in CurlOptionCamelCaseMap
? CurlOptionCamelCaseMap[
keyTyped as keyof typeof CurlOptionCamelCaseMap
]
: (keyTyped as CurlOptionName)
// if it begins with curly we do not set it on the curlHandle
// as it's an specific option for curly
if (optionName.startsWith('curly')) continue
// @ts-ignore @TODO Try to type this
curlHandle.setOpt(optionName, finalOptions[key])
}
// streams!
const {
curlyStreamResponse,
curlyStreamResponseHighWaterMark,
curlyStreamUpload,
} = finalOptions
const isUsingStream = !!(curlyStreamResponse || curlyStreamUpload)
if (finalOptions.curlyProgressCallback) {
if (typeof finalOptions.curlyProgressCallback !== 'function') {
throw new TypeError(
'curlyProgressCallback must be a function with signature (number, number, number, number) => number',
)
}
const fnToCall = isUsingStream
? 'setStreamProgressCallback'
: 'setProgressCallback'
curlHandle[fnToCall](finalOptions.curlyProgressCallback)
}
if (curlyStreamResponse) {
curlHandle.enable(CurlFeature.StreamResponse)
if (curlyStreamResponseHighWaterMark) {
curlHandle.setStreamResponseHighWaterMark(
curlyStreamResponseHighWaterMark,
)
}
}
if (curlyStreamUpload) {
curlHandle.setUploadStream(curlyStreamUpload)
}
const lowerCaseHeadersIfNecessary = (headers: HeaderInfo[]) => {
// in-place modification
// yeah, I know mutability is bad and all that
if (finalOptions.curlyLowerCaseHeaders) {
for (const headersReq of headers) {
const entries = Object.entries(headersReq)
for (const [headerKey, headerValue] of entries) {
delete headersReq[headerKey]
// @ts-expect-error ignoring this for now
headersReq[headerKey.toLowerCase()] = headerValue
}
}
}
}
return new Promise((resolve, reject) => {
let stream: Readable
if (curlyStreamResponse) {
curlHandle.on(
'stream',
(_stream, statusCode, headers: HeaderInfo[]) => {
lowerCaseHeadersIfNecessary(headers)
stream = _stream
resolve({
// @ts-ignore cannot be subtype yada yada
data: stream,
statusCode,
headers,
})
},
)
}
curlHandle.on(
'end',
(statusCode, data: Buffer, headers: HeaderInfo[]) => {
curlHandle.close()
// only need to the remaining here if we did not enabled
// the stream response
if (curlyStreamResponse) {
return
}
const contentTypeEntry = Object.entries(
headers[headers.length - 1],
).find(([k]) => k.toLowerCase() === 'content-type')
let contentType = (
contentTypeEntry ? contentTypeEntry[1] : ''
) as string
// remove the metadata of the content-type, like charset
// See https://tools.ietf.org/html/rfc7231#section-3.1.1.5
contentType = contentType.split(';')[0]
const responseBodyParsers = {
...curly.defaultResponseBodyParsers,
...finalOptions.curlyResponseBodyParsers,
}
let foundParser = finalOptions.curlyResponseBodyParser
if (typeof foundParser === 'undefined') {
for (const [contentTypeFormat, parser] of Object.entries(
responseBodyParsers,
)) {
if (typeof parser !== 'function') {
return reject(
new TypeError(
`Response body parser for ${contentTypeFormat} must be a function`,
),
)
}
if (contentType === contentTypeFormat) {
foundParser = parser
break
} else if (contentTypeFormat === '*') {
foundParser = parser
break
} else {
const partsFormat = contentTypeFormat.split('/')
const partsContentType = contentType.split('/')
if (
partsContentType.length === partsFormat.length &&
partsContentType.every(
(val, index) =>
partsFormat[index] === '*' || partsFormat[index] === val,
)
) {
foundParser = parser
break
}
}
}
}
if (foundParser && typeof foundParser !== 'function') {
return reject(
new TypeError(
'`curlyResponseBodyParser` passed to curly must be false or a function.',
),
)
}
lowerCaseHeadersIfNecessary(headers)
try {
resolve({
statusCode: statusCode,
data: foundParser ? foundParser(data, headers) : data,
headers: headers,
})
} catch (error) {
reject(error)
}
},
)
curlHandle.on('error', (error, errorCode) => {
curlHandle.close()
// @ts-ignore
error.code = errorCode
// @ts-ignore
error.isCurlError = true
// oops, if have a stream it means the promise
// has been resolved with it
// so instead of rejecting the original promise
// we are emitting the error event on the stream
if (stream) {
stream.emit('error', error)
} else {
reject(error)
}
})
try {
curlHandle.perform()
} catch (error) /* istanbul ignore next: this should never happen 🤷♂️ */ {
curlHandle.close()
reject(error)
}
})
}
curly.create = create
curly.defaultResponseBodyParsers = {
'application/json': (data, _headers) => {
try {
const string = data.toString('utf8')
return JSON.parse(string)
} catch (error) {
throw new Error(
`curly failed to parse "application/json" content as JSON. This is generally caused by receiving malformed JSON data from the server.
You can disable this automatic behavior by setting the option curlyResponseBodyParser to false, then a Buffer will be returned as the data.
You can also overwrite the "application/json" parser with your own by changing one of the following:
- curly.defaultResponseBodyParsers['application/json']
or
- options.curlyResponseBodyParsers = { 'application/json': parser }
If you want just a single function to handle all content-types, you can use the option "curlyResponseBodyParser".
`,
)
}
},
// We are in [INSERT CURRENT YEAR], let's assume everyone is using utf8 encoding for text/* content-type.
'text/*': (data, _headers) => data.toString('utf8'),
// otherwise let's just return the raw buffer
'*': (data, _headers) => data,
} as CurlyResponseBodyParsersProperty
const httpMethodOptionsMap: Record<
string,
null | ((m: string, o: CurlyOptions) => CurlyOptions)
> = {
get: null,
post: (_m, o) => ({
post: true,
...o,
}),
head: (_m, o) => ({
nobody: true,
...o,
}),
_: (m, o) => ({
customRequest: m.toUpperCase(),
...o,
}),
}
for (const httpMethod of methods) {
const httpMethodOptionsKey = Object.prototype.hasOwnProperty.call(
httpMethodOptionsMap,
httpMethod,
)
? httpMethod
: '_'
const httpMethodOptions = httpMethodOptionsMap[httpMethodOptionsKey]
// @ts-ignore
curly[httpMethod] =
httpMethodOptions === null
? curly
: (url: string, options: CurlyOptions = {}) =>
curly(url, {
...httpMethodOptions(httpMethod, options),
})
}
// @ts-ignore
return curly
}
/**
* Curly function
*
* @public
*/
export const curly = create()