// The layered project tree, and what a second run of it is allowed to touch.
//
// goctl does not write one file: it writes `etc/.yaml`, `internal/config`,
// `internal/svc`, `internal/types`, `internal/handler` (routes plus one file per
// handler, under the group's own directory when the route is grouped),
// `internal/logic`, `internal/middleware` and the service's entry point. This is the
// same tree in MoonBit, one package per directory.
//
// The second run is the point of it. goctl regenerates only the two machine-owned
// files — the routes and the types — and skips every file that is already on disk,
// because those are where the author's code lives. Each file here therefore says
// which it is, and `tree_plan` is what turns a tree plus "does this exist?" into the
// files to actually write.
///|
/// Whether a regeneration may replace a file that is already on disk. `Always` is
/// for what moonctl owns — the routes and the types, which must follow the spec —
/// and `Once` for what it only seeds: handlers, logic, configuration, manifests.
#derive(Eq)
pub(all) enum Regen {
Always
Once
}
///|
pub extend Regen with Eq::{equal, not_equal}
///|
/// One file of a generated project tree: where it goes, what is in it, and whether
/// regenerating the tree is allowed to overwrite it.
pub(all) struct TreeFile {
path : String
content : String
regen : Regen
}
///|
/// `rest` under `dir`, or `rest` alone when `dir` is empty.
fn at(dir : String, rest : String) -> String {
if dir == "" {
rest
} else {
dir + "/" + rest
}
}
///|
/// `xs` with later repeats dropped, order kept.
fn uniq(xs : Array[String]) -> Array[String] {
let out : Array[String] = []
for x in xs {
let mut seen = false
for y in out {
if y == x {
seen = true
}
}
if seen == false {
out.push(x)
}
}
out
}
///|
/// The directory a route's handler and logic live in — its group, spelled in the
/// style — or "" for a route declared outside any `@server` block.
fn route_dir(r : @api.Route, style : @style.Style) -> String {
match r.group {
Some(g) if g.name != "" => style.format(g.name)
_ => ""
}
}
///|
/// Generate the layered project tree goctl writes, in MoonBit: the service entry
/// point and its `moon.mod`, `etc/.yaml`, `internal/config`,
/// `internal/svc`, `internal/types`, `internal/handler` (the generated routes plus a
/// stub per handler, under `internal/handler//` for a grouped route),
/// `internal/logic`, `internal/middleware` for every middleware an `@server` block
/// named, and a `moon.pkg` for each of those packages.
///
/// `style` (default `gozero`) names the files and the handler, logic and middleware
/// entry points inside them. Names the spec itself chose — the `type` blocks and
/// their fields — are left as written, since a MoonBit type name has to keep its
/// capital. `dir` puts the whole tree under a directory.
///
/// Each file says whether a regeneration may overwrite it; hand the result to
/// `tree_plan` to apply that.
pub fn generate_tree(
spec : @api.Spec,
style? : @style.Style,
dir~ : String = "",
) -> Array[TreeFile] {
let st = match style {
Some(s) => s
None => @style.Style::gozero()
}
let mod_ = spec.service
let config_pkg = mod_ + "/internal/config"
let svc_pkg = mod_ + "/internal/svc"
let handler_pkg = mod_ + "/internal/handler"
let logic_pkg = mod_ + "/internal/logic"
let types_pkg = mod_ + "/internal/types"
let api = ("moonbitstack/moonapi", "")
let asgi = ("moonbitstack/moonasgi", "")
let out = base_tree(
mod_,
st,
dir,
deps=[
"moonbitstack/moonapi", "moonbitstack/moonasgi", "moonbitstack/mooncat",
],
imports=[api, ("moonbitstack/mooncat", ""), (handler_pkg, "")],
main=asgi_main(
mod_, "HTTP", "@handler.register_handlers(@moonapi.App::new(), svc).to_asgi()",
),
)
// internal/types — the spec's schemas and the binders the handlers call,
// rewritten on every run.
let binders = @gen.render_binders(spec)
out.push({
path: at(dir, "internal/types/moon.pkg"),
content: pkg_file(types_imports(spec, binders)),
regen: Once,
})
out.push({
path: at(dir, "internal/types/" + st.format("types") + ".mbt"),
content: "// Code generated by moonctl. DO NOT EDIT.\n\n" +
@gen.render_structs(spec.types, derive_json=true) +
binders,
regen: Always,
})
// internal/handler — the routes, rewritten on every run, and a stub per handler.
let dirs : Array[String] = []
for r in spec.routes {
dirs.push(route_dir(r, st))
}
let group_dirs : Array[String] = []
let mut ungrouped = false
for d in uniq(dirs) {
if d == "" {
ungrouped = true
} else {
group_dirs.push(d)
}
}
let routes_imports : Array[(String, String)] = [api, (svc_pkg, "")]
if ungrouped {
routes_imports.push(asgi)
routes_imports.push((logic_pkg, "logic"))
if binds_in(spec, st, "") {
routes_imports.push((types_pkg, ""))
}
}
for d in group_dirs {
routes_imports.push((handler_pkg + "/" + d, d))
}
// The test's imports do not depend on the spec, so a regeneration never leaves
// the seeded test without them.
let tests = pkg_file([
("moonbitstack/moonasgi/testing", ""),
("moonbitlang/async", ""),
(config_pkg, ""),
])
out.push({
path: at(dir, "internal/handler/moon.pkg"),
content: pkg_file(routes_imports) +
"\n" +
tests.replace(old="}\n", new="} for \"test\"\n"),
regen: Always,
})
let ping =
#|
#| let reply = client.get("/ping")
#| assert_eq(reply.status, 200)
#| assert_eq(reply.text(), "pong")
let test_src =
#|///|
#|async test "the app answers its routes and nothing else" {
#| let svc = @svc.ServiceContext::new(@config.Config::new())
#| let app = @handler.register_handlers(@moonapi.App::new(), svc)
#| let client = @testing.Client::new(app.to_asgi())
#| assert_eq(client.get("/no/such/route").status, 404)%ping
#|}
let pings = spec.routes.any(r => r.verb == "get" && r.path == "/ping")
out.push({
path: at(dir, "internal/handler/" + st.format("routes") + "_test.mbt"),
content: test_src.replace(old="%ping", new=if pings { ping } else { "" }) +
"\n",
regen: Once,
})
out.push({
path: at(dir, "internal/handler/" + st.format("routes") + ".mbt"),
content: render_routes(spec, st),
regen: Always,
})
for d in group_dirs {
out.push({
path: at(dir, "internal/handler/" + d + "/moon.pkg"),
content: pkg_file(
group_handler_imports(
api,
asgi,
svc_pkg,
logic_pkg,
types_pkg,
d,
binds_in(spec, st, d),
),
),
regen: Once,
})
}
for r in spec.routes {
let d = route_dir(r, st)
let name = st.format(r.handler + "_handler")
out.push({
path: at(dir, at("internal/handler", at(d, name + ".mbt"))),
content: api_handler_src(spec, st, r, name),
regen: Once,
})
}
// internal/logic — one package per group, mirroring the handlers.
let logic_dirs = if ungrouped { [""] } else { [] }
for d in group_dirs {
logic_dirs.push(d)
}
for d in logic_dirs {
out.push({
path: at(dir, at("internal/logic", at(d, "moon.pkg"))),
content: pkg_file(
if binds_in(spec, st, d) {
[api, asgi, (svc_pkg, ""), (types_pkg, "")]
} else {
[api, asgi, (svc_pkg, "")]
},
),
regen: Once,
})
}
for r in spec.routes {
let d = route_dir(r, st)
let name = st.format(r.handler + "_logic")
out.push({
path: at(dir, at("internal/logic", at(d, name + ".mbt"))),
content: api_logic_src(spec, r, name),
regen: Once,
})
}
// internal/middleware — one per name any @server block asked for.
let names : Array[String] = []
for g in spec.groups {
for m in g.middleware {
names.push(m)
}
}
let middleware = uniq(names)
if middleware.length() > 0 {
out.push({
path: at(dir, "internal/middleware/moon.pkg"),
content: pkg_file([api, asgi]),
regen: Once,
})
}
for m in middleware {
let name = st.format(m + "_middleware")
out.push({
path: at(dir, "internal/middleware/" + name + ".mbt"),
content: "///|\n/// The " +
m +
" middleware of the " +
spec.service +
" service: wrap the handler it is\n/// given and return the wrapper.\npub fn " +
name +
"(\n next : (@moonapi.Context) -> @moonasgi.Response,\n) -> (@moonapi.Context) -> @moonasgi.Response {\n next\n}\n",
regen: Once,
})
}
out
}
///|
/// The imports of the generated `internal/types` package: the framework and JSON
/// when it holds schemas (every one of them carries a derived JSON pair), and the
/// text parsers when it also holds binders.
fn types_imports(spec : @api.Spec, binders : String) -> Array[(String, String)] {
let out : Array[(String, String)] = []
// A struct carries derived JSON impls, and nothing else, when no route binds it:
// the framework and the text parsers are imported only by the binders.
if spec.types.length() > 0 {
out.push(("moonbitlang/core/json", ""))
}
if binders != "" {
out.push(("moonbitstack/moonapi", ""))
if spec.types.length() == 0 {
out.push(("moonbitlang/core/json", ""))
}
if @gen.binders_parse_text(spec) {
out.push(("moonbitlang/core/string", ""))
}
}
out
}
///|
/// Whether the spec declares a `type` block called `name`.
fn declares(spec : @api.Spec, name : String) -> Bool {
for t in spec.types {
if t.name == name {
return true
}
}
false
}
///|
/// Whether the spec writes a binder for the request type `name`.
fn is_bound(spec : @api.Spec, name : String) -> Bool {
for entry in @gen.bound_types(spec) {
if entry.0 == name {
return true
}
}
false
}
///|
/// The imports of a group's handler package: the framework, the context, the logic
/// package the group calls, and — when this spec has request types — the package
/// holding their binders.
fn group_handler_imports(
api : (String, String),
asgi : (String, String),
svc_pkg : String,
logic_pkg : String,
types_pkg : String,
group : String,
binds : Bool,
) -> Array[(String, String)] {
let out : Array[(String, String)] = [
api,
asgi,
(svc_pkg, ""),
(logic_pkg + "/" + group, "logic"),
]
if binds {
out.push((types_pkg, ""))
}
out
}
///|
/// Whether any route whose handler lives in `group`'s package (`""` for the routes
/// package itself) has a request type the handlers bind.
fn binds_in(spec : @api.Spec, st : @style.Style, group : String) -> Bool {
for r in spec.routes {
if route_dir(r, st) == group && r.req != "" && is_bound(spec, r.req) {
return true
}
}
false
}
///|
/// The handler of one route: bind the request into the type the spec declared,
/// hand it to the logic, and write what the logic answers — a `Result`'s value as
/// JSON when the route declares a response type, its message as `501` when the
/// logic has not been filled in yet. A route that declares no request passes the
/// context through, the way a handler that needs nothing else does.
fn api_handler_src(
spec : @api.Spec,
st : @style.Style,
r : @api.Route,
name : String,
) -> String {
let has_req = r.req != "" && is_bound(spec, r.req)
let has_resp = r.resp != "" && declares(spec, r.resp)
let call = "@logic." + st.format(r.handler + "_logic")
let mut body = ""
if has_req {
body = body +
" let req = match @types.bind_" +
@text.to_snake(r.req) +
"(ctx) {\n Ok(req) => req\n Err(why) => return @moonapi.text(400, why)\n }\n"
}
let arg = if has_req { "req" } else { "ctx" }
if has_resp {
body = body +
" match " +
call +
"(svc, " +
arg +
") {\n Ok(reply) => @moonapi.json(200, ToJson::to_json(reply))\n Err(why) => @moonapi.text(501, why)\n }\n"
} else {
body = body + " " + call + "(svc, " + arg + ")\n"
}
"///|\n/// `" +
r.verb +
" " +
r.path +
"` — the HTTP edge of the route: read the request, call\n/// the logic, write what comes back.\npub fn " +
name +
"(\n svc : @svc.ServiceContext,\n ctx : @moonapi.Context,\n) -> @moonasgi.Response {\n" +
body +
"}\n"
}
///|
/// The logic of one route, seeded: it takes the request the handler bound (or the
/// context, for a route that declares none) and returns what the route answers.
/// A route with a response type returns `Result`: the value the handler writes as
/// JSON, or the message it answers `501` with while the body is still `TODO`.
fn api_logic_src(spec : @api.Spec, r : @api.Route, name : String) -> String {
let has_req = r.req != "" && is_bound(spec, r.req)
let has_resp = r.resp != "" && declares(spec, r.resp)
let param = if has_req {
"_req : @types." + r.req
} else {
"_ctx : @moonapi.Context"
}
let ret = if has_resp {
"Result[@types." + r.resp + ", String]"
} else {
"@moonasgi.Response"
}
let body = if has_resp {
" Err(" + @text.quote("TODO: " + r.handler) + ")\n"
} else {
" @moonapi.text(200, " +
@text.quote(if r.handler == "ping" { "pong" } else { "TODO: " + r.handler }) +
")\n"
}
"///|\n/// `" +
r.verb +
" " +
r.path +
"`" +
(if r.summary == "" { "" } else { " — " + r.summary }) +
". This is where the service does its work.\npub fn " +
name +
"(\n _svc : @svc.ServiceContext,\n " +
param +
",\n) -> " +
ret +
" {\n" +
body +
"}\n"
}
///|
/// The `app.(…)` lines for `routes`, with a comment wherever the `@server`
/// annotations change — the same shape goctl writes above each route group.
fn route_lines(routes : Array[@api.Route], st : @style.Style) -> String {
let mut out = ""
let mut note = ""
for r in routes {
let mark = match r.group {
Some(g) => @gen.group_note(g)
None => ""
}
if mark != note {
out = out +
(if mark == "" {
" // (ungrouped)\n"
} else {
" // @server " + mark + "\n"
})
note = mark
}
let d = route_dir(r, st)
let call = (if d == "" { "" } else { "@" + d + "." }) +
st.format(r.handler + "_handler")
let head = " app." +
r.verb +
"(" +
@text.quote(@gen.route_path(r.path)) +
", ctx => " +
call +
"(svc, ctx)"
out = out +
(if r.summary != "" {
head + ", summary=" + @text.quote(r.summary) + ")\n"
} else {
head + ")\n"
})
}
out
}
///|
/// `internal/handler/routes.mbt`: every route registered on the app, each one
/// calling into the handler package its group lives in. Regenerated on every run,
/// which is why nothing of the author's belongs in it.
///
/// A spec with more than one `service` block gets one registration per service,
/// composed by `register_handlers` in the order the blocks were written: a route
/// belongs to the service that declared it, so two services never end up as one
/// undifferentiated list of routes.
fn render_routes(spec : @api.Spec, st : @style.Style) -> String {
let svc = if spec.routes.length() == 0 { "_svc" } else { "svc" }
let head = "// Code generated by moonctl. DO NOT EDIT.\n\n"
let signature = "(\n app : @moonapi.App,\n " +
svc +
" : @svc.ServiceContext,\n) -> @moonapi.App {\n"
if spec.services.length() <= 1 {
return head +
"///|\n/// Register every route of the " +
spec.service +
" service on `app`.\npub fn register_handlers" +
signature +
route_lines(spec.routes, st) +
" app\n}\n"
}
let mut out = head
for s in spec.services {
out = out +
"///|\n/// Register the routes of the `" +
s.name +
"` service on `app`.\npub fn " +
service_register(s) +
signature +
route_lines(s.routes, st) +
" app\n}\n\n"
}
out = out +
"///|\n/// Register every route of every service on `app`, one service at a\ntime and in the order the spec declared them.\npub fn register_handlers" +
signature
for s in spec.services {
out = out + " let app = " + service_register(s) + "(app, svc)\n"
}
out + " app\n}\n"
}
///|
/// The name of the function that registers `s`'s routes: `register_`.
fn service_register(s : @api.Service) -> String {
"register_" + @text.to_snake(s.name)
}
///|
/// The files of `tree` a run should actually write, given a way to ask whether a
/// path is already on disk. A file moonctl owns (`Always` — the routes and the
/// types) is always written; anything else is written only when it is not there
/// yet, so a second run refreshes what follows the spec and leaves every handler and
/// logic body the author has since filled in exactly as it is.
pub fn tree_plan(
tree : Array[TreeFile],
exists : (String) -> Bool,
) -> Array[@plugin.GenFile] {
let out : Array[@plugin.GenFile] = []
for f in tree {
if f.regen == Always || exists(f.path) == false {
out.push({ path: f.path, content: f.content, })
}
}
out
}