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