Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-httpclient

Named HTTP clients for Pudu: a client factory, delegating handlers, pooled keep-alive connections, handler lifetimes, logging, and resilience

0.1.1Apache-2.01

InstallClose

Content.pudu

Pudu153 lines6.5 KB

GitHub ↗
1/** @HttpClient.Content.Module — message bodies and the headers describing them */2module PuduLangHttpClient.Content34import Std.Bytes as ByteSeq5import Std.Http as Http6import Std.Json as Json7import Std.Random as Random8import PuduLangHttpClient.Constants.Messages as Messages9import PuduLangHttpClient.Domain.Headers as Headers10import PuduLangHttpClient as HttpClient11import PuduLangHttpClient.Utils.Template as Template1213/** @HttpClient.Content.Producer — the next piece of a streamed body, or `None` once it ends */14export type Producer = fn() -> Option[Bytes]1516/** @HttpClient.Content.Content — a body's bytes, or the producer streaming it, and its content headers */17export type Content = { payload: Bytes, headers: Array[(Str, Str)], stream: Option[Producer] }1819/** @HttpClient.Content.Part — one field or file of a multipart body */20export type Part = { name: Str, filename: Option[Str], mediaType: Option[Str], payload: Bytes }2122/// The media type of text bodies when none is named.23const TEXT_PLAIN: Str = "text/plain; charset=utf-8"2425/// The media type of JSON bodies.26const APPLICATION_JSON: Str = "application/json; charset=utf-8"2728/// The media type of form bodies.29const FORM: Str = "application/x-www-form-urlencoded"3031/// The media type of bodies whose bytes have no stated meaning.32const OCTET_STREAM: Str = "application/octet-stream"3334/// The line break multipart framing uses.35const CRLF: Str = "\r\n"3637/// A body with no bytes and no headers.38export fn empty() -> Content { Content{payload: ByteSeq.empty(), headers: [], stream: None} }3940/// UTF-8 text of the given media type, to which `; charset=utf-8` is added when it names no charset.41export fn text(body: Str, mediaType: Str) -> Content {42  let typed = if mediaType.toLower().contains("charset=") { mediaType } else { mediaType + "; charset=utf-8" }43  Content{payload: body.toBytes(), headers: [("content-type", typed)], stream: None}44}4546/// UTF-8 plain text.47export fn plain(body: Str) -> Content { Content{payload: body.toBytes(), headers: [("content-type", TEXT_PLAIN)], stream: None} }4849/// Bytes of the given media type.50export fn bytes(payload: &Bytes, mediaType: Str) -> Content { Content{payload: *payload, headers: [("content-type", mediaType)], stream: None} }5152/// Bytes with no stated meaning.53export fn octets(payload: &Bytes) -> Content { bytes(payload, OCTET_STREAM) }5455/// A JSON document.56export fn json(value: &Json.Json) -> Content {57  Content{payload: Json.encode(value).toBytes(), headers: [("content-type", APPLICATION_JSON)], stream: None}58}5960/// Any value that writes itself as JSON.61export fn jsonOf[T: Json.Encode](value: &T) -> Content { json(&value.encode()) }6263/// Fields encoded as an HTML form.64export fn form(fields: &Array[(Str, Str)]) -> Content {65  Content{payload: Http.renderForm(fields).toBytes(), headers: [("content-type", FORM)], stream: None}66}6768/// A body of the given media type streamed from a producer as it is sent, in chunks, instead of69/// being held whole; it can be sent once, and it is not repeated after a redirect.70export fn streamed(mediaType: Str, producer: Producer) -> Content {71  Content{payload: ByteSeq.empty(), headers: [("content-type", mediaType)], stream: Some(producer)}72}7374/// Whether the body is streamed from a producer.75export fn isStreamed(content: &Content) -> Bool { content.stream != None }7677/// A text field of a multipart body.78export fn field(name: Str, value: Str) -> Part { Part{name: name, filename: None, mediaType: None, payload: value.toBytes()} }7980/// A file of a multipart body.81export fn file(name: Str, filename: Str, mediaType: Str, payload: &Bytes) -> Part {82  Part{name: name, filename: Some(filename), mediaType: Some(mediaType), payload: *payload}83}8485/// The parts as a `multipart/form-data` body separated by a random boundary.86export fn multipart(parts: &Array[Part]) -> Content {87  let random = match Random.secureBytes(16) {88    case Ok(found) => ByteSeq.encodeHex(&found)89    case Err(_) => show(clock())90  }91  multipartWith("pudu-" + random, parts)92}9394/// The parts as a `multipart/form-data` body separated by the given boundary.95export fn multipartWith(boundary: Str, parts: &Array[Part]) -> Content {96  var pieces: Array[Bytes] = []97  for part in parts {98    var head = "--" + boundary + CRLF + "content-disposition: form-data; name=\"" + quoted(part.name) + "\""99    if let Some(filename) = part.filename { head = head + "; filename=\"" + quoted(filename) + "\"" }100    head = head + CRLF101    if let Some(mediaType) = part.mediaType { head = head + "content-type: " + mediaType + CRLF }102    pieces = pieces.push((head + CRLF).toBytes()).push(part.payload).push(CRLF.toBytes())103  }104  pieces = pieces.push(("--" + boundary + "--" + CRLF).toBytes())105  Content{payload: ByteSeq.join(&pieces), headers: [("content-type", "multipart/form-data; boundary=" + boundary)], stream: None}106}107108/// The body with a content header set, replacing any value it had.109export fn withHeader(content: &Content, name: Str, value: Str) -> Content {110  Content{..*content, headers: Headers.set(&content.headers, name, value)}111}112113/// The media type the body states, without parameters.114export fn mediaTypeOf(content: &Content) -> Option[Str] { Http.mediaType(&content.headers) }115116/// The number of bytes in the body.117export fn length(content: &Content) -> Int { content.payload.length() }118119/// The body as UTF-8 text.120export fn readText(content: &Content) -> HttpClient.Outcome[Str] {121  match ByteSeq.toText(&content.payload) {122    case Ok(found) => Ok(found)123    case Err(_) => Err(HttpClient.Unreadable(Messages.NOT_TEXT))124  }125}126127/// The body as a JSON document.128export fn readJson(content: &Content) -> HttpClient.Outcome[Json.Json] {129  let source = readText(content) ?130  match Json.decode(source) {131    case Ok(value) => Ok(value)132    case Err(problem) => Err(HttpClient.Unreadable(Template.fill(Messages.NOT_JSON, &[Json.explain(&problem)])))133  }134}135136/// The body as a value that reads itself from JSON.137export fn readAs[T: Json.Decode](content: &Content) -> HttpClient.Outcome[T] {138  let value = readJson(content) ?139  match T.decode(&value) {140    case Ok(found) => Ok(found)141    case Err(problem) => Err(HttpClient.Unreadable(Template.fill(Messages.NOT_DECODABLE, &[Json.explainDecode(&problem)])))142  }143}144145/// The body as the fields of an HTML form.146export fn readForm(content: &Content) -> HttpClient.Outcome[Array[(Str, Str)]] {147  let source = readText(content) ?148  Ok(Http.parseForm(source))149}150151/// A name or file name with the characters that would end its quotes escaped.152fn quoted(value: Str) -> Str { value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "").replace("\n", "") }153