///|
/// `#app.(path, ...)` and `#router.(path, ...)` on free functions,
/// turned into `register(app)` and `register_router(router)`.
priv struct Routes {}
///|
impl @gen.Generator for Routes with fn name(_) {
"moonapi routes"
}
///|
/// The constructor of `@moonapi.Method` for `m`.
fn ctor(m : @method.Method) -> String {
match m {
Get => "Get"
Head => "Head"
Post => "Post"
Put => "Put"
Delete => "Delete"
Connect => "Connect"
Options => "Options"
Trace => "Trace"
Patch => "Patch"
Other(token) => "Other(" + @quote.string(token).to_string() + ")"
}
}
///|
/// Where `attr` is written, as `file:line:column`.
fn at(attr : @attribute.Attribute, file : String) -> String {
"\{file}:\{attr.loc.start.lnum}:\{attr.loc.start.column()}"
}
///|
/// The arguments an annotation takes. Only what an annotation can spell is
/// here: strings, booleans and repeated strings.
fn spec(name : String, kind : String) -> @attr.Spec {
let base = @attr.Spec::new(name)
let base = if kind == "route" { base.positional("method", Str) } else { base }
let base = base.positional("path", Str)
let base = base.optional("regex", Str).optional("glob", Str)
if kind == "websocket" {
return base.optional("name", Str).many("dependencies", Str)
}
base
.optional("summary", Str)
.optional("description", Str)
.optional("operation_id", Str)
.optional("name", Str)
.optional("status_code", Str)
.optional("response_class", Str)
.optional("deprecated", Bool)
.optional("include_in_schema", Bool)
.optional("validate", Bool)
.many("tags", Str)
.many("dependencies", Str)
}
///|
/// The `response_class` names an annotation may spell, and the constructor each
/// stands for. A new kind of body is one row here and one variant in the enum.
///
/// The constructor is written out in full: unlike `Get`, a class name such as
/// `Text` is a word other libraries use for a kind of their own, and a generated
/// call that fails to resolve in a package that imports one of them would be a
/// build error the annotation cannot explain.
fn class_ctor(name : String) -> String? {
match name {
"json" => Some("@moonapi.ResponseClass::Json")
"text" => Some("@moonapi.ResponseClass::Text")
"html" => Some("@moonapi.ResponseClass::Html")
"xml" => Some("@moonapi.ResponseClass::Xml")
"event_stream" => Some("@moonapi.ResponseClass::EventStream")
"bytes" => Some("@moonapi.ResponseClass::Bytes")
_ => None
}
}
///|
fn quoted(xs : Array[String]) -> String {
"[" + xs.map(x => @quote.string(x).to_string()).join(", ") + "]"
}
///|
/// The labelled arguments after the handler, in the order `App::route` lists them.
fn options(args : @attr.Args, where_ : String) -> Array[String] raise {
let out = []
for key in ["summary", "description"] {
if args.str(key) is Some(v) {
out.push(key + "=" + @quote.string(v).to_string())
}
}
let tags = args.strs("tags")
if !tags.is_empty() {
out.push("tags=" + quoted(tags))
}
if args.bool("deprecated") is Some(v) {
out.push("deprecated=\{v}")
}
if args.str("operation_id") is Some(v) {
out.push("operation_id=" + @quote.string(v).to_string())
}
if args.str("status_code") is Some(v) {
guard v is [_, ..] &&
v.iter().all(c => c is ('0'..<='9')) &&
v.length() <= 3 else {
fail("\{where_}: status_code is a number, not \"\{v}\"")
}
out.push("status_code=" + v)
}
if args.str("response_class") is Some(v) {
guard class_ctor(v) is Some(ctor) else {
fail(
"\{where_}: response_class is json, text, html, xml, event_stream or bytes, not \"\{v}\"",
)
}
out.push("response_class=" + ctor)
}
if args.str("name") is Some(v) {
out.push("name=" + @quote.string(v).to_string())
}
let deps = args.strs("dependencies")
if !deps.is_empty() {
out.push("dependencies=" + quoted(deps))
}
for key in ["include_in_schema", "validate"] {
if args.bool(key) is Some(v) {
out.push("\{key}=\{v}")
}
}
out.push_iter(matchers(args).iter())
out
}
///|
/// The labelled arguments that say how a route matches, on any kind of route.
fn matchers(args : @attr.Args) -> Array[String] {
let out = []
for key in ["regex", "glob"] {
if args.str(key) is Some(v) {
out.push(key + "=" + @quote.string(v).to_string())
}
}
out
}
///|
/// The statement registering `handler` under `attr` on `target` (`app` or
/// `router`). A second parameter gets the request's background tasks, a `Stream`
/// return streams, anything else answers once.
fn statement(
target : String,
attr : @attribute.Attribute,
handler : String,
params : Int32,
returns : String?,
file : String,
) -> String raise {
let where_ = at(attr, file)
guard! @attr.name_of(attr) is Some(name)
let kind = name[target.length() + 1:].to_owned()
let verb = match kind {
"get" => Some(@method.Get)
"post" => Some(Post)
"put" => Some(Put)
"patch" => Some(Patch)
"delete" => Some(Delete)
"head" => Some(Head)
"options" => Some(Options)
"trace" => Some(Trace)
"route" | "websocket" => None
_ => fail("\{where_}: `#\{name}` is not a route annotation")
}
let args = spec(name, kind).bind(attr)
guard! args.str("path") is Some(path)
let path = @quote.string(path).to_string()
if kind == "websocket" {
let parts = [path, "ws => \{handler}(ws)"]
if args.str("name") is Some(v) {
parts.push("name=" + @quote.string(v).to_string())
}
let deps = args.strs("dependencies")
if !deps.is_empty() {
parts.push("dependencies=" + quoted(deps))
}
parts.push_iter(matchers(args).iter())
return "\{target}.websocket(\{parts.join(", ")})"
}
let m = match verb {
Some(m) => m
None => {
guard! args.str("method") is Some(name)
@method.Method::of(name) catch {
Token(_) => fail("\{where_}: `\{name}` is not a method name")
}
}
}
let call = if returns is Some(t) && t.contains("Stream") {
"route_stream"
} else {
"route"
}
// Through a closure: a handler is async, and a named function that is not
// async is only taken where one is expected inside a closure.
let wrapped = if params == 2 {
"ctx => \{handler}(ctx, ctx.background)"
} else {
"ctx => \{handler}(ctx)"
}
let parts = [ctor(m), path, wrapped, ..options(args, where_)]
"\{target}.\{call}(\{parts.join(", ")})"
}
///|
impl @gen.Generator for Routes with fn generate(_, decls) {
let lines : Map[String, Array[String]] = { "app": [], "router": [] }
for decl in decls {
for target in ["app", "router"] {
let found = @attr.under(decl.attrs(), target)
guard found is [first, ..] else { continue }
guard decl is Func(owner=None, name~, params~, returns~, file~, ..) else {
let file = match decl {
Struct(file~, ..)
| Enum(file~, ..)
| Func(file~, ..)
| Other(file~, ..) => file
}
fail("\{at(first, file)}: `#\{target}.*` goes on a free function")
}
for attr in found {
lines[target].push(
statement(target, attr, name, params.length(), returns, file),
)
}
}
}
let out = []
if !lines["app"].is_empty() {
out.push(register("register", "app", "App", lines["app"]))
}
if !lines["router"].is_empty() {
out.push(register("register_router", "router", "Router", lines["router"]))
}
@quote.join(out)
}
///|
fn register(
fn_name : String,
param : String,
ty : String,
lines : Array[String],
) -> @quote.Code raise {
let source =
#|pub fn $name($param : @moonapi.$T) -> Unit {
#| $lines*
#|}
@quote.items(source)
.set("name", @quote.ident(fn_name))
.set("param", @quote.ident(param))
.set("T", @quote.ident(ty))
.splice("lines", lines.map(l => @quote.expr(l).render()))
.render()
}