Oak

http.oak

← Standard library See on GitHub ↗

// libhttp offers utilities for writing HTTP server applications in Oak.
//
// It contains functions and objects we need to implement basic HTTP serving
// and routing functionality.

{
	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')

// queryEncode takes an object of parameters and returns a query string that
// encodes the given params. If any parameter values are composite (lists or
// objects themselves), they will be encoded as serialized JSON strings.
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('&')
}

// queryDecode takes a query string and decodes it into an object of key-value
// pairs representing the query string parameters. Note that because query
// strings are untyped, all values in the returned object will be of type
// :string.
fn queryDecode(params) {
	params |>
		split('&') |>
		filter(fn(s) s != '') |>
		map(fn(kv) kv |> cut('=') |> map(percentDecode)) |>
		fromEntries()
}

// percent encoding, also known as URI encoding
fn _encodeChar(uri?) fn(c) if {
	word?(c)
	'-_.!~*\'()' |> contains?(c)
	uri? & ';,/?:@&=+$#' |> contains?(c) -> c
	_ -> '%' + codepoint(c) |> toHex() |> upper()
}
// percentEncode encodes the string `s` in the "percent encoding" or URI
// encoding scheme. This function roughly corresponds to JavaScript's
// `encodeURIComponent` function.
fn percentEncode(s) s |> map(_encodeChar(false))
// percentEncodeURI encodes the string `s` in the "percent encoding" or URI
// encoding scheme, but preserves characters that are expected to appear in a
// URL. It roughly corresponds to JavaScript's `encodeURI` function.
fn percentEncodeURI(s) s |> map(_encodeChar(true))

// _hex? reports whether a particular character is a valid hexadecimal character
fn _hex?(c) digit?(c) | (c >= 'A' & c <= 'F') | (c >= 'a' & c <= 'f')
// percentDecode decodes a URI-encoded value from `s`
fn percentDecode(s) {
	// possible values:
	// :default
	// :sawPercent
	// :sawFirstHex
	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()
			}
		}
	}
}

// Router constructs a router object, which encapsulates state for routing HTTP
// paths to request handlers.
//
// Methods:
//
// fn add(pattern, handler)     adds a handler for some path pattern.
//                              The pattern may contain :params to capture a part
//                              of the path, or *params to capture the rest of the
//                              remaining path. e.g. /:app/static/*staticPath
//                              The handler must be of type fn(params) fn(req, end).
// fn catch(handler)            adds a catch-all requeset handler
// fn match(path)               takes a path and invokes the correct registered
//                              request handler
fn Router {
	self := []

	fn add(pattern, handler) self << [pattern, handler]
	fn catch(handler) add('', handler)

	fn splitPath(url) url |> split('/') |> filter(fn(s) s != '')

	// if path matches pattern, return a hash of matched params. else, return ?
	fn matchPath(pattern, path) {
		params := {}

		// process query 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 {
				// if len(desired) = len(actual) everything is ok
				len(actual) -> params
				// if pattern did not consume all of the path, there's no match
				_ -> ?
			}
			_ -> {
				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] {
			// '' is used as a catch-all 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'
}

// mimeForPath takes a path and returns a likely MIME type string
fn mimeForPath(path) {
	parts := path |> split('.')
	ending := parts.(len(parts) - 1)
	MimeTypes.(ending) |> default(MimeTypes.blob)
}

// NotFound represents a 404 Not Found response
NotFound := { status: 404, body: 'file not found' }
// MethodNotAllowed represents a 405 Method Not Allowed response
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
}

// Server constructs an HTTP application server capable of routing.
//
// Methods:
//
// fn route(pattern, handler)       adds a handler for some path pattern.
//                                  The arguments are identical to Router.add.
// fn start(port)                   starts the server and begins listening for
//                                  requests to the specified local port.
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
	}
}

// handleStatic is a pre-configured route handler for responding to requests
// for static files. Use like:
//
// server := Server()
// with server.route('/static/*staticPath') fn(params) {
//     serveStatic('./static/' + params.staticPath)
// }
// with server.route('/') fn serveStatic('./index.html')
// server.start(8080)
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)
}