librojo/web/
api.rs

1//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
2//! JSON.
3
4use std::{
5    collections::{HashMap, HashSet},
6    fs,
7    path::PathBuf,
8    str::FromStr,
9    sync::Arc,
10};
11
12use hyper::{body, Body, Method, Request, Response, StatusCode};
13use opener::OpenError;
14use rbx_dom_weak::{
15    types::{Ref, Variant},
16    InstanceBuilder, UstrMap, WeakDom,
17};
18
19use crate::{
20    json,
21    serve_session::ServeSession,
22    snapshot::{InstanceWithMeta, PatchSet, PatchUpdate},
23    web::{
24        interface::{
25            ErrorResponse, Instance, OpenResponse, ReadResponse, ServerInfoResponse,
26            SubscribeMessage, SubscribeResponse, WriteRequest, WriteResponse, PROTOCOL_VERSION,
27            SERVER_VERSION,
28        },
29        util::{json, json_ok},
30    },
31    web_api::{BufferEncode, InstanceUpdate, RefPatchResponse, SerializeResponse},
32};
33
34pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> Response<Body> {
35    let service = ApiService::new(serve_session);
36
37    match (request.method(), request.uri().path()) {
38        (&Method::GET, "/api/rojo") => service.handle_api_rojo().await,
39        (&Method::GET, path) if path.starts_with("/api/read/") => {
40            service.handle_api_read(request).await
41        }
42        (&Method::GET, path) if path.starts_with("/api/subscribe/") => {
43            service.handle_api_subscribe(request).await
44        }
45        (&Method::GET, path) if path.starts_with("/api/serialize/") => {
46            service.handle_api_serialize(request).await
47        }
48        (&Method::GET, path) if path.starts_with("/api/ref-patch/") => {
49            service.handle_api_ref_patch(request).await
50        }
51
52        (&Method::POST, path) if path.starts_with("/api/open/") => {
53            service.handle_api_open(request).await
54        }
55        (&Method::POST, "/api/write") => service.handle_api_write(request).await,
56
57        (_method, path) => json(
58            ErrorResponse::not_found(format!("Route not found: {}", path)),
59            StatusCode::NOT_FOUND,
60        ),
61    }
62}
63
64pub struct ApiService {
65    serve_session: Arc<ServeSession>,
66}
67
68impl ApiService {
69    pub fn new(serve_session: Arc<ServeSession>) -> Self {
70        ApiService { serve_session }
71    }
72
73    /// Get a summary of information about the server
74    async fn handle_api_rojo(&self) -> Response<Body> {
75        let tree = self.serve_session.tree();
76        let root_instance_id = tree.get_root_id();
77
78        json_ok(&ServerInfoResponse {
79            server_version: SERVER_VERSION.to_owned(),
80            protocol_version: PROTOCOL_VERSION,
81            session_id: self.serve_session.session_id(),
82            project_name: self.serve_session.project_name().to_owned(),
83            expected_place_ids: self.serve_session.serve_place_ids().cloned(),
84            unexpected_place_ids: self.serve_session.blocked_place_ids().cloned(),
85            place_id: self.serve_session.place_id(),
86            game_id: self.serve_session.game_id(),
87            root_instance_id,
88        })
89    }
90
91    /// Retrieve any messages past the given cursor index, and if
92    /// there weren't any, subscribe to receive any new messages.
93    async fn handle_api_subscribe(&self, request: Request<Body>) -> Response<Body> {
94        let argument = &request.uri().path()["/api/subscribe/".len()..];
95        let input_cursor: u32 = match argument.parse() {
96            Ok(v) => v,
97            Err(err) => {
98                return json(
99                    ErrorResponse::bad_request(format!("Malformed message cursor: {}", err)),
100                    StatusCode::BAD_REQUEST,
101                );
102            }
103        };
104
105        let session_id = self.serve_session.session_id();
106
107        let result = self
108            .serve_session
109            .message_queue()
110            .subscribe(input_cursor)
111            .await;
112
113        let tree_handle = self.serve_session.tree_handle();
114
115        match result {
116            Ok((message_cursor, messages)) => {
117                let tree = tree_handle.lock().unwrap();
118
119                let api_messages = messages
120                    .into_iter()
121                    .map(|patch| SubscribeMessage::from_patch_update(&tree, patch))
122                    .collect();
123
124                json_ok(SubscribeResponse {
125                    session_id,
126                    message_cursor,
127                    messages: api_messages,
128                })
129            }
130            Err(_) => json(
131                ErrorResponse::internal_error("Message queue disconnected sender"),
132                StatusCode::INTERNAL_SERVER_ERROR,
133            ),
134        }
135    }
136
137    async fn handle_api_write(&self, request: Request<Body>) -> Response<Body> {
138        let session_id = self.serve_session.session_id();
139        let tree_mutation_sender = self.serve_session.tree_mutation_sender();
140
141        let body = body::to_bytes(request.into_body()).await.unwrap();
142
143        let request: WriteRequest = match json::from_slice(&body) {
144            Ok(request) => request,
145            Err(err) => {
146                return json(
147                    ErrorResponse::bad_request(format!("Invalid body: {}", err)),
148                    StatusCode::BAD_REQUEST,
149                );
150            }
151        };
152
153        if request.session_id != session_id {
154            return json(
155                ErrorResponse::bad_request("Wrong session ID"),
156                StatusCode::BAD_REQUEST,
157            );
158        }
159
160        let updated_instances = request
161            .updated
162            .into_iter()
163            .map(|update| PatchUpdate {
164                id: update.id,
165                changed_class_name: update.changed_class_name,
166                changed_name: update.changed_name,
167                changed_properties: update.changed_properties,
168                changed_metadata: None,
169            })
170            .collect();
171
172        tree_mutation_sender
173            .send(PatchSet {
174                removed_instances: Vec::new(),
175                added_instances: Vec::new(),
176                updated_instances,
177            })
178            .unwrap();
179
180        json_ok(WriteResponse { session_id })
181    }
182
183    async fn handle_api_read(&self, request: Request<Body>) -> Response<Body> {
184        let argument = &request.uri().path()["/api/read/".len()..];
185        let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect();
186
187        let requested_ids = match requested_ids {
188            Ok(ids) => ids,
189            Err(_) => {
190                return json(
191                    ErrorResponse::bad_request("Malformed ID list"),
192                    StatusCode::BAD_REQUEST,
193                );
194            }
195        };
196
197        let message_queue = self.serve_session.message_queue();
198        let message_cursor = message_queue.cursor();
199
200        let tree = self.serve_session.tree();
201
202        let mut instances = HashMap::new();
203
204        for id in requested_ids {
205            if let Some(instance) = tree.get_instance(id) {
206                instances.insert(id, Instance::from_rojo_instance(instance));
207
208                for descendant in tree.descendants(id) {
209                    instances.insert(descendant.id(), Instance::from_rojo_instance(descendant));
210                }
211            }
212        }
213
214        json_ok(ReadResponse {
215            session_id: self.serve_session.session_id(),
216            message_cursor,
217            instances,
218        })
219    }
220
221    /// Accepts a list of IDs and returns them serialized as a binary model.
222    /// The model is sent in a schema that causes Roblox to deserialize it as
223    /// a Luau `buffer`.
224    ///
225    /// The returned model is a folder that contains ObjectValues with names
226    /// that correspond to the requested Instances. These values have their
227    /// `Value` property set to point to the requested Instance.
228    async fn handle_api_serialize(&self, request: Request<Body>) -> Response<Body> {
229        let argument = &request.uri().path()["/api/serialize/".len()..];
230        let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect();
231
232        let requested_ids = match requested_ids {
233            Ok(ids) => ids,
234            Err(_) => {
235                return json(
236                    ErrorResponse::bad_request("Malformed ID list"),
237                    StatusCode::BAD_REQUEST,
238                );
239            }
240        };
241        let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder"));
242
243        let tree = self.serve_session.tree();
244        for id in &requested_ids {
245            if let Some(instance) = tree.get_instance(*id) {
246                let clone = response_dom.insert(
247                    Ref::none(),
248                    InstanceBuilder::new(instance.class_name())
249                        .with_name(instance.name())
250                        .with_properties(instance.properties().clone()),
251                );
252                let object_value = response_dom.insert(
253                    response_dom.root_ref(),
254                    InstanceBuilder::new("ObjectValue")
255                        .with_name(id.to_string())
256                        .with_property("Value", clone),
257                );
258
259                let mut child_ref = clone;
260                if let Some(parent_class) = parent_requirements(&instance.class_name()) {
261                    child_ref =
262                        response_dom.insert(object_value, InstanceBuilder::new(parent_class));
263                    response_dom.transfer_within(clone, child_ref);
264                }
265
266                response_dom.transfer_within(child_ref, object_value);
267            } else {
268                json(
269                    ErrorResponse::bad_request(format!("provided id {id} is not in the tree")),
270                    StatusCode::BAD_REQUEST,
271                );
272            }
273        }
274        drop(tree);
275
276        let mut source = Vec::new();
277        rbx_binary::to_writer(&mut source, &response_dom, &[response_dom.root_ref()]).unwrap();
278
279        json_ok(SerializeResponse {
280            session_id: self.serve_session.session_id(),
281            model_contents: BufferEncode::new(source),
282        })
283    }
284
285    /// Returns a list of all referent properties that point towards the
286    /// provided IDs. Used because the plugin does not store a RojoTree,
287    /// and referent properties need to be updated after the serialize
288    /// endpoint is used.
289    async fn handle_api_ref_patch(self, request: Request<Body>) -> Response<Body> {
290        let argument = &request.uri().path()["/api/ref-patch/".len()..];
291        let requested_ids: Result<HashSet<Ref>, _> =
292            argument.split(',').map(Ref::from_str).collect();
293
294        let requested_ids = match requested_ids {
295            Ok(ids) => ids,
296            Err(_) => {
297                return json(
298                    ErrorResponse::bad_request("Malformed ID list"),
299                    StatusCode::BAD_REQUEST,
300                );
301            }
302        };
303
304        let mut instance_updates: HashMap<Ref, InstanceUpdate> = HashMap::new();
305
306        let tree = self.serve_session.tree();
307        for instance in tree.descendants(tree.get_root_id()) {
308            for (prop_name, prop_value) in instance.properties() {
309                let Variant::Ref(prop_value) = prop_value else {
310                    continue;
311                };
312                if let Some(target_id) = requested_ids.get(prop_value) {
313                    let instance_id = instance.id();
314                    let update =
315                        instance_updates
316                            .entry(instance_id)
317                            .or_insert_with(|| InstanceUpdate {
318                                id: instance_id,
319                                changed_class_name: None,
320                                changed_name: None,
321                                changed_metadata: None,
322                                changed_properties: UstrMap::default(),
323                            });
324                    update
325                        .changed_properties
326                        .insert(*prop_name, Some(Variant::Ref(*target_id)));
327                }
328            }
329        }
330
331        json_ok(RefPatchResponse {
332            session_id: self.serve_session.session_id(),
333            patch: SubscribeMessage {
334                added: HashMap::new(),
335                removed: Vec::new(),
336                updated: instance_updates.into_values().collect(),
337            },
338        })
339    }
340
341    /// Open a script with the given ID in the user's default text editor.
342    async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
343        let argument = &request.uri().path()["/api/open/".len()..];
344        let requested_id = match Ref::from_str(argument) {
345            Ok(id) => id,
346            Err(_) => {
347                return json(
348                    ErrorResponse::bad_request("Invalid instance ID"),
349                    StatusCode::BAD_REQUEST,
350                );
351            }
352        };
353
354        let tree = self.serve_session.tree();
355
356        let instance = match tree.get_instance(requested_id) {
357            Some(instance) => instance,
358            None => {
359                return json(
360                    ErrorResponse::bad_request("Instance not found"),
361                    StatusCode::NOT_FOUND,
362                );
363            }
364        };
365
366        let script_path = match pick_script_path(instance) {
367            Some(path) => path,
368            None => {
369                return json(
370                    ErrorResponse::bad_request(
371                        "No appropriate file could be found to open this script",
372                    ),
373                    StatusCode::NOT_FOUND,
374                );
375            }
376        };
377
378        match opener::open(&script_path) {
379            Ok(()) => {}
380            Err(error) => match error {
381                OpenError::Io(io_error) => {
382                    return json(
383                        ErrorResponse::internal_error(format!(
384                            "Attempting to open {} failed because of the following io error: {}",
385                            script_path.display(),
386                            io_error
387                        )),
388                        StatusCode::INTERNAL_SERVER_ERROR,
389                    )
390                }
391                OpenError::ExitStatus {
392                    cmd,
393                    status,
394                    stderr,
395                } => {
396                    return json(
397                        ErrorResponse::internal_error(format!(
398                            r#"The command '{}' to open '{}' failed with the error code '{}'.
399                            Error logs:
400                            {}"#,
401                            cmd,
402                            script_path.display(),
403                            status,
404                            stderr
405                        )),
406                        StatusCode::INTERNAL_SERVER_ERROR,
407                    )
408                }
409            },
410        };
411
412        json_ok(OpenResponse {
413            session_id: self.serve_session.session_id(),
414        })
415    }
416}
417
418/// If this instance is represented by a script, try to find the correct .lua or .luau
419/// file to open to edit it.
420fn pick_script_path(instance: InstanceWithMeta<'_>) -> Option<PathBuf> {
421    match instance.class_name().as_str() {
422        "Script" | "LocalScript" | "ModuleScript" => {}
423        _ => return None,
424    }
425
426    // Pick the first listed relevant path that has an extension of .lua or .luau that
427    // exists.
428    instance
429        .metadata()
430        .relevant_paths
431        .iter()
432        .find(|path| {
433            // We should only ever open Lua or Luau files to be safe.
434            match path.extension().and_then(|ext| ext.to_str()) {
435                Some("lua") => {}
436                Some("luau") => {}
437                _ => return false,
438            }
439
440            fs::metadata(path)
441                .map(|meta| meta.is_file())
442                .unwrap_or(false)
443        })
444        .map(|path| path.to_owned())
445}
446
447/// Certain Instances MUST be a child of specific classes. This function
448/// tracks that information for the Serialize endpoint.
449///
450/// If a parent requirement exists, it will be returned.
451/// Otherwise returns `None`.
452fn parent_requirements(class: &str) -> Option<&str> {
453    Some(match class {
454        "Attachment" | "Bone" => "Part",
455        "Animator" => "Humanoid",
456        "BaseWrap" | "WrapLayer" | "WrapTarget" | "WrapDeformer" => "MeshPart",
457        _ => return None,
458    })
459}