http.oak
← Standard library
See on GitHub ↗
{
println: println
default: default
toHex: toHex
fromHex: fromHex
slice: slice
map: map
each: each
filter: filter
exclude: exclude
reduce: reduce
entries: entries
fromEntries: fromEntries
} := import('std')
{
checkRange: checkRange
cut: cut
join: join
contains?: contains?
digit?: digit?
upper?: upper?
lower?: lower?
word?: word?
upper: upper
lower: lower
split: split
} := import('str')
{
readFile: readFile
} := import('fs')
{
printf: printf
} := import('fmt')
sort := import('sort')
json := import('json')
fn queryEncode(params) {
fn prepare(val) if type(val) {
:list, :object -> json.serialize(val)
_ -> string(val)
}
params |>
entries() |>
exclude(fn(entry) entry.1 = ? | type(entry.1) = :function) |>
map(fn(entry) [entry.0, prepare(entry.1)]) |>
sort.sort(0) |>
map(fn(entry) percentEncode(entry.0) + '=' + percentEncode(entry.1)) |>
join('&')
}
fn queryDecode(params) {
params |>
split('&') |>
filter(fn(s) s != '') |>
map(fn(kv) kv |> cut('=') |> map(percentDecode)) |>
fromEntries()
}
fn _encodeChar(uri?) fn(c) if {
word?(c)
'-_.!~*\'()' |> contains?(c)
uri? & ';,/?:@&=+$#' |> contains?(c) -> c
_ -> '%' + codepoint(c) |> toHex() |> upper()
}
fn percentEncode(s) s |> map(_encodeChar(false))
fn percentEncodeURI(s) s |> map(_encodeChar(true))
fn _hex?(c) digit?(c) | (c >= 'A' & c <= 'F') | (c >= 'a' & c <= 'f')
fn percentDecode(s) {
stage := :default
buf := ?
s |> with reduce('') fn(decoded, curr) if stage {
:default -> if curr {
'+' -> decoded << ' '
'%' -> {
stage <- :sawPercent
decoded
}
_ -> decoded << curr
}
:sawPercent -> if _hex?(curr) {
false -> {
stage <- :default
decoded << '%' << curr
}
_ -> {
stage <- :sawFirstHex
buf <- curr
decoded
}
}
_ -> {
last := buf
stage <- :default
buf <- ?
if _hex?(curr) {
false -> decoded << '%' << last << curr
_ -> decoded << lower(last << curr) |> fromHex() |> char()
}
}
}
}
fn Router {
self := []
fn add(pattern, handler) self << [pattern, handler]
fn catch(handler) add('', handler)
fn splitPath(url) url |> split('/') |> filter(fn(s) s != '')
fn matchPath(pattern, path) {
params := {}
[path, query] := cut(path, '?')
if query {
'' -> ?
_ -> query |>
split('&') |>
map(fn(pair) pair |> cut('=')) |>
with each() fn(pair) params.(pair.0) := percentDecode(pair.1)
}
desired := splitPath(pattern)
actual := splitPath(path)
fn findMatchingParams(i) if i {
len(desired) -> if i {
len(actual) -> params
_ -> ?
}
_ -> {
desiredPart := default(desired.(i), '')
actualPart := default(actual.(i), '')
if desiredPart.0 {
':' -> {
params.(desiredPart |> slice(1)) := percentDecode(actualPart)
findMatchingParams(i + 1)
}
'*' -> {
params.(desiredPart |> slice(1)) := actual |> slice(i) |> map(percentDecode) |> join('/')
}
_ -> if desiredPart {
actualPart -> findMatchingParams(i + 1)
_ -> ?
}
}
}
}
if [len(desired) <= len(actual), pattern] {
[_, ''] -> params
[true, _] -> findMatchingParams(0)
_ -> ?
}
}
fn match(path) {
fn sub(i) if i {
len(self) -> fn(req) req.end({
status: 200
headers: {}
body: 'dropped route. you should never see this in production'
})
_ -> {
[pattern, handler] := self.(i)
if result := matchPath(pattern, path) {
? -> sub(i + 1)
_ -> handler(result)
}
}
}
sub(0)
}
{
add: add
catch: catch
match: match
}
}
MimeTypes := {
blob: 'application/octet-stream'
html: 'text/html; charset=utf-8'
txt: 'text/plain; charset=utf-8'
md: 'text/plain; charset=utf-8'
css: 'text/css; charset=utf-8'
js: 'application/javascript; charset=utf-8'
json: 'application/json; charset=utf-8'
ink: 'text/plain; charset=utf-8'
oak: 'text/plain; charset=utf-8'
jpg: 'image/jpeg'
jpeg: 'image/jpeg'
png: 'image/png'
gif: 'image/gif'
svg: 'image/svg+xml'
webp: 'image/webp'
pdf: 'application/pdf'
zip: 'application/zip'
}
fn mimeForPath(path) {
parts := path |> split('.')
ending := parts.(len(parts) - 1)
MimeTypes.(ending) |> default(MimeTypes.blob)
}
NotFound := { status: 404, body: 'file not found' }
MethodNotAllowed := { status: 405, body: 'method not allowed' }
fn _hdr(attrs) {
base := {
'X-Served-By': 'oak/libhttp'
'Content-Type': 'text/plain'
}
keys(attrs) |> each(fn(k) base.(k) := attrs.(k))
base
}
fn Server {
router := Router()
fn start(port) {
router.catch(fn(params) fn(req, end) end({
status: 404
body: 'service not found'
}))
with listen('0.0.0.0:' + string(port)) fn(evt) if evt.type {
:error -> println('server start error:', evt.error)
_ -> {
{ method: method, url: url } := evt.req
printf('{{ 0 }}: {{ 1 }}', method, url)
with router.match(url)(evt.req) fn(resp) {
resp.headers := _hdr(resp.headers |> default({}))
evt.end(resp)
}
}
}
}
{
route: router.add
start: start
}
}
fn handleStatic(path) fn(req, end) if req.method {
'GET' -> with readFile('./' + path) fn(file) if file {
? -> end(NotFound)
_ -> end({
status: 200
headers: { 'Content-Type': mimeForPath(path) }
body: file
})
}
_ -> end(MethodNotAllowed)
}