diff --git a/packages/hoppscotch-common/src/helpers/kernel/common/content.ts b/packages/hoppscotch-common/src/helpers/kernel/common/content.ts index c4e5cc33..9a5d103f 100644 --- a/packages/hoppscotch-common/src/helpers/kernel/common/content.ts +++ b/packages/hoppscotch-common/src/helpers/kernel/common/content.ts @@ -3,20 +3,33 @@ import * as TE from "fp-ts/TaskEither" import * as O from "fp-ts/Option" import { pipe } from "fp-ts/function" -import { parseJSONAs } from "~/helpers/functional/json" import { ContentType, MediaType, content } from "@hoppscotch/kernel" import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL" +/** + * Content processors for converting raw body strings to standardized ContentType objects. + * + * If a processor is called, it's expected that it's correct content type. + * NOTE: Validation belongs in upper layers, not the processor layer. + */ const Processors = { + /** + * Processes JSON content as pre-stringified JSON text. + * + * NOTE: Assumes input is valid JSON in string format since user selected "JSON". + * Uses `content.text()` with JSON media type to avoid double-encoding. + */ json: { process: (body: string): E.Either => - pipe( - parseJSONAs(body), - E.map(() => content.text(body, MediaType.APPLICATION_JSON)), - E.orElse(() => E.right(content.text(body, MediaType.TEXT_PLAIN))) - ), + E.right(content.text(body, MediaType.APPLICATION_JSON)), }, + /** + * Processes binary content from Blob/File objects. + * + * Converts Blob to Uint8Array while preserving filename and content type. + * Returns TaskEither since arrayBuffer() is async. + */ binary: { process: (file: Blob): TE.TaskEither => pipe( @@ -34,6 +47,12 @@ const Processors = { ), }, + /** + * Processes URL-encoded form data. + * + * Takes a raw string and wraps it in the urlencoded content type. + * The string is expected to be already properly encoded (e.g., "key1=value1&key2=value2"). + */ urlencoded: { process: (body: string): E.Either => pipe( @@ -44,17 +63,33 @@ const Processors = { ), }, + /** + * Processes XML content as text with XML media type. + * + * Assumes input is valid XML string format. + */ xml: { process: (body: string): E.Either => E.right(content.xml(body, MediaType.APPLICATION_XML)), }, + /** + * Processes plain text content. + * + * Fallback processor for any text-based content that doesn't fit other categories. + */ text: { process: (body: string): E.Either => E.right(content.text(body, MediaType.TEXT_PLAIN)), }, } +/** + * Maps content type strings to appropriate processor. + * + * @param contentType - MIME type string (e.g., "application/json") + * @returns Processor function that converts string body to ContentType + */ const getProcessor = (contentType: string) => { switch (contentType) { case "application/json": @@ -75,6 +110,14 @@ const getProcessor = (contentType: string) => { } } +/** + * Transforms HTTP request body content into standardized `ContentType` objects for the `relay` system. + * + * Returns None if no body content is present or if the body type doesn't match the content type. + * + * @param request - EffectiveHoppRESTRequest containing body and content type information + * @returns TaskEither> - Success with optional content, or error + */ export const transformContent = ( request: EffectiveHoppRESTRequest ): TE.TaskEither> => { diff --git a/packages/hoppscotch-kernel/src/relay/v/1.ts b/packages/hoppscotch-kernel/src/relay/v/1.ts index d6562c16..cef85bb8 100644 --- a/packages/hoppscotch-kernel/src/relay/v/1.ts +++ b/packages/hoppscotch-kernel/src/relay/v/1.ts @@ -105,14 +105,17 @@ export enum MediaType { } export type ContentType = - | { kind: "text"; content: string; mediaType: MediaType.TEXT_PLAIN | MediaType.TEXT_HTML | MediaType.TEXT_CSS | MediaType.TEXT_CSV } - | { kind: "json"; content: unknown; mediaType: MediaType.APPLICATION_JSON | MediaType.APPLICATION_LD_JSON } - | { kind: "xml"; content: string; mediaType: MediaType.APPLICATION_XML | MediaType.TEXT_XML } - | { kind: "form"; content: FormData; mediaType: MediaType.APPLICATION_FORM } - | { kind: "binary"; content: Uint8Array; mediaType: MediaType.APPLICATION_OCTET | string; filename?: string } - | { kind: "multipart"; content: FormData; mediaType: MediaType.MULTIPART_FORM } - | { kind: "urlencoded"; content: string; mediaType: MediaType.APPLICATION_FORM } - | { kind: "stream"; content: ReadableStream; mediaType: string } + | { kind: "text"; content: string; mediaType: MediaType | string } + | { kind: "json"; content: unknown; mediaType: MediaType | string } + | { kind: "xml"; content: string; mediaType: MediaType | string } + | { kind: "form"; content: FormData; mediaType: MediaType | string } + | { kind: "binary"; content: Uint8Array; mediaType: MediaType | string; filename?: string } + | { kind: "multipart"; content: FormData; mediaType: MediaType | string } + | { kind: "urlencoded"; content: string; mediaType: MediaType | string } + | { kind: "stream"; content: ReadableStream; mediaType: MediaType | string } + // TODO: Considering adding a "raw" kind for explicit pass-through content in the future, + // not required at the moment tho, needs some plumbing on the relay side. + // | { kind: "raw"; content: string; mediaType: MediaType | string } export interface RelayResponseBody { body: Uint8Array @@ -526,43 +529,79 @@ export const transform = { ) } +/** + * Content factory functions for creating standardized HTTP request content. + * + * The `kind` field determines how content is processed, basically + * - Web (Axios + JS): `kind` is ignored, relying on Axios' auto-detect + * - Desktop (`relay` + Rust): `kind` routes to processing `fn` in `relay` + * + * NOTE: `mediaType` field sets the HTTP `Content-Type` header in both. + * + * There are a bunch of reasons for separating routing and `Content-Type`, + * see `relay` code for more info. + * Essentially this allows for flexible scenarios like: + * - Sending pre-stringified JSON as text to avoid double-encoding + * - Using custom vendor media types with standard processing + * - Future processing evolution without breaking HTTP contracts + */ export const content = { + /** + * Creates text content. Useful for: + * - Plain text + * - Pre-stringified JSON (to avoid encoding escapes) + * - Custom text formats + */ text: ( content: string, - mediaType?: MediaType.TEXT_PLAIN | MediaType.TEXT_HTML | MediaType.TEXT_CSS | MediaType.TEXT_CSV + mediaType?: MediaType | string ): ContentType => ({ kind: "text", content: transform.text(content), mediaType: mediaType ?? MediaType.TEXT_PLAIN }), + /** + * Creates JSON content with automatic serialization. + * Note: If you already have a JSON string, consider using `text()` + * with `APPLICATION_JSON` mediaType to avoid double-encoding. + */ json: ( content: T, - mediaType?: MediaType.APPLICATION_JSON | MediaType.APPLICATION_LD_JSON | MediaType.APPLICATION_JSON + mediaType?: MediaType | string ): ContentType => ({ kind: "json", content: transform.json(content), mediaType: mediaType ?? MediaType.APPLICATION_JSON }), + /** + * Creates XML content. Currently processed as text. + */ xml: ( content: string, - mediaType?: MediaType.APPLICATION_XML | MediaType.TEXT_XML + mediaType?: MediaType | string ): ContentType => ({ kind: "xml", content: transform.xml(content), mediaType: mediaType ?? MediaType.APPLICATION_XML }), - form: (content: FormData): ContentType => ({ + /** + * Creates form-encoded content from FormData. + */ + form: (content: FormData, mediaType?: MediaType | string): ContentType => ({ kind: "form", content: transform.form(content), - mediaType: MediaType.APPLICATION_FORM + mediaType: mediaType ?? MediaType.APPLICATION_FORM }), + /** + * Creates binary content. Supports any binary format. + */ binary: ( content: Uint8Array, - mediaType: string = MediaType.APPLICATION_OCTET, + mediaType: MediaType | string = MediaType.APPLICATION_OCTET, filename?: string ): ContentType => ({ kind: "binary", @@ -571,25 +610,92 @@ export const content = { filename }), - multipart: (content: FormData): ContentType => ({ + /** + * Creates multipart form content with file upload support. + */ + multipart: (content: FormData, mediaType?: MediaType | string): ContentType => ({ kind: "multipart", content: transform.multipart(content), - mediaType: MediaType.MULTIPART_FORM + mediaType: mediaType ?? MediaType.MULTIPART_FORM }), - urlencoded: (content: string | Record): ContentType => ({ + /** + * Creates URL-encoded content from string or object. + */ + urlencoded: (content: string | Record, mediaType?: MediaType | string): ContentType => ({ kind: "urlencoded", content: transform.urlencoded(content), - mediaType: MediaType.APPLICATION_FORM + mediaType: mediaType ?? MediaType.APPLICATION_FORM }), - stream: (content: ReadableStream, mediaType: string): ContentType => ({ + /** + * Creates streaming content for large payloads. + */ + stream: (content: ReadableStream, mediaType: MediaType | string): ContentType => ({ kind: "stream", content: transform.stream(content), mediaType }) + + // TODO: Raw content type for pass-through scenarios: + // raw: (content: string, mediaType: MediaType | string): ContentType => ({ + // kind: "raw", + // content, + // mediaType + // }) } +/** + * Executable usage examples for content factory functions + * usage / patterns / executable guarantees. + * + * These examples show API usage patterns but also act as compile-time + * guarantees that the API works as documented. If the content factory functions + * change in breaking ways, these examples will fail to type-check, + * these aren't exactly docs nor tests but a mix, + * and these also prevent documentations drift. + * + * Pattern borrowed from Rust's documentation tests where executable code examples are + * embedded alongside API definitions. + * See: https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html + * + * Since TypeScript lacks built-in doc-tests, this provides somewhat similar + * guarantees, essentially serving as + * - discoverable docs that devs can copy-paste or import, and also + * - type-checked contracts so these cannot become outdated since they're actual + * executable code validated at compile time. + */ +export const examples = { + // Avoid double-encoding of pre-stringified JSON + preStringifiedJson: content.text( + '{"message": "Hello \\"world\\"", "path": "C:\\\\Users\\\\file.txt"}', + MediaType.APPLICATION_JSON + ), + + // Vendor-specific JSON formats + jsonApi: content.json( + { data: { type: "users", id: "1" } }, + "application/vnd.api+json" + ), + + // Custom XML schema + soapXml: content.xml( + '...', + "application/soap+xml" + ), + + // Custom binary format + customBinary: content.binary( + new Uint8Array([0x89, 0x50, 0x4E, 0x47]), + "image/png" + ), + + // Backwards compatible - uses defaults + standardJson: content.json({ name: "John" }), + standardText: content.text("Hello world") +} + + /** * Helper function to convert standard `FormData` to array of arrays `[string, FormDataValue[]][]` *