diff --git a/README.md b/README.md index 1f012e7..f87eccc 100644 --- a/README.md +++ b/README.md @@ -27,53 +27,54 @@ ## 📢 About -The package is inspired by lukeed [httpie](https://github.com/lukeed/httpie) (The use is relatively similar). This package use new Node.js http client [undici](https://github.com/nodejs/undici) under the hood. +Httpie is a Node.js HTTP client built on [Undici](https://github.com/nodejs/undici). Its request API follows the small, function-based style of lukeed's [httpie](https://github.com/lukeed/httpie), with response parsing, agent selection, rate limiting, and `Result`-based error handling added around it. ## 🔬 Features -- Automatically parse based on the `content-type`. -- Automatically decompress based on the `content-encoding`. -- Includes aliases for common HTTP verbs: `get`, `post`, `put`, `patch`, and `del`. -- Able to automatically detect domains and paths to assign the right Agent (use a LRU cache to avoid repetitive computation). -- Allows to use an accurate rate-limiter like `p-ratelimit` with the `limit` option. -- Safe error handling with Rust-like [Result](https://github.com/OpenAlly/npm-packages/tree/main/src/result). - -Thanks to undici: - -- Support [redirections](https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections) or retry using interceptors. -- Implement high-level API for undici **stream** and **pipeline** method. -- High performance (see [benchmarks](https://undici.nodejs.org/#/?id=benchmarks)). -- Work well with **newest** Node.js API [AbortController](https://nodejs.org/dist/latest-v16.x/docs/api/globals.html#globals_class_abortcontroller) to cancel http request. - -Light with seriously maintained dependencies: - -![](./docs/images/nodesecure.PNG) +- Parses JSON and text responses from their `content-type`; other response bodies remain buffers. +- Decompresses encoded responses before parsing them. +- Provides `get`, `post`, `put`, `patch`, and `del` aliases alongside the general `request` function. +- Provides `safeRequest` and safe HTTP verb aliases that return a [Result](https://github.com/OpenAlly/npm-packages/tree/main/src/result). +- Selects an Undici dispatcher from a registered origin or path, with cached URI resolution. +- Accepts a rate-limiter callback through the `limit` option or an agent registration. ## 🚧 Requirements + - [Node.js](https://nodejs.org/en/) version 22 or higher ## 🚀 Getting Started -This package is available in the Node Package Repository and can be easily installed with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com). +Install the package with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com): ```bash -$ npm i @openally/httpie +npm install @openally/httpie # or -$ yarn add @openally/httpie +yarn add @openally/httpie ``` ## 📚 Usage example -This client is very similar to lukeed httpie http client. - -```js -import * as httpie from "@openally/httpie"; +```ts +import { + get, + post, + isHTTPError +} from "@openally/httpie"; + +interface Post { + id: number; + title: string; + body: string; + userId: number; +} try { - const { data } = await httpie.get("https://jsonplaceholder.typicode.com/posts"); - console.log(data); - - const response = await httpie.post("https://jsonplaceholder.typicode.com/posts", { + const { data: posts } = await get( + "https://jsonplaceholder.typicode.com/posts" + ); + console.log(posts); + + const response = await post("https://jsonplaceholder.typicode.com/posts", { body: { title: "foo", body: "bar", @@ -81,33 +82,35 @@ try { } }); - console.log(response.statusCode); - console.log(response.statusMessage); - console.log(response.data); + console.log(response.statusCode, response.data); } -catch (error) { - console.log(error.message); - console.log(error.statusCode); - console.log(error.headers); - console.log(error.data); +catch (error: unknown) { + if (isHTTPError(error)) { + console.error(error.statusCode, error.data); + } + else { + throw error; + } } ``` -You can also use the `safe` prefix API to get a `Promise>` +The `safe` methods return a `Result` instead of throwing: ```ts -import * as httpie from "@openally/httpie"; +import { safePost } from "@openally/httpie"; -const response = (await httpie.safePost("https://jsonplaceholder.typicode.com/posts", { +const result = await safePost("https://jsonplaceholder.typicode.com/posts", { body: { title: "foo", body: "bar", userId: 1 } -})) - .map((response) => response.data) - .mapErr((error) => new Error("a message here!", { cause: error.data })); - .unwrap(); +}); + +result.match( + (response) => console.log(response.data), + (error) => console.error(error.message) +); ``` > [!TIP] @@ -115,12 +118,15 @@ const response = (await httpie.safePost("https://jsonplaceholder.typicode.com/po ## 📜 API -- [Request API](./docs/request.md) -- [Work and manage Agents](./docs/agents.md) +- [Requests, options, response modes, and safe methods](./docs/request.md) +- [Streams and pipelines](./docs/stream.md) +- [Agent registry and URI resolution](./docs/agents.md) + +Httpie also re-exports selected Undici APIs. Their behavior follows the [Undici documentation](https://undici.nodejs.org). ## Error handling -Read the [error documentation](./docs/errors.md). +Read [Error handling](./docs/errors.md) for thrown errors, safe results, and the `isHttpieError` and `isHTTPError` guards. ## Contributors ✨ diff --git a/docs/agents.md b/docs/agents.md index f428e8c..40e951e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1,34 +1,132 @@ # Agents -Agents are custom constructs that are used to describe internal and external services. +The `agents` registry maps a service path or hostname to an Undici dispatcher. Register an agent once, then use either a short service path such as `/catalog/items` or the service's absolute URL in `request()`, its HTTP verb aliases, `stream()`, and `pipeline()`. -```js -import { agents } from "@openally/httpie"; - -console.log(agents); // <- push a new agent in this Array -``` - -Those custom `agents` are described by the following TypeScript interface: ```ts -export interface CustomHttpAgent { +interface CustomHttpAgent { customPath: string; origin: string; - agent: Agent; + agent: Agent | ProxyAgent | MockAgent; + limit?: InlineCallbackAction; } + +const agents: Set; ``` -Example with a test custom agent: +## Register an agent + ```ts -export const test: CustomHttpAgent = { - customPath: "test", +import { + Agent, + agents, + get, + type CustomHttpAgent +} from "@openally/httpie"; + +const catalog: CustomHttpAgent = { + customPath: "catalog", + origin: "https://catalog.example.com", agent: new Agent({ connections: 30 - }), - origin: "https://test.domain.fr" + }) +}; + +agents.add(catalog); + +const { data } = await get("/catalog/items"); +``` + +The request path `/catalog/items` becomes `https://catalog.example.com/items` and uses `catalog.agent` as its Undici dispatcher. + +`agents` is a standard JavaScript `Set`. Keep a reference to an entry if it may need to be removed later: + +```ts +agents.delete(catalog); +``` + +## Matching rules + +A string URI is resolved in this order: + +1. Httpie checks each registered `customPath` in insertion order. Both `/catalog/items` and `catalog/items` match `customPath: "catalog"`. +2. If no path matches, Httpie parses the string as an absolute URL and looks for an agent whose `origin` has the same hostname. + +A WHATWG `URL` is matched by hostname. Path aliases apply only to string URIs. + +The first matching entry wins. Use distinct path prefixes when several agents are registered. + +```ts +const internalApi: CustomHttpAgent = { + customPath: "internal", + origin: "https://api.example.com", + agent: new Agent() }; -// Note: push it to the package agents list -agents.add(test); +agents.add(internalApi); + +await get("/internal/users"); +await get("https://api.example.com/users"); ``` -The **agent** property is an Undici Agent. +Both requests use `internalApi.agent`. + +## Per-request overrides + +The `agent` option on a request takes precedence over the dispatcher selected from the registry: + +```ts +import { + ProxyAgent, + get +} from "@openally/httpie"; + +const proxy = new ProxyAgent("http://proxy.example.com:8080"); + +const response = await get("/catalog/items", { + agent: proxy +}); +``` + +An agent entry may also provide a `limit` callback. A request-level `limit` overrides the registered one. + +```ts +const catalog: CustomHttpAgent = { + customPath: "catalog", + origin: "https://catalog.example.com", + agent: new Agent(), + limit: async(callback) => callback() +}; +``` + +See [Request options](./request.md#request-options) for the callback signature. + +## `computeURI()` + +`computeURI()` exposes the resolution used internally by requests and streams. + +```ts +computeURI( + uri: string | URL +): { + url: URL; + agent: Agent | ProxyAgent | MockAgent | null; + limit?: InlineCallbackAction; +} +``` + +```ts +import { computeURI } from "@openally/httpie"; + +const resolved = computeURI("/catalog/items"); + +console.log(resolved.url.href); +// https://catalog.example.com/items +``` + +URI resolutions are cached by input string. The cache holds up to 100 entries for 120 minutes. Register agents before sending requests or calling `computeURI()`, because changing the `agents` set does not invalidate entries already in the cache. + +The returned `URL` can be changed by the caller without modifying the cached URL. + +## Undici dispatchers + +Httpie re-exports the dispatcher classes used by the registry, including `Agent`, `ProxyAgent`, and `MockAgent`. It also re-exports `Client`, interceptors, global dispatcher helpers, and Undici's mocking utilities. Configuration and lifecycle behavior for those exports follows the [Undici documentation](https://undici.nodejs.org). diff --git a/docs/errors.md b/docs/errors.md index 048feee..3aec035 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1,141 +1,154 @@ -# ERRORS +# Error handling -All errors generated by Httpie failure inherit [`HttpieError`](../src/errors/HttpieError.ts). +Httpie can fail while dispatching a request, reading or decoding its body, or handling an HTTP error status. Use `isHttpieError()` to identify errors created by this package and `isHTTPError()` when the server returned a status code greater than or equal to `400`. ```ts -interface HttpieError { - headers: IncomingHttpHeaders; - statusCode: number; -} -``` - -## Tools - -### isHttpieError - -The `isHttpieError` function can be used to find out weither the error is a `@openally/httpie` or a `undici` error. -```ts -function isHttpieError(error: unknown): boolean; -``` - -Example: -```ts -import * as httpie from "@openally/httpie"; +import { + get, + isHTTPError, + isHttpieError +} from "@openally/httpie"; try { - await httpie.request("GET", "127.0.0.1"); + await get("https://api.example.com/private"); } -catch (error) { - if (httpie.isHttpieError(error)) { - // This error inherits from HttpieError. - console.log(Boolean(error.headers)) // true - console.log(Boolean(error.statusCode)) // true +catch (error: unknown) { + if (isHTTPError(error)) { + console.error(error.statusCode, error.statusMessage); + console.error(error.data); + } + else if (isHttpieError(error)) { + console.error(error.statusCode, error.message); } else { - // This error can be of any error type. - console.log(Boolean(error.headers)) // false - console.log(Boolean(error.statusCode)) // false + throw error; } } ``` -### isHTTPError +Httpie errors share these response fields: -The `isHTTPError` function can be used to find out if it is an HTTP error. ```ts -function isHTTPError(error: unknown): boolean; +interface HttpieError extends Error { + headers: IncomingHttpHeaders; + statusCode: number; +} ``` -Example: -```ts -import * as httpie from "@openally/httpie"; +The type guards use a global symbol brand, so they continue to work across JavaScript realms and duplicated installations where `instanceof` may fail. -try { - await httpie.request("GET", "127.0.0.1"); -} -catch (error) { - if (httpie.isHTTPError(error)) { - console.log(Boolean(error.data)) // true - console.log(Boolean(error.statusMessage)) // true - console.log(Boolean(error.headers)) // true - console.log(Boolean(error.statusCode)) // true - } - else { - // This error can be of any error type. - console.log(Boolean(error.data)) // false - console.log(Boolean(error.statusMessage)) // false - } -} +## `isHttpieError()` + +```ts +isHttpieError(error: unknown): error is HttpieError ``` ---- +Returns `true` for HTTP status errors and for failures raised while reading, decompressing, or parsing a response body. Connection and URL errors thrown directly by Node.js or Undici return `false`. -## HTTP errors +## `isHTTPError()` + +```ts +isHTTPError< + T extends RequestResponse = RequestResponse +>(error: unknown): error is HttpieOnHttpError +``` -If the `RequestOptions.throwOnHttpError` option is set to true, all HTTP responses with a status code higher than 400 will generate an `HttpieOnHttpError` error. +Returns `true` only for `HttpieOnHttpError`. Use it when code needs access to the parsed error response through `error.data`. -> [!NOTE] -> Use [`isHTTPError`](#ishttperror) function to know if it is an HTTP error. +## HTTP status errors + +Requests use `throwOnHttpError: true` by default. A response with a status code greater than or equal to `400` throws `HttpieOnHttpError` after the response body has been processed according to the selected [response mode](./request.md#response-modes). ```ts -interface HttpieOnHttpError { +class HttpieOnHttpError< + T extends RequestResponse +> extends Error { + name: "HttpieOnHttpError"; statusCode: number; statusMessage: string; headers: IncomingHttpHeaders; - data: T; + data: T["data"]; } ``` -## Failed to retrieve response body +Set `throwOnHttpError` to `false` when the caller should inspect the response directly: ```ts -interface HttpieFetchBodyError { - statusCode: number; - headers: IncomingHttpHeaders; - message: string; - /** @description original error */ - error?: Error; +import { get } from "@openally/httpie"; + +const response = await get("https://api.example.com/missing", { + throwOnHttpError: false +}); + +if (response.statusCode === 404) { + console.log("The resource does not exist"); } ``` -## Failed to decompress response body +## Response body errors + +Errors raised while processing a response body include `statusCode`, `headers`, `message`, and the original failure as `reason: Error | null`. -If the `RequestOptions.mode` option is set with `decompress` or `parse`, Httpie will try to decompress the response body based on the **content-encoding** header. +| Failure | `name` | Additional fields | +|---|---|---| +| The response body could not be read | `ResponseFetchError` | none | +| The content encoding is unsupported | `DecompressionNotSupported` | `buffer`, `encodings` | +| More than five content encodings were supplied | `TooManyContentEncodings` | `buffer`, `encodings` | +| Decompression failed | `UnexpectedDecompressionError` | `buffer`, `encodings` | +| The media type or response data could not be parsed | `ResponseParsingError` | `buffer`, `contentType`, `text` | -If Httpie fails to decompress the response body, an `HttpieDecompressionError` will be raised. +`buffer` contains the response bytes available when processing failed. For parser errors, `text` contains the decoded text when parsing reached that stage; otherwise it is `null`. ```ts -interface HttpieDecompressionError { - statusCode: number; - headers: IncomingHttpHeaders; - message: string; - /** @description original error */ - error?: Error; - /** @description original body as buffer */ - buffer: Buffer; - /** @description encodings from 'content-encoding' header */ - encodings: string[]; +import { + get, + isHttpieError +} from "@openally/httpie"; + +try { + await get("https://api.example.com/broken-json"); +} +catch (error: unknown) { + if ( + isHttpieError(error) && + error.name === "ResponseParsingError" && + "reason" in error && + "text" in error + ) { + console.error(error.reason); + console.error(error.text); + } } ``` -## Failed to parse response body - -If the `RequestOptions.mode` option is set with `parse`, Httpie will try to parse the response body based on the **content-type** header. +## Safe methods -If Httpie fails to parse the response body, an `HttpieParserError` will be raised. +`safeRequest`, `safeGet`, `safePost`, `safePut`, `safePatch`, and `safeDel` return thrown failures in the `Err` branch of a `Result`. Httpie errors still work with the guards above; connection errors from Node.js or Undici remain unbranded errors. ```ts -interface HttpieParserError extends IHttpieHandlerError { - statusCode: number; - headers: IncomingHttpHeaders; +import { + isHTTPError, + safeGet +} from "@openally/httpie"; + +interface ApiError { message: string; - /** @description original error */ - error?: Error; - /** @description content-type from 'content-type' header without params */ - contentType: string; - /** @description original body as buffer */ - buffer: Buffer; - /** @description body as string */ - text: string | null; +} + +const result = await safeGet( + "https://api.example.com/private" +); + +if (result.err) { + const error = result.val; + + if (isHTTPError(error)) { + console.error(error.statusCode, error.data); + } + else { + console.error(error.message); + } } ``` + +See [Safe requests](./request.md#safe-requests) for their signatures and the full `Result` example. diff --git a/docs/images/nodesecure.PNG b/docs/images/nodesecure.PNG deleted file mode 100644 index 9e31576..0000000 Binary files a/docs/images/nodesecure.PNG and /dev/null differ diff --git a/docs/request.md b/docs/request.md index 31af0be..4278626 100644 --- a/docs/request.md +++ b/docs/request.md @@ -1,62 +1,249 @@ -# Request API -The request method is the root method for making http requests. Short method like get or post use it under the hood. +# Requests -The method **options** and **response** are described by the following TypeScript interfaces: +`request()` sends an HTTP or WebDAV request through Undici, reads the response body, and returns the response metadata with the decoded data. The `get`, `post`, `put`, `patch`, and `del` functions bind the method argument for common requests. ```ts -type ModeOfHttpieResponseHandler = "decompress" | "parse" | "raw"; +import { get } from "@openally/httpie"; -export interface RequestOptions { - /** @default{ "user-agent": "httpie" } */ +interface User { + id: number; + name: string; +} + +const { data, statusCode } = await get( + "https://api.example.com/users/42" +); + +console.log(statusCode, data.name); +``` + +## `request()` + +```ts +request( + method: HttpMethod | WebDavMethod, + uri: string | URL, + options?: RequestOptions +): Promise> +``` + +`method` accepts the standard HTTP methods and the following WebDAV methods: `MKCOL`, `COPY`, `MOVE`, `LOCK`, `UNLOCK`, `PROPFIND`, and `PROPPATCH`. + +`uri` may be an absolute URL, a WHATWG `URL`, or a path handled by a registered [agent](./agents.md). A relative path without a matching agent cannot be resolved and throws a URL parsing error. + +The generic `T` describes the expected `data` value to TypeScript. It does not validate the response at runtime. Runtime decoding is controlled by the response `content-type` and the selected [response mode](#response-modes). + +```ts +interface RequestResponse { + data: T; + headers: IncomingHttpHeaders; + statusMessage: string; + statusCode: number; +} +``` + +## Request options + +```ts +interface RequestOptions { headers?: IncomingHttpHeaders; querystring?: string | URLSearchParams; body?: any; authorization?: string; - // Could be dynamically computed depending on the provided URI. - agent?: undici.Agent | undici.ProxyAgent | undici.MockAgent; - /** @description API limiter from a package like `p-ratelimit`. */ + blocking?: boolean; + agent?: Agent | ProxyAgent | MockAgent; limit?: InlineCallbackAction; - /** @default "parse" */ - mode?: ModeOfHttpieResponseHandler; - /** @default true */ + mode?: "parse" | "decompress" | "raw"; throwOnHttpError?: boolean; } +``` -export interface RequestResponse { - data: T; - headers: IncomingHttpHeaders; - statusMessage: string; - statusCode: number; -} +| Option | Default | Behavior | +|---|---|---| +| `headers` | `{ "user-agent": "httpie" }` | Adds request headers. Header names are handled case-insensitively, so a supplied `User-Agent` replaces the default. | +| `querystring` | none | Sets query parameters from a string or `URLSearchParams`. Supplied values replace matching parameters already present in the URL. | +| `body` | `undefined` | Sends a string, `Buffer`, `URLSearchParams`, JSON object, array, `null`, or async iterable body. See [request bodies](#request-bodies). | +| `authorization` | none | Builds an `Authorization` header. A value containing `:` becomes Basic credentials; other non-empty values become Bearer tokens. | +| `blocking` | Undici default | Passes Undici's `blocking` dispatch option. Use it for a response that may hold up further pipelined requests on the same connection. | +| `agent` | resolved agent or global dispatcher | Uses the supplied Undici `Agent`, `ProxyAgent`, or `MockAgent` for this request. It takes precedence over the registered agent for the URI. | +| `limit` | registered limit or none | Wraps the Undici request in an async limiter callback. It takes precedence over a limiter registered with an agent. | +| `mode` | `"parse"` | Controls response decompression and parsing. | +| `throwOnHttpError` | `true` | Throws an `HttpieOnHttpError` for status codes greater than or equal to `400`. | + +The exported `DEFAULT_HEADER` value contains the default user agent: + +```ts +const DEFAULT_HEADER: { "user-agent": string }; ``` -## request< T >(method: string, uri: string | URL, options?: RequestOptions): Promise< RequestResponse< T > > -The first **method** argument take an [HTTP Verb](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) like `GET`, `POST`, `PATCH` etc. The second one **uri** (Uniform Resource Identifier) take a string or a WHATWG URL. +### Request bodies + +Httpie prepares common body values before dispatch: + +| Body value | Sent value | Header behavior | +|---|---|---| +| Object, array, or `null` | `JSON.stringify(body)` | Sets `content-type: application/json` unless one was supplied. | +| `URLSearchParams` | URL-encoded string | Sets `content-type: application/x-www-form-urlencoded` unless one was supplied. | +| `string` or `Buffer` | Original value | Leaves `content-type` unchanged. | +| Async iterable, including Node.js readable streams | Original value | Leaves `content-type` and `content-length` unchanged. | + +For non-streaming bodies, Httpie calculates and replaces `content-length`. An explicitly supplied `content-type` is kept. + +```ts +import { post } from "@openally/httpie"; + +const response = await post<{ id: number }>( + "https://api.example.com/posts", + { + headers: { + "x-request-id": "req-42" + }, + authorization: "secret-token", + body: { + title: "A short title" + } + } +); +``` + +The request above sends `Authorization: Bearer secret-token`. Passing `"username:password"` would send a Base64-encoded Basic authorization value instead. + +### Query parameters -The options allow you to quickly authenticate and add additional headers: -```js -import { request } from "@openally/httpie"; +```ts +import { get } from "@openally/httpie"; -const { data } = await request("GET", "https://test.domain.fr/user/info", { - authorization: "Token here", - headers: { - "society-id": 1 +const { data } = await get( + "https://api.example.com/search?limit=10", + { + querystring: new URLSearchParams({ + limit: "25", + query: "http client" + }) } +); +``` + +The final URL contains `limit=25` and `query=http+client`. + +### Rate limiting + +`limit` is any async function that receives the pending request as a callback and returns its result. Packages such as `p-ratelimit` produce callbacks with this shape. + +```ts +type InlineCallbackAction = ( + callback: () => Promise +) => Promise; +``` + +```ts +import { pRateLimit } from "p-ratelimit"; +import { get } from "@openally/httpie"; + +const limit = pRateLimit({ + interval: 1_000, + rate: 10, + concurrency: 2 }); -console.log(data); + +const response = await get("https://api.example.com/items", { limit }); +``` + +## Response modes + +| Mode | Decompresses | Returned `data` | +|---|---|---| +| `"parse"` | Yes | JSON for `application/json`, a string for `text/*`, and a `Buffer` for other or missing content types. | +| `"decompress"` | Yes | A `Buffer`. The `content-type` is ignored. | +| `"raw"` | No | A `Buffer` containing the response bytes as received. | + +```ts +import { get } from "@openally/httpie"; + +const { data } = await get( + "https://api.example.com/archive.gz", + { mode: "raw" } +); ``` -By default the client will detect the `test.domain.fr` hostname and assign the right Undici Agent (if locally configured). +The supported content encodings are `gzip`, `x-gzip`, `br`, `deflate`, `compress`, and `x-compress`. `zstd` is supported when the running Node.js version provides `zlib.zstdDecompress`. A response may contain at most five content encodings. + +Parsing uses the media type without its parameters. `application/json; charset=utf-8` is parsed as JSON, and `text/plain; charset=utf-8` becomes a string. Other media types remain buffers. -## shorthand methods -Those methods are equivalent to the request arguments (except for `method`) +## HTTP verb aliases ```ts -export type RequestCallback = (uri: string | URL, options?: RequestOptions) => Promise>; +type RequestCallback = ( + uri: string | URL, + options?: RequestOptions +) => Promise>; -export const get = request.bind(null, "GET") as RequestCallback; -export const post = request.bind(null, "POST") as RequestCallback; -export const put = request.bind(null, "PUT") as RequestCallback; -export const del = request.bind(null, "DELETE") as RequestCallback; -export const patch = request.bind(null, "PATCH") as RequestCallback; +const get: RequestCallback; +const post: RequestCallback; +const put: RequestCallback; +const patch: RequestCallback; +const del: RequestCallback; ``` + +Use `request()` directly for `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, or a WebDAV method. + +## Safe requests + +`safeRequest()` catches request, response, and HTTP-status errors and returns a `Result` from `@openally/result`. + +```ts +safeRequest( + method: HttpMethod | WebDavMethod, + uri: string | URL, + options?: RequestOptions +): Promise, RequestError>> +``` + +Safe aliases are available for the same five common HTTP methods: + +```ts +type SafeRequestCallback = ( + uri: string | URL, + options?: RequestOptions +) => Promise, RequestError>>; + +const safeGet: SafeRequestCallback; +const safePost: SafeRequestCallback; +const safePut: SafeRequestCallback; +const safePatch: SafeRequestCallback; +const safeDel: SafeRequestCallback; +``` + +```ts +import { + isHTTPError, + safeGet +} from "@openally/httpie"; + +interface Post { + id: number; + title: string; +} + +interface ApiError { + message: string; +} + +const result = await safeGet( + "https://api.example.com/posts" +); + +result.match( + ({ data }) => console.log(data), + (error) => { + if (isHTTPError(error)) { + console.error(error.statusCode, error.data); + } + else { + console.error(error.message); + } + } +); +``` + +See [Error handling](./errors.md) for the error fields and type guards. For response bodies that should stay as streams, use [`stream()` or `pipeline()`](./stream.md). diff --git a/docs/stream.md b/docs/stream.md new file mode 100644 index 0000000..1b2efee --- /dev/null +++ b/docs/stream.md @@ -0,0 +1,108 @@ +# Streams and pipelines + +`stream()` writes an Undici response body into a writable stream created by the caller. `pipeline()` returns a duplex stream that accepts a request body and emits the response body. Both functions use the same headers, body preparation, query parameters, and [agent resolution](./agents.md) as `request()`. + +These APIs expose raw streams. They do not parse response bodies or turn HTTP status codes into `HttpieOnHttpError` instances. + +```ts +type StreamOptions = Omit & { + opaque?: TOpaque; +}; +``` + +The `limit` option is not available for streams. Options used only by the buffered response handler, including `mode` and `throwOnHttpError`, do not change streaming behavior. + +## `stream()` + +```ts +stream( + method: HttpMethod | WebDavMethod, + uri: string | URL, + options?: StreamOptions +): WritableStreamCallback + +type WritableStreamCallback = ( + factory: Dispatcher.StreamFactory +) => Promise>; +``` + +`stream()` prepares the request and returns a callback. Invoke that callback with an Undici stream factory that receives the response metadata and returns a Node.js `Writable`. + +```ts +import { createWriteStream } from "node:fs"; +import { + Agent, + interceptors, + stream +} from "@openally/httpie"; + +const agent = new Agent() + .compose(interceptors.redirect({ maxRedirections: 2 })); + +const download = stream( + "GET", + "https://github.com/NodeSecure/vulnera/archive/main.tar.gz", + { + agent, + headers: { + "user-agent": "httpie" + } + } +); + +await download(({ headers, statusCode }) => { + console.log(statusCode, headers["content-type"]); + + return createWriteStream("./vulnera-main.tar.gz"); +}); +``` + +Inspect `statusCode` in the factory when non-success responses need separate handling. The response body is still written to the returned stream. + +## `pipeline()` + +```ts +pipeline( + method: HttpMethod | WebDavMethod, + uri: string | URL, + options?: StreamOptions +): Duplex +``` + +The writable side becomes the request body. The readable side emits the response body. + +```ts +import { createReadStream } from "node:fs"; +import { pipeline as nodePipeline } from "node:stream/promises"; +import { pipeline } from "@openally/httpie"; + +await nodePipeline( + createReadStream("./payload.json"), + pipeline( + "POST", + "https://jsonplaceholder.typicode.com/posts", + { + headers: { + "content-type": "application/json" + } + } + ), + process.stdout +); +``` + +When a file or other stream supplies the body, Httpie does not calculate `content-length`. Add it to `headers` when the server requires a fixed length. + +## Options and dispatchers + +The following `RequestOptions` affect `stream()` and `pipeline()`: + +- `headers`, `querystring`, `body`, and `authorization` +- `blocking` +- `agent`, including an agent selected from the registry + +For `pipeline()`, data written to the duplex stream is normally the request body, so avoid supplying both a streamed input and `options.body`. + +`StreamOptions` currently includes `opaque` for Undici type compatibility, but Httpie does not forward that value to the dispatcher. + +See [Requests](./request.md) for buffered and parsed responses.