forked from 1b-agent/opencadstudio-upstream
new src/scene/block_service.rs (references, delete, rename, purge candidates, name validation, one error type and message). The command-line RENAME, palette Remove, plugin API, control/MCP block_delete and PURGE all use it. Layout, xref, dynamic-block, anonymous and open-in-editor records are protected; PURGE removes an anonymous block only when nothing references it. PROTOCOL CHANGE: control/MCP block_delete refuses a referenced block unless force=true (erases referencing INSERTs in one undo step). New REST route DELETE /api/v1/blocks/{name}?force=true. MCP tool digest re-pinned. Undo labels are Delete block X, Rename block X to Y and Purge.
1583 lines
59 KiB
Rust
1583 lines
59 KiB
Rust
//! REST transport — the automation API over plain localhost HTTP.
|
|
//!
|
|
//! `OpenCADStudio --http 8090` serves the same operation dispatcher as
|
|
//! `--serve` behind resource-oriented JSON endpoints that any HTTP client —
|
|
//! curl, Python `urllib`/`requests`, JS `fetch`, C# `HttpClient` — can call
|
|
//! with no SDK and no AI-specific machinery. The REST layer is a thin
|
|
//! adapter: every route builds the protocol-1 JSON request and feeds the
|
|
//! shared `automation_op`, so GUI, stdio, TCP, MCP, wasm and HTTP all run
|
|
//! the identical handlers with identical validation, undo and idempotency.
|
|
//!
|
|
//! Conventions: one request per connection (`Connection: close`), JSON
|
|
//! bodies, loopback bind only, and every request carries
|
|
//! `Authorization: Bearer <token>` (see [`api_token`]). Loopback is shared by
|
|
//! every process and user on the machine, and a web page can aim requests at
|
|
//! it too, so the port alone proves nothing; no CORS headers are sent, so a
|
|
//! browser page cannot read an answer either.
|
|
//! Connections are served one thread each: a slow op (plot, open a large
|
|
//! drawing) or a client that connects and stalls must never keep other
|
|
//! callers — `state`, `get_selection`, `cancel`, `operation` — from being
|
|
//! answered. Mutation endpoints get a server-generated `request_id` and the
|
|
//! active `document_id`; a stale-state refusal refreshes state and retries
|
|
//! once.
|
|
//!
|
|
//! The route table is shared: `plan` resolves (method, path) to a `Plan`
|
|
//! that this headless server executes directly and the GUI-hosted bridge
|
|
//! executes over the live editor's envelope queue (`app::control::http_bridge`),
|
|
//! so both `--http` modes serve the identical surface.
|
|
|
|
use crate::app::OpenCADStudio;
|
|
use crate::io::line_read::{is_line_too_long, read_line_capped};
|
|
use serde_json::{json, Value};
|
|
use std::io::{BufReader, Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
const MAX_BODY: usize = 16 * 1024 * 1024;
|
|
|
|
/// Per-line cap for the request line and each header line. `BufRead::read_line`
|
|
/// grows its `String` by doubling, so a client streaming `X: AAAA…` with no
|
|
/// CRLF could otherwise push a single line to gigabytes until the allocation
|
|
/// aborts the process — which is not a catchable panic. 8 KiB matches the
|
|
/// usual reverse-proxy header-line allowance.
|
|
const MAX_LINE: usize = 8 * 1024;
|
|
|
|
/// Cap on the whole header block: every individual line may sit under
|
|
/// [`MAX_LINE`] while their count is infinite. 64 KiB matches common
|
|
/// reverse-proxy header-buffer budgets.
|
|
const MAX_HEADER_BYTES: usize = 64 * 1024;
|
|
|
|
/// Serial for request ids stamped onto `POST /api/v1/{op}` passthroughs that
|
|
/// arrive without one — the protocol-1 envelope requires a unique id, and
|
|
/// the caller's own id always wins when present.
|
|
static PASSTHROUGH_SERIAL: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// Connections served at once. A client that connects and stalls pins one
|
|
/// thread until its idle timeout, not the whole listener.
|
|
const MAX_CONNECTIONS: usize = 8;
|
|
|
|
/// How long one connection may sit between bytes before the server gives up
|
|
/// on it (a client that opened a socket and never sent the request). Bounded
|
|
/// so a stuck client cannot pin a thread forever.
|
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(15);
|
|
|
|
/// Ops that go through the protocol-1 control envelope when posted via the
|
|
/// generic `POST /api/v1/{op}` passthrough. Everything else runs legacy.
|
|
const ENVELOPE_OPS: &[&str] = &[
|
|
"entities_create",
|
|
"entities_delete",
|
|
"entities_transform",
|
|
"block_define",
|
|
"block_delete",
|
|
"file_identity",
|
|
"xdata_set",
|
|
"view_focus",
|
|
"wblock",
|
|
"plot",
|
|
"embed_image",
|
|
"set_properties",
|
|
"property",
|
|
"activate",
|
|
"get_selection",
|
|
"getpoint",
|
|
];
|
|
|
|
/// Loopback REST channel hosted by the GUI process: `OpenCADStudio.exe
|
|
/// "<file>" --http <port>` boots the editor with a bridge that forwards
|
|
/// requests into the live app (see `app::control::http_bridge`), so a client
|
|
/// can read what the person at the screen selected. `--http` without files
|
|
/// keeps the headless server above.
|
|
pub fn set_gui_http_port(port: u16) {
|
|
crate::app::control::http_bridge::set_gui_http_port(port);
|
|
}
|
|
|
|
/// Failure codes worth one state refresh + retry (the request never started).
|
|
pub(crate) const RETRYABLE: &[&str] = &[
|
|
"document_required",
|
|
"document_closed",
|
|
"document_not_active",
|
|
"stale_state",
|
|
"session_changed",
|
|
];
|
|
|
|
pub fn serve(port: u16) {
|
|
listen(OpenCADStudio::new(), port, Arc::new(AtomicU16::new(0)));
|
|
}
|
|
|
|
/// The secret every TCP automation client presents: `OCS_API_TOKEN` when the
|
|
/// launcher set one (so a script knows it in advance), otherwise 32 random
|
|
/// bytes printed once on stderr when a listener starts. The GUI's private
|
|
/// channel keeps its own per-session token in the user-only descriptor.
|
|
pub fn api_token() -> &'static str {
|
|
static TOKEN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
TOKEN.get_or_init(|| {
|
|
if let Some(token) = std::env::var("OCS_API_TOKEN")
|
|
.ok()
|
|
.filter(|token| !token.trim().is_empty())
|
|
{
|
|
return token.trim().to_owned();
|
|
}
|
|
let mut secret = [0u8; 32];
|
|
getrandom::fill(&mut secret).expect("system random source");
|
|
secret.iter().map(|byte| format!("{byte:02x}")).collect()
|
|
})
|
|
}
|
|
|
|
/// Tells a listener's user how to authenticate. A token the launcher chose
|
|
/// is not echoed back.
|
|
pub(crate) fn announce_token() {
|
|
if std::env::var("OCS_API_TOKEN").map_or(true, |token| token.trim().is_empty()) {
|
|
eprintln!(
|
|
"API token (or set OCS_API_TOKEN before launch): {}",
|
|
api_token()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Whether `presented` is the session's token, compared in constant time.
|
|
pub(crate) fn token_matches(presented: Option<&str>) -> bool {
|
|
let expected = api_token().as_bytes();
|
|
let Some(presented) = presented.map(str::as_bytes) else {
|
|
return false;
|
|
};
|
|
presented.len() == expected.len()
|
|
&& presented
|
|
.iter()
|
|
.zip(expected)
|
|
.fold(0u8, |difference, (a, b)| difference | (a ^ b))
|
|
== 0
|
|
}
|
|
|
|
/// The answer to a request without the session's token.
|
|
pub(crate) fn unauthorized() -> Value {
|
|
json!({
|
|
"ok": false,
|
|
"code": "unauthorized",
|
|
"error": "Send the session token (printed at startup, or OCS_API_TOKEN): Authorization: Bearer <token> over HTTP, \"token\" in each --serve --port request",
|
|
})
|
|
}
|
|
|
|
pub(crate) struct HttpRequest {
|
|
pub(crate) method: String,
|
|
pub(crate) path: String,
|
|
query: Vec<(String, String)>,
|
|
body: Vec<u8>,
|
|
bearer: Option<String>,
|
|
}
|
|
|
|
impl HttpRequest {
|
|
fn param(&self, key: &str) -> Option<&str> {
|
|
self.query
|
|
.iter()
|
|
.find(|(k, _)| k == key)
|
|
.map(|(_, v)| v.as_str())
|
|
}
|
|
|
|
pub(crate) fn json(&self) -> Value {
|
|
serde_json::from_slice(&self.body).unwrap_or(Value::Null)
|
|
}
|
|
|
|
pub(crate) fn authorized(&self) -> bool {
|
|
token_matches(self.bearer.as_deref())
|
|
}
|
|
}
|
|
|
|
/// One request handed from a connection thread to the dispatcher that owns
|
|
/// the private app. `OpenCADStudio` is not `Send` (it holds `Rc`/`RefCell`
|
|
/// scene state), so — exactly like the GUI-hosted bridge queues envelopes
|
|
/// into the editor — connections funnel JSON through this channel and the
|
|
/// dispatcher (the thread that called `listen`) answers on the per-request
|
|
/// reply channel.
|
|
struct Job {
|
|
request: HttpRequest,
|
|
reply: std::sync::mpsc::Sender<(u16, Value)>,
|
|
}
|
|
|
|
fn listen(mut app: OpenCADStudio, port: u16, bound_port: Arc<AtomicU16>) {
|
|
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
|
Ok(listener) => listener,
|
|
Err(error) => {
|
|
eprintln!("--http: cannot bind 127.0.0.1:{port}: {error}");
|
|
return;
|
|
}
|
|
};
|
|
let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
|
bound_port.store(bound, Ordering::SeqCst);
|
|
eprintln!("OpenCADStudio REST listening on http://127.0.0.1:{bound}/api/v1");
|
|
announce_token();
|
|
let (jobs, incoming) = std::sync::mpsc::channel::<Job>();
|
|
// Connections are served one thread each: a slow op (plot, open a large
|
|
// drawing) or a client that connects and stalls must never keep other
|
|
// callers — `state`, `get_selection`, `cancel`, `operation` — from being
|
|
// accepted and answered.
|
|
let clients = Arc::new(AtomicUsize::new(0));
|
|
let acceptor = jobs.clone();
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming().flatten() {
|
|
if clients.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
|
clients.fetch_sub(1, Ordering::SeqCst);
|
|
continue;
|
|
}
|
|
let jobs = acceptor.clone();
|
|
let clients = clients.clone();
|
|
std::thread::spawn(move || {
|
|
serve_connection(stream, jobs);
|
|
clients.fetch_sub(1, Ordering::SeqCst);
|
|
});
|
|
}
|
|
});
|
|
// The dispatcher keeps the private app on the thread that called
|
|
// `listen` and answers one request at a time — the same one-session
|
|
// pipeline every transport shares; concurrency lives in the connection
|
|
// threads above, not in the app.
|
|
let mut document_id: Option<u64> = None;
|
|
let mut counter: u64 = 0;
|
|
for job in incoming {
|
|
let (status, body) = route(&mut app, &job.request, &mut document_id, &mut counter);
|
|
let _ = job.reply.send((status, body));
|
|
}
|
|
}
|
|
|
|
/// Read one request off a connection, hand it to the dispatcher, write the
|
|
/// answer. Everything here is per-connection I/O; the app is never touched.
|
|
fn serve_connection(mut stream: TcpStream, jobs: std::sync::mpsc::Sender<Job>) {
|
|
let _ = stream.set_read_timeout(Some(IDLE_TIMEOUT));
|
|
let _ = stream.set_write_timeout(Some(IDLE_TIMEOUT));
|
|
let Ok(Some(request)) = read_request(&mut stream) else {
|
|
return;
|
|
};
|
|
if !request.authorized() {
|
|
let _ = write_response(&mut stream, 401, &unauthorized());
|
|
return;
|
|
}
|
|
let (reply, answer) = std::sync::mpsc::channel();
|
|
if jobs.send(Job { request, reply }).is_err() {
|
|
return;
|
|
}
|
|
if let Ok((status, mut body)) = answer.recv() {
|
|
if let Some(object) = body.as_object_mut() {
|
|
object.insert("http_status".into(), json!(status));
|
|
}
|
|
let _ = write_response(&mut stream, status, &body);
|
|
}
|
|
}
|
|
|
|
/// Answer a peer whose request line or header block blew a size cap — RFC
|
|
/// 9110's `431` — then hand the caller `Ok(None)` so it closes without
|
|
/// dispatching. Writing the refusal from here keeps the headless `--http`
|
|
/// server and the GUI-hosted bridge (`app::control::http_bridge`), which both
|
|
/// call `read_request`, answering identically.
|
|
fn refuse_oversized(stream: &mut TcpStream) -> std::io::Result<Option<HttpRequest>> {
|
|
let body = json!({
|
|
"ok": false,
|
|
"code": "header_too_large",
|
|
"error": "Request line or headers exceed the size limit.",
|
|
});
|
|
let _ = write_response(stream, 431, &body);
|
|
Ok(None)
|
|
}
|
|
|
|
/// Parse one HTTP/1.1 request. `Ok(None)` = the peer hung up cleanly, or the
|
|
/// request blew a size cap (a `431` has already been written in that case).
|
|
pub(crate) fn read_request(stream: &mut TcpStream) -> std::io::Result<Option<HttpRequest>> {
|
|
let mut reader = BufReader::new(stream.try_clone()?);
|
|
let mut line = String::new();
|
|
match read_line_capped(&mut reader, &mut line, MAX_LINE) {
|
|
Ok(0) => return Ok(None),
|
|
Ok(_) => {}
|
|
Err(error) if is_line_too_long(&error) => return refuse_oversized(stream),
|
|
Err(error) => return Err(error),
|
|
}
|
|
let mut parts = line.split_whitespace();
|
|
let method = parts.next().unwrap_or("").to_ascii_uppercase();
|
|
let target = parts.next().unwrap_or("/").to_string();
|
|
let (path, query) = match target.split_once('?') {
|
|
Some((path, query)) => (path.to_string(), parse_query(query)),
|
|
None => (target, Vec::new()),
|
|
};
|
|
|
|
let mut content_length = 0usize;
|
|
let mut header_bytes = 0usize;
|
|
let mut bearer = None;
|
|
loop {
|
|
let mut header = String::new();
|
|
match read_line_capped(&mut reader, &mut header, MAX_LINE) {
|
|
Ok(0) => return Ok(None),
|
|
Ok(_) => {}
|
|
Err(error) if is_line_too_long(&error) => return refuse_oversized(stream),
|
|
Err(error) => return Err(error),
|
|
}
|
|
header_bytes += header.len();
|
|
if header_bytes > MAX_HEADER_BYTES {
|
|
return refuse_oversized(stream);
|
|
}
|
|
let header = header.trim();
|
|
if header.is_empty() {
|
|
break;
|
|
}
|
|
if let Some(value) = header
|
|
.strip_prefix("Content-Length:")
|
|
.or_else(|| header.strip_prefix("content-length:"))
|
|
{
|
|
content_length = value.trim().parse().unwrap_or(0);
|
|
}
|
|
if let Some((name, value)) = header.split_once(':') {
|
|
if name.trim().eq_ignore_ascii_case("authorization") {
|
|
bearer = value
|
|
.trim()
|
|
.strip_prefix("Bearer ")
|
|
.map(|token| token.trim().to_owned());
|
|
}
|
|
}
|
|
}
|
|
if content_length > MAX_BODY {
|
|
return Ok(Some(HttpRequest {
|
|
method,
|
|
path,
|
|
query,
|
|
body: Vec::new(),
|
|
bearer,
|
|
}));
|
|
}
|
|
let mut body = vec![0u8; content_length];
|
|
if content_length > 0 {
|
|
reader.read_exact(&mut body)?;
|
|
}
|
|
Ok(Some(HttpRequest {
|
|
method,
|
|
path,
|
|
query,
|
|
body,
|
|
bearer,
|
|
}))
|
|
}
|
|
|
|
fn parse_query(raw: &str) -> Vec<(String, String)> {
|
|
raw.split('&')
|
|
.filter(|pair| !pair.is_empty())
|
|
.map(|pair| match pair.split_once('=') {
|
|
Some((key, value)) => (percent_decode(key), percent_decode(value)),
|
|
None => (percent_decode(pair), String::new()),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn percent_decode(raw: &str) -> String {
|
|
let bytes = raw.replace('+', " ").into_bytes();
|
|
let mut out = Vec::with_capacity(bytes.len());
|
|
let mut index = 0;
|
|
while index < bytes.len() {
|
|
if bytes[index] == b'%' && index + 2 < bytes.len() {
|
|
let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or("");
|
|
if hex.len() == 2 {
|
|
if let Ok(byte) = u8::from_str_radix(hex, 16) {
|
|
out.push(byte);
|
|
index += 3;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
out.push(bytes[index]);
|
|
index += 1;
|
|
}
|
|
String::from_utf8_lossy(&out).into_owned()
|
|
}
|
|
|
|
pub(crate) fn write_response(
|
|
stream: &mut TcpStream,
|
|
status: u16,
|
|
body: &Value,
|
|
) -> std::io::Result<()> {
|
|
let body = if body.is_null() {
|
|
Vec::new()
|
|
} else {
|
|
serde_json::to_vec(body).unwrap_or_default()
|
|
};
|
|
let reason = match status {
|
|
200 => "OK",
|
|
201 => "Created",
|
|
204 => "No Content",
|
|
400 => "Bad Request",
|
|
401 => "Unauthorized",
|
|
403 => "Forbidden",
|
|
404 => "Not Found",
|
|
405 => "Method Not Allowed",
|
|
409 => "Conflict",
|
|
431 => "Request Header Fields Too Large",
|
|
503 => "Service Unavailable",
|
|
_ => "OK",
|
|
};
|
|
let header = format!(
|
|
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
body.len()
|
|
);
|
|
stream.write_all(header.as_bytes())?;
|
|
if !body.is_empty() {
|
|
stream.write_all(&body)?;
|
|
}
|
|
stream.flush()
|
|
}
|
|
|
|
/// One resolved REST call, shared by the two `--http` transports: this
|
|
/// headless server executes it against its private app, and the GUI-hosted
|
|
/// bridge (`app::control::http_bridge`) forwards the same request into the
|
|
/// live editor's automation queue. Building the request here keeps both
|
|
/// surfaces identical — same URLs, same ops, same document-id handling.
|
|
pub(crate) enum Plan {
|
|
/// Answer without the automation pipeline (OpenAPI, unknown routes).
|
|
Local(u16, Value),
|
|
/// `GET /ready`: a state read rebuilt as the readiness snapshot.
|
|
Ready,
|
|
/// Run one prebuilt protocol-1 request as-is — reads, and callers that
|
|
/// manage `document_id` themselves (the MCP-style clients).
|
|
Run { request: Value, created: u16 },
|
|
/// Run one op through the mutation wrapper: envelope with a request id,
|
|
/// the cached `document_id` when the caller omitted it, one state-refresh
|
|
/// retry after a stale refusal, and the bulky `state` snapshot stripped
|
|
/// from the answer.
|
|
Mutate {
|
|
op: String,
|
|
fields: Value,
|
|
created: u16,
|
|
},
|
|
}
|
|
|
|
impl Plan {
|
|
/// The automation op this plan runs, for transports that vary connection
|
|
/// behavior per op (the GUI bridge parks the interactive picks).
|
|
pub(crate) fn op(&self) -> Option<&str> {
|
|
match self {
|
|
Plan::Local(..) | Plan::Ready => None,
|
|
Plan::Run { request, .. } => request["op"].as_str(),
|
|
Plan::Mutate { op, .. } => Some(op),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The whole REST surface: map (method, path) to the automation request it
|
|
/// runs. The caller splits `/api/v1` off already; the remaining segments
|
|
/// select the arm.
|
|
pub(crate) fn plan(method: &str, rest: &[&str], request: &HttpRequest) -> Plan {
|
|
let raw = request.json();
|
|
let body = if raw.is_object() { raw } else { json!({}) };
|
|
match (method, rest) {
|
|
("GET", ["ready"]) | ("GET", []) | ("GET", [""]) => Plan::Ready,
|
|
("GET", ["capabilities"]) => Plan::Run {
|
|
request: json!({"op":"capabilities"}),
|
|
created: 200,
|
|
},
|
|
("GET", ["state"]) | ("GET", ["documents"]) => Plan::Run {
|
|
request: json!({"protocol":1,"op":"state"}),
|
|
created: 200,
|
|
},
|
|
("POST", ["documents"]) => {
|
|
if let Some(template) = body["template"].as_str() {
|
|
Plan::Mutate {
|
|
op: "new".into(),
|
|
fields: json!({"template": template}),
|
|
created: 201,
|
|
}
|
|
} else if let Some(path) = body["path"].as_str() {
|
|
Plan::Run {
|
|
request: json!({"op":"open","path":path}),
|
|
created: 201,
|
|
}
|
|
} else {
|
|
// A fresh untitled document in
|
|
// its own tab, so cross-document operations have a target.
|
|
Plan::Mutate {
|
|
op: "new".into(),
|
|
fields: json!({}),
|
|
created: 201,
|
|
}
|
|
}
|
|
}
|
|
("DELETE", ["documents", id]) => {
|
|
let mut fields = json!({});
|
|
if let Some(id) = id.parse::<u64>().ok() {
|
|
fields["document_id"] = json!(id);
|
|
}
|
|
if request
|
|
.param("discard")
|
|
.is_some_and(|v| v == "true" || v == "1")
|
|
{
|
|
fields["discard"] = json!(true);
|
|
}
|
|
Plan::Mutate {
|
|
op: "close".into(),
|
|
fields,
|
|
created: 200,
|
|
}
|
|
}
|
|
("GET", ["sysvars"]) => {
|
|
let names = request
|
|
.param("names")
|
|
.map(|csv| json!(csv.split(',').collect::<Vec<_>>()))
|
|
.unwrap_or_else(|| json!([]));
|
|
Plan::Mutate {
|
|
op: "sysvar".into(),
|
|
fields: json!({"get": names}),
|
|
created: 200,
|
|
}
|
|
}
|
|
("POST", ["sysvars"]) => Plan::Mutate {
|
|
op: "sysvar".into(),
|
|
fields: body,
|
|
created: 200,
|
|
},
|
|
("POST", ["layouts"]) => Plan::Mutate {
|
|
op: "layout_create".into(),
|
|
fields: body,
|
|
created: 201,
|
|
},
|
|
("PUT", ["layouts", layout, "page-setup"]) => {
|
|
let mut fields = body;
|
|
fields["layout"] = json!(layout);
|
|
Plan::Mutate {
|
|
op: "page_setup_set".into(),
|
|
fields,
|
|
created: 200,
|
|
}
|
|
}
|
|
("POST", ["entities", "copy-to"]) => Plan::Mutate {
|
|
op: "entities_copy_to".into(),
|
|
fields: body,
|
|
created: 201,
|
|
},
|
|
("POST", ["groups"]) => Plan::Mutate {
|
|
op: "group_create".into(),
|
|
fields: body,
|
|
created: 201,
|
|
},
|
|
("POST", ["selection-sets"]) => Plan::Mutate {
|
|
op: "selection_set_save".into(),
|
|
fields: body,
|
|
created: 201,
|
|
},
|
|
("GET", ["selection-sets", name]) => {
|
|
let select = request
|
|
.param("select")
|
|
.is_some_and(|v| v == "true" || v == "1");
|
|
Plan::Mutate {
|
|
op: "selection_set_load".into(),
|
|
fields: json!({"name": name, "select": select}),
|
|
created: 200,
|
|
}
|
|
}
|
|
("GET", ["entities"]) => {
|
|
let mut request_json = json!({"op":"query"});
|
|
if let Some(v) = request.param("type") {
|
|
request_json["type"] = json!(v);
|
|
}
|
|
if let Some(v) = request.param("layer") {
|
|
request_json["layer"] = json!(v);
|
|
}
|
|
if let Some(v) = request.param("detail") {
|
|
request_json["detail"] = json!(v);
|
|
}
|
|
if let Some(v) = request.param("offset") {
|
|
request_json["offset"] = json!(v);
|
|
}
|
|
if let Some(v) = request.param("limit") {
|
|
request_json["limit"] = json!(v);
|
|
}
|
|
if let Some(v) = request.param("fields") {
|
|
request_json["fields"] = json!(v.split(',').collect::<Vec<_>>());
|
|
}
|
|
if let Some(v) = request.param("handles") {
|
|
request_json["handles"] = json!(v.split(',').collect::<Vec<_>>());
|
|
}
|
|
if let Some(v) = request.param("bounds") {
|
|
let numbers: Vec<f64> = v.split(',').filter_map(|n| n.parse().ok()).collect();
|
|
if numbers.len() == 4 {
|
|
request_json["bounds"] = json!(numbers);
|
|
}
|
|
}
|
|
if let Some(v) = request.param("near") {
|
|
let numbers: Vec<f64> = v.split(',').filter_map(|n| n.parse().ok()).collect();
|
|
request_json["near"] = json!(numbers);
|
|
}
|
|
if let Some(v) = request.param("contains_point") {
|
|
let numbers: Vec<f64> = v.split(',').filter_map(|n| n.parse().ok()).collect();
|
|
request_json["contains_point"] = json!(numbers);
|
|
}
|
|
if let Some(v) = request.param("intersections") {
|
|
request_json["intersections"] = json!(v.split(',').collect::<Vec<_>>());
|
|
}
|
|
if let Some(v) = request.param("where") {
|
|
match serde_json::from_str::<Value>(v) {
|
|
Ok(filters) => request_json["where"] = filters,
|
|
Err(error) => {
|
|
return Plan::Local(
|
|
400,
|
|
json!({"ok": false, "code": "invalid_where", "error": format!("where must be a JSON array: {error}")}),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
Plan::Run {
|
|
request: request_json,
|
|
created: 200,
|
|
}
|
|
}
|
|
("GET", ["entities", handle]) => Plan::Run {
|
|
request: json!({"op":"query","handles":[handle],"detail":"full"}),
|
|
created: 200,
|
|
},
|
|
("POST", ["entities"]) => Plan::Mutate {
|
|
op: "entities_create".into(),
|
|
fields: body,
|
|
created: 201,
|
|
},
|
|
("DELETE", ["entities"]) => {
|
|
let handles = match request.param("handles") {
|
|
Some(csv) => json!(csv.split(',').collect::<Vec<_>>()),
|
|
None => body["handles"].clone(),
|
|
};
|
|
Plan::Mutate {
|
|
op: "entities_delete".into(),
|
|
fields: json!({"handles": handles}),
|
|
created: 200,
|
|
}
|
|
}
|
|
("POST", ["entities", "transform"]) => Plan::Mutate {
|
|
op: "entities_transform".into(),
|
|
fields: body,
|
|
created: 200,
|
|
},
|
|
("GET", ["entities", handle, "xdata"]) => {
|
|
let mut request_json = json!({"protocol":1,"op":"xdata_get","handles":[handle]});
|
|
if let Some(app_name) = request.param("app") {
|
|
request_json["app"] = json!(app_name);
|
|
}
|
|
Plan::Run {
|
|
request: request_json,
|
|
created: 200,
|
|
}
|
|
}
|
|
("PUT", ["entities", handle, "xdata", app_name]) => {
|
|
let data = request.json();
|
|
let data = if data.is_null() { json!([]) } else { data };
|
|
Plan::Mutate {
|
|
op: "xdata_set".into(),
|
|
fields: json!({"handles":[handle], "app": app_name, "data": data}),
|
|
created: 200,
|
|
}
|
|
}
|
|
("DELETE", ["entities", handle, "xdata", app_name]) => Plan::Mutate {
|
|
op: "xdata_set".into(),
|
|
fields: json!({"handles":[handle], "app": app_name, "data": []}),
|
|
created: 200,
|
|
},
|
|
("POST", ["blocks"]) => Plan::Mutate {
|
|
op: "block_define".into(),
|
|
fields: body,
|
|
created: 201,
|
|
},
|
|
("DELETE", ["blocks", name]) => {
|
|
// `?force=true` mirrors the control op: it erases the referencing
|
|
// inserts instead of refusing a referenced definition.
|
|
let mut fields = json!({"name": name});
|
|
if request
|
|
.param("force")
|
|
.is_some_and(|value| value == "true" || value == "1")
|
|
{
|
|
fields["force"] = json!(true);
|
|
}
|
|
Plan::Mutate {
|
|
op: "block_delete".into(),
|
|
fields,
|
|
created: 200,
|
|
}
|
|
}
|
|
("POST", ["file-identity"]) => Plan::Mutate {
|
|
op: "file_identity".into(),
|
|
fields: body,
|
|
created: 200,
|
|
},
|
|
("POST", ["plot"]) => Plan::Mutate {
|
|
op: "plot".into(),
|
|
fields: body,
|
|
created: 200,
|
|
},
|
|
("POST", ["wblock"]) => Plan::Mutate {
|
|
op: "wblock".into(),
|
|
fields: body,
|
|
created: 200,
|
|
},
|
|
("POST", ["images"]) => Plan::Mutate {
|
|
op: "embed_image".into(),
|
|
fields: body,
|
|
created: 201,
|
|
},
|
|
("POST", ["commands"]) => Plan::Run {
|
|
request: json!({"op":"run","cmd":body["cmd"]}),
|
|
created: 200,
|
|
},
|
|
("POST", ["undo"]) | ("POST", ["redo"]) => {
|
|
let op = if rest == ["undo"] { "undo" } else { "redo" };
|
|
Plan::Run {
|
|
request: json!({"op":op}),
|
|
created: 200,
|
|
}
|
|
}
|
|
("POST", ["save"]) => {
|
|
let mut request_json = json!({"op":"save"});
|
|
if let Some(path) = body["path"].as_str() {
|
|
request_json["path"] = json!(path);
|
|
}
|
|
Plan::Run {
|
|
request: request_json,
|
|
created: 200,
|
|
}
|
|
}
|
|
("GET", ["layers"]) => Plan::Run {
|
|
request: json!({"op":"layers"}),
|
|
created: 200,
|
|
},
|
|
("GET", ["header"]) => Plan::Run {
|
|
request: json!({"op":"header"}),
|
|
created: 200,
|
|
},
|
|
("GET", ["records"]) => {
|
|
let mut request_json = json!({"op":"records"});
|
|
if let Some(v) = request.param("collection") {
|
|
request_json["collection"] = json!(v);
|
|
}
|
|
if let Some(v) = request.param("type") {
|
|
request_json["type"] = json!(v);
|
|
}
|
|
if let Some(v) = request.param("offset") {
|
|
request_json["offset"] = json!(v);
|
|
}
|
|
if let Some(v) = request.param("limit") {
|
|
request_json["limit"] = json!(v);
|
|
}
|
|
Plan::Run {
|
|
request: request_json,
|
|
created: 200,
|
|
}
|
|
}
|
|
("GET", ["openapi"]) => Plan::Local(
|
|
200,
|
|
serde_json::from_str(include_str!("rest_openapi.json")).unwrap_or(json!({})),
|
|
),
|
|
("POST", [op]) if ENVELOPE_OPS.contains(op) => Plan::Mutate {
|
|
op: op.to_string(),
|
|
fields: body,
|
|
created: 200,
|
|
},
|
|
("POST", [op]) => {
|
|
let mut request_json = body;
|
|
request_json["op"] = json!(op);
|
|
// Stamp protocol and a request id the way the GUI bridge's
|
|
// `prepare` does: the passthrough exists for ops only the
|
|
// protocol-1 envelope knows (`user_select`, `operation`,
|
|
// `cancel`), and unstamped they die as "unknown op" on the
|
|
// legacy path instead of answering with their real contract.
|
|
request_json["protocol"] = json!(1);
|
|
if request_json["request_id"]
|
|
.as_str()
|
|
.is_none_or(str::is_empty)
|
|
{
|
|
let serial = PASSTHROUGH_SERIAL.fetch_add(1, Ordering::Relaxed);
|
|
request_json["request_id"] = json!(format!("rest-{serial}"));
|
|
}
|
|
Plan::Run {
|
|
request: request_json,
|
|
created: 200,
|
|
}
|
|
}
|
|
(method, _) if matches!(method, "GET" | "POST" | "PUT" | "DELETE") => {
|
|
let path = request.path.trim_end_matches('/');
|
|
let path = if path.is_empty() { "/" } else { path };
|
|
Plan::Local(
|
|
404,
|
|
json!({"ok": false, "code": "unknown_route", "error": format!("No route {method} {path}")}),
|
|
)
|
|
}
|
|
_ => Plan::Local(
|
|
405,
|
|
json!({"ok": false, "code": "method_not_allowed", "error": format!("{method} not supported here")}),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// The headless executor: run a resolved plan against this server's private
|
|
/// app. The GUI-hosted bridge runs the same plans over its envelope queue.
|
|
fn route(
|
|
app: &mut OpenCADStudio,
|
|
request: &HttpRequest,
|
|
document_id: &mut Option<u64>,
|
|
counter: &mut u64,
|
|
) -> (u16, Value) {
|
|
let path = request.path.trim_end_matches('/');
|
|
let path = if path.is_empty() { "/" } else { path };
|
|
let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
|
if segments.first() != Some(&"api") || segments.get(1) != Some(&"v1") {
|
|
return (
|
|
404,
|
|
json!({"ok": false, "code": "unknown_route", "error": "Use /api/v1/…"}),
|
|
);
|
|
}
|
|
match plan(&request.method, &segments[2..], request) {
|
|
Plan::Local(status, body) => (status, body),
|
|
Plan::Ready => {
|
|
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
|
|
harvest(document_id, &state);
|
|
(
|
|
200,
|
|
json!({
|
|
"ok": true,
|
|
"ready": true,
|
|
"version": state["version"],
|
|
"session_id": state["session_id"],
|
|
"document_id": state["document_id"],
|
|
}),
|
|
)
|
|
}
|
|
Plan::Run { request, created } => {
|
|
let response = app.automation_op(&request.to_string());
|
|
harvest(document_id, &response);
|
|
(map_status(response.clone(), created == 201), response)
|
|
}
|
|
Plan::Mutate {
|
|
op,
|
|
fields,
|
|
created,
|
|
} => run_mutation(app, &op, fields, document_id, counter, created),
|
|
}
|
|
}
|
|
|
|
/// Track the active document from automation responses so later mutations
|
|
/// address it without the caller repeating `document_id`.
|
|
fn harvest(document_id: &mut Option<u64>, response: &Value) {
|
|
let id = response
|
|
.get("document_id")
|
|
.and_then(Value::as_u64)
|
|
.or_else(|| response["state"]["document_id"].as_u64());
|
|
if let Some(id) = id {
|
|
*document_id = Some(id);
|
|
}
|
|
}
|
|
|
|
/// Wrap an operation in the protocol-1 envelope with a server-generated
|
|
/// request_id, address the active document, and — when the dispatcher
|
|
/// refuses because our cached state is behind — refresh once and retry.
|
|
fn run_mutation(
|
|
app: &mut OpenCADStudio,
|
|
op: &str,
|
|
mut fields: Value,
|
|
document_id: &mut Option<u64>,
|
|
counter: &mut u64,
|
|
created: u16,
|
|
) -> (u16, Value) {
|
|
let mut attempt = |app: &mut OpenCADStudio, id: u64, document_id: &mut Option<u64>| -> Value {
|
|
*counter += 1;
|
|
let cached = *document_id;
|
|
let mut envelope = json!({
|
|
"protocol": 1,
|
|
"op": op,
|
|
"request_id": format!("http-{id}-{}", *counter),
|
|
});
|
|
if let Some(id) = cached {
|
|
envelope["document_id"] = json!(id);
|
|
}
|
|
if let Some(object) = fields.as_object_mut() {
|
|
for (key, value) in object.clone() {
|
|
envelope[key] = value;
|
|
}
|
|
}
|
|
let response = app.automation_op(&envelope.to_string());
|
|
// Track the active document before the snapshot is stripped below.
|
|
harvest(document_id, &response);
|
|
compact(response)
|
|
};
|
|
|
|
let id = u64::from(std::process::id());
|
|
let mut response = attempt(app, id, document_id);
|
|
let code = response["code"].as_str().unwrap_or("");
|
|
if response["ok"] == false && RETRYABLE.contains(&code) {
|
|
if let Some(state) = fetch_state(app) {
|
|
*document_id = Some(state);
|
|
}
|
|
response = attempt(app, id, document_id);
|
|
}
|
|
let status = map_status(response.clone(), created == 201);
|
|
(if status == 200 { created } else { status }, response)
|
|
}
|
|
|
|
/// Drop the bulky `state` snapshot from settle responses; REST clients read
|
|
/// it explicitly from GET /state.
|
|
fn compact(response: Value) -> Value {
|
|
if let Some(object) = response.as_object() {
|
|
let trimmed: serde_json::Map<String, Value> = object
|
|
.iter()
|
|
.filter(|(key, _)| key.as_str() != "state")
|
|
.map(|(key, value)| (key.clone(), value.clone()))
|
|
.collect();
|
|
return Value::Object(trimmed);
|
|
}
|
|
response
|
|
}
|
|
|
|
fn fetch_state(app: &mut OpenCADStudio) -> Option<u64> {
|
|
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
|
|
state["document_id"].as_u64()
|
|
}
|
|
|
|
/// ok:true → 200 (the caller may upgrade to 201 for resource creation);
|
|
/// failures map to the closest HTTP status so ordinary clients can branch
|
|
/// on the status code, with the symbolic `code` preserved in the body.
|
|
pub(crate) fn map_status(response: Value, created: bool) -> u16 {
|
|
if response["ok"] == true {
|
|
return if created { 201 } else { 200 };
|
|
}
|
|
match response["code"].as_str().unwrap_or("") {
|
|
"entity_absent" | "unknown_route" => 404,
|
|
"stale_state" | "session_changed" | "document_not_active" | "gui_required" => 409,
|
|
"busy" => 503,
|
|
_ => 400,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app::OpenCADStudio;
|
|
use std::io::BufRead;
|
|
|
|
fn request(method: &str, path: &str, body: &str) -> HttpRequest {
|
|
HttpRequest {
|
|
method: method.to_string(),
|
|
path: path.to_string(),
|
|
query: Vec::new(),
|
|
body: body.as_bytes().to_vec(),
|
|
bearer: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn percent_decode_handles_escaped_and_plus_spaces() {
|
|
assert_eq!(percent_decode("Walls%20and%2BFloors"), "Walls and+Floors");
|
|
assert_eq!(percent_decode("plain"), "plain");
|
|
assert_eq!(percent_decode("bad%2"), "bad%2");
|
|
}
|
|
|
|
/// One connection that never sends a request must not keep the listener
|
|
/// from answering the next caller — the whole point of
|
|
/// thread-per-connection (a wedged client used to freeze every read).
|
|
#[test]
|
|
fn listener_answers_while_one_connection_stalls() {
|
|
let bound = Arc::new(AtomicU16::new(0));
|
|
let listen_bound = bound.clone();
|
|
std::thread::spawn(move || {
|
|
listen(OpenCADStudio::new_for_test(), 0, listen_bound);
|
|
});
|
|
let mut port = 0;
|
|
for _ in 0..50 {
|
|
port = bound.load(Ordering::SeqCst);
|
|
if port != 0 {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
assert_ne!(port, 0, "listener never bound a port");
|
|
|
|
let status = |authorization: &str| {
|
|
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
stream
|
|
.write_all(
|
|
format!("GET /api/v1/ready HTTP/1.1\r\nHost: 127.0.0.1\r\n{authorization}\r\n")
|
|
.as_bytes(),
|
|
)
|
|
.unwrap();
|
|
let mut status_line = String::new();
|
|
BufReader::new(stream)
|
|
.read_line(&mut status_line)
|
|
.expect("readiness answered");
|
|
status_line
|
|
};
|
|
// Stalls the thread that accepted it: no request line, ever.
|
|
let _stalled = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
|
let answered = status(&format!("Authorization: Bearer {}\r\n", api_token()));
|
|
assert!(answered.contains("200"), "{answered}");
|
|
// Anything else on the machine that finds the port is turned away.
|
|
assert!(status("").contains("401"));
|
|
assert!(status("Authorization: Bearer guess\r\n").contains("401"));
|
|
}
|
|
|
|
#[test]
|
|
fn failure_codes_map_to_http_statuses() {
|
|
assert_eq!(map_status(json!({"ok": true}), false), 200);
|
|
assert_eq!(map_status(json!({"ok": true}), true), 201);
|
|
assert_eq!(
|
|
map_status(json!({"ok": false, "code": "entity_absent"}), false),
|
|
404
|
|
);
|
|
assert_eq!(
|
|
map_status(json!({"ok": false, "code": "stale_state"}), false),
|
|
409
|
|
);
|
|
assert_eq!(map_status(json!({"ok": false, "code": "busy"}), false), 503);
|
|
assert_eq!(
|
|
map_status(json!({"ok": false, "code": "invalid_point"}), false),
|
|
400
|
|
);
|
|
}
|
|
|
|
/// The generic `POST /api/v1/{op}` passthrough must reach the protocol-1
|
|
/// envelope: ops like `user_select`/`operation`/`cancel` only exist
|
|
/// there, and unstamped they died as "unknown op" on the legacy path.
|
|
#[test]
|
|
fn passthrough_reaches_envelope_only_ops() {
|
|
let mut app = OpenCADStudio::new();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let mut document_id: Option<u64> = None;
|
|
let mut counter: u64 = 0;
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/user_select",
|
|
r#"{"request_id":"us-headless"}"#,
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 409, "{body}");
|
|
assert_eq!(body["code"], "gui_required", "{body}");
|
|
|
|
// `operation` is a query: an unknown id answers `unknown_operation`,
|
|
// not "unknown op".
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("POST", "/api/v1/operation", r#"{"request_id":"gone"}"#),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 400, "{body}");
|
|
assert_eq!(body["code"], "unknown_operation", "{body}");
|
|
}
|
|
|
|
#[test]
|
|
fn get_selection_reports_selected_entities_read_only() {
|
|
let mut app = OpenCADStudio::new();
|
|
let mut document_id: Option<u64> = None;
|
|
let mut counter: u64 = 0;
|
|
let (_, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/entities",
|
|
r#"{"entities":[
|
|
{"type":"Line","start":[0,0],"end":[10,0]},
|
|
{"type":"Text","value":"PAGE-01","position":[1,1],"height":2.5}
|
|
]}"#,
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
let handles = body["result"]["handles"].as_array().unwrap().clone();
|
|
assert_eq!(handles.len(), 2);
|
|
|
|
// The person "picks" the line and the text; the read mirrors the
|
|
// selection order and matches the `query` field formats.
|
|
app.automation_op(&format!(
|
|
r#"{{"op":"select","handles":["{}","{}"]}}"#,
|
|
handles[0].as_str().unwrap(),
|
|
handles[1].as_str().unwrap()
|
|
));
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("POST", "/api/v1/get_selection", "{}"),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body["status"], "completed");
|
|
assert_eq!(body["result"]["count"], 2);
|
|
assert_eq!(
|
|
body["result"]["entities"][0]["handle"],
|
|
handles[0].as_str().unwrap()
|
|
);
|
|
assert_eq!(body["result"]["entities"][0]["type"], "Line");
|
|
assert_eq!(body["result"]["entities"][0]["text"], Value::Null);
|
|
let sample = &body["result"]["entities"][1];
|
|
assert_eq!(sample["type"], "Text");
|
|
assert_eq!(sample["text"], "PAGE-01");
|
|
assert_eq!(sample["value"], "PAGE-01");
|
|
assert!(sample["bounds"].is_object(), "{sample}");
|
|
// A second read is identical — the read is passive.
|
|
let (_, again) = route(
|
|
&mut app,
|
|
&request("POST", "/api/v1/get_selection", "{}"),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(again, body);
|
|
}
|
|
|
|
#[test]
|
|
fn rest_surface_round_trips_real_entities() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let mut document_id = None;
|
|
let mut counter = 0;
|
|
|
|
// Readiness exposes the version and session.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("GET", "/api/v1/ready", ""),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200);
|
|
assert_eq!(body["ok"], true);
|
|
assert!(body["session_id"].as_str().is_some());
|
|
|
|
// Create: a REST resource POST returns 201 with handles.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/entities",
|
|
r#"{"entities":[
|
|
{"type":"Line","start":[0,0],"end":[10,0],"layer":"Walls"},
|
|
{"type":"Circle","center":[5,5],"radius":2},
|
|
{"type":"Text","value":"PAGE-01","position":[1,1],"height":2.5}
|
|
]}"#,
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 201, "{body}");
|
|
let handles = body["result"]["handles"].as_array().unwrap().clone();
|
|
assert_eq!(handles.len(), 3);
|
|
assert!(document_id.is_some(), "the server cached the document id");
|
|
|
|
// Query through plain GET with query parameters.
|
|
let mut get = request("GET", "/api/v1/entities", "");
|
|
get.query = vec![
|
|
("type".into(), "Line".into()),
|
|
("detail".into(), "full".into()),
|
|
];
|
|
let (status, body) = route(&mut app, &get, &mut document_id, &mut counter);
|
|
assert_eq!(status, 200);
|
|
assert_eq!(body["entities"][0]["start"], json!([0.0, 0.0, 0.0]));
|
|
|
|
// One entity by handle.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"GET",
|
|
&format!("/api/v1/entities/{}", handles[1].as_str().unwrap()),
|
|
"",
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200);
|
|
assert_eq!(body["count"], 1);
|
|
assert_eq!(body["entities"][0]["type"], "Circle");
|
|
|
|
// Transform through the dedicated endpoint.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/entities/transform",
|
|
&format!(
|
|
r#"{{"handles":["{}"],"action":"move","vector":[5,0]}}"#,
|
|
handles[1].as_str().unwrap()
|
|
),
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200, "{body}");
|
|
let mut get = request("GET", "/api/v1/entities", "");
|
|
get.query = vec![
|
|
("type".into(), "Circle".into()),
|
|
("detail".into(), "full".into()),
|
|
];
|
|
let (_, body) = route(&mut app, &get, &mut document_id, &mut counter);
|
|
assert_eq!(body["entities"][0]["center"], json!([10.0, 5.0, 0.0]));
|
|
|
|
// XData write via PUT, read via GET.
|
|
let handle = handles[0].as_str().unwrap().to_owned();
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"PUT",
|
|
&format!("/api/v1/entities/{handle}/xdata/SPM"),
|
|
r#"[{"code":1000,"value":"PAGE-01"}]"#,
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200, "{body}");
|
|
let mut get = request("GET", &format!("/api/v1/entities/{handle}/xdata"), "");
|
|
get.query = vec![("app".into(), "SPM".into())];
|
|
let (status, body) = route(&mut app, &get, &mut document_id, &mut counter);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body["items"][0]["xdata"]["SPM"][0], "PAGE-01");
|
|
|
|
// Unknown handles are 404 with the symbolic code preserved.
|
|
let mut delete = request("DELETE", "/api/v1/entities", "");
|
|
delete.query = vec![("handles".into(), "FF".into())];
|
|
let (status, body) = route(&mut app, &delete, &mut document_id, &mut counter);
|
|
assert_eq!(status, 404);
|
|
assert_eq!(body["code"], "entity_absent");
|
|
|
|
// The OpenAPI document is served and parses as JSON.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("GET", "/api/v1/openapi", ""),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200);
|
|
assert_eq!(body["openapi"], "3.0.3");
|
|
|
|
// Unknown routes are 404, not panics.
|
|
let (status, _) = route(
|
|
&mut app,
|
|
&request("GET", "/api/v1/nope", ""),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 404);
|
|
}
|
|
|
|
#[test]
|
|
fn rest_p1_document_surface_routes_end_to_end() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 1"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 20,0 5"}"#);
|
|
let mut document_id = None;
|
|
let mut counter = 0;
|
|
|
|
// GET /state primes the document-id cache the mutations address.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("GET", "/api/v1/state", ""),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200);
|
|
let doc = document_id.expect("state caches the document id");
|
|
assert_eq!(body["document_id"].as_u64(), Some(doc));
|
|
|
|
// Where filters travel as the JSON-encoded `where` query parameter.
|
|
let mut get = request("GET", "/api/v1/entities", "");
|
|
get.query = vec![
|
|
("type".into(), "Circle".into()),
|
|
("detail".into(), "geometry".into()),
|
|
(
|
|
"where".into(),
|
|
r#"[{"path":"/radius","op":"gt","value":2}]"#.into(),
|
|
),
|
|
];
|
|
let (status, body) = route(&mut app, &get, &mut document_id, &mut counter);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body["count"], 1);
|
|
assert_eq!(body["entities"][0]["radius"], 5.0);
|
|
|
|
// A malformed filter is refused before touching the dispatcher.
|
|
let mut get = request("GET", "/api/v1/entities", "");
|
|
get.query = vec![("where".into(), "not-json".into())];
|
|
let (status, body) = route(&mut app, &get, &mut document_id, &mut counter);
|
|
assert_eq!(status, 400);
|
|
assert_eq!(body["code"], "invalid_where");
|
|
|
|
// Sysvars read through GET with a `names` CSV, write through POST.
|
|
let mut get = request("GET", "/api/v1/sysvars", "");
|
|
get.query = vec![("names".into(), "ltscale,mirrtext".into())];
|
|
let (status, body) = route(&mut app, &get, &mut document_id, &mut counter);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert!(body["result"]["values"]["ltscale"].is_number());
|
|
assert_eq!(body["result"]["values"]["mirrtext"], 0);
|
|
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("POST", "/api/v1/sysvars", r#"{"set":{"ltscale":2.5}}"#),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200, "{body}");
|
|
let mut get = request("GET", "/api/v1/sysvars", "");
|
|
get.query = vec![("names".into(), "ltscale".into())];
|
|
let (_, body) = route(&mut app, &get, &mut document_id, &mut counter);
|
|
assert_eq!(body["result"]["values"]["ltscale"], 2.5);
|
|
|
|
// A layout is created (201) and its page setup patched via PUT.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("POST", "/api/v1/layouts", r#"{"name":"PLAN"}"#),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 201, "{body}");
|
|
assert!(body["result"]["layouts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|n| n == "PLAN"));
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"PUT",
|
|
"/api/v1/layouts/PLAN/page-setup",
|
|
r#"{"paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}"#,
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body["result"]["layout"], "PLAN");
|
|
assert!(body["result"]["paper"].as_str().unwrap().contains("ISO_A4"));
|
|
assert_eq!(body["result"]["scale"]["fit"], true);
|
|
|
|
// Groups and selection sets address the drawing's entities (the
|
|
// created layout also owns a sheet viewport, so filter to circles).
|
|
let q = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#);
|
|
let joined = q["entities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|e| format!("\"{}\"", e["handle"].as_str().unwrap()))
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/groups",
|
|
&format!(r#"{{"name":"FRAME","handles":[{joined}]}}"#),
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 201, "{body}");
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/selection-sets",
|
|
&format!(r#"{{"name":"rest-set","handles":[{joined}]}}"#),
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 201, "{body}");
|
|
app.automation_op(r#"{"op":"select","clear":true}"#);
|
|
// Default GET recalls without touching the selection; ?select=true
|
|
// additionally makes the set the current selection.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("GET", "/api/v1/selection-sets/rest-set", ""),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body["result"]["handles"].as_array().unwrap().len(), 2);
|
|
assert_eq!(body["result"]["selected"], 0);
|
|
let mut get = request("GET", "/api/v1/selection-sets/rest-set", "");
|
|
get.query = vec![("select".into(), "true".into())];
|
|
let (status, body) = route(&mut app, &get, &mut document_id, &mut counter);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body["result"]["selected"], 2);
|
|
|
|
// Copy between documents: the body names the target document.
|
|
let target = app.push_test_document();
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/entities/copy-to",
|
|
&format!(r#"{{"handles":[{joined}],"document_id":{target}}}"#),
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 201, "{body}");
|
|
assert_eq!(body["result"]["count"], 2);
|
|
|
|
// Blocks: define → replace → delete; the drawing carries a stable
|
|
// file identity GUID.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/blocks",
|
|
&format!(r#"{{"name":"MARK","base":[0,0,0],"handles":[{joined}]}}"#),
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 201, "{body}");
|
|
// Replace re-blocks fresh content under the same name — the define
|
|
// consumed the original circles, SPM-style re-import draws new
|
|
// content and wraps it again.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/entities",
|
|
r#"{"entities":[{"type":"Point","location":[9,9]}]}"#,
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 201, "{body}");
|
|
let fresh = body["result"]["handles"][0].as_str().unwrap().to_owned();
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request(
|
|
"POST",
|
|
"/api/v1/blocks",
|
|
&format!(
|
|
r#"{{"name":"MARK","base":[0,0,0],"handles":["{fresh}"],"replace":true}}"#
|
|
),
|
|
),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 201, "{body}");
|
|
// A definition the insert still points at is refused without force.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("DELETE", "/api/v1/blocks/MARK", ""),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 400, "{body}");
|
|
assert_eq!(body["code"], "block_referenced", "{body}");
|
|
// `?force=true` erases the referencing insert in the same step.
|
|
let mut forced = request("DELETE", "/api/v1/blocks/MARK", "");
|
|
forced.query = vec![("force".into(), "true".into())];
|
|
let (status, body) = route(&mut app, &forced, &mut document_id, &mut counter);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body["result"]["references"], 1, "{body}");
|
|
assert!(body["result"]["erased"].as_u64().unwrap() >= 1);
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("POST", "/api/v1/file-identity", "{}"),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body["result"]["identity"].as_str().unwrap().len(), 36);
|
|
|
|
// An empty POST /documents opens a fresh
|
|
// untitled document in its own tab — a copy-to target.
|
|
let (status, body) = route(
|
|
&mut app,
|
|
&request("POST", "/api/v1/documents", "{}"),
|
|
&mut document_id,
|
|
&mut counter,
|
|
);
|
|
assert_eq!(status, 201, "{body}");
|
|
let fresh = document_id.expect("fresh document cached");
|
|
assert_ne!(fresh, doc, "a second document opened");
|
|
|
|
// Close with discard: the dirty document is dropped, 200 returned.
|
|
let mut delete = request("DELETE", &format!("/api/v1/documents/{doc}"), "");
|
|
delete.query = vec![("discard".into(), "true".into())];
|
|
let (status, body) = route(&mut app, &delete, &mut document_id, &mut counter);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body["result"]["closed"], true);
|
|
}
|
|
|
|
/// A connected pair: the test writes the request from `client`,
|
|
/// `read_request` parses it from `server`.
|
|
fn loopback_pair() -> (TcpStream, TcpStream) {
|
|
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let client = TcpStream::connect(addr).unwrap();
|
|
let (server, _) = listener.accept().unwrap();
|
|
(client, server)
|
|
}
|
|
|
|
/// Stream `payload` from a clone of `client` on its own thread: the
|
|
/// bytes exceed any socket buffer, so a single thread would deadlock
|
|
/// writing before `read_request` drains the other end. Write failures
|
|
/// after a refusal are expected and ignored.
|
|
fn spawn_writer(client: &TcpStream, payload: Vec<u8>) -> std::thread::JoinHandle<()> {
|
|
let mut writer = client.try_clone().unwrap();
|
|
std::thread::spawn(move || {
|
|
let _ = writer.write_all(&payload);
|
|
let _ = writer.shutdown(std::net::Shutdown::Write);
|
|
})
|
|
}
|
|
|
|
/// Read the status line of the answer the server just wrote.
|
|
fn read_status(client: TcpStream) -> String {
|
|
client
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let mut reader = BufReader::new(client);
|
|
let mut status = String::new();
|
|
reader.read_line(&mut status).expect("server answers");
|
|
status
|
|
}
|
|
|
|
/// A request line streamed without CRLF must be refused at the cap
|
|
/// instead of growing one `String` to gigabytes — an allocation failure
|
|
/// aborts the process and is not catchable.
|
|
#[test]
|
|
fn oversize_request_line_is_refused_with_431() {
|
|
let (client, mut server) = loopback_pair();
|
|
let mut payload = b"GET /".to_vec();
|
|
payload.extend(std::iter::repeat(b'A').take(1024 * 1024));
|
|
payload.extend_from_slice(b" HTTP/1.1\r\n\r\n");
|
|
let writer = spawn_writer(&client, payload);
|
|
|
|
let parsed =
|
|
read_request(&mut server).expect("an over-cap line is a refusal, not an I/O error");
|
|
assert!(
|
|
parsed.is_none(),
|
|
"over-cap request line must be refused, got a parsed request"
|
|
);
|
|
|
|
let status = read_status(client);
|
|
assert!(status.contains("431"), "expected 431, got: {status:?}");
|
|
drop(server);
|
|
writer.join().unwrap();
|
|
}
|
|
|
|
/// Individually-fine headers must not add up to an unbounded header
|
|
/// block: a client can send an unlimited number of small lines.
|
|
#[test]
|
|
fn oversize_header_block_is_refused_with_431() {
|
|
let (client, mut server) = loopback_pair();
|
|
let mut payload = b"GET /api/v1/state HTTP/1.1\r\n".to_vec();
|
|
for _ in 0..80 {
|
|
payload.extend_from_slice(b"X-Pad: ");
|
|
payload.extend(std::iter::repeat(b'a').take(1024));
|
|
payload.extend_from_slice(b"\r\n");
|
|
}
|
|
payload.extend_from_slice(b"\r\n");
|
|
let writer = spawn_writer(&client, payload);
|
|
|
|
let parsed = read_request(&mut server)
|
|
.expect("an over-cap header block is a refusal, not an I/O error");
|
|
assert!(
|
|
parsed.is_none(),
|
|
"over-cap header block must be refused, got a parsed request"
|
|
);
|
|
|
|
let status = read_status(client);
|
|
assert!(status.contains("431"), "expected 431, got: {status:?}");
|
|
drop(server);
|
|
writer.join().unwrap();
|
|
}
|
|
}
|