Generating a Header File

Instead of having to constantly keep ffi.rs and the various extern blocks scattered through out our C++ code in sync, it'd be really nice if we could generate a header file that corresponds to ffi.rs and just #include that. Fortunately there exists a tool which does exactly this called cbindgen!

Adding Cbindgen

You can use cbindgen to generate header files in a couple ways, the first is to use cargo install and run the binding generator program.

$ cargo install cbindgen
$ cd /path/to/my/project && cbindgen . -o target/my_project.h

However running this after every change can get quite repetitive, therefore the README includes a minimal build script which will automatically generate the header every time you compile.

First add cbindgen as a build dependency (cargo-edit makes this quite easy).

$ cargo add --build cbindgen

You also need to make sure you have a build script entry in your Cargo.toml.

...
description = "The business logic for a REST client"
name = "client"
repository = "https://github.com/Michael-F-Bryan/rust-ffi-guide"
version = "0.1.0"
+ build = "build.rs"
+
+ [build-dependencies]
+ cbindgen = "0.1.29"

[dependencies]
chrono = "0.4.0"
...

Finally you can flesh out the build script itself. This is fairly straightforward, although because we want to put the generated header file in the target/ directory we need to take special care to detect when cmake overrides the default.

// client/build.rs

extern crate cbindgen;

use std::env;
use std::path::PathBuf;
use cbindgen::Config;


fn main() {
    let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();

    let package_name = env::var("CARGO_PKG_NAME").unwrap();
    let output_file = target_dir()
        .join(format!("{}.hpp", package_name))
        .display()
        .to_string();

    let config = Config {
        namespace: Some(String::from("ffi")),
        ..Default::default()
    };

    cbindgen::generate_with_config(&crate_dir, config)
      .unwrap()
      .write_to_file(&output_file);
}

/// Find the location of the `target/` directory. Note that this may be 
/// overridden by `cmake`, so we also need to check the `CARGO_TARGET_DIR` 
/// variable.
fn target_dir() -> PathBuf {
    if let Ok(target) = env::var("CARGO_TARGET_DIR") {
        PathBuf::from(target)
    } else {
        PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("target")
    }
}

Note that the build.rs build script also creates a custom Config that specifies everything in the generated header file should be under the ffi namespace. This means we won't get name clashes between the opaque Request and Response types generated by cbindgen and our own wrapper classes.

If you go back to the build/ directory and recompile, you should now see the client.hpp header file.

$ cd build/
$ cmake -DCMAKE_BUILD_TYPE=Debug ..
$ make
$ ls client
client.hpp  cmake_install.cmake  CMakeFiles  CTestTestfile.cmake  debug  libclient.so  Makefile

$ cat client/client.hpp
#include <cstdint>
#include <cstdlib>

extern "C" {

namespace ffi {

// A HTTP request.
struct Request;

struct Response;

// Initialize the global logger and log to `rest_client.log`.
//
// Note that this is an idempotent function, so you can call it as many
// times as you want and logging will only be initialized the first time.
void initialize_logging();

// Construct a new `Request` which will target the provided URL and fill out
// all other fields with their defaults.
//
// # Note
...

This header file is also #include-able from all your C++ code, meaning you no longer need to write all those manual extern "C" declarations. It also lets you rely on the compiler to do proper type checking instead of getting ugly linker errors if your forward declarations become out of sync (or crashes and data corruption if function arguments change).

To actually #include the generated header file we need to make a couple adjustments to the CMakeLists.txt file to let cmake know to add the build/client/ output directory to the include path.

# gui/CMakeLists.txt

set(CMAKE_INCLUDE_CURRENT_DIR ON)
find_package(Qt5Widgets)

+ set(CLIENT_BUILD_DIR ${CMAKE_BINARY_DIR}/client)
+ include_directories(${CLIENT_BUILD_DIR})
+
set(SOURCE main_window.cpp main_window.hpp wrappers.cpp wrappers.hpp main.cpp)

add_executable(gui ${SOURCE})

Now you just need to update the wrappers.cpp and wrappers.hpp files to #include this new client.hpp, delete the extern "C" block, and update the Rust function call sites to be prefixed with the ffi:: namespace. As a bonus we can also replace a bunch of void * pointers with proper strongly-typed pointers.

This step may take a couple iterations to make sure all the types match up and everything compiles again. Make sure to test it all works by running the gui program and hitting our GUI's dummy button. If everything is okay then you should see HTML for the Rust website printed to the console.