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 new file mode 100644 index 00000000000..d0d4fa4af16 --- /dev/null +++ b/.changeset/parsed-media-types.md @@ -0,0 +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. `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/packages/effect/src/Config.ts b/packages/effect/src/Config.ts index c6eadaba48c..177ba485502 100644 --- a/packages/effect/src/Config.ts +++ b/packages/effect/src/Config.ts @@ -1531,6 +1531,21 @@ 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)`. + * + * @unstable + * @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 1073798fe4f..c65552273e9 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,92 @@ export interface URLFromString extends decodeTo { */ export const URLFromString: URLFromString = URLString.pipe(decodeTo(URL, SchemaTransformation.urlFromString)) +/** + * Type-level representation of {@link MediaType}. + * + * @unstable + * @category models + * @since 4.0.0 + */ +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) + return Result_.isFailure(result) + ? Effect.fail( + new SchemaIssue.InvalidValue( + { message: `${result.failure.message} 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 + * + * @unstable + * @category schemas + * @since 4.0.0 + */ +export const MediaType: MediaType = declare(MediaType_.isMediaType, { + representation: { id: "effect/schema/MediaType", payload: null }, + toCode: () => ({ + runtime: "Schema.MediaType", + Type: "MediaType.MediaType", + importDeclarations: [`import * as MediaType from "effect/unstable/http/MediaType"`] + }), + expected: "MediaType", + toEquivalence: () => MediaType_.Equivalence, + toCodecJson: () => link()(MediaTypeString, mediaTypeTransformation) +}) + +/** + * Reviver for persisted {@link MediaType} declarations. + * + * @unstable + * @category schemas + * @since 4.0.0 + */ +export const MediaTypeReviver = makeFixedDeclarationReviver( + "effect/schema/MediaType", + MediaType +) + +/** + * Type-level representation of {@link MediaTypeFromString}. + * + * @unstable + * @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 + * + * @unstable + * @category schemas + * @since 4.0.0 + */ +export const MediaTypeFromString: MediaTypeFromString = MediaTypeString.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..bbbcad4ce0b 100644 --- a/packages/effect/src/unstable/http/HttpServerRequest.ts +++ b/packages/effect/src/unstable/http/HttpServerRequest.ts @@ -251,9 +251,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 = request.headers["content-type"] + return contentType?.toLowerCase().includes("multipart/form-data") === true || + 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..479724ac7ae --- /dev/null +++ b/packages/effect/src/unstable/http/MediaType.ts @@ -0,0 +1,626 @@ +/** + * 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" + +/** + * Type identifier for `MediaType` values. + * + * @category type IDs + * @since 4.0.0 + */ +export const TypeId = "~effect/http/MediaType" + +/** + * Type of the identifier 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 +} + +/** + * 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. + * + * @category errors + * @since 4.0.0 + */ +export class MediaTypeParseError extends Data.TaggedError("MediaTypeParseError")<{ + readonly input: string + readonly offset: number + readonly message: string +}> {} + +/** + * Returns `true` if the provided value is a `MediaType` value. + * + * @category guards + * @since 4.0.0 + */ +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 + 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 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 => { + return value.length > 0 && tokenEnd(value, 0) === value.length +} + +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 = 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 + 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, message: string) => + Result.fail(new MediaTypeParseError({ input, offset, message })) + +/** + * 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, "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 + ? [] + : Symbol.iterator in 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, `Invalid parameter name ${JSON.stringify(rawName)}`) + if (!isDecodedValue(value)) return fail(input, input.length, `Invalid value for parameter ${JSON.stringify(name)}`) + if (names.has(name)) return fail(input, input.length, `Duplicate parameter ${JSON.stringify(name)}`) + 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 + index = tokenEnd(input, index) + if (index === typeStart) return fail(input, index, "Expected a media type") + const type = input.slice(typeStart, index).toLowerCase() + if (type === "*") return fail(input, typeStart, "Media type cannot be a wildcard") + if (input.charCodeAt(index) !== 47) return fail(input, index, "Expected '/' after the media type") + index++ + const subtypeStart = index + index = tokenEnd(input, index) + if (index === subtypeStart) return fail(input, index, "Expected a media subtype after '/'") + const subtype = input.slice(subtypeStart, index).toLowerCase() + if (subtype === "*") return fail(input, subtypeStart, "Media subtype cannot be a wildcard") + + 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, `Unexpected character ${JSON.stringify(input[index])}`) + } + index = skipOws(input, index + 1) + if (index === length || input.charCodeAt(index) === 59) { + return fail(input, index, "Expected a parameter name after ';'") + } + + const nameStart = index + index = tokenEnd(input, index) + if (index === nameStart) return fail(input, index, "Expected a parameter name after ';'") + const name = input.slice(nameStart, index).toLowerCase() + if (input.charCodeAt(index) !== 61) { + return fail(input, index, `Expected '=' after parameter ${JSON.stringify(name)}`) + } + 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, `Invalid escape in parameter ${JSON.stringify(name)}`) + } + 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, `Invalid character in parameter ${JSON.stringify(name)}`) + } + value += input[index++] + } + 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, `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 }) + } +} + +/** + * 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)) + +/** + * 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. + * + * @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 = makeUnsafe({ type: "application", subtype: "json" }) +/** + * The `application/octet-stream` media type. + * + * @category constants + * @since 4.0.0 + */ +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 = makeUnsafe({ + type: "application", + subtype: "x-www-form-urlencoded" +}) +/** + * The `multipart/form-data` media type. + * + * @category constants + * @since 4.0.0 + */ +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 = makeUnsafe({ type: "text", subtype: "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..5b024a6c9ca 100644 --- a/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts +++ b/packages/effect/src/unstable/http/MultipartParser/internal/multipart.ts @@ -1,3 +1,6 @@ +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" import * as CT from "./contentType.ts" import * as HP from "./headers.ts" @@ -35,8 +38,9 @@ export function defaultIsFile(info: PartInfo) { } function parseBoundary(headers: Record) { - const contentType = CT.parse(headers["content-type"]) - return contentType.parameters.boundary + 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) {} @@ -159,7 +163,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 +192,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.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..7190f02d37a 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,17 @@ 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") - : "application/x-www-form-urlencoded" + 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 existing = payloadBy.get(contentType) if (!existing) { return Response.text(`Unsupported content-type: ${contentType}`, { status: 415 }) @@ -978,7 +986,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) => { @@ -1218,6 +1226,7 @@ function getResponseEncode( e: E, options?: SchemaAST.ParseOptions ) => Effect.Effect { + const contentType = MediaType.format(encoding.contentType) switch (encoding._tag) { case "Json": { return ((e, options) => { @@ -1226,7 +1235,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( @@ -1242,19 +1251,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 365329af41a..f82926b7a41 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 @@ -338,8 +339,13 @@ export const makeClient = >() for (const [status, schemas] of errors.entries()) { const grouped = groupSchemasByContentType(schemas) - for (const [contentType, schemas] of grouped.entries()) { - 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()) { @@ -365,8 +371,13 @@ export const makeClient = >() for (const [status, schemas] of successes.entries()) { const grouped = groupSchemasByContentType(schemas) - for (const [contentType, schemas] of grouped.entries()) { - 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)) { @@ -770,22 +781,21 @@ function toCodecArrayBufferWithHeaders(schema: Schema.Constraint): Schema.Top { type ResponseDecoder = (response: HttpClientResponse.HttpClientResponse) => 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 normalizedContentType = MediaType.normalize(contentType) const alternatives = map.get(status) if (alternatives === undefined) { - map.set(status, [{ contentType: normalizedContentType, decode }]) + map.set(status, [{ contentType, decode }]) } else { - alternatives.push({ contentType: normalizedContentType, decode }) + alternatives.push({ contentType, decode }) } } @@ -795,10 +805,23 @@ function makeResponseDecoder(alternatives: ReadonlyArray): return first.decode } return (response) => { - const contentType = MediaType.normalize(response.headers["content-type"] ?? "") - const alternative = alternatives.find((alternative) => alternative.contentType === 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) + } + const parsedContentType = MediaType.parse(rawContentType) + if (Result.isFailure(parsedContentType)) { + return failUnsupportedContentType(response, rawContentType, alternatives) + } + 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) } } @@ -806,12 +829,12 @@ function makeResponseDecoder(alternatives: ReadonlyArray): function groupSchemasByContentType( schemas: Arr.NonEmptyReadonlyArray ): Map> { - const grouped = new Map]>() + const grouped = new Map>() for (const schema of schemas) { const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema const contentType = HttpApiSchema.isNoContent(body.ast) ? "" - : MediaType.normalize(HttpApiSchema.getResponseEncodingSchema(schema).contentType) + : MediaType.essence(HttpApiSchema.getResponseEncodingSchema(schema).contentType) const existing = grouped.get(contentType) if (existing === undefined) { grouped.set(contentType, [schema]) @@ -824,18 +847,24 @@ function groupSchemasByContentType( 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}` }) }) ) @@ -1083,7 +1112,7 @@ function getEncodePayloadSchemaFromBody( case "Json": { try { const body = JSON.stringify(t) - return Effect.succeed(HttpBody.text(body, encoding.contentType)) + return Effect.succeed(HttpBody.text(body, MediaType.format(encoding.contentType))) } catch { return Effect.fail( new SchemaIssue.InvalidValue( @@ -1100,7 +1129,7 @@ function getEncodePayloadSchemaFromBody( new SchemaIssue.InvalidValue({ message: "Expected a string" }, t, options) ) } - return Effect.succeed(HttpBody.text(t, encoding.contentType)) + return Effect.succeed(HttpBody.text(t, MediaType.format(encoding.contentType))) } case "FormUrlEncoded": { if (!Predicate.isObject(t)) { @@ -1108,7 +1137,10 @@ function getEncodePayloadSchemaFromBody( new SchemaIssue.InvalidValue({ message: "Expected a record" }, t, options) ) } - return Effect.succeed(HttpBody.urlParams(UrlParams.fromInput(t as any), encoding.contentType)) + return Effect.succeed(HttpBody.urlParams( + UrlParams.fromInput(t as any), + MediaType.format(encoding.contentType) + )) } case "Uint8Array": { if (!(t instanceof Uint8Array)) { @@ -1120,7 +1152,7 @@ function getEncodePayloadSchemaFromBody( ) ) } - return Effect.succeed(HttpBody.uint8Array(t, encoding.contentType)) + return Effect.succeed(HttpBody.uint8Array(t, MediaType.format(encoding.contentType))) } } } diff --git a/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts b/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts index 331b4ff84e8..918ef67a40d 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,14 +1127,14 @@ function getPayload( for (const schema of schemas) { const encoding = HttpApiSchema.getPayloadEncoding(schema.ast, method) - const contentType = MediaType.normalize(encoding.contentType) + const contentType = MediaType.essence(encoding.contentType) const existing = result.get(contentType) if (existing) { if (existing.encoding._tag !== encoding._tag) { - throw new Error(`Multiple payload encodings for content-type: ${encoding.contentType}`) + throw new Error(`Multiple payload encodings for content-type: ${MediaType.format(encoding.contentType)}`) } if (existing.encoding._tag === "Multipart") { - throw new Error(`Multiple multipart payloads for content-type: ${encoding.contentType}`) + throw new Error(`Multiple multipart payloads for content-type: ${MediaType.format(encoding.contentType)}`) } existing.schemas.push(transform(schema, method)) } else { @@ -1205,9 +1205,11 @@ 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(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,16 +1222,18 @@ function validateSuccessResponse(schemas: ReadonlyArray, meth } const encoding = HttpApiSchema.getResponseEncodingSchema(schema) if ( - MediaType.normalize(encoding.contentType) === MediaType.normalize(entry.stream.contentType) + 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.normalize(HttpApiSchema.getResponseEncodingSchema(schema).contentType) + MediaType.essence(HttpApiSchema.getResponseEncodingSchema(schema).contentType) ) } entry.noContent = entry.noContent || noContent @@ -1269,7 +1273,7 @@ function validateResponseExclusivity( const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : withHeadersAnnotation?.body ?? schema const contentType = HttpApiSchema.isNoContent(body.ast) ? "" - : MediaType.normalize( + : MediaType.essence( HttpApiSchema.isStreamSchema(body) ? body.contentType : HttpApiSchema.getResponseEncodingSchema(schema).contentType diff --git a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts index b9858156749..db683ebc87e 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" { @@ -69,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 } /** @@ -85,10 +86,11 @@ 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 textEventStreamMediaType = MediaType.makeUnsafe({ type: "text", subtype: "event-stream" }) /** * Common HTTP status code literals accepted by {@link status}. @@ -273,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 @@ -328,7 +330,7 @@ export interface StreamUint8Array extends readonly [StreamSchemaTypeId]: typeof StreamSchemaTypeId readonly _tag: "StreamUint8Array" readonly mode: "uint8array" - readonly contentType: string + readonly contentType: MediaType.MediaType } /** @@ -349,17 +351,17 @@ const streamSchema = Schema.declare(Stream.isStream) */ export const StreamSse: { (options: { - readonly contentType?: string | undefined + readonly contentType?: MediaType.Input | undefined readonly events: Events readonly error?: Error | undefined }): StreamSse (options: { - readonly contentType?: string | undefined + readonly contentType?: MediaType.Input | undefined readonly data: Data readonly error?: Error | undefined }): StreamSse, Error, Data["Type"]> } = (options: { - readonly contentType?: string | undefined + readonly contentType?: MediaType.Input | undefined readonly events?: Sse.EventCodec | undefined readonly data?: Schema.Constraint | undefined readonly error?: Schema.Constraint | undefined @@ -372,15 +374,20 @@ 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, + { + [StreamSchemaTypeId]: StreamSchemaTypeId, + _tag: "StreamSse", + mode: "sse", + sseMode: options.events === undefined ? "data" : "events", + contentType: options.contentType === undefined + ? defaultStreamMediaType("sse") + : MediaType.fromInputUnsafe(options.contentType), + events, + error: options.error ?? Schema.Never + } + ) } /** @@ -390,14 +397,19 @@ export const StreamSse: { * @since 4.0.0 */ export const StreamUint8Array = (options?: { - readonly contentType?: string | undefined + readonly contentType?: MediaType.Input | undefined }): StreamUint8Array => - Schema.make(streamSchema.ast, { - [StreamSchemaTypeId]: StreamSchemaTypeId, - _tag: "StreamUint8Array", - mode: "uint8array", - contentType: options?.contentType ?? defaultStreamContentType("uint8array") - }) + Schema.make( + streamSchema.ast, + { + [StreamSchemaTypeId]: StreamSchemaTypeId, + _tag: "StreamUint8Array", + mode: "uint8array", + contentType: options?.contentType === undefined + ? defaultStreamMediaType("uint8array") + : MediaType.fromInputUnsafe(options.contentType) + } + ) /** @internal */ export const isStreamSchema = (u: unknown): u is StreamSchema => @@ -411,12 +423,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 } } @@ -779,7 +791,7 @@ export function asMultipart(options?: Multipart_.withLimits.Options) { "~httpApiEncoding": { _tag: "Multipart", mode: "buffered", - contentType: defaultContentType("Multipart"), + contentType: MediaType.multipartFormData, limits: options } }) @@ -823,7 +835,7 @@ export function asMultipartStream(options?: Multipart_.withLimits.Options) { "~httpApiEncoding": { _tag: "Multipart", mode: "stream", - contentType: defaultContentType("Multipart"), + contentType: MediaType.multipartFormData, limits: options } }) @@ -831,28 +843,30 @@ export function asMultipartStream(options?: Multipart_.withLimits.Options) { function asNonMultipartEncoding(self: S, options: { readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" - readonly contentType?: string | undefined + readonly contentType?: MediaType.Input | undefined }): S["Rebuild"] { return self.annotate({ "~httpApiEncoding": { _tag: options._tag, - contentType: options.contentType ?? defaultContentType(options._tag) + contentType: options.contentType === undefined + ? defaultMediaType(options._tag) + : MediaType.fromInputUnsafe(options.contentType) } }) } -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 +877,7 @@ function defaultContentType(_tag: Encoding["_tag"]): string { * @since 4.0.0 */ export function asJson(options?: { - readonly contentType?: string + readonly contentType?: MediaType.Input }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Json", ...options }) } @@ -879,7 +893,7 @@ export function asJson(options?: { * @since 4.0.0 */ export function asFormUrlEncoded(options?: { - readonly contentType?: string + readonly contentType?: MediaType.Input }) { return ( self: S @@ -897,7 +911,7 @@ export function asFormUrlEncoded(options?: { * @since 4.0.0 */ export function asText(options?: { - readonly contentType?: string + readonly contentType?: MediaType.Input }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Text", ...options }) @@ -914,7 +928,7 @@ export function asText(options?: { * @since 4.0.0 */ export function asUint8Array(options?: { - readonly contentType?: string + readonly contentType?: MediaType.Input }) { return (self: S) => asNonMultipartEncoding(self, { _tag: "Uint8Array", ...options }) @@ -946,13 +960,10 @@ export const getWithHeadersAnnotation = SchemaAST.resolveAt("httpApiStatus") -const defaultJsonEncoding: Encoding = { - _tag: "Json", - contentType: "application/json" -} +const defaultJsonEncoding: Encoding = { _tag: "Json", contentType: MediaType.applicationJson } const defaultUrlEncodedEncoding: Encoding = { _tag: "FormUrlEncoded", - contentType: "application/x-www-form-urlencoded" + 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..6aaf69fb451 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.essence(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/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/Config.test.ts b/packages/effect/test/Config.test.ts index a412febfc5b..9f033ba4cb6 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, + `Expected '/' after the media type 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..602f9884710 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", "Expected '/' after the media type 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/test/unstable/http/MediaType.test.ts b/packages/effect/test/unstable/http/MediaType.test.ts new file mode 100644 index 00000000000..bbf48c75429 --- /dev/null +++ b/packages/effect/test/unstable/http/MediaType.test.ts @@ -0,0 +1,174 @@ +import { describe, it } from "@effect/vitest" +import { deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils" +import { Equal, Hash, Option, Result } from "effect" +import { MediaType } from "effect/unstable/http" + +const assertFailure = ( + input: string, + message: string, + offset: number +) => { + const result = MediaType.parse(input) + strictEqual(Result.isFailure(result), true) + if (Result.isFailure(result)) { + strictEqual(result.failure.message, message) + strictEqual(result.failure.offset, offset) + } +} + +describe("MediaType", () => { + it("parses and normalizes concrete media types", () => { + 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") + 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 = 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 = MediaType.fromInputUnsafe(input) + strictEqual(MediaType.baseSubtype(mediaType), mediaType.subtype) + strictEqual(Option.isNone(mediaType.suffix), true) + strictEqual(MediaType.hasSuffix(mediaType, "json"), false) + } + }) + + it("accepts every tchar but rejects wildcard ranges", () => { + const token = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + 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, and obs-text", () => { + 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ÿ") + strictEqual(MediaType.format(mediaType), "text/plain; a=token; b=\"a; b\"; c=\"\\\"\\\\\"; d=\"\tÿ\"; empty=\"\"") + }) + + 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\"", 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\"", 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 ';'", 11) + }) + + it("rejects malformed input with a structured error", () => { + 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", () => { + 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("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) + strictEqual(MediaType.isMediaType({}), false) + }) + + it("uses parameter-aware equality and hashing", () => { + 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) + strictEqual(MediaType.sameEssence(left, different), true) + }) + + it("supports parameter, essence, and suffix predicates", () => { + 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) + 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("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), true) + + 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(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/http/Multipart.test.ts b/packages/effect/test/unstable/http/Multipart.test.ts index 50c0f979db2..0a09a6ebbe7 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/HttpApiBuilder.test.ts b/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts index e07d6444502..93d7f9d849d 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts @@ -61,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: "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 })) @@ -136,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: "application/vnd.effect.form" }) + HttpApiSchema.asFormUrlEncoded({ + contentType: "application/vnd.effect.form" + }) ) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( diff --git a/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts b/packages/effect/test/unstable/httpapi/HttpApiClient.test.ts index dbbf7085a2e..e2a4e2629c8 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", () => { @@ -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(() => @@ -795,7 +821,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 +829,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 eeedb9d185c..8430bd17ead 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({ @@ -33,8 +34,12 @@ 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" + 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 })) }) @@ -46,7 +51,9 @@ 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: "Application/Vnd.Effect+Data; charset=utf-8" + }) ) const TextPayload = Schema.String.pipe( HttpApiSchema.asText({ contentType: "application/vnd.effect+data" }) @@ -121,7 +128,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 }) ] }) diff --git a/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts b/packages/effect/test/unstable/httpapi/HttpApiSchema.test.ts index a808f77b657..774c6f2b25a 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) => @@ -32,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) @@ -46,19 +47,17 @@ describe("HttpApiSchema", () => { } }) - it("stores custom content type", () => { + it("normalizes content type inputs", () => { 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 + contentType: "Text/Event-Stream; Charset=UTF-8", + events }) - assert.strictEqual(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", () => { @@ -99,19 +98,19 @@ 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", () => { + it("normalizes custom content types", () => { const stream = HttpApiSchema.StreamUint8Array({ - contentType: "application/custom-binary" + contentType: { type: "Application", subtype: "Custom-Binary" } }) - assert.strictEqual(stream.contentType, "application/custom-binary") + assert.strictEqual(MediaType.format(stream.contentType), "application/custom-binary") }) }) @@ -193,11 +192,11 @@ 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: "Application/Vnd.Custom+JSON" }) ) assert.isTrue(HttpApiSchema.isWithHeaders(onWrapper)) assert.strictEqual( - HttpApiSchema.getResponseEncodingSchema(onWrapper).contentType, + MediaType.format(HttpApiSchema.getResponseEncodingSchema(onWrapper).contentType), "application/vnd.custom+json" ) diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index fbb89b9044b..43b18889524 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, @@ -125,15 +126,15 @@ describe("OpenApi", () => { assert.strictEqual(cached.info.title, "Api") }) - 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" + 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 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 })) ] }) ) @@ -143,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/effect/typetest/Config.tst.ts b/packages/effect/typetest/Config.tst.ts index ea8a3450bcd..f6b1aa74f95 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,10 @@ describe("Config", () => { expect(withPath).type.toBe>>() }) + it("MediaType", () => { + 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 new file mode 100644 index 00000000000..0a0443f67e0 --- /dev/null +++ b/packages/effect/typetest/unstable/http/MediaType.tst.ts @@ -0,0 +1,26 @@ +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 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>() + }) + + 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() + expect>().type.toBe() + }) +}) diff --git a/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts b/packages/effect/typetest/unstable/httpapi/HttpApiSchema.tst.ts index c60f81eb3ed..c32e9e3874c 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 type { 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 media type inputs", () => { + const Events = Schema.Struct({ event: Schema.String, data: Schema.String }) + const stream = HttpApiSchema.StreamSse({ contentType: "text/plain", events: Events }) + + expect(stream).type.toBe>() + }) + it("preserves event and error schemas", () => { const Events = Schema.Struct({ event: Schema.Literal("user.created"), @@ -117,6 +125,16 @@ describe("HttpApiSchema", () => { }) }) + describe("body encodings", () => { + it("accepts media type inputs", () => { + const schema = Schema.String.pipe(HttpApiSchema.asText({ + contentType: { type: "text", subtype: "plain" } + })) + + expect(schema).type.toBe() + }) + }) + describe("WithHeaders", () => { it("preserves the inner schema and headers schema types", () => { const Headers = Schema.Struct({ "x-total-count": Schema.FiniteFromString }) @@ -198,7 +216,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", () => { 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/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 02be5759b1b..5d65422ad12 100644 --- a/packages/tools/openapi-generator/src/HttpApiTransformer.ts +++ b/packages/tools/openapi-generator/src/HttpApiTransformer.ts @@ -52,12 +52,13 @@ export const imports = ( options?: { readonly multipart?: boolean | undefined } -): string => - [ +): string => { + return [ `import * as ${importName} from "effect/Schema"`, ...(options?.multipart === true ? [`import { Multipart } 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. diff --git a/packages/tools/openapi-generator/src/OpenApiGenerator.ts b/packages/tools/openapi-generator/src/OpenApiGenerator.ts index 167b9ea7c94..ed25fb5df29 100644 --- a/packages/tools/openapi-generator/src/OpenApiGenerator.ts +++ b/packages/tools/openapi-generator/src/OpenApiGenerator.ts @@ -175,10 +175,15 @@ 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 + }) + } |${generation} - |${HttpApiTransformer.toImplementation(importName, options.name, parsed)}` + |${implementation}` ) }