feat(kernel): extensible content media types (#5244)
This commit is contained in:
parent
899db05ab8
commit
caadfc8c55
2 changed files with 174 additions and 25 deletions
|
|
@ -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>> => {
|
||||
|
|
|
|||
|
|
@ -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[]][]`
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue