Implement a traditional Win32-style API

Now that we know how to create a DLL in Rust, let's consider what it takes to implement a simple Win32-style API. While WinRT is generally a better choice for new operating system APIs, Win32-style APIs continue to be important. You might need to re-implement an existing API in Rust or just need finer control of the type system or activation model for one reason or another.

To keep things simple but realistic, let's implement a JSON validator API. The idea is to provide a way to efficiently validate a given JSON string against a known schema. Efficiency requires that the schema is pre-compiled, so we can produce a logical JSON validator object that may be created and freed separately from the process of validating the JSON string. You can imagine a hypothetical Win32-style API looking like this:

HRESULT __stdcall CreateJsonValidator(char const* schema, size_t schema_len, uintptr_t* handle);

HRESULT __stdcall ValidateJson(uintptr_t handle, char const* value, size_t value_len, char** sanitized_value, size_t* sanitized_value_len);

void __stdcall CloseJsonValidator(uintptr_t handle);

The CreateJsonValidator function should compile the schema and make it available through the returned handle.

The handle can then be passed to the ValidateJson function to perform the validation. The function can optionally return a sanitized version of the JSON value.

The JSON validator handle can later be freed using the CloseJsonValidator function, causing any memory occupied by the validator "object" to be freed.

Both creation and validation can fail, so those functions return an HRESULT, with rich error information being available via the GetErrorInfo function.

Let's use the windows crate for basic Windows error handling and type support. The popular serde_json crate will be used for parsing JSON strings. Unfortunately, it doesn't provide schema validation. A quick online search reveals the jsonschema crate seems to be the main or only game in town. It will do for this example. The focus here is not really on the particular implementation as much as the process of building such an API in Rust generally.

Given these dependencies and what we learned about creating a DLL in Rust, here's what the project's Cargo.toml file should look like:

[package]
name = "json_validator"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
jsonschema = "0.17"
serde_json = "1.0"

[dependencies.windows]
version = "0.52"
features = [
    "Win32_Foundation",
    "Win32_System_Com",
]

We can employ a use declaration to make things a little easier for ourselves:

#![allow(unused)]
fn main() {
use jsonschema::JSONSchema;
use windows::{core::*, Win32::Foundation::*, Win32::System::Com::*};
}

And let's begin with the CreateJsonValidator API function. Here's how the C++ declaration might look in Rust:

#![allow(unused)]
fn main() {
#[no_mangle]
unsafe extern "system" fn CreateJsonValidator(
    schema: *const u8,
    schema_len: usize,
    handle: *mut usize,
) -> HRESULT {
    create_validator(schema, schema_len, handle).into()
}
}

Nothing too exciting here. We're just using the definition of HRESULT from the windows crate. The implementation calls a different create_validator function for its implementaion. We'll do this so that we can use the syntactic convenience of the standard Result type for error propagation. The specialization of Result provided by the windows crate further supports turning a Result into an HRESULT while discharging its rich error information to the caller. That's what the trailing into() is used for.

The create_validator function looks as follows:

#![allow(unused)]
fn main() {
unsafe fn create_validator(schema: *const u8, schema_len: usize, handle: *mut usize) -> Result<()> {
    // ...

    Ok(())
}
}

As you can see, it carries the exact same parameters and simply switches out the HRESULT for a Result returning the unit type, or nothing other than success or error information.

First up, we need to parse the provided schema using serde_json. Since we need to parse JSON in a couple spots, we'll just drop this in a reusable helper function:

#![allow(unused)]
fn main() {
unsafe fn json_from_raw_parts(value: *const u8, value_len: usize) -> Result<serde_json::Value> {
    if value.is_null() {
        return Err(E_POINTER.into());
    }

    let value = std::slice::from_raw_parts(value, value_len);

    let value =
        std::str::from_utf8(value).map_err(|_| Error::from(ERROR_NO_UNICODE_TRANSLATION))?;

    serde_json::from_str(value).map_err(|error| Error::new(E_INVALIDARG, format!("{error}").into()))
}
}

The json_from_raw_parts function starts by checking that the pointer to a UTF-8 string is not null, return E_POINTER in such cases. We can then turn the pointer and length into a Rust slice and from there a string slice, ensuring that it is in fact a valid UTF-8 string. Finally, we call out to serde_json to turn the string into a JSON value for further processing.

Now that we can parse JSON, completing the create_validator function is relatively straightforward:

#![allow(unused)]
fn main() {
unsafe fn create_validator(schema: *const u8, schema_len: usize, handle: *mut usize) -> Result<()> {
    let schema = json_from_raw_parts(schema, schema_len)?;

    let compiled = JSONSchema::compile(&schema)
        .map_err(|error| Error::new(E_INVALIDARG, error.to_string().into()))?;

    if handle.is_null() {
        return Err(E_POINTER.into());
    }

    *handle = Box::into_raw(Box::new(compiled)) as usize;

    Ok(())
}
}

The JSON value, in this case the JSON schema, is passed to JSONSchema::compile to produce the compiled representation. While the value is known to be JSON at this point, it may not in fact be a valid JSON schema. In such cases, we'll return E_INVALIDARG and include the error message from the JSON schema compiler to aid in debugging. Finally, provided the handle pointer is not null, we can go ahead and box the compiled representation and return it as the "handle".

Now let's move on to the CloseJsonValidator function since it's closely related to the boxing code above. Boxing just means to move the value on to the heap. The CloseJsonValidator function therefore needs to "drop" the object and free that heap allocation:

#![allow(unused)]
fn main() {
#[no_mangle]
unsafe extern "system" fn CloseJsonValidator(handle: usize) {
    if handle != 0 {
        _ = Box::from_raw(handle as *mut JSONSchema);
    }
}
}

We can add a little safeguard if a zero handle is provided. This is a pretty standard convenience feature to simplify generic programming for callers, but a caller can generally avoid the indirection cost of calling CloseJsonValidator if they know the handle is zero.

Finally, let's consider the ValidateJson function's implementation:

#![allow(unused)]
fn main() {
#[no_mangle]
unsafe extern "system" fn ValidateJson(
    handle: usize,
    value: *const u8,
    value_len: usize,
    sanitized_value: *mut *mut u8,
    sanitized_value_len: *mut usize,
) -> HRESULT {
    validate(
        handle,
        value,
        value_len,
        sanitized_value,
        sanitized_value_len,
    )
    .into()
}
}

Here again the implementation forwards to a Result-returning function for convenience:

#![allow(unused)]
fn main() {
unsafe fn validate(
    handle: usize,
    value: *const u8,
    value_len: usize,
    sanitized_value: *mut *mut u8,
    sanitized_value_len: *mut usize,
) -> Result<()> {
    // ...
}
}

First up, we need to ensure that we even have a valid handle, before transforming it into a JSONSchema object reference:

#![allow(unused)]
fn main() {
if handle == 0 {
    return Err(E_HANDLE.into());
}

let schema = &*(handle as *const JSONSchema);
}

This looks a bit tricky but we're just turning the opaque handle into a JSONSchema pointer and then returning a reference to avoid taking ownership of it.

Next, we need to parse the provided JSON value:

#![allow(unused)]
fn main() {
let value = json_from_raw_parts(value, value_len)?;
}

Here again we use the handy json_from_raw_parts helper function and allow error propagation to be handled automatically via the ? operator.

At this point we can perform schema validation, optionally returning a sanitized copy of the JSON value:

#![allow(unused)]
fn main() {
if schema.is_valid(&value) {
    if !sanitized_value.is_null() && !sanitized_value_len.is_null() {
        let value = value.to_string();

        *sanitized_value = CoTaskMemAlloc(value.len()) as _;

        if (*sanitized_value).is_null() {
            return Err(E_OUTOFMEMORY.into());
        }

        (*sanitized_value).copy_from(value.as_ptr(), value.len());
        *sanitized_value_len = value.len();
    }

    Ok(())
} else {
    // ...
}
}

Assuming the JSON value checks out against the compiled schema, we see whether the caller provided pointers to return a sanitized copy of the JSON value. In that case, we call to_string to return a string representation straight from the JSON parser, use CoTaskMemAlloc to allocate a buffer to return to the caller and copy the resulting UTF-8 string into this buffer.

If things don't go well, we can get the compiled schema to produce a handy error message before returning E_INVALIDARG to the caller:

#![allow(unused)]
fn main() {
let mut message = String::new();

if let Some(error) = schema.validate(&value).unwrap_err().next() {
    message = error.to_string();
}

Err(Error::new(E_INVALIDARG, message.into()))
}

The validate method returns a collection of errors. We'll just return the first for simplicity.

And that's it! Your first Win32-style API in Rust. You can find the complete example here.