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}