From c918a3cc2e7dd50b9f45f63af9a62e275f067a0c Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Mon, 31 Aug 2026 18:09:12 +0200 Subject: [PATCH 01/15] Add a dedicated `MediaType` module --- .changeset/parsed-media-types.md | 5 + packages/effect/src/Schema.ts | 69 ++ .../src/unstable/http/HttpServerRequest.ts | 9 +- .../effect/src/unstable/http/MediaType.ts | 636 ++++++++++++++++++ .../MultipartParser/internal/multipart.ts | 20 +- packages/effect/src/unstable/http/index.ts | 5 + .../src/unstable/httpapi/HttpApiBuilder.ts | 11 +- .../src/unstable/httpapi/HttpApiClient.ts | 22 +- .../src/unstable/httpapi/HttpApiEndpoint.ts | 15 +- .../unstable/httpapi/internal/mediaType.ts | 6 - .../test/unstable/http/MediaType.test.ts | 154 +++++ .../test/unstable/http/Multipart.test.ts | 30 + .../unstable/httpapi/HttpApiEndpoint.test.ts | 13 + .../typetest/unstable/http/MediaType.tst.ts | 26 + 14 files changed, 992 insertions(+), 29 deletions(-) create mode 100644 .changeset/parsed-media-types.md create mode 100644 packages/effect/src/unstable/http/MediaType.ts delete mode 100644 packages/effect/src/unstable/httpapi/internal/mediaType.ts create mode 100644 packages/effect/test/unstable/http/MediaType.test.ts create mode 100644 packages/effect/typetest/unstable/http/MediaType.tst.ts diff --git a/.changeset/parsed-media-types.md b/.changeset/parsed-media-types.md new file mode 100644 index 00000000000..808e5795c40 --- /dev/null +++ b/.changeset/parsed-media-types.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, and Schema codecs. HTTP API content-type dispatch and multipart parsing now compare validated media-type essences while preserving string-facing transport APIs; malformed request content types continue to receive a 415 response. diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 1073798fe4f..366c7dc76bb 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -68,6 +68,7 @@ import type { Assign, Lambda, Mutable, Simplify } from "./Struct.ts" import * as Struct_ from "./Struct.ts" import type { RequiredKeys, UnionToIntersection } from "./Types.ts" import type { Unify } from "./Unify.ts" +import * as MediaType_ from "./unstable/http/MediaType.ts" const TypeId = InternalSchema.TypeId @@ -11888,6 +11889,74 @@ export interface URLFromString extends decodeTo { */ export const URLFromString: URLFromString = URLString.pipe(decodeTo(URL, SchemaTransformation.urlFromString)) +/** + * Type-level representation of {@link MediaType}. + * + * @category models + * @since 4.0.0 + */ +export interface MediaType extends declare { + readonly "Rebuild": MediaType +} + +const mediaTypeTransformation = SchemaTransformation.transformOrFail({ + decode: (input: string, options) => { + const result = MediaType_.parse(input) + return Result_.isFailure(result) + ? Effect.fail( + new SchemaIssue.InvalidValue( + { message: `${result.failure.reason} at offset ${result.failure.offset}` }, + input, + options + ) + ) + : Effect.succeed(result.success) + }, + encode: (mediaType: MediaType_.MediaType) => Effect.succeed(MediaType_.format(mediaType)) +}) + +/** + * Schema for parsed HTTP media-type values. + * + * @see {@link MediaTypeFromString} for decoding media types from strings + * + * @category schemas + * @since 4.0.0 + */ +export const MediaType: MediaType = declare(MediaType_.isMediaType, { + representation: { id: "effect/http/MediaType", payload: null }, + toCode: () => ({ + runtime: "Schema.MediaType", + Type: "MediaType.MediaType", + importDeclarations: [`import * as MediaType from "effect/unstable/http/MediaType"`] + }), + expected: "MediaType", + toEquivalence: () => MediaType_.Equivalence, + toCodec: () => link()(String, mediaTypeTransformation) +}) + +/** + * Type-level representation of {@link MediaTypeFromString}. + * + * @category models + * @since 4.0.0 + */ +export interface MediaTypeFromString extends decodeTo { + readonly "Rebuild": MediaTypeFromString +} + +/** + * Schema that decodes strings into normalized HTTP media-type values. + * + * @see {@link MediaType} for validating already parsed media types + * + * @category schemas + * @since 4.0.0 + */ +export const MediaTypeFromString: MediaTypeFromString = String.pipe( + decodeTo(MediaType, mediaTypeTransformation) +) + /** * Type-level representation of {@link Date}. * diff --git a/packages/effect/src/unstable/http/HttpServerRequest.ts b/packages/effect/src/unstable/http/HttpServerRequest.ts index 0fa6521ef39..7b9c386c416 100644 --- a/packages/effect/src/unstable/http/HttpServerRequest.ts +++ b/packages/effect/src/unstable/http/HttpServerRequest.ts @@ -34,6 +34,7 @@ import * as HttpIncomingMessage from "./HttpIncomingMessage.ts" import { hasBody, type HttpMethod } from "./HttpMethod.ts" import { HttpServerError, type RequestError, RequestParseError } from "./HttpServerError.ts" import * as bodyInternal from "./internal/httpBody.ts" +import * as MediaType from "./MediaType.ts" import * as Multipart from "./Multipart.ts" import * as UrlParams from "./UrlParams.ts" @@ -251,9 +252,11 @@ export const schemaBodyJson = ( return Effect.flatMap(HttpServerRequest, parse) } -const isMultipart = (request: HttpServerRequest) => - request.headers["content-type"]?.toLowerCase().includes("multipart/form-data") === true || - getFormDataBody(request) !== undefined +const isMultipart = (request: HttpServerRequest) => { + const contentType = MediaType.parse(request.headers["content-type"] ?? "") + return (Result.isSuccess(contentType) && MediaType.sameEssence(contentType.success, MediaType.multipartFormData)) || + getFormDataBody(request) !== undefined +} /** * Decodes the current request body as form data. diff --git a/packages/effect/src/unstable/http/MediaType.ts b/packages/effect/src/unstable/http/MediaType.ts new file mode 100644 index 00000000000..6eaf8f21887 --- /dev/null +++ b/packages/effect/src/unstable/http/MediaType.ts @@ -0,0 +1,636 @@ +/** + * Models concrete HTTP media types and their parameters. + * + * This module strictly parses and formats concrete `Content-Type` values using + * the RFC 9110 grammar. It intentionally does not implement the more forgiving + * WHATWG MIME parser or model file-extension lookup, wildcards, `Accept`, or + * media ranges. + * + * @since 4.0.0 + */ +import * as Data from "../../Data.ts" +import * as Equal from "../../Equal.ts" +import * as Equ from "../../Equivalence.ts" +import { dual } from "../../Function.ts" +import * as Hash from "../../Hash.ts" +import * as Inspectable from "../../Inspectable.ts" +import * as Option from "../../Option.ts" +import * as Pipeable from "../../Pipeable.ts" +import * as Predicate from "../../Predicate.ts" +import * as Result from "../../Result.ts" + +/** + * Runtime type identifier for `MediaType` values. + * + * @category type IDs + * @since 4.0.0 + */ +export const TypeId: unique symbol = Symbol.for("~effect/http/MediaType") + +/** + * Type of the unique symbol used to brand `MediaType` values. + * + * @category type IDs + * @since 4.0.0 + */ +export type TypeId = typeof TypeId + +/** + * A normalized media-type parameter. + * + * @category models + * @since 4.0.0 + */ +export interface Parameter { + readonly name: string + readonly value: string +} + +/** + * Parts accepted when constructing a concrete media type. + * + * @category models + * @since 4.0.0 + */ +export interface Parts { + readonly type: string + readonly subtype: string + readonly parameters?: + | Readonly> + | Iterable + | undefined +} + +/** + * A parsed, immutable concrete HTTP media type. + * + * **Gotchas** + * + * This model excludes wildcard media ranges and quality parameters used by + * `Accept` negotiation. Parameter values containing `obs-text` use JavaScript's + * isomorphic U+0080 through U+00FF representation of the corresponding octets. + * + * @category models + * @since 4.0.0 + */ +export interface MediaType extends Equal.Equal, Pipeable.Pipeable, Inspectable.Inspectable { + readonly [TypeId]: TypeId + readonly type: string + readonly subtype: string + readonly suffix: Option.Option + readonly parameters: ReadonlyArray +} + +/** + * Reason that parsing or construction of a media type failed. + * + * @category errors + * @since 4.0.0 + */ +export type MediaTypeParseErrorReason = + | "ExpectedType" + | "ExpectedSlash" + | "ExpectedSubtype" + | "ExpectedParameterName" + | "ExpectedEquals" + | "ExpectedParameterValue" + | "InvalidQuotedPair" + | "DuplicateParameter" + | "UnexpectedCharacter" + +/** + * Describes a media type parse failure at an offset in the original input. + * + * @category errors + * @since 4.0.0 + */ +export class MediaTypeParseError extends Data.TaggedError("MediaTypeParseError")<{ + readonly input: string + readonly offset: number + readonly reason: MediaTypeParseErrorReason +}> { + override get message(): string { + return `${this.reason} at offset ${this.offset}` + } +} + +/** + * Returns `true` if the provided value is a `MediaType` value. + * + * @category guards + * @since 4.0.0 + */ +export const isMediaType = (input: unknown): input is MediaType => { + if (!Predicate.hasProperty(input, TypeId)) return false + const candidate = input as Partial + if ( + candidate[TypeId] !== TypeId || + typeof candidate.type !== "string" || candidate.type !== candidate.type.toLowerCase() || + !isToken(candidate.type) || candidate.type === "*" || + typeof candidate.subtype !== "string" || candidate.subtype !== candidate.subtype.toLowerCase() || + !isToken(candidate.subtype) || candidate.subtype === "*" || + !Option.isOption(candidate.suffix) || !Array.isArray(candidate.parameters) || + typeof candidate.pipe !== "function" || typeof candidate[Equal.symbol] !== "function" || + typeof candidate[Hash.symbol] !== "function" || typeof candidate.toJSON !== "function" || + typeof candidate[Inspectable.NodeInspectSymbol] !== "function" + ) return false + + const expectedSuffix = suffixOf(candidate.type, candidate.subtype) + if ( + Option.isSome(expectedSuffix) + ? !Option.isSome(candidate.suffix) || candidate.suffix.value !== expectedSuffix.value + : !Option.isNone(candidate.suffix) + ) return false + + let previousName: string | undefined + for (const parameter of candidate.parameters) { + if ( + typeof parameter !== "object" || parameter === null || + typeof parameter.name !== "string" || parameter.name !== parameter.name.toLowerCase() || + !isToken(parameter.name) || typeof parameter.value !== "string" || !isDecodedValue(parameter.value) || + (previousName !== undefined && previousName >= parameter.name) + ) return false + previousName = parameter.name + } + return true +} + +const parametersEqual = (left: ReadonlyArray, right: ReadonlyArray): boolean => { + if (left.length !== right.length) return false + for (let i = 0; i < left.length; i++) { + if (left[i].name !== right[i].name || left[i].value !== right[i].value) return false + } + return true +} + +/** + * Exact equivalence for normalized media types, including parameters. + * + * @category instances + * @since 4.0.0 + */ +export const Equivalence: Equ.Equivalence = Equ.make((left, right) => + left.type === right.type && left.subtype === right.subtype && parametersEqual(left.parameters, right.parameters) +) + +const Proto: MediaType = { + [TypeId]: TypeId, + type: "", + subtype: "", + suffix: Option.none(), + parameters: [], + pipe() { + return Pipeable.pipeArguments(this, arguments) + }, + [Equal.symbol](this: MediaType, that: unknown): boolean { + return isMediaType(that) && Equivalence(this, that) + }, + [Hash.symbol](this: MediaType): number { + let hash = Hash.combine(Hash.string(this.type))(Hash.string(this.subtype)) + for (const parameter of this.parameters) { + hash = Hash.combine(Hash.string(parameter.name))(hash) + hash = Hash.combine(Hash.string(parameter.value))(hash) + } + return hash + }, + toString(this: MediaType): string { + return format(this) + }, + toJSON(this: MediaType): unknown { + return format(this) + }, + [Inspectable.NodeInspectSymbol](this: MediaType): unknown { + return format(this) + } +} + +const isTchar = (code: number): boolean => + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + code === 33 || code === 35 || code === 36 || code === 37 || code === 38 || code === 39 || code === 42 || + code === 43 || code === 45 || code === 46 || code === 94 || code === 95 || code === 96 || code === 124 || code === 126 + +const isToken = (value: string): boolean => { + if (value.length === 0) return false + for (let i = 0; i < value.length; i++) if (!isTchar(value.charCodeAt(i))) return false + return true +} + +const isDecodedValue = (value: string): boolean => { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i) + if (code !== 9 && (code < 32 || code === 127 || code > 255)) return false + } + return true +} + +const isRestrictedName = (value: string): boolean => { + if (value.length === 0 || value.length > 127) return false + const first = value.charCodeAt(0) + if (!((first >= 48 && first <= 57) || (first >= 65 && first <= 90) || (first >= 97 && first <= 122))) { + return false + } + for (let i = 1; i < value.length; i++) { + const code = value.charCodeAt(i) + if ( + (code < 48 || code > 57) && + (code < 65 || code > 90) && + (code < 97 || code > 122) && + code !== 33 && code !== 35 && code !== 36 && code !== 38 && code !== 43 && code !== 45 && code !== 46 && + code !== 94 && code !== 95 + ) return false + } + return true +} + +const suffixOf = (type: string, subtype: string): Option.Option => { + const index = subtype.lastIndexOf("+") + return isRestrictedName(type) && isRestrictedName(subtype) && index > 0 && index < subtype.length - 1 + ? Option.some(subtype.slice(index + 1)) + : Option.none() +} + +const fromValidated = (type: string, subtype: string, parameters: Array): MediaType => { + parameters.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0) + const self = Object.create(Proto) + self.type = type + self.subtype = subtype + self.suffix = suffixOf(type, subtype) + self.parameters = Object.freeze(parameters.map((parameter) => Object.freeze(parameter))) + return Object.freeze(self) +} + +const fail = (input: string, offset: number, reason: MediaTypeParseErrorReason) => + Result.fail(new MediaTypeParseError({ input, offset, reason })) + +/** + * Creates a concrete media type from validated parts. + * + * @category constructors + * @since 4.0.0 + */ +export const make = (parts: Parts): Result.Result => { + const input = `${parts.type}/${parts.subtype}` + if (!isToken(parts.type) || parts.type === "*") return fail(input, 0, "ExpectedType") + if (!isToken(parts.subtype) || parts.subtype === "*") return fail(input, parts.type.length + 1, "ExpectedSubtype") + const parameters: Array = [] + const names = new Set() + const entries = parts.parameters === undefined + ? [] + : Symbol.iterator in Object(parts.parameters) + ? parts.parameters as Iterable + : Object.entries(parts.parameters) + for (const [rawName, value] of entries) { + const name = rawName.toLowerCase() + if (!isToken(rawName)) return fail(input, input.length, "ExpectedParameterName") + if (!isDecodedValue(value)) return fail(input, input.length, "ExpectedParameterValue") + if (names.has(name)) return fail(input, input.length, "DuplicateParameter") + names.add(name) + parameters.push({ name, value }) + } + return Result.succeed(fromValidated(parts.type.toLowerCase(), parts.subtype.toLowerCase(), parameters)) +} + +/** + * Creates a concrete media type from parts, throwing when any part is invalid. + * + * @category unsafe + * @since 4.0.0 + */ +export const makeUnsafe = (parts: Parts): MediaType => Result.getOrThrow(make(parts)) + +const skipOws = (input: string, start: number): number => { + let index = start + while (input.charCodeAt(index) === 32 || input.charCodeAt(index) === 9) index++ + return index +} + +/** + * Parses a concrete HTTP media type and its parameters. + * + * **Details** + * + * Names are lowercased, quoted pairs are decoded, duplicate parameter names are + * rejected, and leading or trailing optional whitespace is ignored. + * + * @category constructors + * @since 4.0.0 + */ +export const parse = (input: string): Result.Result => { + const length = input.length + let index = skipOws(input, 0) + const typeStart = index + while (index < length && isTchar(input.charCodeAt(index))) index++ + if (index === typeStart) return fail(input, index, "ExpectedType") + const type = input.slice(typeStart, index).toLowerCase() + if (type === "*") return fail(input, typeStart, "ExpectedType") + if (input.charCodeAt(index) !== 47) return fail(input, index, "ExpectedSlash") + index++ + const subtypeStart = index + while (index < length && isTchar(input.charCodeAt(index))) index++ + if (index === subtypeStart) return fail(input, index, "ExpectedSubtype") + const subtype = input.slice(subtypeStart, index).toLowerCase() + if (subtype === "*") return fail(input, subtypeStart, "ExpectedSubtype") + + const parameters: Array = [] + const names = new Set() + while (true) { + index = skipOws(input, index) + if (index === length) return Result.succeed(fromValidated(type, subtype, parameters)) + if (input.charCodeAt(index) !== 59) return fail(input, index, "UnexpectedCharacter") + index = skipOws(input, index + 1) + if (index === length || input.charCodeAt(index) === 59) continue + + const nameStart = index + while (index < length && isTchar(input.charCodeAt(index))) index++ + if (index === nameStart) return fail(input, index, "ExpectedParameterName") + const name = input.slice(nameStart, index).toLowerCase() + if (names.has(name)) return fail(input, nameStart, "DuplicateParameter") + if (input.charCodeAt(index) !== 61) return fail(input, index, "ExpectedEquals") + index++ + + let value = "" + if (input.charCodeAt(index) === 34) { + index++ + let closed = false + while (index < length) { + const code = input.charCodeAt(index) + if (code === 34) { + index++ + closed = true + break + } + if (code === 92) { + const escaped = input.charCodeAt(index + 1) + if ( + index + 1 >= length || + (escaped !== 9 && escaped !== 32 && (escaped < 33 || escaped === 127 || escaped > 255)) + ) { + return fail(input, index, "InvalidQuotedPair") + } + value += input[index + 1] + index += 2 + continue + } + if ( + code !== 9 && code !== 32 && code !== 33 && (code < 35 || code > 91) && (code < 93 || code > 126) && + (code < 128 || code > 255) + ) { + return fail(input, index, "UnexpectedCharacter") + } + value += input[index++] + } + if (!closed) return fail(input, index, "ExpectedParameterValue") + } else { + const valueStart = index + while (index < length && isTchar(input.charCodeAt(index))) index++ + if (index === valueStart) return fail(input, index, "ExpectedParameterValue") + value = input.slice(valueStart, index) + } + names.add(name) + parameters.push({ name, value }) + } +} + +/** + * Parses a concrete media type, throwing when the input is invalid. + * + * @category unsafe + * @since 4.0.0 + */ +export const parseUnsafe = (input: string): MediaType => Result.getOrThrow(parse(input)) + +/** + * Returns the normalized `type/subtype` without parameters. + * + * @category getters + * @since 4.0.0 + */ +export const essence = (self: MediaType): string => `${self.type}/${self.subtype}` + +/** + * Returns the subtype portion before an RFC 6838-compatible structured syntax suffix. + * + * **Gotchas** + * + * Returns the complete subtype when the type or subtype does not satisfy the + * RFC 6838 registered-name grammar or does not have a structured suffix. + * + * @category getters + * @since 4.0.0 + */ +export const baseSubtype = (self: MediaType): string => + Option.match(self.suffix, { + onNone: () => self.subtype, + onSome: (suffix) => self.subtype.slice(0, -(suffix.length + 1)) + }) + +const formatValue = (value: string): string => { + if (isToken(value)) return value + return `"${value.replace(/["\\]/g, "\\$&")}"` +} + +/** + * Formats a media type as a deterministic HTTP field value. + * + * @category formatting + * @since 4.0.0 + */ +export const format = (self: MediaType): string => { + let output = essence(self) + for (const parameter of self.parameters) output += `; ${parameter.name}=${formatValue(parameter.value)}` + return output +} + +/** + * Returns a named parameter value, ignoring parameter-name casing. + * + * @category getters + * @since 4.0.0 + */ +export const getParameter: { + (name: string): (self: MediaType) => Option.Option + (self: MediaType, name: string): Option.Option +} = dual(2, (self: MediaType, name: string): Option.Option => { + if (!isToken(name)) return Option.none() + const normalized = name.toLowerCase() + const parameter = self.parameters.find((parameter) => parameter.name === normalized) + return parameter === undefined ? Option.none() : Option.some(parameter.value) +}) + +/** + * Returns the normalized value of the `charset` parameter. + * + * **Details** + * + * Charset names are case-insensitive, so the returned value is lowercased. + * + * @category getters + * @since 4.0.0 + */ +export const getCharset = (self: MediaType): Option.Option => + Option.map(getParameter(self, "charset"), (value) => value.toLowerCase()) + +/** + * Returns whether a named parameter is present. + * + * @category predicates + * @since 4.0.0 + */ +export const hasParameter: { + (name: string): (self: MediaType) => boolean + (self: MediaType, name: string): boolean +} = dual(2, (self: MediaType, name: string): boolean => Option.isSome(getParameter(self, name))) + +/** + * Returns whether two media types have the same normalized type and subtype. + * + * @category comparisons + * @since 4.0.0 + */ +export const sameEssence: { + (that: MediaType): (self: MediaType) => boolean + (self: MediaType, that: MediaType): boolean +} = dual(2, (self: MediaType, that: MediaType): boolean => self.type === that.type && self.subtype === that.subtype) + +/** + * Returns whether a candidate has the expected essence and all expected parameters. + * + * **Details** + * + * Charset values are compared case-insensitively. Other parameter values use + * exact comparison because their semantics are defined by each media type. + * + * @category comparisons + * @since 4.0.0 + */ +export const matchesParameters: { + (expected: MediaType): (candidate: MediaType) => boolean + (candidate: MediaType, expected: MediaType): boolean +} = dual( + 2, + (candidate: MediaType, expected: MediaType): boolean => + sameEssence(candidate, expected) && + expected.parameters.every((parameter) => + Option.exists( + getParameter(candidate, parameter.name), + (value) => + parameter.name === "charset" + ? value.toLowerCase() === parameter.value.toLowerCase() + : value === parameter.value + ) + ) +) + +/** + * Returns whether the normalized top-level type equals `type`. + * + * @category predicates + * @since 4.0.0 + */ +export const isType: { + (type: string): (self: MediaType) => boolean + (self: MediaType, type: string): boolean +} = dual(2, (self: MediaType, type: string): boolean => isToken(type) && self.type === type.toLowerCase()) + +/** + * Returns whether the normalized subtype equals `subtype`. + * + * @category predicates + * @since 4.0.0 + */ +export const isSubtype: { + (subtype: string): (self: MediaType) => boolean + (self: MediaType, subtype: string): boolean +} = dual(2, (self: MediaType, subtype: string): boolean => isToken(subtype) && self.subtype === subtype.toLowerCase()) + +/** + * Returns whether the structured syntax suffix equals `suffix`. + * + * @category predicates + * @since 4.0.0 + */ +export const hasSuffix: { + (suffix: string): (self: MediaType) => boolean + (self: MediaType, suffix: string): boolean +} = dual( + 2, + (self: MediaType, suffix: string): boolean => + isToken(suffix) && Option.getOrUndefined(self.suffix) === suffix.toLowerCase() +) + +/** + * Returns whether the media type belongs to the JSON media-type family. + * + * **Details** + * + * Recognizes `application/json`, `text/json`, and RFC 6838-compatible subtypes + * with a `+json` structured syntax suffix. + * + * @category predicates + * @since 4.0.0 + */ +export const isJson = (self: MediaType): boolean => + (self.type === "application" && self.subtype === "json") || + (self.type === "text" && self.subtype === "json") || + Option.getOrUndefined(self.suffix) === "json" + +/** + * Returns whether the media type belongs to the XML media-type family. + * + * **Details** + * + * Recognizes `application/xml`, `text/xml`, and RFC 6838-compatible subtypes + * with a `+xml` structured syntax suffix. + * + * @category predicates + * @since 4.0.0 + */ +export const isXml = (self: MediaType): boolean => + ((self.type === "application" || self.type === "text") && self.subtype === "xml") || + Option.getOrUndefined(self.suffix) === "xml" + +/** + * Returns whether the normalized top-level type is `text`. + * + * @category predicates + * @since 4.0.0 + */ +export const isText = (self: MediaType): boolean => self.type === "text" + +/** + * The `application/json` media type. + * + * @category constants + * @since 4.0.0 + */ +export const applicationJson: MediaType = parseUnsafe("application/json") +/** + * The `application/octet-stream` media type. + * + * @category constants + * @since 4.0.0 + */ +export const applicationOctetStream: MediaType = parseUnsafe("application/octet-stream") +/** + * The `application/x-www-form-urlencoded` media type. + * + * @category constants + * @since 4.0.0 + */ +export const applicationFormUrlEncoded: MediaType = parseUnsafe("application/x-www-form-urlencoded") +/** + * The `multipart/form-data` media type. + * + * @category constants + * @since 4.0.0 + */ +export const multipartFormData: MediaType = parseUnsafe("multipart/form-data") +/** + * The `text/plain` media type. + * + * @category constants + * @since 4.0.0 + */ +export const textPlain: MediaType = parseUnsafe("text/plain") diff --git a/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts b/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts index 78d7fb07740..bbcbdcacb9e 100644 --- a/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts +++ b/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts @@ -1,3 +1,5 @@ +import * as Result from "../../../../Result.ts" +import * as MediaType from "../../MediaType.ts" import type { Config, MultipartError, PartInfo } from "../../MultipartParser.ts" import * as CT from "./contentType.ts" import * as HP from "./headers.ts" @@ -35,8 +37,12 @@ export function defaultIsFile(info: PartInfo) { } function parseBoundary(headers: Record) { - const contentType = CT.parse(headers["content-type"]) - return contentType.parameters.boundary + const contentType = MediaType.parse(headers["content-type"] ?? "") + return Result.isSuccess(contentType) + ? MediaType.getParameter(contentType.success, "boundary").pipe((option) => + option._tag === "Some" ? option.value : undefined + ) + : undefined } function noopOnChunk(_chunk: Uint8Array | null) {} @@ -159,7 +165,7 @@ export function make({ return onError({ _tag: "BadHeaders", error: result }) } - const contentType = CT.parse(result.headers["content-type"] as string) + const contentType = MediaType.parse((result.headers["content-type"] as string | undefined) ?? "") const contentDisposition = CT.parse( result.headers["content-disposition"] as string, true @@ -188,12 +194,14 @@ export function make({ state.info = { name: contentDisposition.parameters.name ?? "", filename: encodedFilename ?? contentDisposition.parameters.filename, - contentType: contentType.value === "" + contentType: Result.isFailure(contentType) ? contentDisposition.parameters.filename !== undefined ? "application/octet-stream" : "text/plain" - : contentType.value, - contentTypeParameters: contentType.parameters, + : MediaType.essence(contentType.success), + contentTypeParameters: Result.isFailure(contentType) + ? Object.create(null) + : Object.fromEntries(contentType.success.parameters.map((parameter) => [parameter.name, parameter.value])), contentDisposition: contentDisposition.value, contentDispositionParameters: contentDisposition.parameters as any, headers: result.headers diff --git a/packages/effect/src/unstable/http/index.ts b/packages/effect/src/unstable/http/index.ts index 7ed60cdcc0f..6a8c294c3b8 100644 --- a/packages/effect/src/unstable/http/index.ts +++ b/packages/effect/src/unstable/http/index.ts @@ -124,6 +124,11 @@ export * as HttpStatus from "./HttpStatus.ts" */ export * as HttpTraceContext from "./HttpTraceContext.ts" +/** + * @since 4.0.0 + */ +export * as MediaType from "./MediaType.ts" + /** * @since 4.0.0 */ diff --git a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index 72bddab7c49..66f0613a5af 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -42,6 +42,7 @@ import * as Request from "../http/HttpServerRequest.ts" import { HttpServerRequest } from "../http/HttpServerRequest.ts" import * as Response from "../http/HttpServerResponse.ts" import type { HttpServerResponse } from "../http/HttpServerResponse.ts" +import * as MediaType from "../http/MediaType.ts" import * as Multipart from "../http/Multipart.ts" import * as UrlParams from "../http/UrlParams.ts" import type * as HttpApi from "./HttpApi.ts" @@ -51,7 +52,6 @@ import type * as HttpApiGroup from "./HttpApiGroup.ts" import * as HttpApiMiddleware from "./HttpApiMiddleware.ts" import * as HttpApiSchema from "./HttpApiSchema.ts" import type * as HttpApiSecurity from "./HttpApiSecurity.ts" -import * as MediaType from "./internal/mediaType.ts" import * as OpenApi from "./OpenApi.ts" /** @@ -699,9 +699,14 @@ function decodePayload( query: Record> ): Effect.Effect | HttpServerResponse | undefined { const hasBody = HttpMethod.hasBody(httpRequest.method) - const contentType = hasBody - ? MediaType.normalize(httpRequest.headers["content-type"] ?? "application/json") + const rawContentType = hasBody + ? httpRequest.headers["content-type"] ?? "application/json" : "application/x-www-form-urlencoded" + const parsedContentType = MediaType.parse(rawContentType) + if (Result.isFailure(parsedContentType)) { + return Response.text(`Unsupported content-type: ${rawContentType}`, { status: 415 }) + } + const contentType = MediaType.essence(parsedContentType.success) const existing = payloadBy.get(contentType) if (!existing) { return Response.text(`Unsupported content-type: ${contentType}`, { status: 415 }) diff --git a/packages/effect/src/unstable/httpapi/HttpApiClient.ts b/packages/effect/src/unstable/httpapi/HttpApiClient.ts index 365329af41a..61939fd4417 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiClient.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiClient.ts @@ -17,6 +17,7 @@ import * as Effect from "../../Effect.ts" import { identity } from "../../Function.ts" import * as InternalRecord from "../../internal/record.ts" import * as Predicate from "../../Predicate.ts" +import * as Result from "../../Result.ts" import * as Schema from "../../Schema.ts" import * as SchemaAST from "../../SchemaAST.ts" import * as SchemaIssue from "../../SchemaIssue.ts" @@ -31,13 +32,13 @@ import * as HttpClientError from "../http/HttpClientError.ts" import * as HttpClientRequest from "../http/HttpClientRequest.ts" import * as HttpClientResponse from "../http/HttpClientResponse.ts" import * as HttpMethod from "../http/HttpMethod.ts" +import * as MediaType from "../http/MediaType.ts" import * as UrlParams from "../http/UrlParams.ts" import * as HttpApi from "./HttpApi.ts" import * as HttpApiEndpoint from "./HttpApiEndpoint.ts" import type * as HttpApiGroup from "./HttpApiGroup.ts" import type * as HttpApiMiddleware from "./HttpApiMiddleware.ts" import * as HttpApiSchema from "./HttpApiSchema.ts" -import * as MediaType from "./internal/mediaType.ts" /** * The type-safe client shape generated from HTTP API groups, with non-top-level @@ -780,7 +781,9 @@ function addResponseAlternative( contentType: string, decode: ResponseDecoder ) { - const normalizedContentType = MediaType.normalize(contentType) + const normalizedContentType = contentType === "" + ? "" + : MediaType.essence(MediaType.parseUnsafe(contentType)) const alternatives = map.get(status) if (alternatives === undefined) { map.set(status, [{ contentType: normalizedContentType, decode }]) @@ -795,7 +798,18 @@ function makeResponseDecoder(alternatives: ReadonlyArray): return first.decode } return (response) => { - const contentType = MediaType.normalize(response.headers["content-type"] ?? "") + const rawContentType = response.headers["content-type"] ?? "" + if (rawContentType === "") { + const alternative = alternatives.find((alternative) => alternative.contentType === "") + return alternative === undefined + ? failUnsupportedContentType(response, rawContentType, alternatives) + : alternative.decode(response) + } + const parsedContentType = MediaType.parse(rawContentType) + if (Result.isFailure(parsedContentType)) { + return failUnsupportedContentType(response, rawContentType, alternatives) + } + const contentType = MediaType.essence(parsedContentType.success) const alternative = alternatives.find((alternative) => alternative.contentType === contentType) return alternative === undefined ? failUnsupportedContentType(response, contentType, alternatives) @@ -811,7 +825,7 @@ function groupSchemasByContentType( const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema const contentType = HttpApiSchema.isNoContent(body.ast) ? "" - : MediaType.normalize(HttpApiSchema.getResponseEncodingSchema(schema).contentType) + : MediaType.essence(MediaType.parseUnsafe(HttpApiSchema.getResponseEncodingSchema(schema).contentType)) const existing = grouped.get(contentType) if (existing === undefined) { grouped.set(contentType, [schema]) diff --git a/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts b/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts index 331b4ff84e8..1039fb54ec2 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts @@ -27,11 +27,11 @@ import type { HttpMethod } from "../http/HttpMethod.ts" import * as HttpRouter from "../http/HttpRouter.ts" import type { HttpServerRequest } from "../http/HttpServerRequest.ts" import type { HttpServerResponse } from "../http/HttpServerResponse.ts" +import * as MediaType from "../http/MediaType.ts" import type * as Multipart from "../http/Multipart.ts" import type * as HttpApiGroup from "./HttpApiGroup.ts" import type * as HttpApiMiddleware from "./HttpApiMiddleware.ts" import * as HttpApiSchema from "./HttpApiSchema.ts" -import * as MediaType from "./internal/mediaType.ts" const TypeId = "~effect/httpapi/HttpApiEndpoint" @@ -1127,7 +1127,7 @@ function getPayload( for (const schema of schemas) { const encoding = HttpApiSchema.getPayloadEncoding(schema.ast, method) - const contentType = MediaType.normalize(encoding.contentType) + const contentType = MediaType.essence(MediaType.parseUnsafe(encoding.contentType)) const existing = result.get(contentType) if (existing) { if (existing.encoding._tag !== encoding._tag) { @@ -1205,7 +1205,7 @@ function validateSuccessResponse(schemas: ReadonlyArray, meth if (entry.noContent) { throw new Error(`Cannot combine no-content and streaming success responses for status: ${status}`) } - if (entry.bufferedContentTypes.has(MediaType.normalize(inner.contentType))) { + if (entry.bufferedContentTypes.has(MediaType.essence(MediaType.parseUnsafe(inner.contentType)))) { throw new Error( `Cannot combine buffered and streaming success responses for status ${status} and content-type: ${inner.contentType}` ) @@ -1220,7 +1220,8 @@ function validateSuccessResponse(schemas: ReadonlyArray, meth } const encoding = HttpApiSchema.getResponseEncodingSchema(schema) if ( - MediaType.normalize(encoding.contentType) === MediaType.normalize(entry.stream.contentType) + MediaType.essence(MediaType.parseUnsafe(encoding.contentType)) === + MediaType.essence(MediaType.parseUnsafe(entry.stream.contentType)) ) { throw new Error( `Cannot combine buffered and streaming success responses for status ${status} and content-type: ${encoding.contentType}` @@ -1229,7 +1230,7 @@ function validateSuccessResponse(schemas: ReadonlyArray, meth } if (!noContent) { entry.bufferedContentTypes.add( - MediaType.normalize(HttpApiSchema.getResponseEncodingSchema(schema).contentType) + MediaType.essence(MediaType.parseUnsafe(HttpApiSchema.getResponseEncodingSchema(schema).contentType)) ) } entry.noContent = entry.noContent || noContent @@ -1269,11 +1270,11 @@ function validateResponseExclusivity( const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : withHeadersAnnotation?.body ?? schema const contentType = HttpApiSchema.isNoContent(body.ast) ? "" - : MediaType.normalize( + : MediaType.essence(MediaType.parseUnsafe( HttpApiSchema.isStreamSchema(body) ? body.contentType : HttpApiSchema.getResponseEncodingSchema(schema).contentType - ) + )) let entry = statuses.get(status) if (entry === undefined) { entry = { headerContentType: undefined, plainContentTypes: new Set() } diff --git a/packages/effect/src/unstable/httpapi/internal/mediaType.ts b/packages/effect/src/unstable/httpapi/internal/mediaType.ts deleted file mode 100644 index 63bf85222ff..00000000000 --- a/packages/effect/src/unstable/httpapi/internal/mediaType.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** @internal */ -export function normalize(contentType: string): string { - const normalized = contentType.toLowerCase().trim() - const index = normalized.indexOf(";") - return index === -1 ? normalized : normalized.slice(0, index).trim() -} diff --git a/packages/effect/test/unstable/http/MediaType.test.ts b/packages/effect/test/unstable/http/MediaType.test.ts new file mode 100644 index 00000000000..ad21f0b0110 --- /dev/null +++ b/packages/effect/test/unstable/http/MediaType.test.ts @@ -0,0 +1,154 @@ +import { describe, it } from "@effect/vitest" +import { deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils" +import { Equal, Hash, Option, Result, Schema } from "effect" +import { MediaType } from "effect/unstable/http" + +const parse = (input: string) => Result.getOrThrow(MediaType.parse(input)) + +const assertFailure = ( + input: string, + reason: MediaType.MediaTypeParseErrorReason, + offset?: number +) => { + const result = MediaType.parse(input) + strictEqual(Result.isFailure(result), true) + if (Result.isFailure(result)) { + strictEqual(result.failure.reason, reason) + if (offset !== undefined) strictEqual(result.failure.offset, offset) + } +} + +describe("MediaType", () => { + it("parses and normalizes concrete media types", () => { + const mediaType = parse("\t Application/Vnd.Example+JSON ; Charset=utf-8; profile=Example \t") + strictEqual(mediaType.type, "application") + strictEqual(mediaType.subtype, "vnd.example+json") + strictEqual(Option.getOrUndefined(mediaType.suffix), "json") + deepStrictEqual(mediaType.parameters, [ + { name: "charset", value: "utf-8" }, + { name: "profile", value: "Example" } + ]) + strictEqual(MediaType.format(mediaType), "application/vnd.example+json; charset=utf-8; profile=Example") + }) + + it("distinguishes structured suffixes from broad HTTP token syntax", () => { + const structured = parse("application/vnd.example+json") + strictEqual(MediaType.baseSubtype(structured), "vnd.example") + strictEqual(Option.getOrUndefined(structured.suffix), "json") + + for (const input of ["application/+json", "application/vnd.*+json", "application/example+", "app*/problem+json"]) { + const mediaType = parse(input) + strictEqual(MediaType.baseSubtype(mediaType), mediaType.subtype) + strictEqual(Option.isNone(mediaType.suffix), true) + strictEqual(MediaType.hasSuffix(mediaType, "json"), false) + } + }) + + it("accepts every tchar and embedded stars but rejects wildcard ranges", () => { + const token = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + strictEqual(MediaType.essence(parse(`${token}/${token}`)), `${token.toLowerCase()}/${token.toLowerCase()}`) + strictEqual(MediaType.essence(parse("application/vnd.*+json")), "application/vnd.*+json") + assertFailure("*/*", "ExpectedType", 0) + assertFailure("text/*", "ExpectedSubtype", 5) + }) + + it("parses quoted values, escapes, empty separators, and obs-text", () => { + const mediaType = parse("text/plain;;; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\";") + strictEqual(MediaType.getParameter(mediaType, "b").pipe(Option.getOrUndefined), "a; b") + strictEqual(MediaType.getParameter(mediaType, "c").pipe(Option.getOrUndefined), "\"\\") + strictEqual(MediaType.getParameter(mediaType, "d").pipe(Option.getOrUndefined), "\tÿ") + strictEqual(MediaType.format(mediaType), "text/plain; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"") + }) + + it("rejects malformed input with a structured error", () => { + assertFailure("", "ExpectedType") + assertFailure("text", "ExpectedSlash") + assertFailure("text/", "ExpectedSubtype") + assertFailure("text /plain", "ExpectedSlash") + assertFailure("text/plain; charset =utf-8", "ExpectedEquals") + assertFailure("text/plain; charset=", "ExpectedParameterValue") + assertFailure("text/plain; charset=\"unterminated", "ExpectedParameterValue") + assertFailure("text/plain; charset=\"x\\\"", "ExpectedParameterValue") + assertFailure("text/plain; charset=\"x\r\nInjected: yes\"", "UnexpectedCharacter") + assertFailure("text/plain; charset=\"\u0000\"", "UnexpectedCharacter") + assertFailure("text/plain; charset=\"\u007f\"", "UnexpectedCharacter") + assertFailure("text/plain; charset=\"\\\u007f\"", "InvalidQuotedPair") + assertFailure("text/plain; charset=\"Ā\"", "UnexpectedCharacter") + assertFailure("text/plain garbage", "UnexpectedCharacter") + assertFailure("text/plain; A=1; a=2", "DuplicateParameter") + }) + + it("constructs immutable values and rejects invalid parts", () => { + const entries: Array = [["Profile", "a b"], ["charset", "utf-8"]] + const mediaType = Result.getOrThrow(MediaType.make({ type: "Text", subtype: "Plain", parameters: entries })) + entries.push(["later", "ignored"]) + strictEqual(MediaType.format(mediaType), "text/plain; charset=utf-8; profile=\"a b\"") + strictEqual(Object.isFrozen(mediaType), true) + strictEqual(Object.isFrozen(mediaType.parameters), true) + strictEqual(Result.isFailure(MediaType.make({ type: "text", subtype: "plain", parameters: { x: "Ā" } })), true) + }) + + it("recognizes only complete MediaType values", () => { + strictEqual(MediaType.isMediaType(MediaType.textPlain), true) + strictEqual(MediaType.isMediaType({ [MediaType.TypeId]: MediaType.TypeId }), false) + strictEqual(MediaType.isMediaType({ [MediaType.TypeId]: "MediaType" }), false) + }) + + it("uses parameter-aware equality and hashing", () => { + const left = parse("TEXT/PLAIN; B=two; a=one") + const right = parse("text/plain; a=\"one\"; b=two") + const different = parse("text/plain; a=ONE; b=two") + strictEqual(Equal.equals(left, right), true) + strictEqual(Hash.hash(left), Hash.hash(right)) + strictEqual(Equal.equals(left, different), false) + strictEqual(MediaType.sameEssence(left, different), true) + }) + + it("supports parameter, essence, and suffix predicates", () => { + const candidate = parse("application/problem+json; charset=utf-8; profile=errors") + const expected = parse("application/problem+json; charset=utf-8") + strictEqual(MediaType.matchesParameters(candidate, expected), true) + strictEqual(MediaType.matchesParameters(expected, candidate), false) + strictEqual(candidate.pipe(MediaType.isType("APPLICATION")), true) + strictEqual(MediaType.isSubtype(candidate, "problem+json"), true) + strictEqual(candidate.pipe(MediaType.hasSuffix("JSON")), true) + strictEqual(MediaType.hasParameter(candidate, "CHARSET"), true) + strictEqual(Option.isNone(MediaType.getParameter(candidate, "not valid")), true) + }) + + it("applies charset semantics without changing generic parameter identity", () => { + const upper = parse("text/plain; charset=UTF-8; profile=Example") + const lower = parse("text/plain; charset=utf-8; profile=Example") + strictEqual(Option.getOrUndefined(MediaType.getCharset(upper)), "utf-8") + strictEqual(MediaType.matchesParameters(upper, lower), true) + strictEqual(MediaType.matchesParameters(lower, upper), true) + strictEqual(Equal.equals(upper, lower), false) + + const differentProfile = parse("text/plain; charset=utf-8; profile=example") + strictEqual(MediaType.matchesParameters(upper, differentProfile), false) + }) + + it("classifies common media-type families", () => { + strictEqual(MediaType.isJson(MediaType.applicationJson), true) + strictEqual(MediaType.isJson(parse("application/problem+json")), true) + strictEqual(MediaType.isJson(parse("text/json")), true) + strictEqual(MediaType.isJson(parse("application/json-seq")), false) + strictEqual(MediaType.isXml(parse("application/atom+xml")), true) + strictEqual(MediaType.isXml(parse("text/xml")), true) + strictEqual(MediaType.isText(parse("text/event-stream")), true) + strictEqual(MediaType.isText(MediaType.applicationJson), false) + }) + + it("round trips through the string Schema", () => { + const decoded = Schema.decodeUnknownSync(Schema.MediaTypeFromString)("Text/Plain; z=\"a b\"; A=one") + strictEqual(MediaType.format(decoded), "text/plain; a=one; z=\"a b\"") + strictEqual(Schema.encodeSync(Schema.MediaTypeFromString)(decoded), "text/plain; a=one; z=\"a b\"") + strictEqual(Schema.is(Schema.MediaType)(decoded), true) + throws( + () => Schema.decodeUnknownSync(Schema.MediaTypeFromString)("not a media type"), + (error) => { + strictEqual(String(error).includes("ExpectedSlash at offset 3"), true) + } + ) + }) +}) diff --git a/packages/effect/test/unstable/http/Multipart.test.ts b/packages/effect/test/unstable/http/Multipart.test.ts index 50c0f979db2..cf649840711 100644 --- a/packages/effect/test/unstable/http/Multipart.test.ts +++ b/packages/effect/test/unstable/http/Multipart.test.ts @@ -11,6 +11,36 @@ import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondab import { deepStrictEqual, notStrictEqual, strictEqual } from "node:assert" describe("Multipart", () => { + it("parses quoted boundaries and normalized part content-type parameters", () => { + const boundary = "quoted-boundary" + const encoder = new TextEncoder() + const parts: Array = [] + const errors: Array = [] + const parser = MultipartParser.make({ + headers: { "content-type": `Multipart/Form-Data; boundary="${boundary}"` }, + onField(info) { + parts.push(info) + }, + onFile: () => () => {}, + onError(error) { + errors.push(error) + }, + onDone() {} + }) + + parser.write(encoder.encode( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="field"\r\n` + + `Content-Type: Text/Plain; Profile="a b"; Charset=UTF-8\r\n\r\n` + + `value\r\n--${boundary}--\r\n` + )) + parser.end() + + strictEqual(parts[0].contentType, "text/plain") + deepStrictEqual(parts[0].contentTypeParameters, { charset: "UTF-8", profile: "a b" }) + deepStrictEqual(errors, []) + }) + it.effect("schemaJson applies a JSON reviver", () => Effect.gen(function*() { const decoded = yield* Multipart.schemaJson(Schema.Struct({ value: Schema.String }), { diff --git a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts index eeedb9d185c..c9f1478da6f 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts @@ -1,5 +1,6 @@ import { assert, describe, it } from "@effect/vitest" import { Schema } from "effect" +import { MediaType } from "effect/unstable/http" import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi" const Events = Schema.Struct({ @@ -57,6 +58,18 @@ describe("HttpApiEndpoint payload schemas", () => { /Multiple payload encodings/ ) }) + + it("rejects malformed declared content types", () => { + const Payload = Schema.String.pipe(HttpApiSchema.asText({ contentType: "not a media type" })) + + let error: unknown + try { + HttpApiEndpoint.post("create", "/", { payload: Payload }) + } catch (cause) { + error = cause + } + assert.instanceOf(error, MediaType.MediaTypeParseError) + }) }) describe("HttpApiEndpoint streaming success schemas", () => { diff --git a/packages/effect/typetest/unstable/http/MediaType.tst.ts b/packages/effect/typetest/unstable/http/MediaType.tst.ts new file mode 100644 index 00000000000..d8bd4b645d5 --- /dev/null +++ b/packages/effect/typetest/unstable/http/MediaType.tst.ts @@ -0,0 +1,26 @@ +import type { Result } from "effect" +import { Schema } from "effect" +import { MediaType } from "effect/unstable/http" +import { describe, expect, it } from "tstyche" + +describe("MediaType", () => { + it("constructors and dual helpers preserve their public types", () => { + expect(MediaType.parse("text/plain")).type.toBe< + Result.Result + >() + expect(MediaType.parseUnsafe("text/plain")).type.toBe() + const mediaType = MediaType.textPlain + expect(MediaType.getParameter(mediaType, "charset")).type.toBe>() + expect(mediaType.pipe(MediaType.getParameter("charset"))).type.toBe>() + expect(MediaType.sameEssence(mediaType, mediaType)).type.toBe() + expect(mediaType.pipe(MediaType.sameEssence(mediaType))).type.toBe() + }) + + it("Schema types use the string representation without services", () => { + expect(Schema.MediaType.Type).type.toBe() + expect(Schema.MediaTypeFromString.Type).type.toBe() + expect>().type.toBe() + expect>().type.toBe() + expect>().type.toBe() + }) +}) From 2d7aa21f971aa92ea7f091740c2e5ccbdfdb1829 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Mon, 31 Aug 2026 19:59:23 +0200 Subject: [PATCH 02/15] Refine MediaType Schema integration --- .changeset/parsed-media-types.md | 2 +- packages/effect/src/Config.ts | 14 +++++++++++ packages/effect/src/Schema.ts | 21 ++++++++++++---- .../MultipartParser/internal/multipart.ts | 10 ++++---- packages/effect/test/Config.test.ts | 20 ++++++++++++++++ packages/effect/test/schema/Schema.test.ts | 24 +++++++++++++++++++ .../representation/builtInRevivers.test.ts | 9 +++++++ packages/effect/typetest/Config.tst.ts | 6 +++++ ...emaBuiltInAtomicDeclarationRevivers.tst.ts | 2 ++ .../typetest/unstable/http/MediaType.tst.ts | 4 ++-- 10 files changed, 99 insertions(+), 13 deletions(-) diff --git a/.changeset/parsed-media-types.md b/.changeset/parsed-media-types.md index 808e5795c40..0f28157141f 100644 --- a/.changeset/parsed-media-types.md +++ b/.changeset/parsed-media-types.md @@ -2,4 +2,4 @@ "effect": patch --- -Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, and Schema codecs. HTTP API content-type dispatch and multipart parsing now compare validated media-type essences while preserving string-facing transport APIs; malformed request content types continue to receive a 415 response. +Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. HTTP API content-type dispatch and multipart parsing now compare validated media-type essences while preserving string-facing transport APIs; malformed request content types continue to receive a 415 response. diff --git a/packages/effect/src/Config.ts b/packages/effect/src/Config.ts index c6eadaba48c..03c49586ed7 100644 --- a/packages/effect/src/Config.ts +++ b/packages/effect/src/Config.ts @@ -1531,6 +1531,20 @@ export function URL(name?: string) { return schema(Schema.URL, name) } +/** + * Creates a config for a normalized HTTP media type parsed from a string. + * + * **Details** + * + * This is a shortcut for `Config.schema(Schema.MediaType, name)`. + * + * @category constructors + * @since 4.0.0 + */ +export function MediaType(name?: string) { + return schema(Schema.MediaType, name) +} + /** * Creates a config for a `Date` value parsed from a string. * diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 366c7dc76bb..13740301c41 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -11895,10 +11895,12 @@ export const URLFromString: URLFromString = URLString.pipe(decodeTo(URL, SchemaT * @category models * @since 4.0.0 */ -export interface MediaType extends declare { +export interface MediaType extends declare { readonly "Rebuild": MediaType } +const MediaTypeString = String.annotate({ expected: "a string that will be decoded as an HTTP media type" }) + const mediaTypeTransformation = SchemaTransformation.transformOrFail({ decode: (input: string, options) => { const result = MediaType_.parse(input) @@ -11924,7 +11926,7 @@ const mediaTypeTransformation = SchemaTransformation.transformOrFail({ * @since 4.0.0 */ export const MediaType: MediaType = declare(MediaType_.isMediaType, { - representation: { id: "effect/http/MediaType", payload: null }, + representation: { id: "effect/schema/MediaType", payload: null }, toCode: () => ({ runtime: "Schema.MediaType", Type: "MediaType.MediaType", @@ -11932,9 +11934,20 @@ export const MediaType: MediaType = declare(MediaType_.isMediaType, { }), expected: "MediaType", toEquivalence: () => MediaType_.Equivalence, - toCodec: () => link()(String, mediaTypeTransformation) + toCodecJson: () => link()(MediaTypeString, mediaTypeTransformation) }) +/** + * Reviver for persisted {@link MediaType} declarations. + * + * @category schemas + * @since 4.0.0 + */ +export const MediaTypeReviver = makeFixedDeclarationReviver( + "effect/schema/MediaType", + MediaType +) + /** * Type-level representation of {@link MediaTypeFromString}. * @@ -11953,7 +11966,7 @@ export interface MediaTypeFromString extends decodeTo { * @category schemas * @since 4.0.0 */ -export const MediaTypeFromString: MediaTypeFromString = String.pipe( +export const MediaTypeFromString: MediaTypeFromString = MediaTypeString.pipe( decodeTo(MediaType, mediaTypeTransformation) ) diff --git a/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts b/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts index bbcbdcacb9e..505f7a419ba 100644 --- a/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts +++ b/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts @@ -1,3 +1,4 @@ +import * as Option from "../../../../Option.ts" import * as Result from "../../../../Result.ts" import * as MediaType from "../../MediaType.ts" import type { Config, MultipartError, PartInfo } from "../../MultipartParser.ts" @@ -37,12 +38,9 @@ export function defaultIsFile(info: PartInfo) { } function parseBoundary(headers: Record) { - const contentType = MediaType.parse(headers["content-type"] ?? "") - return Result.isSuccess(contentType) - ? MediaType.getParameter(contentType.success, "boundary").pipe((option) => - option._tag === "Some" ? option.value : undefined - ) - : undefined + const contentType = Result.getOrUndefined(MediaType.parse(headers["content-type"] ?? "")) + if (contentType === undefined) return undefined + return Option.getOrUndefined(MediaType.getParameter(contentType, "boundary")) } function noopOnChunk(_chunk: Uint8Array | null) {} diff --git a/packages/effect/test/Config.test.ts b/packages/effect/test/Config.test.ts index a412febfc5b..bec219b8e39 100644 --- a/packages/effect/test/Config.test.ts +++ b/packages/effect/test/Config.test.ts @@ -13,6 +13,7 @@ import { SchemaIssue, SchemaTransformation } from "effect" +import { MediaType } from "effect/unstable/http" import { vi } from "vitest" import type * as ConfigProviderModule from "../src/ConfigProvider.ts" @@ -211,6 +212,25 @@ describe("Config", () => { at ["failure"]` ) }) + + it("media type decodes normalized values and reports invalid input", async () => { + const provider = ConfigProvider.fromUnknown({ + mediaType: "Text/Plain; Charset=UTF-8", + invalid: "not a media type" + }) + + await assertSuccess( + Config.MediaType("mediaType"), + provider, + MediaType.parseUnsafe("text/plain; charset=UTF-8") + ) + await assertFailure( + Config.MediaType("invalid"), + provider, + `ExpectedSlash at offset 3 + at ["invalid"]` + ) + }) }) describe("combinators", () => { diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index dde3c3d710f..212e425a47d 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -33,6 +33,7 @@ import { Tuple } from "effect" import { TestSchema } from "effect/testing" +import { MediaType } from "effect/unstable/http" import { produce } from "immer" import { deepStrictEqual, fail, strictEqual } from "node:assert" import { @@ -5777,6 +5778,21 @@ Expected a value between -2147483648 and 2147483647` } }) + it("MediaType", async () => { + const mediaType = MediaType.parseUnsafe("text/plain; charset=UTF-8") + const asserts = new TestSchema.Asserts(Schema.MediaType) + await asserts.decoding().succeed(mediaType) + await asserts.decoding().fail("text/plain", "Expected MediaType") + + const json = new TestSchema.Asserts(Schema.toCodecJson(Schema.MediaType)) + await json.decoding().succeed("Text/Plain; Charset=UTF-8", mediaType) + await json.encoding().succeed(mediaType, "text/plain; charset=UTF-8") + + const stringTree = new TestSchema.Asserts(Schema.toCodecStringTree(Schema.MediaType)) + await stringTree.decoding().succeed("Text/Plain; Charset=UTF-8", mediaType) + await stringTree.encoding().succeed(mediaType, "text/plain; charset=UTF-8") + }) + it("RegExp", async () => { const schema = Schema.RegExp const asserts = new TestSchema.Asserts(schema) @@ -5804,6 +5820,14 @@ Expected a value between -2147483648 and 2147483647` await encoding.succeed(new URL("https://effect.website"), "https://effect.website/") }) + it("MediaTypeFromString", async () => { + const mediaType = MediaType.parseUnsafe("text/plain; charset=UTF-8") + const asserts = new TestSchema.Asserts(Schema.MediaTypeFromString) + await asserts.decoding().succeed("Text/Plain; Charset=UTF-8", mediaType) + await asserts.decoding().fail("not a media type", "ExpectedSlash at offset 3") + await asserts.encoding().succeed(mediaType, "text/plain; charset=UTF-8") + }) + describe("UnknownFromJsonString / fromJsonString", () => { it("use case: Unknown <-> JSON string", async () => { const schema = Schema.UnknownFromJsonString diff --git a/packages/effect/test/schema/representation/builtInRevivers.test.ts b/packages/effect/test/schema/representation/builtInRevivers.test.ts index 07743add6bd..a73d6952486 100644 --- a/packages/effect/test/schema/representation/builtInRevivers.test.ts +++ b/packages/effect/test/schema/representation/builtInRevivers.test.ts @@ -912,6 +912,15 @@ describe("SchemaRepresentation built-in declaration revivers", () => { }) }) + it("revives MediaType", () => { + assertDeclarationReviver({ + schema: Schema.MediaType, + id: "effect/schema/MediaType", + payload: null, + reviver: Schema.MediaTypeReviver + }) + }) + it("revives Date", () => { assertDeclarationReviver({ schema: Schema.Date, diff --git a/packages/effect/typetest/Config.tst.ts b/packages/effect/typetest/Config.tst.ts index ea8a3450bcd..84d306ff6db 100644 --- a/packages/effect/typetest/Config.tst.ts +++ b/packages/effect/typetest/Config.tst.ts @@ -1,4 +1,5 @@ import { Config, ConfigProvider, Schema } from "effect" +import type { MediaType } from "effect/unstable/http" import { describe, expect, it } from "tstyche" describe("Config", () => { @@ -47,6 +48,11 @@ describe("Config", () => { expect(withPath).type.toBe>>() }) + it("MediaType", () => { + expect(Config.MediaType()).type.toBe>() + expect(Config.MediaType("CONTENT_TYPE")).type.toBe>() + }) + it("parse", () => { const config = Config.String("a") const provider = ConfigProvider.fromUnknown({ a: "value" }) diff --git a/packages/effect/typetest/schema/SchemaBuiltInAtomicDeclarationRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInAtomicDeclarationRevivers.tst.ts index 6eb183e48e1..f53e3b6e191 100644 --- a/packages/effect/typetest/schema/SchemaBuiltInAtomicDeclarationRevivers.tst.ts +++ b/packages/effect/typetest/schema/SchemaBuiltInAtomicDeclarationRevivers.tst.ts @@ -5,6 +5,7 @@ describe("Schema built-in atomic declaration revivers", () => { it("composes every atomic declaration reviver without casts", () => { const revivers: ReadonlyArray = [ Schema.DateReviver, + Schema.MediaTypeReviver, Schema.FileReviver, Schema.FormDataReviver, Schema.RegExpReviver, @@ -15,6 +16,7 @@ describe("Schema built-in atomic declaration revivers", () => { expect(revivers).type.toBe>() expect(Schema.DateReviver).type.toBe>() + expect(Schema.MediaTypeReviver).type.toBe>() expect(Schema.FileReviver).type.toBe>() expect(Schema.FormDataReviver).type.toBe>() }) diff --git a/packages/effect/typetest/unstable/http/MediaType.tst.ts b/packages/effect/typetest/unstable/http/MediaType.tst.ts index d8bd4b645d5..fbbbbb92f64 100644 --- a/packages/effect/typetest/unstable/http/MediaType.tst.ts +++ b/packages/effect/typetest/unstable/http/MediaType.tst.ts @@ -1,5 +1,4 @@ -import type { Result } from "effect" -import { Schema } from "effect" +import { type Result, Schema } from "effect" import { MediaType } from "effect/unstable/http" import { describe, expect, it } from "tstyche" @@ -18,6 +17,7 @@ describe("MediaType", () => { it("Schema types use the string representation without services", () => { expect(Schema.MediaType.Type).type.toBe() + expect(Schema.MediaType.Iso).type.toBe() expect(Schema.MediaTypeFromString.Type).type.toBe() expect>().type.toBe() expect>().type.toBe() From ac9c3c3bae311f0d33869d32c8b7f7d68a7bd61c Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Mon, 31 Aug 2026 20:29:37 +0200 Subject: [PATCH 03/15] Prune redundant MediaType tests --- .../effect/src/unstable/http/MediaType.ts | 48 ++++--------------- .../test/unstable/http/MediaType.test.ts | 23 ++------- packages/effect/typetest/Config.tst.ts | 1 - .../typetest/unstable/http/MediaType.tst.ts | 10 ++-- 4 files changed, 17 insertions(+), 65 deletions(-) diff --git a/packages/effect/src/unstable/http/MediaType.ts b/packages/effect/src/unstable/http/MediaType.ts index 6eaf8f21887..1c01b9b4397 100644 --- a/packages/effect/src/unstable/http/MediaType.ts +++ b/packages/effect/src/unstable/http/MediaType.ts @@ -120,40 +120,7 @@ export class MediaTypeParseError extends Data.TaggedError("MediaTypeParseError") * @category guards * @since 4.0.0 */ -export const isMediaType = (input: unknown): input is MediaType => { - if (!Predicate.hasProperty(input, TypeId)) return false - const candidate = input as Partial - if ( - candidate[TypeId] !== TypeId || - typeof candidate.type !== "string" || candidate.type !== candidate.type.toLowerCase() || - !isToken(candidate.type) || candidate.type === "*" || - typeof candidate.subtype !== "string" || candidate.subtype !== candidate.subtype.toLowerCase() || - !isToken(candidate.subtype) || candidate.subtype === "*" || - !Option.isOption(candidate.suffix) || !Array.isArray(candidate.parameters) || - typeof candidate.pipe !== "function" || typeof candidate[Equal.symbol] !== "function" || - typeof candidate[Hash.symbol] !== "function" || typeof candidate.toJSON !== "function" || - typeof candidate[Inspectable.NodeInspectSymbol] !== "function" - ) return false - - const expectedSuffix = suffixOf(candidate.type, candidate.subtype) - if ( - Option.isSome(expectedSuffix) - ? !Option.isSome(candidate.suffix) || candidate.suffix.value !== expectedSuffix.value - : !Option.isNone(candidate.suffix) - ) return false - - let previousName: string | undefined - for (const parameter of candidate.parameters) { - if ( - typeof parameter !== "object" || parameter === null || - typeof parameter.name !== "string" || parameter.name !== parameter.name.toLowerCase() || - !isToken(parameter.name) || typeof parameter.value !== "string" || !isDecodedValue(parameter.value) || - (previousName !== undefined && previousName >= parameter.name) - ) return false - previousName = parameter.name - } - return true -} +export const isMediaType = (input: unknown): input is MediaType => Predicate.hasProperty(input, TypeId) const parametersEqual = (left: ReadonlyArray, right: ReadonlyArray): boolean => { if (left.length !== right.length) return false @@ -605,32 +572,35 @@ export const isText = (self: MediaType): boolean => self.type === "text" * @category constants * @since 4.0.0 */ -export const applicationJson: MediaType = parseUnsafe("application/json") +export const applicationJson: MediaType = makeUnsafe({ type: "application", subtype: "json" }) /** * The `application/octet-stream` media type. * * @category constants * @since 4.0.0 */ -export const applicationOctetStream: MediaType = parseUnsafe("application/octet-stream") +export const applicationOctetStream: MediaType = makeUnsafe({ type: "application", subtype: "octet-stream" }) /** * The `application/x-www-form-urlencoded` media type. * * @category constants * @since 4.0.0 */ -export const applicationFormUrlEncoded: MediaType = parseUnsafe("application/x-www-form-urlencoded") +export const applicationFormUrlEncoded: MediaType = makeUnsafe({ + type: "application", + subtype: "x-www-form-urlencoded" +}) /** * The `multipart/form-data` media type. * * @category constants * @since 4.0.0 */ -export const multipartFormData: MediaType = parseUnsafe("multipart/form-data") +export const multipartFormData: MediaType = makeUnsafe({ type: "multipart", subtype: "form-data" }) /** * The `text/plain` media type. * * @category constants * @since 4.0.0 */ -export const textPlain: MediaType = parseUnsafe("text/plain") +export const textPlain: MediaType = makeUnsafe({ type: "text", subtype: "plain" }) diff --git a/packages/effect/test/unstable/http/MediaType.test.ts b/packages/effect/test/unstable/http/MediaType.test.ts index ad21f0b0110..6b9b815511d 100644 --- a/packages/effect/test/unstable/http/MediaType.test.ts +++ b/packages/effect/test/unstable/http/MediaType.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "@effect/vitest" -import { deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils" -import { Equal, Hash, Option, Result, Schema } from "effect" +import { deepStrictEqual, strictEqual } from "@effect/vitest/utils" +import { Equal, Hash, Option, Result } from "effect" import { MediaType } from "effect/unstable/http" const parse = (input: string) => Result.getOrThrow(MediaType.parse(input)) @@ -88,10 +88,10 @@ describe("MediaType", () => { strictEqual(Result.isFailure(MediaType.make({ type: "text", subtype: "plain", parameters: { x: "Ā" } })), true) }) - it("recognizes only complete MediaType values", () => { + it("recognizes branded MediaType values", () => { strictEqual(MediaType.isMediaType(MediaType.textPlain), true) - strictEqual(MediaType.isMediaType({ [MediaType.TypeId]: MediaType.TypeId }), false) - strictEqual(MediaType.isMediaType({ [MediaType.TypeId]: "MediaType" }), false) + strictEqual(MediaType.isMediaType({ [MediaType.TypeId]: MediaType.TypeId }), true) + strictEqual(MediaType.isMediaType({}), false) }) it("uses parameter-aware equality and hashing", () => { @@ -138,17 +138,4 @@ describe("MediaType", () => { strictEqual(MediaType.isText(parse("text/event-stream")), true) strictEqual(MediaType.isText(MediaType.applicationJson), false) }) - - it("round trips through the string Schema", () => { - const decoded = Schema.decodeUnknownSync(Schema.MediaTypeFromString)("Text/Plain; z=\"a b\"; A=one") - strictEqual(MediaType.format(decoded), "text/plain; a=one; z=\"a b\"") - strictEqual(Schema.encodeSync(Schema.MediaTypeFromString)(decoded), "text/plain; a=one; z=\"a b\"") - strictEqual(Schema.is(Schema.MediaType)(decoded), true) - throws( - () => Schema.decodeUnknownSync(Schema.MediaTypeFromString)("not a media type"), - (error) => { - strictEqual(String(error).includes("ExpectedSlash at offset 3"), true) - } - ) - }) }) diff --git a/packages/effect/typetest/Config.tst.ts b/packages/effect/typetest/Config.tst.ts index 84d306ff6db..f6b1aa74f95 100644 --- a/packages/effect/typetest/Config.tst.ts +++ b/packages/effect/typetest/Config.tst.ts @@ -49,7 +49,6 @@ describe("Config", () => { }) it("MediaType", () => { - expect(Config.MediaType()).type.toBe>() expect(Config.MediaType("CONTENT_TYPE")).type.toBe>() }) diff --git a/packages/effect/typetest/unstable/http/MediaType.tst.ts b/packages/effect/typetest/unstable/http/MediaType.tst.ts index fbbbbb92f64..a73df5f0d54 100644 --- a/packages/effect/typetest/unstable/http/MediaType.tst.ts +++ b/packages/effect/typetest/unstable/http/MediaType.tst.ts @@ -1,18 +1,14 @@ -import { type Result, Schema } from "effect" +import { type Option, type Result, Schema } from "effect" import { MediaType } from "effect/unstable/http" import { describe, expect, it } from "tstyche" describe("MediaType", () => { - it("constructors and dual helpers preserve their public types", () => { + it("parse errors and data-last parameter lookup preserve their public types", () => { expect(MediaType.parse("text/plain")).type.toBe< Result.Result >() - expect(MediaType.parseUnsafe("text/plain")).type.toBe() const mediaType = MediaType.textPlain - expect(MediaType.getParameter(mediaType, "charset")).type.toBe>() - expect(mediaType.pipe(MediaType.getParameter("charset"))).type.toBe>() - expect(MediaType.sameEssence(mediaType, mediaType)).type.toBe() - expect(mediaType.pipe(MediaType.sameEssence(mediaType))).type.toBe() + expect(mediaType.pipe(MediaType.getParameter("charset"))).type.toBe>() }) it("Schema types use the string representation without services", () => { From de3e73a1be92063bc9dd6dbd0aeab0873d3bc99c Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Mon, 31 Aug 2026 21:56:49 +0200 Subject: [PATCH 04/15] Improve parser logic --- .../effect/src/unstable/http/MediaType.ts | 18 ++++++++------ .../test/unstable/http/MediaType.test.ts | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/effect/src/unstable/http/MediaType.ts b/packages/effect/src/unstable/http/MediaType.ts index 1c01b9b4397..4956cbec4b6 100644 --- a/packages/effect/src/unstable/http/MediaType.ts +++ b/packages/effect/src/unstable/http/MediaType.ts @@ -178,10 +178,14 @@ const isTchar = (code: number): boolean => code === 33 || code === 35 || code === 36 || code === 37 || code === 38 || code === 39 || code === 42 || code === 43 || code === 45 || code === 46 || code === 94 || code === 95 || code === 96 || code === 124 || code === 126 +const tokenEnd = (input: string, start: number): number => { + let end = start + while (end < input.length && isTchar(input.charCodeAt(end))) end++ + return end +} + const isToken = (value: string): boolean => { - if (value.length === 0) return false - for (let i = 0; i < value.length; i++) if (!isTchar(value.charCodeAt(i))) return false - return true + return value.length > 0 && tokenEnd(value, 0) === value.length } const isDecodedValue = (value: string): boolean => { @@ -288,14 +292,14 @@ export const parse = (input: string): Result.Result { strictEqual(MediaType.format(mediaType), "text/plain; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"") }) + it("handles strict vectors from Go and WHATWG parser corpora", () => { + const vectors = [ + ["text/plain; empty=\"\"", "text/plain; empty=\"\""], + ["application/pdf; name=\"Here's a semicolon;.pdf\"", "application/pdf; name=\"Here's a semicolon;.pdf\""], + ["text/plain; charset=utf-8 \t", "text/plain; charset=utf-8"] + ] as const + for (const [input, expected] of vectors) { + strictEqual(MediaType.format(parse(input)), expected) + } + }) + + it("preserves intentional differences from Go and WHATWG parsers", () => { + // Go's MIME grammar accepts braces and equal duplicate parameters; RFC 9110 does not. + assertFailure("text/plain; filename={file}.txt", "ExpectedParameterValue") + assertFailure("text/plain; charset=utf-8; charset=utf-8", "DuplicateParameter") + // Go preserves unnecessary backslashes for legacy IE paths; RFC quoted-pair decodes them. + strictEqual(MediaType.format(parse("text/plain; escaped=\"foo\\xbar\"")), "text/plain; escaped=fooxbar") + // WHATWG recovers from malformed parameters; this parser validates the complete input. + assertFailure("text/html; charset=\"shift_jis\"iso-2022-jp", "UnexpectedCharacter") + assertFailure("text/plain; charset=utf-8; broken", "ExpectedEquals") + // HTTP OWS is SP / HTAB, not arbitrary Unicode whitespace. + assertFailure("text/plain;\u00a0charset=utf-8", "ExpectedParameterName") + }) + it("rejects malformed input with a structured error", () => { assertFailure("", "ExpectedType") assertFailure("text", "ExpectedSlash") From 2ae530ae8f8890ad71aa456caee641588acb98c3 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Mon, 31 Aug 2026 22:31:41 +0200 Subject: [PATCH 05/15] Simplify error messages --- packages/effect/src/Schema.ts | 2 +- .../effect/src/unstable/http/MediaType.ts | 71 ++++++++----------- packages/effect/test/schema/Schema.test.ts | 2 +- .../test/unstable/http/MediaType.test.ts | 49 ++++++------- 4 files changed, 55 insertions(+), 69 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 13740301c41..1eb87ce99f8 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -11907,7 +11907,7 @@ const mediaTypeTransformation = SchemaTransformation.transformOrFail({ return Result_.isFailure(result) ? Effect.fail( new SchemaIssue.InvalidValue( - { message: `${result.failure.reason} at offset ${result.failure.offset}` }, + { message: result.failure.message }, input, options ) diff --git a/packages/effect/src/unstable/http/MediaType.ts b/packages/effect/src/unstable/http/MediaType.ts index 4956cbec4b6..ca5700ddec6 100644 --- a/packages/effect/src/unstable/http/MediaType.ts +++ b/packages/effect/src/unstable/http/MediaType.ts @@ -81,23 +81,6 @@ export interface MediaType extends Equal.Equal, Pipeable.Pipeable, Inspectable.I readonly parameters: ReadonlyArray } -/** - * Reason that parsing or construction of a media type failed. - * - * @category errors - * @since 4.0.0 - */ -export type MediaTypeParseErrorReason = - | "ExpectedType" - | "ExpectedSlash" - | "ExpectedSubtype" - | "ExpectedParameterName" - | "ExpectedEquals" - | "ExpectedParameterValue" - | "InvalidQuotedPair" - | "DuplicateParameter" - | "UnexpectedCharacter" - /** * Describes a media type parse failure at an offset in the original input. * @@ -107,12 +90,8 @@ export type MediaTypeParseErrorReason = export class MediaTypeParseError extends Data.TaggedError("MediaTypeParseError")<{ readonly input: string readonly offset: number - readonly reason: MediaTypeParseErrorReason -}> { - override get message(): string { - return `${this.reason} at offset ${this.offset}` - } -} + readonly message: string +}> {} /** * Returns `true` if the provided value is a `MediaType` value. @@ -232,8 +211,8 @@ const fromValidated = (type: string, subtype: string, parameters: Array - Result.fail(new MediaTypeParseError({ input, offset, reason })) +const fail = (input: string, offset: number, message: string) => + Result.fail(new MediaTypeParseError({ input, offset, message: `${message} at offset ${offset}` })) /** * Creates a concrete media type from validated parts. @@ -243,8 +222,10 @@ const fail = (input: string, offset: number, reason: MediaTypeParseErrorReason) */ export const make = (parts: Parts): Result.Result => { const input = `${parts.type}/${parts.subtype}` - if (!isToken(parts.type) || parts.type === "*") return fail(input, 0, "ExpectedType") - if (!isToken(parts.subtype) || parts.subtype === "*") return fail(input, parts.type.length + 1, "ExpectedSubtype") + if (!isToken(parts.type) || parts.type === "*") return fail(input, 0, "Expected a valid media type") + if (!isToken(parts.subtype) || parts.subtype === "*") { + return fail(input, parts.type.length + 1, "Expected a valid media subtype") + } const parameters: Array = [] const names = new Set() const entries = parts.parameters === undefined @@ -254,9 +235,9 @@ export const make = (parts: Parts): Result.Result = [] const names = new Set() while (true) { index = skipOws(input, index) if (index === length) return Result.succeed(fromValidated(type, subtype, parameters)) - if (input.charCodeAt(index) !== 59) return fail(input, index, "UnexpectedCharacter") + if (input.charCodeAt(index) !== 59) { + return fail(input, index, `Unexpected character ${JSON.stringify(input[index])}`) + } index = skipOws(input, index + 1) if (index === length || input.charCodeAt(index) === 59) continue const nameStart = index index = tokenEnd(input, index) - if (index === nameStart) return fail(input, index, "ExpectedParameterName") + if (index === nameStart) return fail(input, index, "Expected a parameter name after ';'") const name = input.slice(nameStart, index).toLowerCase() - if (names.has(name)) return fail(input, nameStart, "DuplicateParameter") - if (input.charCodeAt(index) !== 61) return fail(input, index, "ExpectedEquals") + if (input.charCodeAt(index) !== 61) { + return fail(input, index, `Expected '=' after parameter ${JSON.stringify(name)}`) + } index++ let value = "" @@ -338,7 +322,7 @@ export const parse = (input: string): Result.Result= length || (escaped !== 9 && escaped !== 32 && (escaped < 33 || escaped === 127 || escaped > 255)) ) { - return fail(input, index, "InvalidQuotedPair") + return fail(input, index, `Invalid escape in parameter ${JSON.stringify(name)}`) } value += input[index + 1] index += 2 @@ -348,17 +332,18 @@ export const parse = (input: string): Result.Result 91) && (code < 93 || code > 126) && (code < 128 || code > 255) ) { - return fail(input, index, "UnexpectedCharacter") + return fail(input, index, `Invalid character in parameter ${JSON.stringify(name)}`) } value += input[index++] } - if (!closed) return fail(input, index, "ExpectedParameterValue") + if (!closed) return fail(input, index, `Unterminated quoted value for parameter ${JSON.stringify(name)}`) } else { const valueStart = index index = tokenEnd(input, index) - if (index === valueStart) return fail(input, index, "ExpectedParameterValue") + if (index === valueStart) return fail(input, index, `Expected a value for parameter ${JSON.stringify(name)}`) value = input.slice(valueStart, index) } + if (names.has(name)) return fail(input, nameStart, `Duplicate parameter ${JSON.stringify(name)}`) names.add(name) parameters.push({ name, value }) } diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 212e425a47d..ebf1b900cbb 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -5824,7 +5824,7 @@ Expected a value between -2147483648 and 2147483647` const mediaType = MediaType.parseUnsafe("text/plain; charset=UTF-8") const asserts = new TestSchema.Asserts(Schema.MediaTypeFromString) await asserts.decoding().succeed("Text/Plain; Charset=UTF-8", mediaType) - await asserts.decoding().fail("not a media type", "ExpectedSlash at offset 3") + await asserts.decoding().fail("not a media type", "Expected '/' after the media type at offset 3") await asserts.encoding().succeed(mediaType, "text/plain; charset=UTF-8") }) diff --git a/packages/effect/test/unstable/http/MediaType.test.ts b/packages/effect/test/unstable/http/MediaType.test.ts index 671b90fd22b..71c7973ee71 100644 --- a/packages/effect/test/unstable/http/MediaType.test.ts +++ b/packages/effect/test/unstable/http/MediaType.test.ts @@ -7,13 +7,13 @@ const parse = (input: string) => Result.getOrThrow(MediaType.parse(input)) const assertFailure = ( input: string, - reason: MediaType.MediaTypeParseErrorReason, + message: string, offset?: number ) => { const result = MediaType.parse(input) strictEqual(Result.isFailure(result), true) if (Result.isFailure(result)) { - strictEqual(result.failure.reason, reason) + strictEqual(result.failure.message, `${message} at offset ${result.failure.offset}`) if (offset !== undefined) strictEqual(result.failure.offset, offset) } } @@ -48,8 +48,8 @@ describe("MediaType", () => { const token = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" strictEqual(MediaType.essence(parse(`${token}/${token}`)), `${token.toLowerCase()}/${token.toLowerCase()}`) strictEqual(MediaType.essence(parse("application/vnd.*+json")), "application/vnd.*+json") - assertFailure("*/*", "ExpectedType", 0) - assertFailure("text/*", "ExpectedSubtype", 5) + assertFailure("*/*", "Media type cannot be a wildcard", 0) + assertFailure("text/*", "Media subtype cannot be a wildcard", 5) }) it("parses quoted values, escapes, empty separators, and obs-text", () => { @@ -73,33 +73,34 @@ describe("MediaType", () => { it("preserves intentional differences from Go and WHATWG parsers", () => { // Go's MIME grammar accepts braces and equal duplicate parameters; RFC 9110 does not. - assertFailure("text/plain; filename={file}.txt", "ExpectedParameterValue") - assertFailure("text/plain; charset=utf-8; charset=utf-8", "DuplicateParameter") + assertFailure("text/plain; filename={file}.txt", "Expected a value for parameter \"filename\"") + assertFailure("text/plain; charset=utf-8; charset=utf-8", "Duplicate parameter \"charset\"") // Go preserves unnecessary backslashes for legacy IE paths; RFC quoted-pair decodes them. strictEqual(MediaType.format(parse("text/plain; escaped=\"foo\\xbar\"")), "text/plain; escaped=fooxbar") // WHATWG recovers from malformed parameters; this parser validates the complete input. - assertFailure("text/html; charset=\"shift_jis\"iso-2022-jp", "UnexpectedCharacter") - assertFailure("text/plain; charset=utf-8; broken", "ExpectedEquals") + assertFailure("text/html; charset=\"shift_jis\"iso-2022-jp", "Unexpected character \"i\"") + assertFailure("text/plain; charset=utf-8; broken", "Expected '=' after parameter \"broken\"") // HTTP OWS is SP / HTAB, not arbitrary Unicode whitespace. - assertFailure("text/plain;\u00a0charset=utf-8", "ExpectedParameterName") + assertFailure("text/plain;\u00a0charset=utf-8", "Expected a parameter name after ';'") }) it("rejects malformed input with a structured error", () => { - assertFailure("", "ExpectedType") - assertFailure("text", "ExpectedSlash") - assertFailure("text/", "ExpectedSubtype") - assertFailure("text /plain", "ExpectedSlash") - assertFailure("text/plain; charset =utf-8", "ExpectedEquals") - assertFailure("text/plain; charset=", "ExpectedParameterValue") - assertFailure("text/plain; charset=\"unterminated", "ExpectedParameterValue") - assertFailure("text/plain; charset=\"x\\\"", "ExpectedParameterValue") - assertFailure("text/plain; charset=\"x\r\nInjected: yes\"", "UnexpectedCharacter") - assertFailure("text/plain; charset=\"\u0000\"", "UnexpectedCharacter") - assertFailure("text/plain; charset=\"\u007f\"", "UnexpectedCharacter") - assertFailure("text/plain; charset=\"\\\u007f\"", "InvalidQuotedPair") - assertFailure("text/plain; charset=\"Ā\"", "UnexpectedCharacter") - assertFailure("text/plain garbage", "UnexpectedCharacter") - assertFailure("text/plain; A=1; a=2", "DuplicateParameter") + assertFailure("", "Expected a media type") + assertFailure("text", "Expected '/' after the media type") + assertFailure("text/", "Expected a media subtype after '/'") + assertFailure("text /plain", "Expected '/' after the media type") + assertFailure("text/plain; charset =utf-8", "Expected '=' after parameter \"charset\"") + assertFailure("text/plain; charset=", "Expected a value for parameter \"charset\"") + assertFailure("text/plain; charset=\"unterminated", "Unterminated quoted value for parameter \"charset\"") + assertFailure("text/plain; charset=\"x\\\"", "Unterminated quoted value for parameter \"charset\"") + assertFailure("text/plain; charset=\"x\r\nInjected: yes\"", "Invalid character in parameter \"charset\"") + assertFailure("text/plain; charset=\"\u0000\"", "Invalid character in parameter \"charset\"") + assertFailure("text/plain; charset=\"\u007f\"", "Invalid character in parameter \"charset\"") + assertFailure("text/plain; charset=\"\\\u007f\"", "Invalid escape in parameter \"charset\"") + assertFailure("text/plain; charset=\"Ā\"", "Invalid character in parameter \"charset\"") + assertFailure("text/plain garbage", "Unexpected character \"g\"") + assertFailure("text/plain; A=1; a=2", "Duplicate parameter \"a\"") + assertFailure("text/plain; a=1; a", "Expected '=' after parameter \"a\"") }) it("constructs immutable values and rejects invalid parts", () => { From a1a9e0b31b0edd765c473807cb15e7d7a057b4c8 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 00:35:43 +0200 Subject: [PATCH 06/15] Integrate MediaType with HttpApiSchema --- .changeset/parsed-media-types.md | 2 +- .../src/unstable/httpapi/HttpApiClient.ts | 11 +- .../src/unstable/httpapi/HttpApiEndpoint.ts | 18 +-- .../src/unstable/httpapi/HttpApiSchema.ts | 133 +++++++++++------- .../unstable/httpapi/HttpApiEndpoint.test.ts | 27 ++-- .../unstable/httpapi/HttpApiSchema.test.ts | 34 +++++ .../unstable/httpapi/HttpApiSchema.tst.ts | 16 +++ 7 files changed, 158 insertions(+), 83 deletions(-) diff --git a/.changeset/parsed-media-types.md b/.changeset/parsed-media-types.md index 0f28157141f..3a093a92d9d 100644 --- a/.changeset/parsed-media-types.md +++ b/.changeset/parsed-media-types.md @@ -2,4 +2,4 @@ "effect": patch --- -Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. HTTP API content-type dispatch and multipart parsing now compare validated media-type essences while preserving string-facing transport APIs; malformed request content types continue to receive a 415 response. +Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options now accept parsed media types and validate string declarations when encoding metadata is created. HTTP API dispatch and multipart parsing compare validated media-type essences while preserving string-facing transport APIs; malformed request content types continue to receive a 415 response. diff --git a/packages/effect/src/unstable/httpapi/HttpApiClient.ts b/packages/effect/src/unstable/httpapi/HttpApiClient.ts index 61939fd4417..1f6c6e93967 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiClient.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiClient.ts @@ -375,7 +375,7 @@ export const makeClient = , meth if (entry.noContent) { throw new Error(`Cannot combine no-content and streaming success responses for status: ${status}`) } - if (entry.bufferedContentTypes.has(MediaType.essence(MediaType.parseUnsafe(inner.contentType)))) { + if (entry.bufferedContentTypes.has(MediaType.essence(HttpApiSchema.getEncodingMediaType(inner)))) { throw new Error( `Cannot combine buffered and streaming success responses for status ${status} and content-type: ${inner.contentType}` ) @@ -1220,8 +1220,10 @@ function validateSuccessResponse(schemas: ReadonlyArray, meth } const encoding = HttpApiSchema.getResponseEncodingSchema(schema) if ( - MediaType.essence(MediaType.parseUnsafe(encoding.contentType)) === - MediaType.essence(MediaType.parseUnsafe(entry.stream.contentType)) + MediaType.sameEssence( + HttpApiSchema.getEncodingMediaType(encoding), + HttpApiSchema.getEncodingMediaType(entry.stream) + ) ) { throw new Error( `Cannot combine buffered and streaming success responses for status ${status} and content-type: ${encoding.contentType}` @@ -1230,7 +1232,7 @@ function validateSuccessResponse(schemas: ReadonlyArray, meth } if (!noContent) { entry.bufferedContentTypes.add( - MediaType.essence(MediaType.parseUnsafe(HttpApiSchema.getResponseEncodingSchema(schema).contentType)) + MediaType.essence(HttpApiSchema.getEncodingMediaType(HttpApiSchema.getResponseEncodingSchema(schema))) ) } entry.noContent = entry.noContent || noContent @@ -1270,10 +1272,8 @@ function validateResponseExclusivity( const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : withHeadersAnnotation?.body ?? schema const contentType = HttpApiSchema.isNoContent(body.ast) ? "" - : MediaType.essence(MediaType.parseUnsafe( - HttpApiSchema.isStreamSchema(body) - ? body.contentType - : HttpApiSchema.getResponseEncodingSchema(schema).contentType + : MediaType.essence(HttpApiSchema.getEncodingMediaType( + HttpApiSchema.isStreamSchema(body) ? body : HttpApiSchema.getResponseEncodingSchema(schema) )) let entry = statuses.get(status) if (entry === undefined) { diff --git a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts index b9858156749..b781c7a875b 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts @@ -18,6 +18,7 @@ import * as Stream from "../../Stream.ts" import type * as Sse from "../encoding/Sse.ts" import { hasBody, type HttpMethod } from "../http/HttpMethod.ts" import * as HttpStatus from "../http/HttpStatus.ts" +import * as MediaType from "../http/MediaType.ts" import type * as Multipart_ from "../http/Multipart.ts" declare module "../../Schema.ts" { @@ -89,6 +90,25 @@ export type ResponseEncoding = { } const StreamSchemaTypeId = "~effect/httpapi/HttpApiSchema/Stream" +const MediaTypeSymbol = Symbol() +const textEventStreamMediaType = MediaType.makeUnsafe({ type: "text", subtype: "event-stream" }) + +interface HasMediaType { + readonly contentType: string + readonly [MediaTypeSymbol]: MediaType.MediaType +} + +const withMediaType = ( + fields: A, + contentType: string | MediaType.MediaType +): A & HasMediaType => { + const mediaType = typeof contentType === "string" ? MediaType.parseUnsafe(contentType) : contentType + return { + ...fields, + contentType: typeof contentType === "string" ? contentType : MediaType.format(contentType), + [MediaTypeSymbol]: mediaType + } +} /** * Common HTTP status code literals accepted by {@link status}. @@ -349,17 +369,17 @@ const streamSchema = Schema.declare(Stream.isStream) */ export const StreamSse: { (options: { - readonly contentType?: string | undefined + readonly contentType?: string | MediaType.MediaType | undefined readonly events: Events readonly error?: Error | undefined }): StreamSse (options: { - readonly contentType?: string | undefined + readonly contentType?: string | MediaType.MediaType | undefined readonly data: Data readonly error?: Error | undefined }): StreamSse, Error, Data["Type"]> } = (options: { - readonly contentType?: string | undefined + readonly contentType?: string | MediaType.MediaType | undefined readonly events?: Sse.EventCodec | undefined readonly data?: Schema.Constraint | undefined readonly error?: Schema.Constraint | undefined @@ -372,15 +392,17 @@ export const StreamSse: { if (events === undefined) { throw new Error("StreamSse requires either an events schema or a data schema") } - return Schema.make>(streamSchema.ast, { - [StreamSchemaTypeId]: StreamSchemaTypeId, - _tag: "StreamSse", - mode: "sse", - sseMode: options.events === undefined ? "data" : "events", - contentType: options.contentType ?? defaultStreamContentType("sse"), - events, - error: options.error ?? Schema.Never - }) + return Schema.make>( + streamSchema.ast, + withMediaType({ + [StreamSchemaTypeId]: StreamSchemaTypeId, + _tag: "StreamSse", + mode: "sse", + sseMode: options.events === undefined ? "data" : "events", + events, + error: options.error ?? Schema.Never + }, options.contentType ?? defaultStreamMediaType("sse")) + ) } /** @@ -390,14 +412,16 @@ export const StreamSse: { * @since 4.0.0 */ export const StreamUint8Array = (options?: { - readonly contentType?: string | undefined + readonly contentType?: string | MediaType.MediaType | undefined }): StreamUint8Array => - Schema.make(streamSchema.ast, { - [StreamSchemaTypeId]: StreamSchemaTypeId, - _tag: "StreamUint8Array", - mode: "uint8array", - contentType: options?.contentType ?? defaultStreamContentType("uint8array") - }) + Schema.make( + streamSchema.ast, + withMediaType({ + [StreamSchemaTypeId]: StreamSchemaTypeId, + _tag: "StreamUint8Array", + mode: "uint8array" + }, options?.contentType ?? defaultStreamMediaType("uint8array")) + ) /** @internal */ export const isStreamSchema = (u: unknown): u is StreamSchema => @@ -411,12 +435,12 @@ export const isStreamSse = (u: unknown): u is StreamSse isStreamSchema(u) && u._tag === "StreamUint8Array" -function defaultStreamContentType(mode: StreamMode): string { +function defaultStreamMediaType(mode: StreamMode): MediaType.MediaType { switch (mode) { case "sse": - return "text/event-stream" + return textEventStreamMediaType case "uint8array": - return "application/octet-stream" + return MediaType.applicationOctetStream } } @@ -776,12 +800,11 @@ export interface asMultipart extends Schema.brand(self: S): asMultipart => self.pipe(Schema.brand(MultipartTypeId)).annotate({ - "~httpApiEncoding": { - _tag: "Multipart", - mode: "buffered", - contentType: defaultContentType("Multipart"), + "~httpApiEncoding": withMediaType({ + _tag: "Multipart" as const, + mode: "buffered" as const, limits: options - } + }, MediaType.multipartFormData) }) } @@ -820,39 +843,37 @@ export interface asMultipartStream extends Schema.brand(self: S): asMultipartStream => self.pipe(Schema.brand(MultipartStreamTypeId)).annotate({ - "~httpApiEncoding": { - _tag: "Multipart", - mode: "stream", - contentType: defaultContentType("Multipart"), + "~httpApiEncoding": withMediaType({ + _tag: "Multipart" as const, + mode: "stream" as const, limits: options - } + }, MediaType.multipartFormData) }) } function asNonMultipartEncoding(self: S, options: { readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" - readonly contentType?: string | undefined + readonly contentType?: string | MediaType.MediaType | undefined }): S["Rebuild"] { return self.annotate({ - "~httpApiEncoding": { - _tag: options._tag, - contentType: options.contentType ?? defaultContentType(options._tag) - } + "~httpApiEncoding": withMediaType({ + _tag: options._tag + }, options.contentType ?? defaultMediaType(options._tag)) }) } -function defaultContentType(_tag: Encoding["_tag"]): string { +function defaultMediaType(_tag: Encoding["_tag"]): MediaType.MediaType { switch (_tag) { case "Multipart": - return "multipart/form-data" + return MediaType.multipartFormData case "Json": - return "application/json" + return MediaType.applicationJson case "FormUrlEncoded": - return "application/x-www-form-urlencoded" + return MediaType.applicationFormUrlEncoded case "Uint8Array": - return "application/octet-stream" + return MediaType.applicationOctetStream case "Text": - return "text/plain" + return MediaType.textPlain } } @@ -863,7 +884,7 @@ function defaultContentType(_tag: Encoding["_tag"]): string { * @since 4.0.0 */ export function asJson(options?: { - readonly contentType?: string + readonly contentType?: string | MediaType.MediaType }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Json", ...options }) } @@ -879,7 +900,7 @@ export function asJson(options?: { * @since 4.0.0 */ export function asFormUrlEncoded(options?: { - readonly contentType?: string + readonly contentType?: string | MediaType.MediaType }) { return ( self: S @@ -897,7 +918,7 @@ export function asFormUrlEncoded(options?: { * @since 4.0.0 */ export function asText(options?: { - readonly contentType?: string + readonly contentType?: string | MediaType.MediaType }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Text", ...options }) @@ -914,7 +935,7 @@ export function asText(options?: { * @since 4.0.0 */ export function asUint8Array(options?: { - readonly contentType?: string + readonly contentType?: string | MediaType.MediaType }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Uint8Array", ...options }) @@ -946,13 +967,17 @@ export const getWithHeadersAnnotation = SchemaAST.resolveAt("httpApiStatus") -const defaultJsonEncoding: Encoding = { - _tag: "Json", - contentType: "application/json" -} -const defaultUrlEncodedEncoding: Encoding = { - _tag: "FormUrlEncoded", - contentType: "application/x-www-form-urlencoded" +const defaultJsonEncoding: Encoding = withMediaType({ _tag: "Json" }, MediaType.applicationJson) +const defaultUrlEncodedEncoding: Encoding = withMediaType( + { _tag: "FormUrlEncoded" }, + MediaType.applicationFormUrlEncoded +) + +/** @internal */ +export function getEncodingMediaType(self: Encoding | StreamSchema): MediaType.MediaType { + const mediaType = (self as Partial)[MediaTypeSymbol] + if (mediaType === undefined) throw new Error("Missing parsed content-type metadata") + return mediaType } function getEncoding(ast: SchemaAST.AST): Encoding { diff --git a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts index c9f1478da6f..cce2cbeba2f 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts @@ -45,6 +45,21 @@ describe("HttpApiEndpoint payload schemas", () => { assert.strictEqual(entry.encoding.contentType, contentType) }) + it("accepts parsed content types and stores their canonical representation", () => { + const contentType = MediaType.makeUnsafe({ + type: "Application", + subtype: "Vnd.Effect+JSON", + parameters: { charset: "UTF-8" } + }) + const endpoint = HttpApiEndpoint.post("create", "/", { + payload: Schema.Struct({ name: Schema.String }).pipe(HttpApiSchema.asJson({ contentType })) + }) + + const entry = endpoint.payload.get("application/vnd.effect+json") + assert.isDefined(entry) + assert.strictEqual(entry.encoding.contentType, "application/vnd.effect+json; charset=UTF-8") + }) + it("rejects incompatible encodings for equivalent content types", () => { const JsonPayload = Schema.Struct({ name: Schema.String }).pipe( HttpApiSchema.asJson({ contentType: "Application/Vnd.Effect+Data; charset=utf-8" }) @@ -58,18 +73,6 @@ describe("HttpApiEndpoint payload schemas", () => { /Multiple payload encodings/ ) }) - - it("rejects malformed declared content types", () => { - const Payload = Schema.String.pipe(HttpApiSchema.asText({ contentType: "not a media type" })) - - let error: unknown - try { - HttpApiEndpoint.post("create", "/", { payload: Payload }) - } catch (cause) { - error = cause - } - assert.instanceOf(error, MediaType.MediaTypeParseError) - }) }) describe("HttpApiEndpoint streaming success schemas", () => { diff --git a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts index a808f77b657..06e8e28bafa 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts @@ -1,5 +1,6 @@ import { assert, describe, it } from "@effect/vitest" import { Schema } from "effect" +import { MediaType } from "effect/unstable/http" import { HttpApiSchema } from "effect/unstable/httpapi" const getStreamMetadata = (self: HttpApiSchema.StreamSchema) => @@ -61,6 +62,23 @@ describe("HttpApiSchema", () => { assert.strictEqual(stream.contentType, "text/event-stream; charset=utf-8") }) + it("formats parsed content types", () => { + const events = Schema.Struct({ + event: Schema.Literal("custom"), + data: Schema.String + }) + const stream = HttpApiSchema.StreamSse({ + contentType: MediaType.makeUnsafe({ + type: "Text", + subtype: "Event-Stream", + parameters: { charset: "UTF-8" } + }), + events + }) + + assert.strictEqual(stream.contentType, "text/event-stream; charset=UTF-8") + }) + it("defaults the stream error schema to Never", () => { const events = Schema.Struct({ event: Schema.Literal("custom"), @@ -113,6 +131,22 @@ describe("HttpApiSchema", () => { assert.strictEqual(stream.contentType, "application/custom-binary") }) + + it("rejects invalid content types when constructed", () => { + assert.throws( + () => HttpApiSchema.StreamUint8Array({ contentType: "not a media type" }), + MediaType.MediaTypeParseError + ) + }) + }) + + describe("body encodings", () => { + it("rejects invalid content types when annotated", () => { + assert.throws( + () => Schema.String.pipe(HttpApiSchema.asText({ contentType: "not a media type" })), + MediaType.MediaTypeParseError + ) + }) }) describe("WithHeaders", () => { diff --git a/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts b/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts index c60f81eb3ed..657331981d9 100644 --- a/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts +++ b/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import type * as Sse from "effect/unstable/encoding/Sse" +import { MediaType } from "effect/unstable/http" import { HttpApiSchema } from "effect/unstable/httpapi" import { describe, expect, it } from "tstyche" @@ -17,6 +18,13 @@ describe("HttpApiSchema", () => { }) describe("StreamSse", () => { + it("accepts parsed content types", () => { + const Events = Schema.Struct({ event: Schema.String, data: Schema.String }) + const stream = HttpApiSchema.StreamSse({ contentType: MediaType.textPlain, events: Events }) + + expect(stream).type.toBe>() + }) + it("preserves event and error schemas", () => { const Events = Schema.Struct({ event: Schema.Literal("user.created"), @@ -117,6 +125,14 @@ describe("HttpApiSchema", () => { }) }) + describe("body encodings", () => { + it("accepts parsed content types", () => { + const schema = Schema.String.pipe(HttpApiSchema.asText({ contentType: MediaType.textPlain })) + + expect(schema).type.toBe() + }) + }) + describe("WithHeaders", () => { it("preserves the inner schema and headers schema types", () => { const Headers = Schema.Struct({ "x-total-count": Schema.FiniteFromString }) From 0f85020434acf2d790b9f9bf9f64345a7415049c Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 00:59:46 +0200 Subject: [PATCH 07/15] Require MediaType in HttpApiSchema --- .changeset/parsed-media-types.md | 3 +- .../src/51_http-server/fixtures/api/Users.ts | 3 +- .../src/unstable/httpapi/HttpApiSchema.ts | 23 ++++++------ .../unstable/httpapi/HttpApiBuilder.test.ts | 23 ++++++------ .../unstable/httpapi/HttpApiClient.test.ts | 6 +-- .../unstable/httpapi/HttpApiEndpoint.test.ts | 30 +++++---------- .../unstable/httpapi/HttpApiSchema.test.ts | 37 ++----------------- .../test/unstable/httpapi/OpenApi.test.ts | 11 ++++-- .../unstable/httpapi/HttpApiSchema.tst.ts | 10 +++++ packages/platform/node/test/HttpApi.test.ts | 5 ++- packages/platform/node/test/OpenApi.test.ts | 10 +++-- .../src/HttpApiTransformer.ts | 26 ++++++++----- .../openapi-generator/src/OpenApiGenerator.ts | 10 ++++- .../test/OpenApiGenerator.test.ts | 10 ++--- 14 files changed, 102 insertions(+), 105 deletions(-) diff --git a/.changeset/parsed-media-types.md b/.changeset/parsed-media-types.md index 3a093a92d9d..be4fc41e21c 100644 --- a/.changeset/parsed-media-types.md +++ b/.changeset/parsed-media-types.md @@ -1,5 +1,6 @@ --- "effect": patch +"@effect/openapi-generator": patch --- -Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options now accept parsed media types and validate string declarations when encoding metadata is created. HTTP API dispatch and multipart parsing compare validated media-type essences while preserving string-facing transport APIs; malformed request content types continue to receive a 415 response. +Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options now require parsed media types, while encoding metadata, OpenAPI documents, and wire values remain string-based. HTTP API dispatch and multipart parsing compare validated media-type essences; malformed request content types continue to receive a 415 response. OpenAPI-generated HTTP APIs now construct parsed media types for custom content-type declarations. diff --git a/ai-docs/src/51_http-server/fixtures/api/Users.ts b/ai-docs/src/51_http-server/fixtures/api/Users.ts index ad370e1631b..923c98b858f 100644 --- a/ai-docs/src/51_http-server/fixtures/api/Users.ts +++ b/ai-docs/src/51_http-server/fixtures/api/Users.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { MediaType } from "effect/unstable/http" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { User, UserId } from "../domain/User.ts" import { SearchQueryTooShort, UserNotFound } from "../domain/UserErrors.ts" @@ -23,7 +24,7 @@ export class UsersApiGroup extends HttpApiGroup.make("users") success: [ Schema.Array(User.json), Schema.String.pipe(HttpApiSchema.asText({ - contentType: "text/csv" + contentType: MediaType.makeUnsafe({ type: "text", subtype: "csv" }) })) ], error: [ diff --git a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts index b781c7a875b..e9f213e6f3b 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts @@ -100,12 +100,11 @@ interface HasMediaType { const withMediaType = ( fields: A, - contentType: string | MediaType.MediaType + mediaType: MediaType.MediaType ): A & HasMediaType => { - const mediaType = typeof contentType === "string" ? MediaType.parseUnsafe(contentType) : contentType return { ...fields, - contentType: typeof contentType === "string" ? contentType : MediaType.format(contentType), + contentType: MediaType.format(mediaType), [MediaTypeSymbol]: mediaType } } @@ -369,17 +368,17 @@ const streamSchema = Schema.declare(Stream.isStream) */ export const StreamSse: { (options: { - readonly contentType?: string | MediaType.MediaType | undefined + readonly contentType?: MediaType.MediaType | undefined readonly events: Events readonly error?: Error | undefined }): StreamSse (options: { - readonly contentType?: string | MediaType.MediaType | undefined + readonly contentType?: MediaType.MediaType | undefined readonly data: Data readonly error?: Error | undefined }): StreamSse, Error, Data["Type"]> } = (options: { - readonly contentType?: string | MediaType.MediaType | undefined + readonly contentType?: MediaType.MediaType | undefined readonly events?: Sse.EventCodec | undefined readonly data?: Schema.Constraint | undefined readonly error?: Schema.Constraint | undefined @@ -412,7 +411,7 @@ export const StreamSse: { * @since 4.0.0 */ export const StreamUint8Array = (options?: { - readonly contentType?: string | MediaType.MediaType | undefined + readonly contentType?: MediaType.MediaType | undefined }): StreamUint8Array => Schema.make( streamSchema.ast, @@ -853,7 +852,7 @@ export function asMultipartStream(options?: Multipart_.withLimits.Options) { function asNonMultipartEncoding(self: S, options: { readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" - readonly contentType?: string | MediaType.MediaType | undefined + readonly contentType?: MediaType.MediaType | undefined }): S["Rebuild"] { return self.annotate({ "~httpApiEncoding": withMediaType({ @@ -884,7 +883,7 @@ function defaultMediaType(_tag: Encoding["_tag"]): MediaType.MediaType { * @since 4.0.0 */ export function asJson(options?: { - readonly contentType?: string | MediaType.MediaType + readonly contentType?: MediaType.MediaType }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Json", ...options }) } @@ -900,7 +899,7 @@ export function asJson(options?: { * @since 4.0.0 */ export function asFormUrlEncoded(options?: { - readonly contentType?: string | MediaType.MediaType + readonly contentType?: MediaType.MediaType }) { return ( self: S @@ -918,7 +917,7 @@ export function asFormUrlEncoded(options?: { * @since 4.0.0 */ export function asText(options?: { - readonly contentType?: string | MediaType.MediaType + readonly contentType?: MediaType.MediaType }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Text", ...options }) @@ -935,7 +934,7 @@ export function asText(options?: { * @since 4.0.0 */ export function asUint8Array(options?: { - readonly contentType?: string | MediaType.MediaType + readonly contentType?: MediaType.MediaType }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Uint8Array", ...options }) diff --git a/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts b/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts index e07d6444502..81ab1d0ad40 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts @@ -11,7 +11,7 @@ import { SchemaTransformation, Stream } from "effect" -import { Etag, HttpPlatform } from "effect/unstable/http" +import { Etag, HttpPlatform, MediaType } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, @@ -26,6 +26,7 @@ import { const textDecoder = new TextDecoder() const StreamError = Schema.Struct({ reason: Schema.String }) +const mediaType = MediaType.parseUnsafe const TestServices = Layer.mergeAll( Path.layer, @@ -61,7 +62,7 @@ it.layer(TestServices)("HttpApiBuilder query parameters", (it) => { it.effect("reuses response schema transformations by source AST", () => { const SharedSuccess = Schema.String.pipe(HttpApiSchema.asText()) - const DistinctSuccess = Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/custom" })) + const DistinctSuccess = Schema.String.pipe(HttpApiSchema.asText({ contentType: mediaType("text/custom") })) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test") .add(HttpApiEndpoint.get("first", "/first", { success: SharedSuccess })) @@ -97,7 +98,7 @@ it.layer(TestServices)("HttpApiBuilder payload content types", (it) => { Effect.gen(function*() { const Payload = Schema.Struct({ name: Schema.String }).pipe( HttpApiSchema.asJson({ - contentType: "Application/Vnd.Effect+JSON; profile=declared" + contentType: mediaType("Application/Vnd.Effect+JSON; profile=declared") }) ) const Api = HttpApi.make("Api").add( @@ -136,7 +137,7 @@ it.layer(TestServices)("HttpApiBuilder payload content types", (it) => { it.effect("round trips custom form-urlencoded media types", () => Effect.gen(function*() { const Payload = Schema.Struct({ name: Schema.String }).pipe( - HttpApiSchema.asFormUrlEncoded({ contentType: "application/vnd.effect.form" }) + HttpApiSchema.asFormUrlEncoded({ contentType: mediaType("application/vnd.effect.form") }) ) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( @@ -497,7 +498,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { const Plain = Schema.Struct({ body: Schema.String, headers: Schema.Struct({ "x-source": Schema.String }) - }).pipe(HttpApiSchema.asJson({ contentType: "application/vnd.plain+json" })) + }).pipe(HttpApiSchema.asJson({ contentType: mediaType("application/vnd.plain+json") })) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("mixed", "/test", { @@ -529,7 +530,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { { "x-source": Schema.String } ) const Plain = Schema.TaggedStruct("Plain", { value: Schema.String }).pipe( - HttpApiSchema.asJson({ contentType: "application/vnd.plain+json" }) + HttpApiSchema.asJson({ contentType: mediaType("application/vnd.plain+json") }) ) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( @@ -554,7 +555,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { HttpApiGroup.make("test").add( HttpApiEndpoint.get("override", "/test", { success: HttpApiSchema.WithHeaders( - Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/plain" })), + Schema.String.pipe(HttpApiSchema.asText({ contentType: MediaType.textPlain })), { "content-type": Schema.String, "x-source": Schema.String } ) }) @@ -796,7 +797,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { HttpApiGroup.make("test").add( HttpApiEndpoint.get("download", "/test", { success: HttpApiSchema.status(206)( - HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) + HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-bytes") }) ) }) ) @@ -828,7 +829,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { HttpApiEndpoint.get("download", "/test", { success: HttpApiSchema.WithHeaders( HttpApiSchema.status(206)( - HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) + HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-bytes") }) ), { "content-type": Schema.String, "x-count": Schema.Int } ) @@ -868,7 +869,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { HttpApiEndpoint.get("events", "/test", { success: HttpApiSchema.status(202)( HttpApiSchema.StreamSse({ - contentType: "text/event-stream; charset=utf-8", + contentType: mediaType("text/event-stream; charset=utf-8"), events: Events, error: StreamError }) @@ -911,7 +912,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { success: HttpApiSchema.WithHeaders( HttpApiSchema.status(202)( HttpApiSchema.StreamSse({ - contentType: "text/event-stream; charset=utf-8", + contentType: mediaType("text/event-stream; charset=utf-8"), events: Events, error: StreamError }) diff --git a/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts b/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts index dbbf7085a2e..b4e0ed0aa30 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts @@ -2,7 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import { strictEqual } from "@effect/vitest/utils" import { Cause, Effect, Schema, Stream } from "effect" import { Sse } from "effect/unstable/encoding" -import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, MediaType } from "effect/unstable/http" import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" describe("HttpApiClient", () => { @@ -795,7 +795,7 @@ const FirstJsonResponseError = Schema.Struct({ _tag: Schema.Literal("FirstJsonError"), code: Schema.Number }).pipe( - HttpApiSchema.asJson({ contentType: "Application/Problem+JSON" }), + HttpApiSchema.asJson({ contentType: MediaType.parseUnsafe("Application/Problem+JSON") }), HttpApiSchema.status(400) ) @@ -803,7 +803,7 @@ const SecondJsonResponseError = Schema.Struct({ _tag: Schema.Literal("SecondJsonError"), message: Schema.String }).pipe( - HttpApiSchema.asJson({ contentType: "application/problem+json; charset=utf-8" }), + HttpApiSchema.asJson({ contentType: MediaType.parseUnsafe("application/problem+json; charset=utf-8") }), HttpApiSchema.status(400) ) diff --git a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts index cce2cbeba2f..7c2650c07c5 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts @@ -8,6 +8,7 @@ const Events = Schema.Struct({ data: Schema.String }) const StreamError = Schema.Struct({ reason: Schema.String }) +const mediaType = MediaType.parseUnsafe const sse = () => HttpApiSchema.StreamSse({ events: Events, error: StreamError }) @@ -34,17 +35,6 @@ describe("HttpApiEndpoint", () => { }) describe("HttpApiEndpoint payload schemas", () => { - it("normalizes payload map keys while preserving the declared content type", () => { - const contentType = "Application/Vnd.Effect+JSON; Charset=UTF-8" - const endpoint = HttpApiEndpoint.post("create", "/", { - payload: Schema.Struct({ name: Schema.String }).pipe(HttpApiSchema.asJson({ contentType })) - }) - - const entry = endpoint.payload.get("application/vnd.effect+json") - assert.isDefined(entry) - assert.strictEqual(entry.encoding.contentType, contentType) - }) - it("accepts parsed content types and stores their canonical representation", () => { const contentType = MediaType.makeUnsafe({ type: "Application", @@ -62,10 +52,10 @@ describe("HttpApiEndpoint payload schemas", () => { it("rejects incompatible encodings for equivalent content types", () => { const JsonPayload = Schema.Struct({ name: Schema.String }).pipe( - HttpApiSchema.asJson({ contentType: "Application/Vnd.Effect+Data; charset=utf-8" }) + HttpApiSchema.asJson({ contentType: mediaType("Application/Vnd.Effect+Data; charset=utf-8") }) ) const TextPayload = Schema.String.pipe( - HttpApiSchema.asText({ contentType: "application/vnd.effect+data" }) + HttpApiSchema.asText({ contentType: mediaType("application/vnd.effect+data") }) ) assert.throws( @@ -137,7 +127,7 @@ describe("HttpApiEndpoint streaming success schemas", () => { assert.throws(() => HttpApiEndpoint.get("events", "/events", { success: [ - HttpApiSchema.StreamSse({ contentType: "application/json", events: Events, error: StreamError }), + HttpApiSchema.StreamSse({ contentType: MediaType.applicationJson, events: Events, error: StreamError }), Schema.Struct({ ok: Schema.Boolean }) ] }) @@ -149,7 +139,7 @@ describe("HttpApiEndpoint streaming success schemas", () => { HttpApiEndpoint.get("events", "/events", { success: [ HttpApiSchema.StreamSse({ - contentType: "application/json; charset=utf-8", + contentType: mediaType("application/json; charset=utf-8"), events: Events, error: StreamError }), @@ -188,7 +178,7 @@ describe("HttpApiEndpoint streaming success schemas", () => { HttpApiEndpoint.get("events", "/events", { success: [ sse(), - HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" }) + HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-stream") }) ] }) ) @@ -197,7 +187,7 @@ describe("HttpApiEndpoint streaming success schemas", () => { it("two streaming successes for distinct statuses throw", () => { const stream = HttpApiSchema.status(206)(sse()) const bytes = HttpApiSchema.status(200)( - HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" }) + HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-stream") }) ) assert.throws( @@ -423,7 +413,7 @@ describe("HttpApiEndpoint WithHeaders schemas", () => { HttpApiEndpoint.get("events", "/events", { success: [ HttpApiSchema.WithHeaders(sse(), { "x-count": Schema.Int }), - HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" }) + HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-stream") }) ] }) ) @@ -440,11 +430,11 @@ describe("HttpApiEndpoint WithHeaders schemas", () => { HttpApiEndpoint.get("events", "/events", { success: [ HttpApiSchema.status(206)( - HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) + HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-bytes") }) ), HttpApiSchema.WithHeaders( HttpApiSchema.status(201)( - HttpApiSchema.StreamUint8Array({ contentType: "application/other-bytes" }) + HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/other-bytes") }) ), { "x-source": Schema.String } ) diff --git a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts index 06e8e28bafa..313b8dbf04a 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts @@ -47,21 +47,6 @@ describe("HttpApiSchema", () => { } }) - it("stores custom content type", () => { - const events = Schema.Struct({ - event: Schema.Literal("custom"), - data: Schema.String - }) - const error = Schema.String - const stream = HttpApiSchema.StreamSse({ - contentType: "text/event-stream; charset=utf-8", - events, - error - }) - - assert.strictEqual(stream.contentType, "text/event-stream; charset=utf-8") - }) - it("formats parsed content types", () => { const events = Schema.Struct({ event: Schema.Literal("custom"), @@ -126,27 +111,11 @@ describe("HttpApiSchema", () => { it("stores custom content type", () => { const stream = HttpApiSchema.StreamUint8Array({ - contentType: "application/custom-binary" + contentType: MediaType.makeUnsafe({ type: "application", subtype: "custom-binary" }) }) assert.strictEqual(stream.contentType, "application/custom-binary") }) - - it("rejects invalid content types when constructed", () => { - assert.throws( - () => HttpApiSchema.StreamUint8Array({ contentType: "not a media type" }), - MediaType.MediaTypeParseError - ) - }) - }) - - describe("body encodings", () => { - it("rejects invalid content types when annotated", () => { - assert.throws( - () => Schema.String.pipe(HttpApiSchema.asText({ contentType: "not a media type" })), - MediaType.MediaTypeParseError - ) - }) }) describe("WithHeaders", () => { @@ -227,7 +196,9 @@ describe("HttpApiSchema", () => { assert.strictEqual(HttpApiSchema.getResponseEncodingSchema(onInner)._tag, "Text") const onWrapper = HttpApiSchema.WithHeaders(Schema.String, headers).pipe( - HttpApiSchema.asJson({ contentType: "application/vnd.custom+json" }) + HttpApiSchema.asJson({ + contentType: MediaType.makeUnsafe({ type: "application", subtype: "vnd.custom+json" }) + }) ) assert.isTrue(HttpApiSchema.isWithHeaders(onWrapper)) assert.strictEqual( diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index fbb89b9044b..4e0b8d70544 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -1,5 +1,6 @@ import { assert, describe, it } from "@effect/vitest" import { type Context, Schema } from "effect" +import { MediaType } from "effect/unstable/http" import { HttpApi, HttpApiEndpoint, @@ -126,14 +127,16 @@ describe("OpenApi", () => { }) it("preserves every declared payload content type for normalized equivalents", () => { - const profileA = "Application/Vnd.Effect+JSON; Profile=A" - const profileB = "application/vnd.effect+json; profile=b" + const profileAMediaType = MediaType.parseUnsafe("Application/Vnd.Effect+JSON; Profile=A") + const profileBMediaType = MediaType.parseUnsafe("application/vnd.effect+json; profile=b") + const profileA = MediaType.format(profileAMediaType) + const profileB = MediaType.format(profileBMediaType) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.post("create", "/create", { payload: [ - Schema.Struct({ a: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileA })), - Schema.Struct({ b: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileB })) + Schema.Struct({ a: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileAMediaType })), + Schema.Struct({ b: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileBMediaType })) ] }) ) diff --git a/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts b/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts index 657331981d9..5d3fea1b88e 100644 --- a/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts +++ b/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts @@ -25,6 +25,12 @@ describe("HttpApiSchema", () => { expect(stream).type.toBe>() }) + it("rejects string content types", () => { + const Events = Schema.Struct({ event: Schema.String, data: Schema.String }) + + expect(HttpApiSchema.StreamSse).type.not.toBeCallableWith({ contentType: "text/plain", events: Events }) + }) + it("preserves event and error schemas", () => { const Events = Schema.Struct({ event: Schema.Literal("user.created"), @@ -131,6 +137,10 @@ describe("HttpApiSchema", () => { expect(schema).type.toBe() }) + + it("rejects string content types", () => { + expect(HttpApiSchema.asText).type.not.toBeCallableWith({ contentType: "text/plain" }) + }) }) describe("WithHeaders", () => { diff --git a/packages/platform/node/test/HttpApi.test.ts b/packages/platform/node/test/HttpApi.test.ts index d435ce52e97..40acc60d1e3 100644 --- a/packages/platform/node/test/HttpApi.test.ts +++ b/packages/platform/node/test/HttpApi.test.ts @@ -27,6 +27,7 @@ import { HttpServer, HttpServerRequest, HttpServerResponse, + MediaType, Multipart } from "effect/unstable/http" import { @@ -962,7 +963,9 @@ describe("HttpApi", () => { const Api = HttpApi.make("api").add( HttpApiGroup.make("group").add( HttpApiEndpoint.get("a", "/a", { - success: Schema.String.pipe(HttpApiSchema.asJson({ contentType: "application/scim+json" })) + success: Schema.String.pipe(HttpApiSchema.asJson({ + contentType: MediaType.makeUnsafe({ type: "application", subtype: "scim+json" }) + })) }) ) ) diff --git a/packages/platform/node/test/OpenApi.test.ts b/packages/platform/node/test/OpenApi.test.ts index 4a7d6616a95..58d7bd54b44 100644 --- a/packages/platform/node/test/OpenApi.test.ts +++ b/packages/platform/node/test/OpenApi.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest" import { Schema } from "effect" -import { Multipart } from "effect/unstable/http" +import { MediaType, Multipart } from "effect/unstable/http" import { HttpApi, HttpApiEndpoint, @@ -551,7 +551,9 @@ describe("OpenAPI spec", () => { .add( HttpApiEndpoint.post("a", "/a", { payload: Schema.String.pipe( - HttpApiSchema.asJson({ contentType: "application/problem+json" }) + HttpApiSchema.asJson({ + contentType: MediaType.makeUnsafe({ type: "application", subtype: "problem+json" }) + }) ) }) ) @@ -907,7 +909,9 @@ describe("OpenAPI spec", () => { .add( HttpApiEndpoint.get("a", "/a", { success: Schema.String.pipe( - HttpApiSchema.asJson({ contentType: "application/problem+json" }) + HttpApiSchema.asJson({ + contentType: MediaType.makeUnsafe({ type: "application", subtype: "problem+json" }) + }) ) }) ) diff --git a/packages/tools/openapi-generator/src/HttpApiTransformer.ts b/packages/tools/openapi-generator/src/HttpApiTransformer.ts index 02be5759b1b..1311eb37434 100644 --- a/packages/tools/openapi-generator/src/HttpApiTransformer.ts +++ b/packages/tools/openapi-generator/src/HttpApiTransformer.ts @@ -51,13 +51,19 @@ export const imports = ( importName: string, options?: { readonly multipart?: boolean | undefined + readonly mediaType?: boolean | undefined } -): string => - [ +): string => { + const httpImports = [ + ...(options?.multipart === true ? ["Multipart"] : []), + ...(options?.mediaType === true ? ["MediaType"] : []) + ] + return [ `import * as ${importName} from "effect/Schema"`, - ...(options?.multipart === true ? [`import { Multipart } from "effect/unstable/http"`] : []), + ...(httpImports.length === 0 ? [] : [`import { ${httpImports.join(", ")} } from "effect/unstable/http"`]), `import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi"` ].join("\n") +} /** * Convert a parsed OpenAPI document into Effect HttpApi source code. @@ -293,11 +299,13 @@ const renderResponseSet = ( const joinSchemas = (schemas: ReadonlyArray): string => schemas.length === 1 ? schemas[0] : `[${schemas.join(", ")}]` +const renderMediaType = (contentType: string): string => `MediaType.parseUnsafe(${JSON.stringify(contentType)})` + const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => { if (media.effectStream === "sse") { const options = media.contentType === "text/event-stream" ? `{ events: ${media.schema}, error: ${media.errorSchema} }` - : `{ contentType: ${JSON.stringify(media.contentType)}, events: ${media.schema}, error: ${media.errorSchema} }` + : `{ contentType: ${renderMediaType(media.contentType)}, events: ${media.schema}, error: ${media.errorSchema} }` return `HttpApiSchema.StreamSse(${options})` } @@ -305,7 +313,7 @@ const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => { if (media.contentType === "application/octet-stream") { return "HttpApiSchema.StreamUint8Array()" } - return `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(media.contentType)} })` + return `HttpApiSchema.StreamUint8Array({ contentType: ${renderMediaType(media.contentType)} })` } switch (media.encoding) { @@ -313,7 +321,7 @@ const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => { if (media.contentType === "application/json") { return media.schema } - return `${media.schema}.pipe(HttpApiSchema.asJson({ contentType: ${JSON.stringify(media.contentType)} }))` + return `${media.schema}.pipe(HttpApiSchema.asJson({ contentType: ${renderMediaType(media.contentType)} }))` } case "multipart": { return `${media.schema}.pipe(HttpApiSchema.asMultipart())` @@ -323,20 +331,20 @@ const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => { return `${media.schema}.pipe(HttpApiSchema.asFormUrlEncoded())` } return `${media.schema}.pipe(HttpApiSchema.asFormUrlEncoded({ contentType: ${ - JSON.stringify(media.contentType) + renderMediaType(media.contentType) } }))` } case "text": { if (media.contentType === "text/plain") { return `${media.schema}.pipe(HttpApiSchema.asText())` } - return `${media.schema}.pipe(HttpApiSchema.asText({ contentType: ${JSON.stringify(media.contentType)} }))` + return `${media.schema}.pipe(HttpApiSchema.asText({ contentType: ${renderMediaType(media.contentType)} }))` } case "binary": { if (media.contentType === "application/octet-stream") { return `${media.schema}.pipe(HttpApiSchema.asUint8Array())` } - return `${media.schema}.pipe(HttpApiSchema.asUint8Array({ contentType: ${JSON.stringify(media.contentType)} }))` + return `${media.schema}.pipe(HttpApiSchema.asUint8Array({ contentType: ${renderMediaType(media.contentType)} }))` } } } diff --git a/packages/tools/openapi-generator/src/OpenApiGenerator.ts b/packages/tools/openapi-generator/src/OpenApiGenerator.ts index 167b9ea7c94..8c6e7647e26 100644 --- a/packages/tools/openapi-generator/src/OpenApiGenerator.ts +++ b/packages/tools/openapi-generator/src/OpenApiGenerator.ts @@ -175,10 +175,16 @@ export const make = Effect.gen(function*() { if (options.format === "httpapi") { const needsMultipartImport = generation.includes("Multipart.") + const implementation = HttpApiTransformer.toImplementation(importName, options.name, parsed) return String.stripMargin( - `|${HttpApiTransformer.imports(importName, { multipart: needsMultipartImport })} + `|${ + HttpApiTransformer.imports(importName, { + multipart: needsMultipartImport, + mediaType: implementation.includes("MediaType.") + }) + } |${generation} - |${HttpApiTransformer.toImplementation(importName, options.name, parsed)}` + |${implementation}` ) } diff --git a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts index 1e581af5e4c..2b15e73b0c7 100644 --- a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts +++ b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts @@ -1723,7 +1723,7 @@ export const CreatePayloadRequestText = Schema.String`, [ `export type DualJson200 = { readonly "value": string }`, `export type DualJson200ApplicationProblemJson = { readonly "title": string }`, - `HttpApiEndpoint.get("dualJson", "/dual", { success: [DualJson200, DualJson200ApplicationProblemJson.pipe(HttpApiSchema.asJson({ contentType: "application/problem+json" }))] })` + `HttpApiEndpoint.get("dualJson", "/dual", { success: [DualJson200, DualJson200ApplicationProblemJson.pipe(HttpApiSchema.asJson({ contentType: MediaType.parseUnsafe("application/problem+json") }))] })` ] )) @@ -1828,11 +1828,11 @@ export const CreatePayloadRequestText = Schema.String`, }, [ `HttpApiEndpoint.get("streamEvents", "/events", { success: HttpApiSchema.StreamSse({ events: StreamEvents200Sse, error: StreamEvents200SseError }) })`, - `HttpApiEndpoint.get("streamEventsCustom", "/events/custom", { success: HttpApiSchema.StreamSse({ contentType: "application/custom-sse", events: StreamEventsCustom200Sse, error: StreamEventsCustom200SseError }) })` + `HttpApiEndpoint.get("streamEventsCustom", "/events/custom", { success: HttpApiSchema.StreamSse({ contentType: MediaType.parseUnsafe("application/custom-sse"), events: StreamEventsCustom200Sse, error: StreamEventsCustom200SseError }) })` ], [ `StreamEvents200Sse.pipe(HttpApiSchema.asText())`, - `StreamEventsCustom200ApplicationCustomSse.pipe(HttpApiSchema.asText({ contentType: "application/custom-sse" }))` + `StreamEventsCustom200ApplicationCustomSse.pipe(HttpApiSchema.asText({ contentType: MediaType.parseUnsafe("application/custom-sse") }))` ] )) @@ -1989,11 +1989,11 @@ export const CreatePayloadRequestText = Schema.String`, }, [ `HttpApiEndpoint.get("download", "/download", { success: HttpApiSchema.StreamUint8Array() })`, - `HttpApiEndpoint.get("downloadCustom", "/download/custom", { success: HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) })` + `HttpApiEndpoint.get("downloadCustom", "/download/custom", { success: HttpApiSchema.StreamUint8Array({ contentType: MediaType.parseUnsafe("application/custom-bytes") }) })` ], [ `Download200Binary.pipe(HttpApiSchema.asUint8Array())`, - `DownloadCustom200ApplicationCustomBytes.pipe(HttpApiSchema.asUint8Array({ contentType: "application/custom-bytes" }))` + `DownloadCustom200ApplicationCustomBytes.pipe(HttpApiSchema.asUint8Array({ contentType: MediaType.parseUnsafe("application/custom-bytes") }))` ] )) From 1bc4d8f2b2f81a50be8227281fe41bee2311487e Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 01:07:19 +0200 Subject: [PATCH 08/15] Store MediaType in HTTP API metadata --- .changeset/parsed-media-types.md | 2 +- .../src/unstable/httpapi/HttpApiBuilder.ts | 11 +-- .../src/unstable/httpapi/HttpApiClient.ts | 15 ++-- .../src/unstable/httpapi/HttpApiEndpoint.ts | 31 ++++---- .../src/unstable/httpapi/HttpApiSchema.ts | 79 +++++++------------ .../effect/src/unstable/httpapi/OpenApi.ts | 16 ++-- .../unstable/httpapi/HttpApiEndpoint.test.ts | 2 +- .../unstable/httpapi/HttpApiSchema.test.ts | 42 +++++----- .../unstable/httpapi/HttpApiSchema.tst.ts | 2 +- 9 files changed, 93 insertions(+), 107 deletions(-) diff --git a/.changeset/parsed-media-types.md b/.changeset/parsed-media-types.md index be4fc41e21c..c0d0310af7f 100644 --- a/.changeset/parsed-media-types.md +++ b/.changeset/parsed-media-types.md @@ -3,4 +3,4 @@ "@effect/openapi-generator": patch --- -Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options now require parsed media types, while encoding metadata, OpenAPI documents, and wire values remain string-based. HTTP API dispatch and multipart parsing compare validated media-type essences; malformed request content types continue to receive a 415 response. OpenAPI-generated HTTP APIs now construct parsed media types for custom content-type declarations. +Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options and encoding metadata now use parsed media types, formatting them only when producing OpenAPI documents or wire values. HTTP API dispatch and multipart parsing compare validated media-type essences; malformed request content types continue to receive a 415 response. OpenAPI-generated HTTP APIs now construct parsed media types for custom content-type declarations. diff --git a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index 66f0613a5af..0a61e73d2d2 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -983,7 +983,7 @@ function makeStreamEncoder(endpoint: HttpApiEndpoint.Top): StreamEncoder | undef const hasBuffered = hasBufferedSuccess(endpoint) const status = HttpApiSchema.getStatusStream(streamSchema) - const contentType = streamSchema.contentType + const contentType = MediaType.format(streamSchema.contentType) if (HttpApiSchema.isStreamUint8Array(streamSchema)) { return (response, context) => { @@ -1223,6 +1223,7 @@ function getResponseEncode( e: E, options?: SchemaAST.ParseOptions ) => Effect.Effect { + const contentType = MediaType.format(encoding.contentType) switch (encoding._tag) { case "Json": { return ((e, options) => { @@ -1231,7 +1232,7 @@ function getResponseEncode( } try { const s = JSON.stringify(e) - return Effect.succeed(Response.text(s, { status, contentType: encoding.contentType })) + return Effect.succeed(Response.text(s, { status, contentType })) } catch { return Effect.fail( new SchemaIssue.InvalidValue( @@ -1247,19 +1248,19 @@ function getResponseEncode( return (e) => Effect.succeed(Response.text(e as string, { status, - contentType: encoding.contentType + contentType })) case "Uint8Array": return (e) => Effect.succeed(Response.uint8Array(e as Uint8Array, { status, - contentType: encoding.contentType + contentType })) case "FormUrlEncoded": return (e) => Effect.succeed( Response.urlParams(e as URLSearchParams, { status }).pipe( - Response.setHeader("content-type", encoding.contentType) + Response.setHeader("content-type", contentType) ) ) } diff --git a/packages/effect/src/unstable/httpapi/HttpApiClient.ts b/packages/effect/src/unstable/httpapi/HttpApiClient.ts index 1f6c6e93967..1b3472fe51e 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiClient.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiClient.ts @@ -375,7 +375,7 @@ export const makeClient = , meth if (entry.noContent) { throw new Error(`Cannot combine no-content and streaming success responses for status: ${status}`) } - if (entry.bufferedContentTypes.has(MediaType.essence(HttpApiSchema.getEncodingMediaType(inner)))) { + if (entry.bufferedContentTypes.has(MediaType.essence(inner.contentType))) { throw new Error( - `Cannot combine buffered and streaming success responses for status ${status} and content-type: ${inner.contentType}` + `Cannot combine buffered and streaming success responses for status ${status} and content-type: ${ + MediaType.format(inner.contentType) + }` ) } statuses.set(status, { ...entry, stream: inner }) @@ -1220,19 +1222,18 @@ function validateSuccessResponse(schemas: ReadonlyArray, meth } const encoding = HttpApiSchema.getResponseEncodingSchema(schema) if ( - MediaType.sameEssence( - HttpApiSchema.getEncodingMediaType(encoding), - HttpApiSchema.getEncodingMediaType(entry.stream) - ) + MediaType.sameEssence(encoding.contentType, entry.stream.contentType) ) { throw new Error( - `Cannot combine buffered and streaming success responses for status ${status} and content-type: ${encoding.contentType}` + `Cannot combine buffered and streaming success responses for status ${status} and content-type: ${ + MediaType.format(encoding.contentType) + }` ) } } if (!noContent) { entry.bufferedContentTypes.add( - MediaType.essence(HttpApiSchema.getEncodingMediaType(HttpApiSchema.getResponseEncodingSchema(schema))) + MediaType.essence(HttpApiSchema.getResponseEncodingSchema(schema).contentType) ) } entry.noContent = entry.noContent || noContent @@ -1272,9 +1273,11 @@ function validateResponseExclusivity( const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : withHeadersAnnotation?.body ?? schema const contentType = HttpApiSchema.isNoContent(body.ast) ? "" - : MediaType.essence(HttpApiSchema.getEncodingMediaType( - HttpApiSchema.isStreamSchema(body) ? body : HttpApiSchema.getResponseEncodingSchema(schema) - )) + : MediaType.essence( + HttpApiSchema.isStreamSchema(body) + ? body.contentType + : HttpApiSchema.getResponseEncodingSchema(schema).contentType + ) let entry = statuses.get(status) if (entry === undefined) { entry = { headerContentType: undefined, plainContentTypes: new Set() } diff --git a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts index e9f213e6f3b..1068f349d6f 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts @@ -70,12 +70,12 @@ export type PayloadEncoding = | { readonly _tag: "Multipart" readonly mode: "buffered" | "stream" - readonly contentType: string + readonly contentType: MediaType.MediaType readonly limits?: Multipart_.withLimits.Options | undefined } | { readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" - readonly contentType: string + readonly contentType: MediaType.MediaType } /** @@ -86,29 +86,12 @@ export type PayloadEncoding = */ export type ResponseEncoding = { readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" - readonly contentType: string + readonly contentType: MediaType.MediaType } const StreamSchemaTypeId = "~effect/httpapi/HttpApiSchema/Stream" -const MediaTypeSymbol = Symbol() const textEventStreamMediaType = MediaType.makeUnsafe({ type: "text", subtype: "event-stream" }) -interface HasMediaType { - readonly contentType: string - readonly [MediaTypeSymbol]: MediaType.MediaType -} - -const withMediaType = ( - fields: A, - mediaType: MediaType.MediaType -): A & HasMediaType => { - return { - ...fields, - contentType: MediaType.format(mediaType), - [MediaTypeSymbol]: mediaType - } -} - /** * Common HTTP status code literals accepted by {@link status}. * @@ -292,7 +275,7 @@ export interface StreamSse< readonly _tag: "StreamSse" readonly mode: "sse" readonly sseMode: StreamSseMode - readonly contentType: string + readonly contentType: MediaType.MediaType readonly events: Events readonly error: Error readonly "~Value"?: Value | undefined @@ -347,7 +330,7 @@ export interface StreamUint8Array extends readonly [StreamSchemaTypeId]: typeof StreamSchemaTypeId readonly _tag: "StreamUint8Array" readonly mode: "uint8array" - readonly contentType: string + readonly contentType: MediaType.MediaType } /** @@ -393,14 +376,15 @@ export const StreamSse: { } return Schema.make>( streamSchema.ast, - withMediaType({ + { [StreamSchemaTypeId]: StreamSchemaTypeId, _tag: "StreamSse", mode: "sse", sseMode: options.events === undefined ? "data" : "events", + contentType: options.contentType ?? defaultStreamMediaType("sse"), events, error: options.error ?? Schema.Never - }, options.contentType ?? defaultStreamMediaType("sse")) + } ) } @@ -415,11 +399,12 @@ export const StreamUint8Array = (options?: { }): StreamUint8Array => Schema.make( streamSchema.ast, - withMediaType({ + { [StreamSchemaTypeId]: StreamSchemaTypeId, _tag: "StreamUint8Array", - mode: "uint8array" - }, options?.contentType ?? defaultStreamMediaType("uint8array")) + mode: "uint8array", + contentType: options?.contentType ?? defaultStreamMediaType("uint8array") + } ) /** @internal */ @@ -799,11 +784,12 @@ export interface asMultipart extends Schema.brand(self: S): asMultipart => self.pipe(Schema.brand(MultipartTypeId)).annotate({ - "~httpApiEncoding": withMediaType({ - _tag: "Multipart" as const, - mode: "buffered" as const, + "~httpApiEncoding": { + _tag: "Multipart", + mode: "buffered", + contentType: MediaType.multipartFormData, limits: options - }, MediaType.multipartFormData) + } }) } @@ -842,11 +828,12 @@ export interface asMultipartStream extends Schema.brand(self: S): asMultipartStream => self.pipe(Schema.brand(MultipartStreamTypeId)).annotate({ - "~httpApiEncoding": withMediaType({ - _tag: "Multipart" as const, - mode: "stream" as const, + "~httpApiEncoding": { + _tag: "Multipart", + mode: "stream", + contentType: MediaType.multipartFormData, limits: options - }, MediaType.multipartFormData) + } }) } @@ -855,9 +842,10 @@ function asNonMultipartEncoding(self: S, options: { readonly contentType?: MediaType.MediaType | undefined }): S["Rebuild"] { return self.annotate({ - "~httpApiEncoding": withMediaType({ - _tag: options._tag - }, options.contentType ?? defaultMediaType(options._tag)) + "~httpApiEncoding": { + _tag: options._tag, + contentType: options.contentType ?? defaultMediaType(options._tag) + } }) } @@ -966,17 +954,10 @@ export const getWithHeadersAnnotation = SchemaAST.resolveAt("httpApiStatus") -const defaultJsonEncoding: Encoding = withMediaType({ _tag: "Json" }, MediaType.applicationJson) -const defaultUrlEncodedEncoding: Encoding = withMediaType( - { _tag: "FormUrlEncoded" }, - MediaType.applicationFormUrlEncoded -) - -/** @internal */ -export function getEncodingMediaType(self: Encoding | StreamSchema): MediaType.MediaType { - const mediaType = (self as Partial)[MediaTypeSymbol] - if (mediaType === undefined) throw new Error("Missing parsed content-type metadata") - return mediaType +const defaultJsonEncoding: Encoding = { _tag: "Json", contentType: MediaType.applicationJson } +const defaultUrlEncodedEncoding: Encoding = { + _tag: "FormUrlEncoded", + contentType: MediaType.applicationFormUrlEncoded } function getEncoding(ast: SchemaAST.AST): Encoding { diff --git a/packages/effect/src/unstable/httpapi/OpenApi.ts b/packages/effect/src/unstable/httpapi/OpenApi.ts index a4dc20075cc..883e824635d 100644 --- a/packages/effect/src/unstable/httpapi/OpenApi.ts +++ b/packages/effect/src/unstable/httpapi/OpenApi.ts @@ -24,6 +24,7 @@ import * as Schema from "../../Schema.ts" import * as SchemaAST from "../../SchemaAST.ts" import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" import * as HttpMethod from "../http/HttpMethod.ts" +import * as MediaType from "../http/MediaType.ts" import * as HttpApi from "./HttpApi.ts" import * as HttpApiEndpoint from "./HttpApiEndpoint.ts" import type * as HttpApiGroup from "./HttpApiGroup.ts" @@ -572,9 +573,10 @@ function makeOpenApi( for (const schema of HttpApiEndpoint.getPayloadSchemas(endpoint)) { if (HttpApiSchema.isNoContent(schema.ast)) continue const encoding = HttpApiSchema.getPayloadEncoding(schema.ast, endpoint.method) - const existing = schemasByContentType.get(encoding.contentType) + const contentType = MediaType.format(encoding.contentType) + const existing = schemasByContentType.get(contentType) if (existing === undefined) { - schemasByContentType.set(encoding.contentType, { encoding, schemas: [schema] }) + schemasByContentType.set(contentType, { encoding, schemas: [schema] }) } else { existing.schemas.push(schema) } @@ -799,7 +801,8 @@ function extractResponseBodies( description: string | undefined ) { const statusMap = map.get(status) - const { _tag, contentType } = encoding + const { _tag } = encoding + const contentType = MediaType.format(encoding.contentType) if (statusMap === undefined) { map.set(status, { descriptions: new Set(description !== undefined ? [description] : []), @@ -835,19 +838,20 @@ function extractResponseBodies( stream: HttpApiSchema.StreamSchema, status: number ) { + const contentType = MediaType.format(stream.contentType) const statusMap = map.get(status) if (statusMap === undefined) { map.set(status, { descriptions: new Set(), content: undefined, headers: [], - streamContent: new Map([[stream.contentType, stream]]) + streamContent: new Map([[contentType, stream]]) }) } else { if (statusMap.streamContent === undefined) { - statusMap.streamContent = new Map([[stream.contentType, stream]]) + statusMap.streamContent = new Map([[contentType, stream]]) } else { - statusMap.streamContent.set(stream.contentType, stream) + statusMap.streamContent.set(contentType, stream) } } } diff --git a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts index 7c2650c07c5..4ce3c9b5b19 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts @@ -47,7 +47,7 @@ describe("HttpApiEndpoint payload schemas", () => { const entry = endpoint.payload.get("application/vnd.effect+json") assert.isDefined(entry) - assert.strictEqual(entry.encoding.contentType, "application/vnd.effect+json; charset=UTF-8") + assert.strictEqual(entry.encoding.contentType, contentType) }) it("rejects incompatible encodings for equivalent content types", () => { diff --git a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts index 313b8dbf04a..5ef2b14e2fb 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts @@ -33,13 +33,13 @@ describe("HttpApiSchema", () => { assert.isFalse(HttpApiSchema.isStreamUint8Array(stream)) assert.strictEqual(stream.mode, "sse") assert.strictEqual(stream.sseMode, "events") - assert.strictEqual(stream.contentType, "text/event-stream") + assert.strictEqual(MediaType.format(stream.contentType), "text/event-stream") assert.strictEqual(stream.events, events) assert.strictEqual(stream.error, error) const metadata = getStreamMetadata(stream) assert.strictEqual(metadata.mode, "sse") - assert.strictEqual(metadata.contentType, "text/event-stream") + assert.strictEqual(metadata.contentType, stream.contentType) if (metadata.mode === "sse") { assert.strictEqual(metadata.sseMode, "events") assert.strictEqual(metadata.events, events) @@ -47,21 +47,22 @@ describe("HttpApiSchema", () => { } }) - it("formats parsed content types", () => { + it("stores parsed content types", () => { const events = Schema.Struct({ event: Schema.Literal("custom"), data: Schema.String }) + const contentType = MediaType.makeUnsafe({ + type: "Text", + subtype: "Event-Stream", + parameters: { charset: "UTF-8" } + }) const stream = HttpApiSchema.StreamSse({ - contentType: MediaType.makeUnsafe({ - type: "Text", - subtype: "Event-Stream", - parameters: { charset: "UTF-8" } - }), + contentType, events }) - assert.strictEqual(stream.contentType, "text/event-stream; charset=UTF-8") + assert.strictEqual(stream.contentType, contentType) }) it("defaults the stream error schema to Never", () => { @@ -102,19 +103,18 @@ describe("HttpApiSchema", () => { assert.isFalse(HttpApiSchema.isStreamSse(stream)) assert.isTrue(HttpApiSchema.isStreamUint8Array(stream)) assert.strictEqual(stream.mode, "uint8array") - assert.strictEqual(stream.contentType, "application/octet-stream") + assert.strictEqual(stream.contentType, MediaType.applicationOctetStream) assert.deepStrictEqual(getStreamMetadata(stream), { mode: "uint8array", - contentType: "application/octet-stream" + contentType: MediaType.applicationOctetStream }) }) it("stores custom content type", () => { - const stream = HttpApiSchema.StreamUint8Array({ - contentType: MediaType.makeUnsafe({ type: "application", subtype: "custom-binary" }) - }) + const contentType = MediaType.makeUnsafe({ type: "application", subtype: "custom-binary" }) + const stream = HttpApiSchema.StreamUint8Array({ contentType }) - assert.strictEqual(stream.contentType, "application/custom-binary") + assert.strictEqual(stream.contentType, contentType) }) }) @@ -195,16 +195,10 @@ describe("HttpApiSchema", () => { const onInner = HttpApiSchema.WithHeaders(Schema.String.pipe(HttpApiSchema.asText()), headers) assert.strictEqual(HttpApiSchema.getResponseEncodingSchema(onInner)._tag, "Text") - const onWrapper = HttpApiSchema.WithHeaders(Schema.String, headers).pipe( - HttpApiSchema.asJson({ - contentType: MediaType.makeUnsafe({ type: "application", subtype: "vnd.custom+json" }) - }) - ) + const contentType = MediaType.makeUnsafe({ type: "application", subtype: "vnd.custom+json" }) + const onWrapper = HttpApiSchema.WithHeaders(Schema.String, headers).pipe(HttpApiSchema.asJson({ contentType })) assert.isTrue(HttpApiSchema.isWithHeaders(onWrapper)) - assert.strictEqual( - HttpApiSchema.getResponseEncodingSchema(onWrapper).contentType, - "application/vnd.custom+json" - ) + assert.strictEqual(HttpApiSchema.getResponseEncodingSchema(onWrapper).contentType, contentType) const onNeither = HttpApiSchema.WithHeaders(Schema.String, headers) assert.strictEqual(HttpApiSchema.getResponseEncodingSchema(onNeither)._tag, "Json") diff --git a/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts b/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts index 5d3fea1b88e..d92b53180e1 100644 --- a/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts +++ b/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts @@ -224,7 +224,7 @@ describe("HttpApiSchema", () => { expect(stream).type.toBe() expect(stream.mode).type.toBe<"uint8array">() - expect(stream.contentType).type.toBe() + expect(stream.contentType).type.toBe() }) it("preserves the stream schema type when annotated with status", () => { From b3b26c42667a71310facbc8f69cb28fafdbca201 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 01:15:22 +0200 Subject: [PATCH 09/15] Use MediaType for response alternatives --- .changeset/parsed-media-types.md | 2 +- .../effect/src/unstable/http/MediaType.ts | 6 +- .../src/unstable/httpapi/HttpApiClient.ts | 65 ++++++++++++------- .../unstable/httpapi/HttpApiClient.test.ts | 26 ++++++++ 4 files changed, 71 insertions(+), 28 deletions(-) diff --git a/.changeset/parsed-media-types.md b/.changeset/parsed-media-types.md index c0d0310af7f..bd23d362ec1 100644 --- a/.changeset/parsed-media-types.md +++ b/.changeset/parsed-media-types.md @@ -3,4 +3,4 @@ "@effect/openapi-generator": patch --- -Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options and encoding metadata now use parsed media types, formatting them only when producing OpenAPI documents or wire values. HTTP API dispatch and multipart parsing compare validated media-type essences; malformed request content types continue to receive a 415 response. OpenAPI-generated HTTP APIs now construct parsed media types for custom content-type declarations. +Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options and encoding metadata now use parsed media types, formatting them only when producing OpenAPI documents or wire values. HTTP API dispatch, response selection, and multipart parsing compare validated media-type essences; missing response content types remain distinct from empty or malformed fields, and malformed request content types continue to receive a 415 response. OpenAPI-generated HTTP APIs now construct parsed media types for custom content-type declarations. diff --git a/packages/effect/src/unstable/http/MediaType.ts b/packages/effect/src/unstable/http/MediaType.ts index ca5700ddec6..19c5ae08f95 100644 --- a/packages/effect/src/unstable/http/MediaType.ts +++ b/packages/effect/src/unstable/http/MediaType.ts @@ -20,15 +20,15 @@ import * as Predicate from "../../Predicate.ts" import * as Result from "../../Result.ts" /** - * Runtime type identifier for `MediaType` values. + * Type identifier for `MediaType` values. * * @category type IDs * @since 4.0.0 */ -export const TypeId: unique symbol = Symbol.for("~effect/http/MediaType") +export const TypeId = "~effect/http/MediaType" /** - * Type of the unique symbol used to brand `MediaType` values. + * Type of the identifier used to brand `MediaType` values. * * @category type IDs * @since 4.0.0 diff --git a/packages/effect/src/unstable/httpapi/HttpApiClient.ts b/packages/effect/src/unstable/httpapi/HttpApiClient.ts index 1b3472fe51e..e6fcf8d5e33 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiClient.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiClient.ts @@ -339,7 +339,7 @@ export const makeClient = >() for (const [status, schemas] of errors.entries()) { const grouped = groupSchemasByContentType(schemas) - for (const [contentType, schemas] of grouped.entries()) { + for (const { contentType, schemas } of grouped) { addResponseAlternative(errorAlternatives, status, contentType, schemasToResponse(schemas)) } } @@ -366,7 +366,7 @@ export const makeClient = >() for (const [status, schemas] of successes.entries()) { const grouped = groupSchemasByContentType(schemas) - for (const [contentType, schemas] of grouped.entries()) { + for (const { contentType, schemas } of grouped) { addResponseAlternative(successAlternatives, status, contentType, schemasToResponse(schemas)) } } @@ -375,7 +375,7 @@ export const makeClient = Effect.Effect interface ResponseAlternative { - readonly contentType: string + readonly contentType: MediaType.MediaType | undefined readonly decode: ResponseDecoder } function addResponseAlternative( map: Map>, status: number, - contentType: string, + contentType: MediaType.MediaType | undefined, decode: ResponseDecoder ) { const alternatives = map.get(status) @@ -795,9 +795,9 @@ function makeResponseDecoder(alternatives: ReadonlyArray): return first.decode } return (response) => { - const rawContentType = response.headers["content-type"] ?? "" - if (rawContentType === "") { - const alternative = alternatives.find((alternative) => alternative.contentType === "") + const rawContentType = response.headers["content-type"] + if (rawContentType === undefined) { + const alternative = alternatives.find((alternative) => alternative.contentType === undefined) return alternative === undefined ? failUnsupportedContentType(response, rawContentType, alternatives) : alternative.decode(response) @@ -806,47 +806,64 @@ function makeResponseDecoder(alternatives: ReadonlyArray): if (Result.isFailure(parsedContentType)) { return failUnsupportedContentType(response, rawContentType, alternatives) } - const contentType = MediaType.essence(parsedContentType.success) - const alternative = alternatives.find((alternative) => alternative.contentType === contentType) + const alternative = alternatives.find((alternative) => + alternative.contentType !== undefined && + MediaType.sameEssence(alternative.contentType, parsedContentType.success) + ) return alternative === undefined - ? failUnsupportedContentType(response, contentType, alternatives) + ? failUnsupportedContentType(response, rawContentType, alternatives) : alternative.decode(response) } } +interface ResponseSchemaGroup { + readonly contentType: MediaType.MediaType | undefined + readonly schemas: [Schema.Top, ...Array] +} + function groupSchemasByContentType( schemas: Arr.NonEmptyReadonlyArray -): Map> { - const grouped = new Map]>() +): Arr.NonEmptyArray { + const grouped: Array = [] for (const schema of schemas) { const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema const contentType = HttpApiSchema.isNoContent(body.ast) - ? "" - : MediaType.essence(HttpApiSchema.getResponseEncodingSchema(schema).contentType) - const existing = grouped.get(contentType) + ? undefined + : HttpApiSchema.getResponseEncodingSchema(schema).contentType + const existing = grouped.find((group) => + contentType === undefined + ? group.contentType === undefined + : group.contentType !== undefined && MediaType.sameEssence(group.contentType, contentType) + ) if (existing === undefined) { - grouped.set(contentType, [schema]) + grouped.push({ contentType, schemas: [schema] }) } else { - existing.push(schema) + existing.schemas.push(schema) } } - return grouped + return grouped as Arr.NonEmptyArray } function failUnsupportedContentType( response: HttpClientResponse.HttpClientResponse, - contentType: string, + contentType: string | undefined, alternatives: ReadonlyArray ) { - const expected = Array.from(new Set(alternatives.map((alternative) => alternative.contentType))).join(", ") + const expected = Array.from( + new Set( + alternatives.map((alternative) => + alternative.contentType === undefined ? "" : MediaType.format(alternative.contentType) + ) + ) + ).join(", ") + const actual = contentType === undefined ? "" : contentType === "" ? "" : contentType return Effect.fail( new HttpClientError.HttpClientError({ reason: new HttpClientError.DecodeError({ request: response.request, response, - description: `Unsupported response content-type for status ${response.status}: ${ - contentType || "" - }. Expected one of: ${expected}` + description: + `Unsupported response content-type for status ${response.status}: ${actual}. Expected one of: ${expected}` }) }) ) diff --git a/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts b/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts index b4e0ed0aa30..e2a4e2629c8 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts @@ -409,6 +409,32 @@ describe("HttpApiClient", () => { assert.strictEqual(error, "NoContentError") })) + it.effect("does not treat empty or malformed content-type headers as missing", () => + Effect.gen(function*() { + for ( + const [contentType, expected] of [ + ["", ""], + ["not a media type", "not a media type"] + ] as const + ) { + const client = yield* makeClient(() => + new Response(null, { status: 400, headers: { "content-type": contentType } }) + ) + const exit = yield* Effect.exit(client.test.noContent({})) + assert.strictEqual(exit._tag, "Failure") + if (exit._tag === "Failure") { + const decodeError = exit.cause.reasons.find((reason) => + Cause.isFailReason(reason) && HttpClientError.isHttpClientError(reason.error) && + reason.error.reason._tag === "DecodeError" + ) + assert.isDefined(decodeError) + if (Cause.isFailReason(decodeError) && HttpClientError.isHttpClientError(decodeError.error)) { + assert.include(decodeError.error.reason.description, expected) + } + } + } + })) + it.effect("groups schemas by normalized declared content type", () => Effect.gen(function*() { const client = yield* makeClient(() => From 155d534b804df518a58a187da073cd66c118d1e8 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 01:26:12 +0200 Subject: [PATCH 10/15] Prune redundant MediaType parser coverage --- .../test/unstable/http/MediaType.test.ts | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/effect/test/unstable/http/MediaType.test.ts b/packages/effect/test/unstable/http/MediaType.test.ts index 71c7973ee71..bcac628e76f 100644 --- a/packages/effect/test/unstable/http/MediaType.test.ts +++ b/packages/effect/test/unstable/http/MediaType.test.ts @@ -34,7 +34,6 @@ describe("MediaType", () => { it("distinguishes structured suffixes from broad HTTP token syntax", () => { const structured = parse("application/vnd.example+json") strictEqual(MediaType.baseSubtype(structured), "vnd.example") - strictEqual(Option.getOrUndefined(structured.suffix), "json") for (const input of ["application/+json", "application/vnd.*+json", "application/example+", "app*/problem+json"]) { const mediaType = parse(input) @@ -44,31 +43,19 @@ describe("MediaType", () => { } }) - it("accepts every tchar and embedded stars but rejects wildcard ranges", () => { + it("accepts every tchar but rejects wildcard ranges", () => { const token = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" strictEqual(MediaType.essence(parse(`${token}/${token}`)), `${token.toLowerCase()}/${token.toLowerCase()}`) - strictEqual(MediaType.essence(parse("application/vnd.*+json")), "application/vnd.*+json") assertFailure("*/*", "Media type cannot be a wildcard", 0) assertFailure("text/*", "Media subtype cannot be a wildcard", 5) }) it("parses quoted values, escapes, empty separators, and obs-text", () => { - const mediaType = parse("text/plain;;; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\";") + const mediaType = parse("text/plain;;; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"; empty=\"\";") strictEqual(MediaType.getParameter(mediaType, "b").pipe(Option.getOrUndefined), "a; b") strictEqual(MediaType.getParameter(mediaType, "c").pipe(Option.getOrUndefined), "\"\\") strictEqual(MediaType.getParameter(mediaType, "d").pipe(Option.getOrUndefined), "\tÿ") - strictEqual(MediaType.format(mediaType), "text/plain; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"") - }) - - it("handles strict vectors from Go and WHATWG parser corpora", () => { - const vectors = [ - ["text/plain; empty=\"\"", "text/plain; empty=\"\""], - ["application/pdf; name=\"Here's a semicolon;.pdf\"", "application/pdf; name=\"Here's a semicolon;.pdf\""], - ["text/plain; charset=utf-8 \t", "text/plain; charset=utf-8"] - ] as const - for (const [input, expected] of vectors) { - strictEqual(MediaType.format(parse(input)), expected) - } + strictEqual(MediaType.format(mediaType), "text/plain; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"; empty=\"\"") }) it("preserves intentional differences from Go and WHATWG parsers", () => { @@ -94,13 +81,11 @@ describe("MediaType", () => { assertFailure("text/plain; charset=\"unterminated", "Unterminated quoted value for parameter \"charset\"") assertFailure("text/plain; charset=\"x\\\"", "Unterminated quoted value for parameter \"charset\"") assertFailure("text/plain; charset=\"x\r\nInjected: yes\"", "Invalid character in parameter \"charset\"") - assertFailure("text/plain; charset=\"\u0000\"", "Invalid character in parameter \"charset\"") assertFailure("text/plain; charset=\"\u007f\"", "Invalid character in parameter \"charset\"") assertFailure("text/plain; charset=\"\\\u007f\"", "Invalid escape in parameter \"charset\"") assertFailure("text/plain; charset=\"Ā\"", "Invalid character in parameter \"charset\"") assertFailure("text/plain garbage", "Unexpected character \"g\"") assertFailure("text/plain; A=1; a=2", "Duplicate parameter \"a\"") - assertFailure("text/plain; a=1; a", "Expected '=' after parameter \"a\"") }) it("constructs immutable values and rejects invalid parts", () => { From 33a6ed5f502d5c02322c7583fe4dee075bbfe0fc Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 07:36:26 +0200 Subject: [PATCH 11/15] Accept MediaType inputs at API boundaries --- .changeset/parsed-media-types.md | 2 +- .../effect/src/unstable/http/MediaType.ts | 26 ++++++++++++++++ .../src/unstable/httpapi/HttpApiSchema.ts | 30 +++++++++++-------- .../test/unstable/http/MediaType.test.ts | 17 ++++++++++- .../unstable/httpapi/HttpApiSchema.test.ts | 30 +++++++++---------- .../typetest/unstable/http/MediaType.tst.ts | 6 +++- .../unstable/httpapi/HttpApiSchema.tst.ts | 22 +++++--------- .../src/HttpApiTransformer.ts | 8 ++--- .../openapi-generator/src/OpenApiGenerator.ts | 3 +- .../test/OpenApiGenerator.test.ts | 10 +++---- 10 files changed, 96 insertions(+), 58 deletions(-) diff --git a/.changeset/parsed-media-types.md b/.changeset/parsed-media-types.md index bd23d362ec1..84c7d1b225c 100644 --- a/.changeset/parsed-media-types.md +++ b/.changeset/parsed-media-types.md @@ -3,4 +3,4 @@ "@effect/openapi-generator": patch --- -Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options and encoding metadata now use parsed media types, formatting them only when producing OpenAPI documents or wire values. HTTP API dispatch, response selection, and multipart parsing compare validated media-type essences; missing response content types remain distinct from empty or malformed fields, and malformed request content types continue to receive a 415 response. OpenAPI-generated HTTP APIs now construct parsed media types for custom content-type declarations. +Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, input conversion, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options accept media type values, strings, or structured parts and normalize them into parsed encoding metadata, formatting them only when producing OpenAPI documents or wire values. HTTP API dispatch, response selection, and multipart parsing compare validated media-type essences; missing response content types remain distinct from empty or malformed fields, and malformed request content types continue to receive a 415 response. OpenAPI-generated HTTP APIs pass custom content-type strings through these normalized input boundaries. diff --git a/packages/effect/src/unstable/http/MediaType.ts b/packages/effect/src/unstable/http/MediaType.ts index 19c5ae08f95..bfa7f7876ad 100644 --- a/packages/effect/src/unstable/http/MediaType.ts +++ b/packages/effect/src/unstable/http/MediaType.ts @@ -81,6 +81,14 @@ export interface MediaType extends Equal.Equal, Pipeable.Pipeable, Inspectable.I readonly parameters: ReadonlyArray } +/** + * Input accepted when constructing a media type. + * + * @category models + * @since 4.0.0 + */ +export type Input = MediaType | Parts | string + /** * Describes a media type parse failure at an offset in the original input. * @@ -357,6 +365,24 @@ export const parse = (input: string): Result.Result Result.getOrThrow(parse(input)) +/** + * Converts a supported input into a normalized media type. + * + * @category constructors + * @since 4.0.0 + */ +export const fromInput = (input: Input): Result.Result => + isMediaType(input) ? Result.succeed(input) : typeof input === "string" ? parse(input) : make(input) + +/** + * Converts a supported input into a normalized media type, throwing when the + * input is invalid. + * + * @category unsafe + * @since 4.0.0 + */ +export const fromInputUnsafe = (input: Input): MediaType => Result.getOrThrow(fromInput(input)) + /** * Returns the normalized `type/subtype` without parameters. * diff --git a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts index 1068f349d6f..db683ebc87e 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts @@ -351,17 +351,17 @@ const streamSchema = Schema.declare(Stream.isStream) */ export const StreamSse: { (options: { - readonly contentType?: MediaType.MediaType | undefined + readonly contentType?: MediaType.Input | undefined readonly events: Events readonly error?: Error | undefined }): StreamSse (options: { - readonly contentType?: MediaType.MediaType | undefined + readonly contentType?: MediaType.Input | undefined readonly data: Data readonly error?: Error | undefined }): StreamSse, Error, Data["Type"]> } = (options: { - readonly contentType?: MediaType.MediaType | undefined + readonly contentType?: MediaType.Input | undefined readonly events?: Sse.EventCodec | undefined readonly data?: Schema.Constraint | undefined readonly error?: Schema.Constraint | undefined @@ -381,7 +381,9 @@ export const StreamSse: { _tag: "StreamSse", mode: "sse", sseMode: options.events === undefined ? "data" : "events", - contentType: options.contentType ?? defaultStreamMediaType("sse"), + contentType: options.contentType === undefined + ? defaultStreamMediaType("sse") + : MediaType.fromInputUnsafe(options.contentType), events, error: options.error ?? Schema.Never } @@ -395,7 +397,7 @@ export const StreamSse: { * @since 4.0.0 */ export const StreamUint8Array = (options?: { - readonly contentType?: MediaType.MediaType | undefined + readonly contentType?: MediaType.Input | undefined }): StreamUint8Array => Schema.make( streamSchema.ast, @@ -403,7 +405,9 @@ export const StreamUint8Array = (options?: { [StreamSchemaTypeId]: StreamSchemaTypeId, _tag: "StreamUint8Array", mode: "uint8array", - contentType: options?.contentType ?? defaultStreamMediaType("uint8array") + contentType: options?.contentType === undefined + ? defaultStreamMediaType("uint8array") + : MediaType.fromInputUnsafe(options.contentType) } ) @@ -839,12 +843,14 @@ export function asMultipartStream(options?: Multipart_.withLimits.Options) { function asNonMultipartEncoding(self: S, options: { readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" - readonly contentType?: MediaType.MediaType | undefined + readonly contentType?: MediaType.Input | undefined }): S["Rebuild"] { return self.annotate({ "~httpApiEncoding": { _tag: options._tag, - contentType: options.contentType ?? defaultMediaType(options._tag) + contentType: options.contentType === undefined + ? defaultMediaType(options._tag) + : MediaType.fromInputUnsafe(options.contentType) } }) } @@ -871,7 +877,7 @@ function defaultMediaType(_tag: Encoding["_tag"]): MediaType.MediaType { * @since 4.0.0 */ export function asJson(options?: { - readonly contentType?: MediaType.MediaType + readonly contentType?: MediaType.Input }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Json", ...options }) } @@ -887,7 +893,7 @@ export function asJson(options?: { * @since 4.0.0 */ export function asFormUrlEncoded(options?: { - readonly contentType?: MediaType.MediaType + readonly contentType?: MediaType.Input }) { return ( self: S @@ -905,7 +911,7 @@ export function asFormUrlEncoded(options?: { * @since 4.0.0 */ export function asText(options?: { - readonly contentType?: MediaType.MediaType + readonly contentType?: MediaType.Input }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Text", ...options }) @@ -922,7 +928,7 @@ export function asText(options?: { * @since 4.0.0 */ export function asUint8Array(options?: { - readonly contentType?: MediaType.MediaType + readonly contentType?: MediaType.Input }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Uint8Array", ...options }) diff --git a/packages/effect/test/unstable/http/MediaType.test.ts b/packages/effect/test/unstable/http/MediaType.test.ts index bcac628e76f..2db626bfe77 100644 --- a/packages/effect/test/unstable/http/MediaType.test.ts +++ b/packages/effect/test/unstable/http/MediaType.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "@effect/vitest" -import { deepStrictEqual, strictEqual } from "@effect/vitest/utils" +import { deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils" import { Equal, Hash, Option, Result } from "effect" import { MediaType } from "effect/unstable/http" @@ -98,6 +98,21 @@ describe("MediaType", () => { strictEqual(Result.isFailure(MediaType.make({ type: "text", subtype: "plain", parameters: { x: "Ā" } })), true) }) + it("converts supported input shapes", () => { + const existing = MediaType.textPlain + strictEqual(Result.getOrThrow(MediaType.fromInput(existing)), existing) + strictEqual( + MediaType.format(Result.getOrThrow(MediaType.fromInput("Text/Plain; Charset=UTF-8"))), + "text/plain; charset=UTF-8" + ) + strictEqual( + MediaType.format(MediaType.fromInputUnsafe({ type: "application", subtype: "json" })), + "application/json" + ) + strictEqual(Result.isFailure(MediaType.fromInput("invalid")), true) + throws(() => MediaType.fromInputUnsafe("invalid")) + }) + it("recognizes branded MediaType values", () => { strictEqual(MediaType.isMediaType(MediaType.textPlain), true) strictEqual(MediaType.isMediaType({ [MediaType.TypeId]: MediaType.TypeId }), true) diff --git a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts index 5ef2b14e2fb..57c1dd80deb 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts @@ -47,22 +47,17 @@ describe("HttpApiSchema", () => { } }) - it("stores parsed content types", () => { + it("normalizes content type inputs", () => { const events = Schema.Struct({ event: Schema.Literal("custom"), data: Schema.String }) - const contentType = MediaType.makeUnsafe({ - type: "Text", - subtype: "Event-Stream", - parameters: { charset: "UTF-8" } - }) const stream = HttpApiSchema.StreamSse({ - contentType, + contentType: "Text/Event-Stream; Charset=UTF-8", events }) - assert.strictEqual(stream.contentType, contentType) + assert.strictEqual(MediaType.format(stream.contentType), "text/event-stream; charset=UTF-8") }) it("defaults the stream error schema to Never", () => { @@ -110,11 +105,12 @@ describe("HttpApiSchema", () => { }) }) - it("stores custom content type", () => { - const contentType = MediaType.makeUnsafe({ type: "application", subtype: "custom-binary" }) - const stream = HttpApiSchema.StreamUint8Array({ contentType }) + it("normalizes custom content types", () => { + const stream = HttpApiSchema.StreamUint8Array({ + contentType: { type: "Application", subtype: "Custom-Binary" } + }) - assert.strictEqual(stream.contentType, contentType) + assert.strictEqual(MediaType.format(stream.contentType), "application/custom-binary") }) }) @@ -195,10 +191,14 @@ describe("HttpApiSchema", () => { const onInner = HttpApiSchema.WithHeaders(Schema.String.pipe(HttpApiSchema.asText()), headers) assert.strictEqual(HttpApiSchema.getResponseEncodingSchema(onInner)._tag, "Text") - const contentType = MediaType.makeUnsafe({ type: "application", subtype: "vnd.custom+json" }) - const onWrapper = HttpApiSchema.WithHeaders(Schema.String, headers).pipe(HttpApiSchema.asJson({ contentType })) + const onWrapper = HttpApiSchema.WithHeaders(Schema.String, headers).pipe( + HttpApiSchema.asJson({ contentType: "Application/Vnd.Custom+JSON" }) + ) assert.isTrue(HttpApiSchema.isWithHeaders(onWrapper)) - assert.strictEqual(HttpApiSchema.getResponseEncodingSchema(onWrapper).contentType, contentType) + assert.strictEqual( + MediaType.format(HttpApiSchema.getResponseEncodingSchema(onWrapper).contentType), + "application/vnd.custom+json" + ) const onNeither = HttpApiSchema.WithHeaders(Schema.String, headers) assert.strictEqual(HttpApiSchema.getResponseEncodingSchema(onNeither)._tag, "Json") diff --git a/packages/effect/typetest/unstable/http/MediaType.tst.ts b/packages/effect/typetest/unstable/http/MediaType.tst.ts index a73df5f0d54..0a0443f67e0 100644 --- a/packages/effect/typetest/unstable/http/MediaType.tst.ts +++ b/packages/effect/typetest/unstable/http/MediaType.tst.ts @@ -3,10 +3,14 @@ import { MediaType } from "effect/unstable/http" import { describe, expect, it } from "tstyche" describe("MediaType", () => { - it("parse errors and data-last parameter lookup preserve their public types", () => { + it("constructors and data-last parameter lookup preserve their public types", () => { expect(MediaType.parse("text/plain")).type.toBe< Result.Result >() + expect(MediaType.fromInput({ type: "text", subtype: "plain" })).type.toBe< + Result.Result + >() + expect(MediaType.fromInputUnsafe("text/plain")).type.toBe() const mediaType = MediaType.textPlain expect(mediaType.pipe(MediaType.getParameter("charset"))).type.toBe>() }) diff --git a/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts b/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts index d92b53180e1..c32e9e3874c 100644 --- a/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts +++ b/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import type * as Sse from "effect/unstable/encoding/Sse" -import { MediaType } from "effect/unstable/http" +import type { MediaType } from "effect/unstable/http" import { HttpApiSchema } from "effect/unstable/httpapi" import { describe, expect, it } from "tstyche" @@ -18,19 +18,13 @@ describe("HttpApiSchema", () => { }) describe("StreamSse", () => { - it("accepts parsed content types", () => { + it("accepts media type inputs", () => { const Events = Schema.Struct({ event: Schema.String, data: Schema.String }) - const stream = HttpApiSchema.StreamSse({ contentType: MediaType.textPlain, events: Events }) + const stream = HttpApiSchema.StreamSse({ contentType: "text/plain", events: Events }) expect(stream).type.toBe>() }) - it("rejects string content types", () => { - const Events = Schema.Struct({ event: Schema.String, data: Schema.String }) - - expect(HttpApiSchema.StreamSse).type.not.toBeCallableWith({ contentType: "text/plain", events: Events }) - }) - it("preserves event and error schemas", () => { const Events = Schema.Struct({ event: Schema.Literal("user.created"), @@ -132,15 +126,13 @@ describe("HttpApiSchema", () => { }) describe("body encodings", () => { - it("accepts parsed content types", () => { - const schema = Schema.String.pipe(HttpApiSchema.asText({ contentType: MediaType.textPlain })) + it("accepts media type inputs", () => { + const schema = Schema.String.pipe(HttpApiSchema.asText({ + contentType: { type: "text", subtype: "plain" } + })) expect(schema).type.toBe() }) - - it("rejects string content types", () => { - expect(HttpApiSchema.asText).type.not.toBeCallableWith({ contentType: "text/plain" }) - }) }) describe("WithHeaders", () => { diff --git a/packages/tools/openapi-generator/src/HttpApiTransformer.ts b/packages/tools/openapi-generator/src/HttpApiTransformer.ts index 1311eb37434..a4b17e824e9 100644 --- a/packages/tools/openapi-generator/src/HttpApiTransformer.ts +++ b/packages/tools/openapi-generator/src/HttpApiTransformer.ts @@ -51,13 +51,9 @@ export const imports = ( importName: string, options?: { readonly multipart?: boolean | undefined - readonly mediaType?: boolean | undefined } ): string => { - const httpImports = [ - ...(options?.multipart === true ? ["Multipart"] : []), - ...(options?.mediaType === true ? ["MediaType"] : []) - ] + const httpImports = options?.multipart === true ? ["Multipart"] : [] return [ `import * as ${importName} from "effect/Schema"`, ...(httpImports.length === 0 ? [] : [`import { ${httpImports.join(", ")} } from "effect/unstable/http"`]), @@ -299,7 +295,7 @@ const renderResponseSet = ( const joinSchemas = (schemas: ReadonlyArray): string => schemas.length === 1 ? schemas[0] : `[${schemas.join(", ")}]` -const renderMediaType = (contentType: string): string => `MediaType.parseUnsafe(${JSON.stringify(contentType)})` +const renderMediaType = (contentType: string): string => JSON.stringify(contentType) const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => { if (media.effectStream === "sse") { diff --git a/packages/tools/openapi-generator/src/OpenApiGenerator.ts b/packages/tools/openapi-generator/src/OpenApiGenerator.ts index 8c6e7647e26..ed25fb5df29 100644 --- a/packages/tools/openapi-generator/src/OpenApiGenerator.ts +++ b/packages/tools/openapi-generator/src/OpenApiGenerator.ts @@ -179,8 +179,7 @@ export const make = Effect.gen(function*() { return String.stripMargin( `|${ HttpApiTransformer.imports(importName, { - multipart: needsMultipartImport, - mediaType: implementation.includes("MediaType.") + multipart: needsMultipartImport }) } |${generation} diff --git a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts index 2b15e73b0c7..1e581af5e4c 100644 --- a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts +++ b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts @@ -1723,7 +1723,7 @@ export const CreatePayloadRequestText = Schema.String`, [ `export type DualJson200 = { readonly "value": string }`, `export type DualJson200ApplicationProblemJson = { readonly "title": string }`, - `HttpApiEndpoint.get("dualJson", "/dual", { success: [DualJson200, DualJson200ApplicationProblemJson.pipe(HttpApiSchema.asJson({ contentType: MediaType.parseUnsafe("application/problem+json") }))] })` + `HttpApiEndpoint.get("dualJson", "/dual", { success: [DualJson200, DualJson200ApplicationProblemJson.pipe(HttpApiSchema.asJson({ contentType: "application/problem+json" }))] })` ] )) @@ -1828,11 +1828,11 @@ export const CreatePayloadRequestText = Schema.String`, }, [ `HttpApiEndpoint.get("streamEvents", "/events", { success: HttpApiSchema.StreamSse({ events: StreamEvents200Sse, error: StreamEvents200SseError }) })`, - `HttpApiEndpoint.get("streamEventsCustom", "/events/custom", { success: HttpApiSchema.StreamSse({ contentType: MediaType.parseUnsafe("application/custom-sse"), events: StreamEventsCustom200Sse, error: StreamEventsCustom200SseError }) })` + `HttpApiEndpoint.get("streamEventsCustom", "/events/custom", { success: HttpApiSchema.StreamSse({ contentType: "application/custom-sse", events: StreamEventsCustom200Sse, error: StreamEventsCustom200SseError }) })` ], [ `StreamEvents200Sse.pipe(HttpApiSchema.asText())`, - `StreamEventsCustom200ApplicationCustomSse.pipe(HttpApiSchema.asText({ contentType: MediaType.parseUnsafe("application/custom-sse") }))` + `StreamEventsCustom200ApplicationCustomSse.pipe(HttpApiSchema.asText({ contentType: "application/custom-sse" }))` ] )) @@ -1989,11 +1989,11 @@ export const CreatePayloadRequestText = Schema.String`, }, [ `HttpApiEndpoint.get("download", "/download", { success: HttpApiSchema.StreamUint8Array() })`, - `HttpApiEndpoint.get("downloadCustom", "/download/custom", { success: HttpApiSchema.StreamUint8Array({ contentType: MediaType.parseUnsafe("application/custom-bytes") }) })` + `HttpApiEndpoint.get("downloadCustom", "/download/custom", { success: HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) })` ], [ `Download200Binary.pipe(HttpApiSchema.asUint8Array())`, - `DownloadCustom200ApplicationCustomBytes.pipe(HttpApiSchema.asUint8Array({ contentType: MediaType.parseUnsafe("application/custom-bytes") }))` + `DownloadCustom200ApplicationCustomBytes.pipe(HttpApiSchema.asUint8Array({ contentType: "application/custom-bytes" }))` ] )) From 3493c6ea23429f4a8fe8a3518bc25e98771bb77f Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 09:44:37 +0200 Subject: [PATCH 12/15] Simplify MediaType test inputs --- .../test/unstable/http/MediaType.test.ts | 50 +++++++++++-------- .../unstable/httpapi/HttpApiBuilder.test.ts | 27 +++++----- .../unstable/httpapi/HttpApiEndpoint.test.ts | 19 +++---- 3 files changed, 53 insertions(+), 43 deletions(-) diff --git a/packages/effect/test/unstable/http/MediaType.test.ts b/packages/effect/test/unstable/http/MediaType.test.ts index 2db626bfe77..c41ac179b57 100644 --- a/packages/effect/test/unstable/http/MediaType.test.ts +++ b/packages/effect/test/unstable/http/MediaType.test.ts @@ -3,8 +3,6 @@ import { deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils" import { Equal, Hash, Option, Result } from "effect" import { MediaType } from "effect/unstable/http" -const parse = (input: string) => Result.getOrThrow(MediaType.parse(input)) - const assertFailure = ( input: string, message: string, @@ -20,7 +18,7 @@ const assertFailure = ( describe("MediaType", () => { it("parses and normalizes concrete media types", () => { - const mediaType = parse("\t Application/Vnd.Example+JSON ; Charset=utf-8; profile=Example \t") + const mediaType = MediaType.fromInputUnsafe("\t Application/Vnd.Example+JSON ; Charset=utf-8; profile=Example \t") strictEqual(mediaType.type, "application") strictEqual(mediaType.subtype, "vnd.example+json") strictEqual(Option.getOrUndefined(mediaType.suffix), "json") @@ -32,11 +30,11 @@ describe("MediaType", () => { }) it("distinguishes structured suffixes from broad HTTP token syntax", () => { - const structured = parse("application/vnd.example+json") + const structured = MediaType.fromInputUnsafe("application/vnd.example+json") strictEqual(MediaType.baseSubtype(structured), "vnd.example") for (const input of ["application/+json", "application/vnd.*+json", "application/example+", "app*/problem+json"]) { - const mediaType = parse(input) + const mediaType = MediaType.fromInputUnsafe(input) strictEqual(MediaType.baseSubtype(mediaType), mediaType.subtype) strictEqual(Option.isNone(mediaType.suffix), true) strictEqual(MediaType.hasSuffix(mediaType, "json"), false) @@ -45,13 +43,18 @@ describe("MediaType", () => { it("accepts every tchar but rejects wildcard ranges", () => { const token = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - strictEqual(MediaType.essence(parse(`${token}/${token}`)), `${token.toLowerCase()}/${token.toLowerCase()}`) + strictEqual( + MediaType.essence(MediaType.fromInputUnsafe(`${token}/${token}`)), + `${token.toLowerCase()}/${token.toLowerCase()}` + ) assertFailure("*/*", "Media type cannot be a wildcard", 0) assertFailure("text/*", "Media subtype cannot be a wildcard", 5) }) it("parses quoted values, escapes, empty separators, and obs-text", () => { - const mediaType = parse("text/plain;;; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"; empty=\"\";") + const mediaType = MediaType.fromInputUnsafe( + "text/plain;;; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"; empty=\"\";" + ) strictEqual(MediaType.getParameter(mediaType, "b").pipe(Option.getOrUndefined), "a; b") strictEqual(MediaType.getParameter(mediaType, "c").pipe(Option.getOrUndefined), "\"\\") strictEqual(MediaType.getParameter(mediaType, "d").pipe(Option.getOrUndefined), "\tÿ") @@ -63,7 +66,10 @@ describe("MediaType", () => { assertFailure("text/plain; filename={file}.txt", "Expected a value for parameter \"filename\"") assertFailure("text/plain; charset=utf-8; charset=utf-8", "Duplicate parameter \"charset\"") // Go preserves unnecessary backslashes for legacy IE paths; RFC quoted-pair decodes them. - strictEqual(MediaType.format(parse("text/plain; escaped=\"foo\\xbar\"")), "text/plain; escaped=fooxbar") + strictEqual( + MediaType.format(MediaType.fromInputUnsafe("text/plain; escaped=\"foo\\xbar\"")), + "text/plain; escaped=fooxbar" + ) // WHATWG recovers from malformed parameters; this parser validates the complete input. assertFailure("text/html; charset=\"shift_jis\"iso-2022-jp", "Unexpected character \"i\"") assertFailure("text/plain; charset=utf-8; broken", "Expected '=' after parameter \"broken\"") @@ -120,9 +126,9 @@ describe("MediaType", () => { }) it("uses parameter-aware equality and hashing", () => { - const left = parse("TEXT/PLAIN; B=two; a=one") - const right = parse("text/plain; a=\"one\"; b=two") - const different = parse("text/plain; a=ONE; b=two") + const left = MediaType.fromInputUnsafe("TEXT/PLAIN; B=two; a=one") + const right = MediaType.fromInputUnsafe("text/plain; a=\"one\"; b=two") + const different = MediaType.fromInputUnsafe("text/plain; a=ONE; b=two") strictEqual(Equal.equals(left, right), true) strictEqual(Hash.hash(left), Hash.hash(right)) strictEqual(Equal.equals(left, different), false) @@ -130,8 +136,8 @@ describe("MediaType", () => { }) it("supports parameter, essence, and suffix predicates", () => { - const candidate = parse("application/problem+json; charset=utf-8; profile=errors") - const expected = parse("application/problem+json; charset=utf-8") + const candidate = MediaType.fromInputUnsafe("application/problem+json; charset=utf-8; profile=errors") + const expected = MediaType.fromInputUnsafe("application/problem+json; charset=utf-8") strictEqual(MediaType.matchesParameters(candidate, expected), true) strictEqual(MediaType.matchesParameters(expected, candidate), false) strictEqual(candidate.pipe(MediaType.isType("APPLICATION")), true) @@ -142,25 +148,25 @@ describe("MediaType", () => { }) it("applies charset semantics without changing generic parameter identity", () => { - const upper = parse("text/plain; charset=UTF-8; profile=Example") - const lower = parse("text/plain; charset=utf-8; profile=Example") + const upper = MediaType.fromInputUnsafe("text/plain; charset=UTF-8; profile=Example") + const lower = MediaType.fromInputUnsafe("text/plain; charset=utf-8; profile=Example") strictEqual(Option.getOrUndefined(MediaType.getCharset(upper)), "utf-8") strictEqual(MediaType.matchesParameters(upper, lower), true) strictEqual(MediaType.matchesParameters(lower, upper), true) strictEqual(Equal.equals(upper, lower), false) - const differentProfile = parse("text/plain; charset=utf-8; profile=example") + const differentProfile = MediaType.fromInputUnsafe("text/plain; charset=utf-8; profile=example") strictEqual(MediaType.matchesParameters(upper, differentProfile), false) }) it("classifies common media-type families", () => { strictEqual(MediaType.isJson(MediaType.applicationJson), true) - strictEqual(MediaType.isJson(parse("application/problem+json")), true) - strictEqual(MediaType.isJson(parse("text/json")), true) - strictEqual(MediaType.isJson(parse("application/json-seq")), false) - strictEqual(MediaType.isXml(parse("application/atom+xml")), true) - strictEqual(MediaType.isXml(parse("text/xml")), true) - strictEqual(MediaType.isText(parse("text/event-stream")), true) + strictEqual(MediaType.isJson(MediaType.fromInputUnsafe("application/problem+json")), true) + strictEqual(MediaType.isJson(MediaType.fromInputUnsafe("text/json")), true) + strictEqual(MediaType.isJson(MediaType.fromInputUnsafe("application/json-seq")), false) + strictEqual(MediaType.isXml(MediaType.fromInputUnsafe("application/atom+xml")), true) + strictEqual(MediaType.isXml(MediaType.fromInputUnsafe("text/xml")), true) + strictEqual(MediaType.isText(MediaType.fromInputUnsafe("text/event-stream")), true) strictEqual(MediaType.isText(MediaType.applicationJson), false) }) }) diff --git a/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts b/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts index 81ab1d0ad40..93d7f9d849d 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts @@ -11,7 +11,7 @@ import { SchemaTransformation, Stream } from "effect" -import { Etag, HttpPlatform, MediaType } from "effect/unstable/http" +import { Etag, HttpPlatform } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, @@ -26,7 +26,6 @@ import { const textDecoder = new TextDecoder() const StreamError = Schema.Struct({ reason: Schema.String }) -const mediaType = MediaType.parseUnsafe const TestServices = Layer.mergeAll( Path.layer, @@ -62,7 +61,9 @@ it.layer(TestServices)("HttpApiBuilder query parameters", (it) => { it.effect("reuses response schema transformations by source AST", () => { const SharedSuccess = Schema.String.pipe(HttpApiSchema.asText()) - const DistinctSuccess = Schema.String.pipe(HttpApiSchema.asText({ contentType: mediaType("text/custom") })) + const DistinctSuccess = Schema.String.pipe( + HttpApiSchema.asText({ contentType: "text/custom" }) + ) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test") .add(HttpApiEndpoint.get("first", "/first", { success: SharedSuccess })) @@ -98,7 +99,7 @@ it.layer(TestServices)("HttpApiBuilder payload content types", (it) => { Effect.gen(function*() { const Payload = Schema.Struct({ name: Schema.String }).pipe( HttpApiSchema.asJson({ - contentType: mediaType("Application/Vnd.Effect+JSON; profile=declared") + contentType: "Application/Vnd.Effect+JSON; profile=declared" }) ) const Api = HttpApi.make("Api").add( @@ -137,7 +138,9 @@ it.layer(TestServices)("HttpApiBuilder payload content types", (it) => { it.effect("round trips custom form-urlencoded media types", () => Effect.gen(function*() { const Payload = Schema.Struct({ name: Schema.String }).pipe( - HttpApiSchema.asFormUrlEncoded({ contentType: mediaType("application/vnd.effect.form") }) + HttpApiSchema.asFormUrlEncoded({ + contentType: "application/vnd.effect.form" + }) ) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( @@ -498,7 +501,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { const Plain = Schema.Struct({ body: Schema.String, headers: Schema.Struct({ "x-source": Schema.String }) - }).pipe(HttpApiSchema.asJson({ contentType: mediaType("application/vnd.plain+json") })) + }).pipe(HttpApiSchema.asJson({ contentType: "application/vnd.plain+json" })) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("mixed", "/test", { @@ -530,7 +533,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { { "x-source": Schema.String } ) const Plain = Schema.TaggedStruct("Plain", { value: Schema.String }).pipe( - HttpApiSchema.asJson({ contentType: mediaType("application/vnd.plain+json") }) + HttpApiSchema.asJson({ contentType: "application/vnd.plain+json" }) ) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( @@ -555,7 +558,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { HttpApiGroup.make("test").add( HttpApiEndpoint.get("override", "/test", { success: HttpApiSchema.WithHeaders( - Schema.String.pipe(HttpApiSchema.asText({ contentType: MediaType.textPlain })), + Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/plain" })), { "content-type": Schema.String, "x-source": Schema.String } ) }) @@ -797,7 +800,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { HttpApiGroup.make("test").add( HttpApiEndpoint.get("download", "/test", { success: HttpApiSchema.status(206)( - HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-bytes") }) + HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) ) }) ) @@ -829,7 +832,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { HttpApiEndpoint.get("download", "/test", { success: HttpApiSchema.WithHeaders( HttpApiSchema.status(206)( - HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-bytes") }) + HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) ), { "content-type": Schema.String, "x-count": Schema.Int } ) @@ -869,7 +872,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { HttpApiEndpoint.get("events", "/test", { success: HttpApiSchema.status(202)( HttpApiSchema.StreamSse({ - contentType: mediaType("text/event-stream; charset=utf-8"), + contentType: "text/event-stream; charset=utf-8", events: Events, error: StreamError }) @@ -912,7 +915,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { success: HttpApiSchema.WithHeaders( HttpApiSchema.status(202)( HttpApiSchema.StreamSse({ - contentType: mediaType("text/event-stream; charset=utf-8"), + contentType: "text/event-stream; charset=utf-8", events: Events, error: StreamError }) diff --git a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts index 4ce3c9b5b19..8430bd17ead 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiEndpoint.test.ts @@ -8,7 +8,6 @@ const Events = Schema.Struct({ data: Schema.String }) const StreamError = Schema.Struct({ reason: Schema.String }) -const mediaType = MediaType.parseUnsafe const sse = () => HttpApiSchema.StreamSse({ events: Events, error: StreamError }) @@ -52,10 +51,12 @@ describe("HttpApiEndpoint payload schemas", () => { it("rejects incompatible encodings for equivalent content types", () => { const JsonPayload = Schema.Struct({ name: Schema.String }).pipe( - HttpApiSchema.asJson({ contentType: mediaType("Application/Vnd.Effect+Data; charset=utf-8") }) + HttpApiSchema.asJson({ + contentType: "Application/Vnd.Effect+Data; charset=utf-8" + }) ) const TextPayload = Schema.String.pipe( - HttpApiSchema.asText({ contentType: mediaType("application/vnd.effect+data") }) + HttpApiSchema.asText({ contentType: "application/vnd.effect+data" }) ) assert.throws( @@ -139,7 +140,7 @@ describe("HttpApiEndpoint streaming success schemas", () => { HttpApiEndpoint.get("events", "/events", { success: [ HttpApiSchema.StreamSse({ - contentType: mediaType("application/json; charset=utf-8"), + contentType: "application/json; charset=utf-8", events: Events, error: StreamError }), @@ -178,7 +179,7 @@ describe("HttpApiEndpoint streaming success schemas", () => { HttpApiEndpoint.get("events", "/events", { success: [ sse(), - HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-stream") }) + HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" }) ] }) ) @@ -187,7 +188,7 @@ describe("HttpApiEndpoint streaming success schemas", () => { it("two streaming successes for distinct statuses throw", () => { const stream = HttpApiSchema.status(206)(sse()) const bytes = HttpApiSchema.status(200)( - HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-stream") }) + HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" }) ) assert.throws( @@ -413,7 +414,7 @@ describe("HttpApiEndpoint WithHeaders schemas", () => { HttpApiEndpoint.get("events", "/events", { success: [ HttpApiSchema.WithHeaders(sse(), { "x-count": Schema.Int }), - HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-stream") }) + HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" }) ] }) ) @@ -430,11 +431,11 @@ describe("HttpApiEndpoint WithHeaders schemas", () => { HttpApiEndpoint.get("events", "/events", { success: [ HttpApiSchema.status(206)( - HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/custom-bytes") }) + HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) ), HttpApiSchema.WithHeaders( HttpApiSchema.status(201)( - HttpApiSchema.StreamUint8Array({ contentType: mediaType("application/other-bytes") }) + HttpApiSchema.StreamUint8Array({ contentType: "application/other-bytes" }) ), { "x-source": Schema.String } ) From 1f0b967715650c9c9a411dbd78cd9aa40e27f041 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 10:32:53 +0200 Subject: [PATCH 13/15] Fix MediaType config error expectation --- packages/effect/test/Config.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect/test/Config.test.ts b/packages/effect/test/Config.test.ts index bec219b8e39..9f033ba4cb6 100644 --- a/packages/effect/test/Config.test.ts +++ b/packages/effect/test/Config.test.ts @@ -227,7 +227,7 @@ describe("Config", () => { await assertFailure( Config.MediaType("invalid"), provider, - `ExpectedSlash at offset 3 + `Expected '/' after the media type at offset 3 at ["invalid"]` ) }) From 7113b2a0dca4ac33e11915b66708d5ca0ac0d7b5 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 12:22:36 +0200 Subject: [PATCH 14/15] Address MediaType review feedback --- .agents/skills/jsdocs/declarations.md | 8 ++- .changeset/parsed-media-types.md | 3 +- .../src/51_http-server/fixtures/api/Users.ts | 3 +- packages/effect/src/Config.ts | 1 + packages/effect/src/Schema.ts | 7 ++- .../src/unstable/http/HttpServerRequest.ts | 5 +- .../effect/src/unstable/http/MediaType.ts | 11 +++- .../MultipartParser/internal/multipart.ts | 2 +- .../src/unstable/httpapi/HttpApiBuilder.ts | 17 +++--- .../src/unstable/httpapi/HttpApiClient.ts | 43 +++++++------- .../effect/src/unstable/httpapi/OpenApi.ts | 2 +- packages/effect/test/schema/Schema.test.ts | 6 +- .../test/unstable/http/MediaType.test.ts | 56 ++++++++++--------- .../test/unstable/http/Multipart.test.ts | 2 +- .../test/unstable/httpapi/OpenApi.test.ts | 29 +++++----- packages/tools/jsdocs/src/Jsdocs.ts | 9 ++- packages/tools/jsdocs/test/jsdocs.test.ts | 11 ++++ .../src/HttpApiTransformer.ts | 17 +++--- 18 files changed, 131 insertions(+), 101 deletions(-) diff --git a/.agents/skills/jsdocs/declarations.md b/.agents/skills/jsdocs/declarations.md index 62937bd366b..de55728ea5b 100644 --- a/.agents/skills/jsdocs/declarations.md +++ b/.agents/skills/jsdocs/declarations.md @@ -53,11 +53,13 @@ Declaration tags appear in this order: 1. `@deprecated` 2. `@default` 3. `@see` -4. `@category` -5. `@since` +4. `@unstable` +5. `@category` +6. `@since` - Roots require stable-semver `@since` and no `@default`; category requirements - live in [categories.md](categories.md). + live in [categories.md](categories.md). Root declarations may include one + valueless `@unstable` marker. - Namespaces and their declarations require stable-semver `@since`, permit `@category`, and reject `@default`. - Member JSDoc is optional; when present it permits stable-semver `@since` and diff --git a/.changeset/parsed-media-types.md b/.changeset/parsed-media-types.md index 84c7d1b225c..d0d4fa4af16 100644 --- a/.changeset/parsed-media-types.md +++ b/.changeset/parsed-media-types.md @@ -1,6 +1,7 @@ --- "effect": patch +"@effect/jsdocs": patch "@effect/openapi-generator": patch --- -Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, input conversion, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options accept media type values, strings, or structured parts and normalize them into parsed encoding metadata, formatting them only when producing OpenAPI documents or wire values. HTTP API dispatch, response selection, and multipart parsing compare validated media-type essences; missing response content types remain distinct from empty or malformed fields, and malformed request content types continue to receive a 415 response. OpenAPI-generated HTTP APIs pass custom content-type strings through these normalized input boundaries. +Add a parsed `effect/unstable/http/MediaType` value with validated construction, deterministic formatting, input conversion, parameter and RFC 6838 structured-suffix access, charset-aware matching, common JSON/XML/text predicates, `Schema.MediaType` codecs, and `Config.MediaType` support. `HttpApiSchema` content-type options accept media type values, strings, or structured parts and normalize them into parsed encoding metadata. `PayloadEncoding["contentType"]`, `ResponseEncoding["contentType"]`, and `StreamSchema["contentType"]` now return `MediaType` values instead of strings; use `MediaType.format` when a string is required. HTTP API dispatch, response selection, multipart parsing, and OpenAPI generation compare validated media-type essences. Missing response content types remain distinct from empty or malformed fields, and malformed request content types continue to receive a 415 response. OpenAPI-generated HTTP APIs pass custom content-type strings through these normalized input boundaries. The JSDoc checker now accepts the valueless `@unstable` marker on public declarations. diff --git a/ai-docs/src/51_http-server/fixtures/api/Users.ts b/ai-docs/src/51_http-server/fixtures/api/Users.ts index 923c98b858f..ad370e1631b 100644 --- a/ai-docs/src/51_http-server/fixtures/api/Users.ts +++ b/ai-docs/src/51_http-server/fixtures/api/Users.ts @@ -1,5 +1,4 @@ import { Schema } from "effect" -import { MediaType } from "effect/unstable/http" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { User, UserId } from "../domain/User.ts" import { SearchQueryTooShort, UserNotFound } from "../domain/UserErrors.ts" @@ -24,7 +23,7 @@ export class UsersApiGroup extends HttpApiGroup.make("users") success: [ Schema.Array(User.json), Schema.String.pipe(HttpApiSchema.asText({ - contentType: MediaType.makeUnsafe({ type: "text", subtype: "csv" }) + contentType: "text/csv" })) ], error: [ diff --git a/packages/effect/src/Config.ts b/packages/effect/src/Config.ts index 03c49586ed7..177ba485502 100644 --- a/packages/effect/src/Config.ts +++ b/packages/effect/src/Config.ts @@ -1538,6 +1538,7 @@ export function URL(name?: string) { * * This is a shortcut for `Config.schema(Schema.MediaType, name)`. * + * @unstable * @category constructors * @since 4.0.0 */ diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 1eb87ce99f8..c65552273e9 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -11892,6 +11892,7 @@ export const URLFromString: URLFromString = URLString.pipe(decodeTo(URL, SchemaT /** * Type-level representation of {@link MediaType}. * + * @unstable * @category models * @since 4.0.0 */ @@ -11907,7 +11908,7 @@ const mediaTypeTransformation = SchemaTransformation.transformOrFail({ return Result_.isFailure(result) ? Effect.fail( new SchemaIssue.InvalidValue( - { message: result.failure.message }, + { message: `${result.failure.message} at offset ${result.failure.offset}` }, input, options ) @@ -11922,6 +11923,7 @@ const mediaTypeTransformation = SchemaTransformation.transformOrFail({ * * @see {@link MediaTypeFromString} for decoding media types from strings * + * @unstable * @category schemas * @since 4.0.0 */ @@ -11940,6 +11942,7 @@ export const MediaType: MediaType = declare(MediaType_.isMediaType, { /** * Reviver for persisted {@link MediaType} declarations. * + * @unstable * @category schemas * @since 4.0.0 */ @@ -11951,6 +11954,7 @@ export const MediaTypeReviver = makeFixedDeclarationReviver( /** * Type-level representation of {@link MediaTypeFromString}. * + * @unstable * @category models * @since 4.0.0 */ @@ -11963,6 +11967,7 @@ export interface MediaTypeFromString extends decodeTo { * * @see {@link MediaType} for validating already parsed media types * + * @unstable * @category schemas * @since 4.0.0 */ diff --git a/packages/effect/src/unstable/http/HttpServerRequest.ts b/packages/effect/src/unstable/http/HttpServerRequest.ts index 7b9c386c416..bbbcad4ce0b 100644 --- a/packages/effect/src/unstable/http/HttpServerRequest.ts +++ b/packages/effect/src/unstable/http/HttpServerRequest.ts @@ -34,7 +34,6 @@ import * as HttpIncomingMessage from "./HttpIncomingMessage.ts" import { hasBody, type HttpMethod } from "./HttpMethod.ts" import { HttpServerError, type RequestError, RequestParseError } from "./HttpServerError.ts" import * as bodyInternal from "./internal/httpBody.ts" -import * as MediaType from "./MediaType.ts" import * as Multipart from "./Multipart.ts" import * as UrlParams from "./UrlParams.ts" @@ -253,8 +252,8 @@ export const schemaBodyJson = ( } const isMultipart = (request: HttpServerRequest) => { - const contentType = MediaType.parse(request.headers["content-type"] ?? "") - return (Result.isSuccess(contentType) && MediaType.sameEssence(contentType.success, MediaType.multipartFormData)) || + const contentType = request.headers["content-type"] + return contentType?.toLowerCase().includes("multipart/form-data") === true || getFormDataBody(request) !== undefined } diff --git a/packages/effect/src/unstable/http/MediaType.ts b/packages/effect/src/unstable/http/MediaType.ts index bfa7f7876ad..479724ac7ae 100644 --- a/packages/effect/src/unstable/http/MediaType.ts +++ b/packages/effect/src/unstable/http/MediaType.ts @@ -210,6 +210,9 @@ const suffixOf = (type: string, subtype: string): Option.Option => { } const fromValidated = (type: string, subtype: string, parameters: Array): MediaType => { + parameters = parameters.map((parameter) => + parameter.name === "charset" ? { name: parameter.name, value: parameter.value.toLowerCase() } : parameter + ) parameters.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0) const self = Object.create(Proto) self.type = type @@ -220,7 +223,7 @@ const fromValidated = (type: string, subtype: string, parameters: Array - Result.fail(new MediaTypeParseError({ input, offset, message: `${message} at offset ${offset}` })) + Result.fail(new MediaTypeParseError({ input, offset, message })) /** * Creates a concrete media type from validated parts. @@ -238,7 +241,7 @@ export const make = (parts: Parts): Result.Result() const entries = parts.parameters === undefined ? [] - : Symbol.iterator in Object(parts.parameters) + : Symbol.iterator in parts.parameters ? parts.parameters as Iterable : Object.entries(parts.parameters) for (const [rawName, value] of entries) { @@ -302,7 +305,9 @@ export const parse = (input: string): Result.Result [parameter.name, parameter.value])), contentDisposition: contentDisposition.value, contentDispositionParameters: contentDisposition.parameters as any, diff --git a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index 0a61e73d2d2..7190f02d37a 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -699,14 +699,17 @@ function decodePayload( query: Record> ): Effect.Effect | HttpServerResponse | undefined { const hasBody = HttpMethod.hasBody(httpRequest.method) - const rawContentType = hasBody - ? httpRequest.headers["content-type"] ?? "application/json" - : "application/x-www-form-urlencoded" - const parsedContentType = MediaType.parse(rawContentType) - if (Result.isFailure(parsedContentType)) { - return Response.text(`Unsupported content-type: ${rawContentType}`, { status: 415 }) + const rawContentType = hasBody ? httpRequest.headers["content-type"] : undefined + let contentType: string + if (rawContentType === undefined) { + contentType = MediaType.essence(hasBody ? MediaType.applicationJson : MediaType.applicationFormUrlEncoded) + } else { + const parsedContentType = MediaType.parse(rawContentType) + if (Result.isFailure(parsedContentType)) { + return Response.text(`Unsupported content-type: ${rawContentType}`, { status: 415 }) + } + contentType = MediaType.essence(parsedContentType.success) } - const contentType = MediaType.essence(parsedContentType.success) const existing = payloadBy.get(contentType) if (!existing) { return Response.text(`Unsupported content-type: ${contentType}`, { status: 415 }) diff --git a/packages/effect/src/unstable/httpapi/HttpApiClient.ts b/packages/effect/src/unstable/httpapi/HttpApiClient.ts index e6fcf8d5e33..f82926b7a41 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiClient.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiClient.ts @@ -339,8 +339,13 @@ export const makeClient = >() for (const [status, schemas] of errors.entries()) { const grouped = groupSchemasByContentType(schemas) - for (const { contentType, schemas } of grouped) { - addResponseAlternative(errorAlternatives, status, contentType, schemasToResponse(schemas)) + for (const [contentType, schemas] of grouped) { + addResponseAlternative( + errorAlternatives, + status, + contentType === "" ? undefined : MediaType.parseUnsafe(contentType), + schemasToResponse(schemas) + ) } } for (const [status, alternatives] of errorAlternatives.entries()) { @@ -366,8 +371,13 @@ export const makeClient = >() for (const [status, schemas] of successes.entries()) { const grouped = groupSchemasByContentType(schemas) - for (const { contentType, schemas } of grouped) { - addResponseAlternative(successAlternatives, status, contentType, schemasToResponse(schemas)) + for (const [contentType, schemas] of grouped) { + addResponseAlternative( + successAlternatives, + status, + contentType === "" ? undefined : MediaType.parseUnsafe(contentType), + schemasToResponse(schemas) + ) } } for (const streamSuccess of getStreamSuccessSchemas(endpoint)) { @@ -816,32 +826,23 @@ function makeResponseDecoder(alternatives: ReadonlyArray): } } -interface ResponseSchemaGroup { - readonly contentType: MediaType.MediaType | undefined - readonly schemas: [Schema.Top, ...Array] -} - function groupSchemasByContentType( schemas: Arr.NonEmptyReadonlyArray -): Arr.NonEmptyArray { - const grouped: Array = [] +): Map> { + const grouped = new Map>() for (const schema of schemas) { const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema const contentType = HttpApiSchema.isNoContent(body.ast) - ? undefined - : HttpApiSchema.getResponseEncodingSchema(schema).contentType - const existing = grouped.find((group) => - contentType === undefined - ? group.contentType === undefined - : group.contentType !== undefined && MediaType.sameEssence(group.contentType, contentType) - ) + ? "" + : MediaType.essence(HttpApiSchema.getResponseEncodingSchema(schema).contentType) + const existing = grouped.get(contentType) if (existing === undefined) { - grouped.push({ contentType, schemas: [schema] }) + grouped.set(contentType, [schema]) } else { - existing.schemas.push(schema) + existing.push(schema) } } - return grouped as Arr.NonEmptyArray + return grouped } function failUnsupportedContentType( diff --git a/packages/effect/src/unstable/httpapi/OpenApi.ts b/packages/effect/src/unstable/httpapi/OpenApi.ts index 883e824635d..6aaf69fb451 100644 --- a/packages/effect/src/unstable/httpapi/OpenApi.ts +++ b/packages/effect/src/unstable/httpapi/OpenApi.ts @@ -573,7 +573,7 @@ function makeOpenApi( for (const schema of HttpApiEndpoint.getPayloadSchemas(endpoint)) { if (HttpApiSchema.isNoContent(schema.ast)) continue const encoding = HttpApiSchema.getPayloadEncoding(schema.ast, endpoint.method) - const contentType = MediaType.format(encoding.contentType) + const contentType = MediaType.essence(encoding.contentType) const existing = schemasByContentType.get(contentType) if (existing === undefined) { schemasByContentType.set(contentType, { encoding, schemas: [schema] }) diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index ebf1b900cbb..602f9884710 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -5786,11 +5786,11 @@ Expected a value between -2147483648 and 2147483647` const json = new TestSchema.Asserts(Schema.toCodecJson(Schema.MediaType)) await json.decoding().succeed("Text/Plain; Charset=UTF-8", mediaType) - await json.encoding().succeed(mediaType, "text/plain; charset=UTF-8") + await json.encoding().succeed(mediaType, "text/plain; charset=utf-8") const stringTree = new TestSchema.Asserts(Schema.toCodecStringTree(Schema.MediaType)) await stringTree.decoding().succeed("Text/Plain; Charset=UTF-8", mediaType) - await stringTree.encoding().succeed(mediaType, "text/plain; charset=UTF-8") + await stringTree.encoding().succeed(mediaType, "text/plain; charset=utf-8") }) it("RegExp", async () => { @@ -5825,7 +5825,7 @@ Expected a value between -2147483648 and 2147483647` const asserts = new TestSchema.Asserts(Schema.MediaTypeFromString) await asserts.decoding().succeed("Text/Plain; Charset=UTF-8", mediaType) await asserts.decoding().fail("not a media type", "Expected '/' after the media type at offset 3") - await asserts.encoding().succeed(mediaType, "text/plain; charset=UTF-8") + await asserts.encoding().succeed(mediaType, "text/plain; charset=utf-8") }) describe("UnknownFromJsonString / fromJsonString", () => { diff --git a/packages/effect/test/unstable/http/MediaType.test.ts b/packages/effect/test/unstable/http/MediaType.test.ts index c41ac179b57..bbf48c75429 100644 --- a/packages/effect/test/unstable/http/MediaType.test.ts +++ b/packages/effect/test/unstable/http/MediaType.test.ts @@ -6,13 +6,13 @@ import { MediaType } from "effect/unstable/http" const assertFailure = ( input: string, message: string, - offset?: number + offset: number ) => { const result = MediaType.parse(input) strictEqual(Result.isFailure(result), true) if (Result.isFailure(result)) { - strictEqual(result.failure.message, `${message} at offset ${result.failure.offset}`) - if (offset !== undefined) strictEqual(result.failure.offset, offset) + strictEqual(result.failure.message, message) + strictEqual(result.failure.offset, offset) } } @@ -51,9 +51,9 @@ describe("MediaType", () => { assertFailure("text/*", "Media subtype cannot be a wildcard", 5) }) - it("parses quoted values, escapes, empty separators, and obs-text", () => { + it("parses quoted values, escapes, and obs-text", () => { const mediaType = MediaType.fromInputUnsafe( - "text/plain;;; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"; empty=\"\";" + "text/plain; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"; empty=\"\"" ) strictEqual(MediaType.getParameter(mediaType, "b").pipe(Option.getOrUndefined), "a; b") strictEqual(MediaType.getParameter(mediaType, "c").pipe(Option.getOrUndefined), "\"\\") @@ -63,35 +63,37 @@ describe("MediaType", () => { it("preserves intentional differences from Go and WHATWG parsers", () => { // Go's MIME grammar accepts braces and equal duplicate parameters; RFC 9110 does not. - assertFailure("text/plain; filename={file}.txt", "Expected a value for parameter \"filename\"") - assertFailure("text/plain; charset=utf-8; charset=utf-8", "Duplicate parameter \"charset\"") + assertFailure("text/plain; filename={file}.txt", "Expected a value for parameter \"filename\"", 21) + assertFailure("text/plain; charset=utf-8; charset=utf-8", "Duplicate parameter \"charset\"", 27) // Go preserves unnecessary backslashes for legacy IE paths; RFC quoted-pair decodes them. strictEqual( MediaType.format(MediaType.fromInputUnsafe("text/plain; escaped=\"foo\\xbar\"")), "text/plain; escaped=fooxbar" ) // WHATWG recovers from malformed parameters; this parser validates the complete input. - assertFailure("text/html; charset=\"shift_jis\"iso-2022-jp", "Unexpected character \"i\"") - assertFailure("text/plain; charset=utf-8; broken", "Expected '=' after parameter \"broken\"") + assertFailure("text/html; charset=\"shift_jis\"iso-2022-jp", "Unexpected character \"i\"", 30) + assertFailure("text/plain; charset=utf-8; broken", "Expected '=' after parameter \"broken\"", 33) // HTTP OWS is SP / HTAB, not arbitrary Unicode whitespace. - assertFailure("text/plain;\u00a0charset=utf-8", "Expected a parameter name after ';'") + assertFailure("text/plain;\u00a0charset=utf-8", "Expected a parameter name after ';'", 11) }) it("rejects malformed input with a structured error", () => { - assertFailure("", "Expected a media type") - assertFailure("text", "Expected '/' after the media type") - assertFailure("text/", "Expected a media subtype after '/'") - assertFailure("text /plain", "Expected '/' after the media type") - assertFailure("text/plain; charset =utf-8", "Expected '=' after parameter \"charset\"") - assertFailure("text/plain; charset=", "Expected a value for parameter \"charset\"") - assertFailure("text/plain; charset=\"unterminated", "Unterminated quoted value for parameter \"charset\"") - assertFailure("text/plain; charset=\"x\\\"", "Unterminated quoted value for parameter \"charset\"") - assertFailure("text/plain; charset=\"x\r\nInjected: yes\"", "Invalid character in parameter \"charset\"") - assertFailure("text/plain; charset=\"\u007f\"", "Invalid character in parameter \"charset\"") - assertFailure("text/plain; charset=\"\\\u007f\"", "Invalid escape in parameter \"charset\"") - assertFailure("text/plain; charset=\"Ā\"", "Invalid character in parameter \"charset\"") - assertFailure("text/plain garbage", "Unexpected character \"g\"") - assertFailure("text/plain; A=1; a=2", "Duplicate parameter \"a\"") + assertFailure("", "Expected a media type", 0) + assertFailure("text", "Expected '/' after the media type", 4) + assertFailure("text/", "Expected a media subtype after '/'", 5) + assertFailure("text /plain", "Expected '/' after the media type", 4) + assertFailure("text/plain; charset =utf-8", "Expected '=' after parameter \"charset\"", 19) + assertFailure("text/plain; charset=", "Expected a value for parameter \"charset\"", 20) + assertFailure("text/plain; charset=\"unterminated", "Unterminated quoted value for parameter \"charset\"", 33) + assertFailure("text/plain; charset=\"x\\\"", "Unterminated quoted value for parameter \"charset\"", 24) + assertFailure("text/plain; charset=\"x\r\nInjected: yes\"", "Invalid character in parameter \"charset\"", 22) + assertFailure("text/plain; charset=\"\u007f\"", "Invalid character in parameter \"charset\"", 21) + assertFailure("text/plain; charset=\"\\\u007f\"", "Invalid escape in parameter \"charset\"", 21) + assertFailure("text/plain; charset=\"Ā\"", "Invalid character in parameter \"charset\"", 21) + assertFailure("text/plain garbage", "Unexpected character \"g\"", 11) + assertFailure("text/plain; A=1; a=2", "Duplicate parameter \"a\"", 17) + assertFailure("text/plain;;;", "Expected a parameter name after ';'", 11) + assertFailure("text/plain;", "Expected a parameter name after ';'", 11) }) it("constructs immutable values and rejects invalid parts", () => { @@ -109,7 +111,7 @@ describe("MediaType", () => { strictEqual(Result.getOrThrow(MediaType.fromInput(existing)), existing) strictEqual( MediaType.format(Result.getOrThrow(MediaType.fromInput("Text/Plain; Charset=UTF-8"))), - "text/plain; charset=UTF-8" + "text/plain; charset=utf-8" ) strictEqual( MediaType.format(MediaType.fromInputUnsafe({ type: "application", subtype: "json" })), @@ -147,13 +149,13 @@ describe("MediaType", () => { strictEqual(Option.isNone(MediaType.getParameter(candidate, "not valid")), true) }) - it("applies charset semantics without changing generic parameter identity", () => { + it("normalizes charset values", () => { const upper = MediaType.fromInputUnsafe("text/plain; charset=UTF-8; profile=Example") const lower = MediaType.fromInputUnsafe("text/plain; charset=utf-8; profile=Example") strictEqual(Option.getOrUndefined(MediaType.getCharset(upper)), "utf-8") strictEqual(MediaType.matchesParameters(upper, lower), true) strictEqual(MediaType.matchesParameters(lower, upper), true) - strictEqual(Equal.equals(upper, lower), false) + strictEqual(Equal.equals(upper, lower), true) const differentProfile = MediaType.fromInputUnsafe("text/plain; charset=utf-8; profile=example") strictEqual(MediaType.matchesParameters(upper, differentProfile), false) diff --git a/packages/effect/test/unstable/http/Multipart.test.ts b/packages/effect/test/unstable/http/Multipart.test.ts index cf649840711..0a09a6ebbe7 100644 --- a/packages/effect/test/unstable/http/Multipart.test.ts +++ b/packages/effect/test/unstable/http/Multipart.test.ts @@ -37,7 +37,7 @@ describe("Multipart", () => { parser.end() strictEqual(parts[0].contentType, "text/plain") - deepStrictEqual(parts[0].contentTypeParameters, { charset: "UTF-8", profile: "a b" }) + deepStrictEqual(parts[0].contentTypeParameters, { charset: "utf-8", profile: "a b" }) deepStrictEqual(errors, []) }) diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index 4e0b8d70544..43b18889524 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -126,11 +126,9 @@ describe("OpenApi", () => { assert.strictEqual(cached.info.title, "Api") }) - it("preserves every declared payload content type for normalized equivalents", () => { + it("groups payload content types by essence", () => { const profileAMediaType = MediaType.parseUnsafe("Application/Vnd.Effect+JSON; Profile=A") const profileBMediaType = MediaType.parseUnsafe("application/vnd.effect+json; profile=b") - const profileA = MediaType.format(profileAMediaType) - const profileB = MediaType.format(profileBMediaType) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.post("create", "/create", { @@ -146,19 +144,18 @@ describe("OpenApi", () => { const content = spec.paths["/create"]?.post?.requestBody?.content assert.isDefined(content) - assert.property(content, profileA) - assert.property(content, profileB) - assert.deepStrictEqual(content[profileA]?.schema, { - type: "object", - properties: { a: { type: "string" } }, - required: ["a"], - additionalProperties: false - }) - assert.deepStrictEqual(content[profileB]?.schema, { - type: "object", - properties: { b: { type: "string" } }, - required: ["b"], - additionalProperties: false + assert.deepStrictEqual(content["application/vnd.effect+json"]?.schema, { + anyOf: [{ + type: "object", + properties: { a: { type: "string" } }, + required: ["a"], + additionalProperties: false + }, { + type: "object", + properties: { b: { type: "string" } }, + required: ["b"], + additionalProperties: false + }] }) }) diff --git a/packages/tools/jsdocs/src/Jsdocs.ts b/packages/tools/jsdocs/src/Jsdocs.ts index a5b9d0d0afc..bf0a4c5b524 100644 --- a/packages/tools/jsdocs/src/Jsdocs.ts +++ b/packages/tools/jsdocs/src/Jsdocs.ts @@ -1460,7 +1460,7 @@ function buildTags( ): Result { const diagnostics: Array = [] const allowed = scope === "declaration" - ? new Set(["deprecated", "see", "category", "since"]) + ? new Set(["deprecated", "see", "unstable", "category", "since"]) : scope === "member" ? new Set(["deprecated", "default", "see", "since"]) : scope === "module" @@ -1511,6 +1511,13 @@ function buildTags( } const deprecated = values.get("deprecated")?.[0] ?? null if (deprecated === "") diagnostics.push(diagnostic("empty-tag", "@deprecated must include a message")) + const unstable = values.get("unstable") ?? [] + if (unstable.length > 1) { + diagnostics.push(diagnostic("duplicate-tag", "JSDoc blocks may contain at most one @unstable tag")) + } + if (unstable[0] !== undefined && unstable[0] !== "") { + diagnostics.push(diagnostic("non-empty-tag", "@unstable must not include a value")) + } const since = values.get("since")?.[0] ?? null if ((scope === "declaration" || scope === "namespace" || scope === "namespace-declaration") && since === null) { diagnostics.push( diff --git a/packages/tools/jsdocs/test/jsdocs.test.ts b/packages/tools/jsdocs/test/jsdocs.test.ts index a9f86a7a17b..97bb2667fe4 100644 --- a/packages/tools/jsdocs/test/jsdocs.test.ts +++ b/packages/tools/jsdocs/test/jsdocs.test.ts @@ -33,6 +33,17 @@ describe("jsdocs", () => { } }) + it("accepts an unstable marker on declarations", () => { + const result = parseJSDoc(`/** + * Creates an unstable value. + * + * @unstable + * @category constructors + * @since 1.0.0 + */`) + assert.strictEqual(result._tag, "Success") + }) + it("accepts doctest metadata on TypeScript fences", () => { const result = parseJSDoc(`/** * Creates a value. diff --git a/packages/tools/openapi-generator/src/HttpApiTransformer.ts b/packages/tools/openapi-generator/src/HttpApiTransformer.ts index a4b17e824e9..5d65422ad12 100644 --- a/packages/tools/openapi-generator/src/HttpApiTransformer.ts +++ b/packages/tools/openapi-generator/src/HttpApiTransformer.ts @@ -53,10 +53,9 @@ export const imports = ( readonly multipart?: boolean | undefined } ): string => { - const httpImports = options?.multipart === true ? ["Multipart"] : [] return [ `import * as ${importName} from "effect/Schema"`, - ...(httpImports.length === 0 ? [] : [`import { ${httpImports.join(", ")} } from "effect/unstable/http"`]), + ...(options?.multipart === true ? [`import { Multipart } from "effect/unstable/http"`] : []), `import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi"` ].join("\n") } @@ -295,13 +294,11 @@ const renderResponseSet = ( const joinSchemas = (schemas: ReadonlyArray): string => schemas.length === 1 ? schemas[0] : `[${schemas.join(", ")}]` -const renderMediaType = (contentType: string): string => JSON.stringify(contentType) - const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => { if (media.effectStream === "sse") { const options = media.contentType === "text/event-stream" ? `{ events: ${media.schema}, error: ${media.errorSchema} }` - : `{ contentType: ${renderMediaType(media.contentType)}, events: ${media.schema}, error: ${media.errorSchema} }` + : `{ contentType: ${JSON.stringify(media.contentType)}, events: ${media.schema}, error: ${media.errorSchema} }` return `HttpApiSchema.StreamSse(${options})` } @@ -309,7 +306,7 @@ const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => { if (media.contentType === "application/octet-stream") { return "HttpApiSchema.StreamUint8Array()" } - return `HttpApiSchema.StreamUint8Array({ contentType: ${renderMediaType(media.contentType)} })` + return `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(media.contentType)} })` } switch (media.encoding) { @@ -317,7 +314,7 @@ const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => { if (media.contentType === "application/json") { return media.schema } - return `${media.schema}.pipe(HttpApiSchema.asJson({ contentType: ${renderMediaType(media.contentType)} }))` + return `${media.schema}.pipe(HttpApiSchema.asJson({ contentType: ${JSON.stringify(media.contentType)} }))` } case "multipart": { return `${media.schema}.pipe(HttpApiSchema.asMultipart())` @@ -327,20 +324,20 @@ const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => { return `${media.schema}.pipe(HttpApiSchema.asFormUrlEncoded())` } return `${media.schema}.pipe(HttpApiSchema.asFormUrlEncoded({ contentType: ${ - renderMediaType(media.contentType) + JSON.stringify(media.contentType) } }))` } case "text": { if (media.contentType === "text/plain") { return `${media.schema}.pipe(HttpApiSchema.asText())` } - return `${media.schema}.pipe(HttpApiSchema.asText({ contentType: ${renderMediaType(media.contentType)} }))` + return `${media.schema}.pipe(HttpApiSchema.asText({ contentType: ${JSON.stringify(media.contentType)} }))` } case "binary": { if (media.contentType === "application/octet-stream") { return `${media.schema}.pipe(HttpApiSchema.asUint8Array())` } - return `${media.schema}.pipe(HttpApiSchema.asUint8Array({ contentType: ${renderMediaType(media.contentType)} }))` + return `${media.schema}.pipe(HttpApiSchema.asUint8Array({ contentType: ${JSON.stringify(media.contentType)} }))` } } } From 1b25b4c5c7f09fe890f4703dc16a129dff698ba0 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 1 Sep 2026 12:29:58 +0200 Subject: [PATCH 15/15] Fix normalized charset expectation --- packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts index 57c1dd80deb..774c6f2b25a 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts @@ -57,7 +57,7 @@ describe("HttpApiSchema", () => { events }) - assert.strictEqual(MediaType.format(stream.contentType), "text/event-stream; charset=UTF-8") + assert.strictEqual(MediaType.format(stream.contentType), "text/event-stream; charset=utf-8") }) it("defaults the stream error schema to Never", () => {