feat(kernel): extensible content media types (#5244)

This commit is contained in:
Shreyas 2025-07-24 16:29:50 +05:30 committed by GitHub
parent 899db05ab8
commit caadfc8c55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 174 additions and 25 deletions

View file

@ -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<Error, ContentType> =>
pipe(
parseJSONAs<unknown>(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<Error, ContentType> =>
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<Error, ContentType> =>
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<Error, ContentType> =>
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<Error, ContentType> =>
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<Error, Option<ContentType>> - Success with optional content, or error
*/
export const transformContent = (
request: EffectiveHoppRESTRequest
): TE.TaskEither<Error, O.Option<ContentType>> => {

View file

@ -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: <T>(
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<string, any>): ContentType => ({
/**
* Creates URL-encoded content from string or object.
*/
urlencoded: (content: string | Record<string, any>, 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(
'<soap:Envelope>...</soap:Envelope>',
"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[]][]`
*