zuzu-rust 0.4.0

Rust implementation of ZuzuScript
Documentation
from test/more import *;

requires_capability( "fs" );

from std/internals import load_module;
from std/io import Path;
from std/string import join, split;
from std/web import Request, Routes;

function env ( method_name, path, headers := null ) {
	return {
		method:  method_name,
		path:    path,
		headers: headers ≡ null ? new PairList() : headers,
	};
}

function header_env ( method_name, path, name, value ) {
	let headers := new PairList();
	headers.add( name, value );
	return env( method_name, path, headers );
}

function body_text ( response ) {
	return join(
		"",
		response[2].map(
			fn part -> part instanceof BinaryString ? to_string(part) : "" _ part,
		),
	);
}

let root := Path.tempdir();
root.child("hello.txt").spew_utf8("hello\n");
root.child("app.css").spew_utf8("body { color: #333; }\n");
root.child("photo.thing").spew_utf8("custom\n");
root.child("docs").mkdir();
root.child("docs").child("index.html").spew_utf8("<h1>Docs</h1>\n");
root.child("listing").mkdir();
root.child("listing").child("one.txt").spew_utf8("one\n");

let routes := new Routes();
routes.get("/assets/*path").to(
	controller: "std/web/static#StaticHandler",
	action: "handle",
	root: root,
);
routes.get("/str/*path").to(
	controller: "std/web/static#StaticHandler",
	action: "handle",
	root: root.to_String(),
);
routes.get("/custom/*path").to(
	controller: "std/web/static#StaticHandler",
	action: "handle",
	root: root,
	content_types: { ".thing": "text/x-thing" },
);
routes.get("/files/*file").to(
	controller: "std/web/static#StaticHandler",
	action: "handle",
	root: root,
	path_param: "file",
);
routes.get("/list/*path").to(
	controller: "std/web/static#StaticHandler",
	action: "handle",
	root: root,
	directory_indexes: true,
);

let out := routes.dispatch( env( "GET", "/assets/hello.txt" ) );
is( out[0], 200, "static route serves file" );
is( body_text(out), "hello\n", "static route returns file body" );
is(
	out[1].get("Content-Type"),
	"text/plain; charset=UTF-8",
	"static route detects text content type",
);
is( out[1].get("Content-Length"), "6", "static route sets content length" );
like( out[1].get("ETag"), /^"[0-9a-f]+"$/, "static route sets ETag" );
like(
	out[1].get("Last-Modified"),
	/^[A-Z][a-z]{2}, /,
	"static route sets Last-Modified",
);

let head := routes.dispatch( env( "HEAD", "/assets/hello.txt" ) );
is( head[0], 200, "static route accepts HEAD" );
is( body_text(head), "", "HEAD response has no body" );
is(
	head[1].get("Content-Length"),
	"6",
	"HEAD response keeps GET content length",
);

let etag_out := routes.dispatch(
	header_env( "GET", "/assets/hello.txt", "If-None-Match", out[1].get("ETag") ),
);
is( etag_out[0], 304, "matching If-None-Match returns 304" );
is( body_text(etag_out), "", "304 response has no body" );

let modified_out := routes.dispatch(
	header_env(
		"GET",
		"/assets/hello.txt",
		"If-Modified-Since",
		out[1].get("Last-Modified"),
	),
);
is( modified_out[0], 304, "matching If-Modified-Since returns 304" );

let css := routes.dispatch( env( "GET", "/assets/app.css" ) );
is(
	css[1].get("Content-Type"),
	"text/css; charset=UTF-8",
	"static route detects CSS content type",
);

let custom := routes.dispatch( env( "GET", "/custom/photo.thing" ) );
is(
	custom[1].get("Content-Type"),
	"text/x-thing",
	"static route accepts content type overrides",
);

let string_root := routes.dispatch( env( "GET", "/str/hello.txt" ) );
is( body_text(string_root), "hello\n", "static route accepts string root" );

let custom_param := routes.dispatch( env( "GET", "/files/hello.txt" ) );
is( body_text(custom_param), "hello\n", "static route accepts path_param" );

let index := routes.dispatch( env( "GET", "/assets/docs" ) );
is( index[0], 200, "static route serves directory index file" );
is( body_text(index), "<h1>Docs</h1>\n", "directory index body is served" );

let no_listing := routes.dispatch( env( "GET", "/assets/listing" ) );
is( no_listing[0], 403, "directory listing is disabled by default" );

let listing := routes.dispatch( env( "GET", "/list/listing" ) );
is( listing[0], 200, "directory listing can be enabled" );
like( body_text(listing), /one\.txt/, "directory listing includes children" );
like(
	body_text(listing),
	/href="listing\/one\.txt"/,
	"directory listing links resolve under directory paths",
);
let listing_head := routes.dispatch( env( "HEAD", "/list/listing" ) );
is( listing_head[0], 200, "directory listing accepts HEAD" );
is( body_text(listing_head), "", "directory listing HEAD has no body" );

let missing := routes.dispatch( env( "GET", "/assets/missing.txt" ) );
is( missing[0], 404, "missing static file returns 404" );

let traversal := routes.dispatch( env( "GET", "/assets/../hello.txt" ) );
is( traversal[0], 403, "path traversal is forbidden" );

let post := routes.dispatch( env( "POST", "/assets/hello.txt" ) );
is( post[0], 405, "route rejects unsupported methods" );
is( post[1].get("Allow"), "GET", "route method response keeps Allow header" );

let StaticHandler := load_module( "std/web/static", "StaticHandler" );
let direct_req := new Request( env: env( "POST", "/direct" ) );
direct_req.set_route_match( { root: root, path: "hello.txt" }, null );
let direct := StaticHandler.handle(direct_req).finalize();
is( direct[0], 405, "StaticHandler rejects direct non-GET methods" );
is(
	direct[1].get("Allow"),
	"GET, HEAD",
	"StaticHandler direct 405 advertises GET and HEAD",
);

done_testing();