Skip to main content

webui_ffi/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI FFI (Foreign Function Interface) for interoperability with other languages.
5//!
6//! This crate provides C-compatible APIs for the WebUI handler to be used from languages
7//! like Go, C#, Python, etc.
8//!
9//! ## Quick Start
10//!
11//! The simplest way to render an HTML template with data:
12//!
13//! ```c
14//! char *result = webui_render("<h1>{{title}}</h1>", "{\"title\":\"Hello\"}");
15//! if (result == NULL) {
16//!     const char *err = webui_last_error();
17//!     // handle error...
18//! } else {
19//!     // use result...
20//!     webui_free(result);
21//! }
22//! ```
23//!
24//! ## Error Handling
25//!
26//! All functions that can fail return `NULL` on error. Call [`webui_last_error`] to
27//! retrieve a human-readable error message. The error string is valid until the next
28//! FFI call on the same thread (follows the POSIX `dlerror()` pattern).
29
30use serde_json::Value;
31use std::cell::RefCell;
32use std::ffi::{CStr, CString};
33use std::os::raw::{c_char, c_void};
34use webui_handler::plugin::FastHydrationPlugin;
35use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
36use webui_parser::HtmlParser;
37use webui_protocol::WebUIProtocol;
38
39// ---------------------------------------------------------------------------
40// Thread-local error storage (POSIX dlerror() pattern)
41// ---------------------------------------------------------------------------
42
43thread_local! {
44    /// Stores the last error message for the current thread.
45    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
46}
47
48/// Record an error message so that `webui_last_error()` can return it.
49fn set_last_error(msg: impl Into<String>) {
50    let mut bytes = msg.into().into_bytes();
51    if let Some(nul_pos) = bytes.iter().position(|byte| *byte == 0) {
52        bytes.truncate(nul_pos);
53    }
54
55    // SAFETY: Any interior NUL byte was removed by truncating at its first position.
56    let c_string = unsafe { CString::from_vec_unchecked(bytes) };
57    LAST_ERROR.with(|cell| {
58        cell.replace(Some(c_string));
59    });
60}
61
62/// Clear any previously stored error.
63fn clear_last_error() {
64    LAST_ERROR.with(|cell| {
65        cell.replace(None);
66    });
67}
68
69// ---------------------------------------------------------------------------
70// Internal helpers
71// ---------------------------------------------------------------------------
72
73/// Opaque context wrapping a `WebUIHandler`.
74struct HandlerContext {
75    handler: WebUIHandler,
76}
77
78/// A simple string buffer for collecting rendered output.
79struct StringResponseWriter {
80    content: String,
81}
82
83impl StringResponseWriter {
84    fn new() -> Self {
85        Self {
86            content: String::new(),
87        }
88    }
89}
90
91impl ResponseWriter for StringResponseWriter {
92    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
93        self.content.push_str(content);
94        Ok(())
95    }
96
97    fn end(&mut self) -> webui_handler::Result<()> {
98        Ok(())
99    }
100}
101
102// ---------------------------------------------------------------------------
103// FFI: error reporting
104// ---------------------------------------------------------------------------
105
106/// Return the last error message, or `NULL` if no error has occurred.
107///
108/// The returned pointer is valid until the next FFI call **on the same thread**.
109/// Callers **must not** free the returned pointer.
110///
111/// # Thread Safety
112///
113/// Each thread has its own independent error state.
114#[no_mangle]
115pub extern "C" fn webui_last_error() -> *const c_char {
116    LAST_ERROR.with(|cell| {
117        let borrow = cell.borrow();
118        match borrow.as_ref() {
119            Some(c_str) => c_str.as_ptr(),
120            None => std::ptr::null(),
121        }
122    })
123}
124
125// ---------------------------------------------------------------------------
126// FFI: handler lifecycle
127// ---------------------------------------------------------------------------
128
129/// Create a new WebUI handler instance.
130///
131/// Returns an opaque pointer that must be passed to other `webui_handler_*`
132/// functions and eventually freed with [`webui_handler_destroy`].
133#[no_mangle]
134pub extern "C" fn webui_handler_create() -> *mut c_void {
135    let handler = WebUIHandler::new();
136    let context = Box::new(HandlerContext { handler });
137    Box::into_raw(context) as *mut c_void
138}
139
140/// Create a new WebUI handler instance with a named plugin.
141///
142/// # Arguments
143///
144/// * `plugin_id` - Null-terminated UTF-8 string identifying the plugin.
145///   Currently supported: `"fast"`. Pass `NULL` for no plugin.
146///
147/// # Returns
148///
149/// An opaque pointer that must be freed with [`webui_handler_destroy`],
150/// or `NULL` on error (call [`webui_last_error`] for details).
151///
152/// # Safety
153///
154/// `plugin_id` must be a valid null-terminated UTF-8 string, or `NULL`.
155#[no_mangle]
156pub unsafe extern "C" fn webui_handler_create_with_plugin(plugin_id: *const c_char) -> *mut c_void {
157    clear_last_error();
158
159    let handler = if plugin_id.is_null() {
160        WebUIHandler::new()
161    } else {
162        match CStr::from_ptr(plugin_id).to_str() {
163            Ok("fast") => WebUIHandler::with_plugin(|| Box::new(FastHydrationPlugin::new())),
164            Ok(unknown) => {
165                set_last_error(format!("unknown plugin: {unknown}"));
166                return std::ptr::null_mut();
167            }
168            Err(e) => {
169                set_last_error(format!("invalid UTF-8 in plugin_id: {e}"));
170                return std::ptr::null_mut();
171            }
172        }
173    };
174
175    let context = Box::new(HandlerContext { handler });
176    Box::into_raw(context) as *mut c_void
177}
178
179/// Destroy a WebUI handler instance.
180///
181/// # Safety
182///
183/// `handler_ptr` must be a valid pointer returned by [`webui_handler_create`],
184/// or `NULL` (in which case this function is a no-op).
185#[no_mangle]
186pub unsafe extern "C" fn webui_handler_destroy(handler_ptr: *mut c_void) {
187    if !handler_ptr.is_null() {
188        let _ = Box::from_raw(handler_ptr as *mut HandlerContext);
189    }
190}
191
192// ---------------------------------------------------------------------------
193// FFI: protobuf-based render (existing API, now with error reporting)
194// ---------------------------------------------------------------------------
195
196/// Render a WebUI protocol (protobuf binary) with JSON data.
197///
198/// # Arguments
199///
200/// * `handler_ptr`   - Pointer returned by [`webui_handler_create`].
201/// * `protocol_data` - Pointer to protobuf binary data.
202/// * `protocol_len`  - Length of the protobuf data in bytes.
203/// * `data_json`     - Null-terminated JSON string with the render state.
204/// * `entry_id`      - Null-terminated UTF-8 string for the entry fragment.
205/// * `request_path`  - Null-terminated UTF-8 string for the URL path to match
206///   routes against (e.g., `"/contacts/42"`).
207///
208/// # Returns
209///
210/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or
211/// `NULL` on error.  The caller **must** free the returned string with
212/// [`webui_free`].  On error, call [`webui_last_error`] for details.
213///
214/// # Safety
215///
216/// * `handler_ptr` must be a valid pointer returned by [`webui_handler_create`].
217/// * `protocol_data` must point to `protocol_len` bytes of valid memory.
218/// * `data_json`, `entry_id`, and `request_path` must be valid null-terminated UTF-8 strings.
219#[no_mangle]
220pub unsafe extern "C" fn webui_handler_render(
221    handler_ptr: *mut c_void,
222    protocol_data: *const u8,
223    protocol_len: usize,
224    data_json: *const c_char,
225    entry_id: *const c_char,
226    request_path: *const c_char,
227) -> *mut c_char {
228    clear_last_error();
229
230    if handler_ptr.is_null()
231        || protocol_data.is_null()
232        || data_json.is_null()
233        || entry_id.is_null()
234        || request_path.is_null()
235    {
236        set_last_error("one or more required arguments are null");
237        return std::ptr::null_mut();
238    }
239
240    // SAFETY: caller guarantees handler_ptr is valid and exclusively owned.
241    let context = &*(handler_ptr as *const HandlerContext);
242
243    // SAFETY: caller guarantees protocol_data points to protocol_len valid bytes.
244    let protocol_bytes = std::slice::from_raw_parts(protocol_data, protocol_len);
245
246    // SAFETY: caller guarantees data_json is a valid null-terminated string.
247    let data_str = match CStr::from_ptr(data_json).to_str() {
248        Ok(s) => s,
249        Err(e) => {
250            set_last_error(format!("invalid UTF-8 in data_json: {e}"));
251            return std::ptr::null_mut();
252        }
253    };
254
255    // SAFETY: caller guarantees entry_id is a valid null-terminated string.
256    let entry_str = match CStr::from_ptr(entry_id).to_str() {
257        Ok(s) => s,
258        Err(e) => {
259            set_last_error(format!("invalid UTF-8 in entry_id: {e}"));
260            return std::ptr::null_mut();
261        }
262    };
263
264    // SAFETY: caller guarantees request_path is a valid null-terminated string.
265    let path_str = match CStr::from_ptr(request_path).to_str() {
266        Ok(s) => s,
267        Err(e) => {
268            set_last_error(format!("invalid UTF-8 in request_path: {e}"));
269            return std::ptr::null_mut();
270        }
271    };
272
273    // Parse protocol from protobuf binary data
274    let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) {
275        Ok(p) => p,
276        Err(e) => {
277            set_last_error(format!("failed to parse protobuf protocol: {e}"));
278            return std::ptr::null_mut();
279        }
280    };
281
282    // Parse data JSON
283    let data: Value = match serde_json::from_str(data_str) {
284        Ok(d) => d,
285        Err(e) => {
286            set_last_error(format!("failed to parse data JSON: {e}"));
287            return std::ptr::null_mut();
288        }
289    };
290
291    // Render
292    let mut writer = StringResponseWriter::new();
293    match context.handler.render(
294        &protocol,
295        &data,
296        &RenderOptions::new(entry_str, path_str),
297        &mut writer,
298    ) {
299        Ok(_) => match CString::new(writer.content) {
300            Ok(s) => s.into_raw(),
301            Err(e) => {
302                set_last_error(format!("rendered output contains interior NUL byte: {e}"));
303                std::ptr::null_mut()
304            }
305        },
306        Err(e) => {
307            set_last_error(format!("render failed: {e}"));
308            std::ptr::null_mut()
309        }
310    }
311}
312
313// ---------------------------------------------------------------------------
314// FFI: HTML-based render (new high-level API)
315// ---------------------------------------------------------------------------
316
317/// Parse an HTML template and render it with JSON data in a single call.
318///
319/// This is the **recommended entry point** for Go, C#, and Python consumers.
320/// It eliminates the need for callers to deal with protobuf serialisation.
321///
322/// # Arguments
323///
324/// * `html`      - Null-terminated UTF-8 string containing the HTML template.
325/// * `data_json` - Null-terminated UTF-8 JSON string with the render state.
326///
327/// # Returns
328///
329/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or
330/// `NULL` on error.  The caller **must** free the returned string with
331/// [`webui_free`].  On error, call [`webui_last_error`] for details.
332///
333/// # Safety
334///
335/// Both `html` and `data_json` must be valid null-terminated UTF-8 strings.
336#[no_mangle]
337pub unsafe extern "C" fn webui_render(
338    html: *const c_char,
339    data_json: *const c_char,
340) -> *mut c_char {
341    clear_last_error();
342
343    if html.is_null() || data_json.is_null() {
344        set_last_error("html and data_json must not be null");
345        return std::ptr::null_mut();
346    }
347
348    // --- Extract C strings ---------------------------------------------------
349    let html_str = match CStr::from_ptr(html).to_str() {
350        Ok(s) => s,
351        Err(e) => {
352            set_last_error(format!("invalid UTF-8 in html: {e}"));
353            return std::ptr::null_mut();
354        }
355    };
356
357    let data_str = match CStr::from_ptr(data_json).to_str() {
358        Ok(s) => s,
359        Err(e) => {
360            set_last_error(format!("invalid UTF-8 in data_json: {e}"));
361            return std::ptr::null_mut();
362        }
363    };
364
365    // --- Parse HTML template into a WebUI protocol ---------------------------
366    let entry_key = "template";
367    let mut parser = HtmlParser::new();
368    if let Err(e) = parser.parse(entry_key, html_str) {
369        set_last_error(format!("HTML parse error: {e}"));
370        return std::ptr::null_mut();
371    }
372
373    let protocol = WebUIProtocol::new(parser.into_fragment_records());
374
375    // --- Parse JSON state ----------------------------------------------------
376    let data: Value = match serde_json::from_str(data_str) {
377        Ok(d) => d,
378        Err(e) => {
379            set_last_error(format!("failed to parse data JSON: {e}"));
380            return std::ptr::null_mut();
381        }
382    };
383
384    // --- Render --------------------------------------------------------------
385    let handler = WebUIHandler::new();
386    let mut writer = StringResponseWriter::new();
387
388    match handler.render(
389        &protocol,
390        &data,
391        &RenderOptions::new(entry_key, "/"),
392        &mut writer,
393    ) {
394        Ok(_) => match CString::new(writer.content) {
395            Ok(s) => s.into_raw(),
396            Err(e) => {
397                set_last_error(format!("rendered output contains interior NUL byte: {e}"));
398                std::ptr::null_mut()
399            }
400        },
401        Err(e) => {
402            set_last_error(format!("render failed: {e}"));
403            std::ptr::null_mut()
404        }
405    }
406}
407
408// ---------------------------------------------------------------------------
409// FFI: unified partial response
410// ---------------------------------------------------------------------------
411
412/// Produce a complete JSON partial response for client-side navigation.
413///
414/// Combines route templates, inventory, and matched route chain into a single
415/// JSON string: `{"templates":[...],"inventory":"...","chain":[...]}`.
416///
417/// # Arguments
418///
419/// * `protocol_data` - Pointer to protobuf binary data.
420/// * `protocol_len`  - Length of the protobuf data in bytes.
421/// * `entry_id`      - Null-terminated UTF-8 string for the persistent entry fragment.
422/// * `request_path`  - Null-terminated UTF-8 route path used to select the active route chain.
423/// * `inventory_hex` - Null-terminated hex string of the client's inventory bitmask
424///   (pass empty string `""` if no inventory).
425///
426/// # Returns
427///
428/// A heap-allocated JSON string, or `NULL` on error. Caller frees with [`webui_free`].
429///
430/// # Safety
431///
432/// * `protocol_data` must point to `protocol_len` bytes of valid memory.
433/// * `state_json`, `entry_id`, `request_path`, and `inventory_hex` must be valid
434///   null-terminated UTF-8 strings.
435#[no_mangle]
436pub unsafe extern "C" fn webui_render_partial(
437    protocol_data: *const u8,
438    protocol_len: usize,
439    state_json: *const c_char,
440    entry_id: *const c_char,
441    request_path: *const c_char,
442    inventory_hex: *const c_char,
443) -> *mut c_char {
444    clear_last_error();
445
446    match std::panic::catch_unwind(|| {
447        if protocol_data.is_null()
448            || state_json.is_null()
449            || entry_id.is_null()
450            || request_path.is_null()
451            || inventory_hex.is_null()
452        {
453            set_last_error("one or more required arguments are null");
454            return std::ptr::null_mut();
455        }
456
457        // SAFETY: The caller guarantees `protocol_data` points to `protocol_len` readable bytes.
458        let protocol_bytes = unsafe { std::slice::from_raw_parts(protocol_data, protocol_len) };
459
460        // SAFETY: The caller guarantees `state_json` is a valid null-terminated string.
461        let state_str = match unsafe { CStr::from_ptr(state_json) }.to_str() {
462            Ok(s) => s,
463            Err(e) => {
464                set_last_error(format!("invalid UTF-8 in state_json: {e}"));
465                return std::ptr::null_mut();
466            }
467        };
468
469        let state: Value = match serde_json::from_str(state_str) {
470            Ok(v) => v,
471            Err(e) => {
472                set_last_error(format!("invalid state JSON: {e}"));
473                return std::ptr::null_mut();
474            }
475        };
476
477        // SAFETY: The caller guarantees `entry_id` is a valid null-terminated string.
478        let entry_str = match unsafe { CStr::from_ptr(entry_id) }.to_str() {
479            Ok(s) => s,
480            Err(e) => {
481                set_last_error(format!("invalid UTF-8 in entry_id: {e}"));
482                return std::ptr::null_mut();
483            }
484        };
485
486        // SAFETY: The caller guarantees `request_path` is a valid null-terminated string.
487        let request_path_str = match unsafe { CStr::from_ptr(request_path) }.to_str() {
488            Ok(s) => s,
489            Err(e) => {
490                set_last_error(format!("invalid UTF-8 in request_path: {e}"));
491                return std::ptr::null_mut();
492            }
493        };
494
495        // SAFETY: The caller guarantees `inventory_hex` is a valid null-terminated string.
496        let inv_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
497            Ok(s) => s,
498            Err(e) => {
499                set_last_error(format!("invalid UTF-8 in inventory_hex: {e}"));
500                return std::ptr::null_mut();
501            }
502        };
503
504        let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) {
505            Ok(p) => p,
506            Err(e) => {
507                set_last_error(format!("failed to parse protobuf protocol: {e}"));
508                return std::ptr::null_mut();
509            }
510        };
511
512        let result = webui_handler::route_handler::render_partial(
513            &protocol,
514            state,
515            entry_str,
516            request_path_str,
517            inv_str,
518        );
519
520        match CString::new(result.to_string()) {
521            Ok(s) => s.into_raw(),
522            Err(e) => {
523                set_last_error(format!("JSON output contains interior NUL byte: {e}"));
524                std::ptr::null_mut()
525            }
526        }
527    }) {
528        Ok(ptr) => ptr,
529        Err(_) => {
530            set_last_error("panic in webui_render_partial");
531            std::ptr::null_mut()
532        }
533    }
534}
535
536/// Free a string returned by a WebUI FFI function.
537///
538/// # Safety
539///
540/// `string_ptr` must be a pointer returned by a WebUI FFI function (e.g.
541/// [`webui_handler_render`] or [`webui_render`]), or `NULL`
542/// (in which case this function is a no-op).
543#[no_mangle]
544pub unsafe extern "C" fn webui_free(string_ptr: *mut c_char) {
545    if !string_ptr.is_null() {
546        let _ = CString::from_raw(string_ptr);
547    }
548}