290 lines
10 KiB
Rust
290 lines
10 KiB
Rust
//! REST routing built from `service rest` blocks in the compiled catalog.
|
|
//!
|
|
//! For each type that declares `service rest "/path" expose ...`, we bind the
|
|
//! exposed operations at the given path:
|
|
//!
|
|
//! ```text
|
|
//! list GET /path
|
|
//! get GET /path/:id
|
|
//! create POST /path
|
|
//! update PATCH /path/:id
|
|
//! delete DELETE /path/:id
|
|
//! subscribe stubbed in Stage 2; wires up in Stage 3 (501)
|
|
//! me GET /path/me (501)
|
|
//! ```
|
|
//!
|
|
//! Phase-04 cutover: the axum + tokio backend was replaced with the
|
|
//! hand-rolled [`http::Router`](crate::http::Router) running on the
|
|
//! phase-02 [`EventLoop`](crate::runtime::EventLoop). Handlers are
|
|
//! synchronous, the engine sits behind `Arc<std::sync::Mutex<_>>`, and
|
|
//! the binary owns one event loop on one thread.
|
|
|
|
use crate::ast::{Operation, ServiceKind};
|
|
use crate::compile::Catalog;
|
|
use crate::engine::Engine;
|
|
use crate::http::{Method, Request, Response, RouteParams, Router, Status};
|
|
|
|
use serde_json::{json, Value};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
pub type Shared = Arc<Mutex<Engine>>;
|
|
|
|
/// Build the fully-wired [`Router`] for a running engine.
|
|
pub fn router(engine: Shared, catalog: &Catalog) -> Router {
|
|
let mut r = Router::new()
|
|
.route(Method::Get, "/", |_, _| root_response())
|
|
.route(Method::Get, "/healthz", |_, _| Response::ok().text("ok"));
|
|
|
|
for name in &catalog.order {
|
|
let t = catalog.get(name).expect("type present");
|
|
for svc in &t.services {
|
|
if svc.kind != ServiceKind::Rest { continue; }
|
|
r = attach_rest(r, engine.clone(), t.name.clone(), svc.path.clone(), &svc.expose);
|
|
}
|
|
}
|
|
r
|
|
}
|
|
|
|
fn root_response() -> Response {
|
|
Response::ok().json(&json!({
|
|
"runtime": "wo",
|
|
"stage": 2,
|
|
"notes": "REST CRUD for each `service rest` block. /healthz for liveness. LIVE subscribe in Stage 3."
|
|
}))
|
|
}
|
|
|
|
fn attach_rest(
|
|
mut r: Router,
|
|
engine: Shared,
|
|
ty: String,
|
|
path: String,
|
|
ops: &[Operation],
|
|
) -> Router {
|
|
let id_path = format!("{path}/:id");
|
|
|
|
// Register literal sub-paths (`/live`, `/me`) BEFORE the `/:id` param
|
|
// route — the first matching pattern wins, so `:id` would otherwise
|
|
// swallow "live" / "me" and produce a 400 invalid-id response.
|
|
for op in ops {
|
|
match op {
|
|
Operation::Subscribe => {
|
|
r = r.route(Method::Get, &format!("{path}/live"),
|
|
|_, _| Response::status(Status::NOT_IMPLEMENTED).text("LIVE subscriptions arrive in Stage 3"));
|
|
}
|
|
Operation::Me => {
|
|
r = r.route(Method::Get, &format!("{path}/me"),
|
|
|_, _| Response::status(Status::NOT_IMPLEMENTED).text("session layer not yet implemented"));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
for op in ops {
|
|
match op {
|
|
Operation::List => {
|
|
let eng = engine.clone(); let ty = ty.clone();
|
|
r = r.route(Method::Get, &path, move |req, params| list_h(&eng, &ty, req, params));
|
|
}
|
|
Operation::Create => {
|
|
let eng = engine.clone(); let ty = ty.clone();
|
|
r = r.route(Method::Post, &path, move |req, params| create_h(&eng, &ty, req, params));
|
|
}
|
|
Operation::Get => {
|
|
let eng = engine.clone(); let ty = ty.clone();
|
|
r = r.route(Method::Get, &id_path, move |req, params| get_h(&eng, &ty, req, params));
|
|
}
|
|
Operation::Update => {
|
|
let eng = engine.clone(); let ty = ty.clone();
|
|
r = r.route(Method::Patch, &id_path, move |req, params| update_h(&eng, &ty, req, params));
|
|
}
|
|
Operation::Delete => {
|
|
let eng = engine.clone(); let ty = ty.clone();
|
|
r = r.route(Method::Delete, &id_path, move |req, params| delete_h(&eng, &ty, req, params));
|
|
}
|
|
Operation::Subscribe | Operation::Me | Operation::Custom => {}
|
|
}
|
|
}
|
|
r
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
fn list_h(engine: &Shared, ty: &str, _req: &Request, _params: &RouteParams) -> Response {
|
|
let eng = engine.lock().unwrap();
|
|
match eng.list(ty) {
|
|
Ok(rows) => Response::ok().json(&json!(rows)),
|
|
Err(e) => Response::status(Status::INTERNAL_SERVER_ERROR).text(e.to_string()),
|
|
}
|
|
}
|
|
|
|
fn get_h(engine: &Shared, ty: &str, _req: &Request, params: &RouteParams) -> Response {
|
|
let id = match parse_id(params) {
|
|
Ok(id) => id,
|
|
Err(r) => return r,
|
|
};
|
|
let eng = engine.lock().unwrap();
|
|
match eng.get(ty, id) {
|
|
Ok(Some(row)) => Response::ok().json(&json!(row)),
|
|
Ok(None) => Response::status(Status::NOT_FOUND).text(format!("no {ty} with id {id}")),
|
|
Err(e) => Response::status(Status::INTERNAL_SERVER_ERROR).text(e.to_string()),
|
|
}
|
|
}
|
|
|
|
fn create_h(engine: &Shared, ty: &str, req: &Request, _params: &RouteParams) -> Response {
|
|
let body = match parse_json_body(req) {
|
|
Ok(v) => v,
|
|
Err(r) => return r,
|
|
};
|
|
let mut eng = engine.lock().unwrap();
|
|
match eng.create(ty, body) {
|
|
Ok(row) => Response::status(Status::CREATED).json(&json!(row)),
|
|
Err(e) => Response::status(Status::BAD_REQUEST).text(e.to_string()),
|
|
}
|
|
}
|
|
|
|
fn update_h(engine: &Shared, ty: &str, req: &Request, params: &RouteParams) -> Response {
|
|
let id = match parse_id(params) {
|
|
Ok(id) => id,
|
|
Err(r) => return r,
|
|
};
|
|
let body = match parse_json_body(req) {
|
|
Ok(v) => v,
|
|
Err(r) => return r,
|
|
};
|
|
let mut eng = engine.lock().unwrap();
|
|
match eng.update(ty, id, body) {
|
|
Ok(Some(row)) => Response::ok().json(&json!(row)),
|
|
Ok(None) => Response::status(Status::NOT_FOUND).text(format!("no {ty} with id {id}")),
|
|
Err(e) => Response::status(Status::BAD_REQUEST).text(e.to_string()),
|
|
}
|
|
}
|
|
|
|
fn delete_h(engine: &Shared, ty: &str, _req: &Request, params: &RouteParams) -> Response {
|
|
let id = match parse_id(params) {
|
|
Ok(id) => id,
|
|
Err(r) => return r,
|
|
};
|
|
let mut eng = engine.lock().unwrap();
|
|
match eng.delete(ty, id) {
|
|
Ok(true) => Response::no_content(),
|
|
Ok(false) => Response::status(Status::NOT_FOUND).text(format!("no {ty} with id {id}")),
|
|
Err(e) => Response::status(Status::INTERNAL_SERVER_ERROR).text(e.to_string()),
|
|
}
|
|
}
|
|
|
|
fn parse_id(params: &RouteParams) -> Result<i64, Response> {
|
|
params.get("id")
|
|
.and_then(|s| s.parse::<i64>().ok())
|
|
.ok_or_else(|| Response::status(Status::BAD_REQUEST).text("invalid id"))
|
|
}
|
|
|
|
fn parse_json_body(req: &Request) -> Result<Value, Response> {
|
|
if req.body.is_empty() {
|
|
return Ok(Value::Object(Default::default()));
|
|
}
|
|
serde_json::from_slice::<Value>(&req.body)
|
|
.map_err(|e| Response::status(Status::BAD_REQUEST).text(format!("invalid JSON: {e}")))
|
|
}
|
|
|
|
/// Format the endpoint banner the CLI prints on startup.
|
|
pub fn describe_routes(engine: &Engine) -> String {
|
|
let mut out = String::new();
|
|
out.push_str(" GET / runtime info\n");
|
|
out.push_str(" GET /healthz liveness\n");
|
|
for name in &engine.catalog().order {
|
|
let t = engine.catalog().get(name).unwrap();
|
|
for svc in &t.services {
|
|
if svc.kind != ServiceKind::Rest { continue; }
|
|
for op in &svc.expose {
|
|
let (m, p, n) = match op {
|
|
Operation::List => ("GET ", svc.path.clone(), format!("list {name}")),
|
|
Operation::Get => ("GET ", format!("{}/:id", svc.path), format!("get {name}")),
|
|
Operation::Create => ("POST ", svc.path.clone(), format!("create {name}")),
|
|
Operation::Update => ("PATCH ", format!("{}/:id", svc.path), format!("update {name}")),
|
|
Operation::Delete => ("DELETE", format!("{}/:id", svc.path), format!("delete {name}")),
|
|
Operation::Subscribe => ("WS ", format!("{}/live", svc.path), format!("subscribe {name} (Stage 3)")),
|
|
Operation::Me => ("GET ", format!("{}/me", svc.path), "(Stage 3)".into()),
|
|
Operation::Custom => continue,
|
|
};
|
|
out.push_str(&format!(" {m} {p:<30} {n}\n"));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::compile::Catalog;
|
|
use crate::parser::parse;
|
|
|
|
fn build(src: &str) -> (Shared, Router) {
|
|
let cat = Catalog::from_schemas(vec![parse(src).unwrap()]).unwrap();
|
|
let eng = Arc::new(Mutex::new(Engine::new(cat.clone())));
|
|
let r = router(eng.clone(), &cat);
|
|
(eng, r)
|
|
}
|
|
|
|
fn req(method: Method, path: &str, body: &[u8]) -> Request {
|
|
Request {
|
|
method,
|
|
path: path.into(),
|
|
query: None,
|
|
headers: Default::default(),
|
|
body: body.to_vec(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn router_serves_crud_for_a_service_rest_block() {
|
|
let (_eng, r) = build(r#"
|
|
type Article { id: Id
|
|
title: Text
|
|
service rest "/api/articles" expose list, get, create, update, delete }
|
|
"#);
|
|
|
|
// empty list
|
|
let resp = r.dispatch(&req(Method::Get, "/api/articles", b""));
|
|
assert_eq!(resp.status.0, 200);
|
|
assert_eq!(resp.body, b"[]");
|
|
|
|
// create
|
|
let resp = r.dispatch(&req(Method::Post, "/api/articles", br#"{"title":"hello"}"#));
|
|
assert_eq!(resp.status.0, 201);
|
|
|
|
// get by id
|
|
let resp = r.dispatch(&req(Method::Get, "/api/articles/1", b""));
|
|
assert_eq!(resp.status.0, 200);
|
|
|
|
// delete
|
|
let resp = r.dispatch(&req(Method::Delete, "/api/articles/1", b""));
|
|
assert_eq!(resp.status.0, 204);
|
|
|
|
// 404 after delete
|
|
let resp = r.dispatch(&req(Method::Get, "/api/articles/1", b""));
|
|
assert_eq!(resp.status.0, 404);
|
|
}
|
|
|
|
#[test]
|
|
fn unexposed_method_yields_405() {
|
|
let (_eng, r) = build(r#"
|
|
type Tag { id: Id
|
|
label: Text
|
|
service rest "/api/tags" expose list, get }
|
|
"#);
|
|
let resp = r.dispatch(&req(Method::Post, "/api/tags", br#"{"label":"rust"}"#));
|
|
assert_eq!(resp.status.0, 405);
|
|
}
|
|
|
|
#[test]
|
|
fn live_subscribe_is_501() {
|
|
let (_eng, r) = build(r#"
|
|
type Article { id: Id
|
|
title: Text
|
|
service rest "/api/articles" expose list, subscribe }
|
|
"#);
|
|
let resp = r.dispatch(&req(Method::Get, "/api/articles/live", b""));
|
|
assert_eq!(resp.status.0, 501);
|
|
}
|
|
}
|