
Content.pudu
Pudu153 lines6.5 KB
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 }212223const TEXT_PLAIN: Str = "text/plain; charset=utf-8"242526const APPLICATION_JSON: Str = "application/json; charset=utf-8"272829const FORM: Str = "application/x-www-form-urlencoded"303132const OCTET_STREAM: Str = "application/octet-stream"333435const CRLF: Str = "\r\n"363738export fn empty() -> Content { Content{payload: ByteSeq.empty(), headers: [], stream: None} }394041export 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}454647export fn plain(body: Str) -> Content { Content{payload: body.toBytes(), headers: [("content-type", TEXT_PLAIN)], stream: None} }484950export fn bytes(payload: &Bytes, mediaType: Str) -> Content { Content{payload: *payload, headers: [("content-type", mediaType)], stream: None} }515253export fn octets(payload: &Bytes) -> Content { bytes(payload, OCTET_STREAM) }545556export fn json(value: &Json.Json) -> Content {57 Content{payload: Json.encode(value).toBytes(), headers: [("content-type", APPLICATION_JSON)], stream: None}58}596061export fn jsonOf[T: Json.Encode](value: &T) -> Content { json(&value.encode()) }626364export fn form(fields: &Array[(Str, Str)]) -> Content {65 Content{payload: Http.renderForm(fields).toBytes(), headers: [("content-type", FORM)], stream: None}66}67686970export fn streamed(mediaType: Str, producer: Producer) -> Content {71 Content{payload: ByteSeq.empty(), headers: [("content-type", mediaType)], stream: Some(producer)}72}737475export fn isStreamed(content: &Content) -> Bool { content.stream != None }767778export fn field(name: Str, value: Str) -> Part { Part{name: name, filename: None, mediaType: None, payload: value.toBytes()} }798081export fn file(name: Str, filename: Str, mediaType: Str, payload: &Bytes) -> Part {82 Part{name: name, filename: Some(filename), mediaType: Some(mediaType), payload: *payload}83}848586export 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}939495export 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}107108109export fn withHeader(content: &Content, name: Str, value: Str) -> Content {110 Content{..*content, headers: Headers.set(&content.headers, name, value)}111}112113114export fn mediaTypeOf(content: &Content) -> Option[Str] { Http.mediaType(&content.headers) }115116117export fn length(content: &Content) -> Int { content.payload.length() }118119120export 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}126127128export 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}135136137export 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}144145146export fn readForm(content: &Content) -> HttpClient.Outcome[Array[(Str, Str)]] {147 let source = readText(content) ?148 Ok(Http.parseForm(source))149}150151152fn quoted(value: Str) -> Str { value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "").replace("\n", "") }153