Replace gRPC Backend (#10)

**Rationale:**

Having two separate servers and communication methods resulted in additional maintenance & the need to convert often between backend & frontend data types.
By moving the backend communication off of gRPC and to just use websockets it both gives more control & allows for simplification of the implementation.

#8

**Changes:**

- Replaces gRPC backend.
  - New implementation automatically handles reconnect logic
- Implements an api layer
- Migrates examples to the api layer
- Implements a proc macro to make command handling easier
- Implements unit tests for the api layer (90+% coverage)
- Implements integration tests for the proc macro (90+% coverage)

Reviewed-on: #10
Co-authored-by: Sergey Savelyev <sergeysav.nn@gmail.com>
Co-committed-by: Sergey Savelyev <sergeysav.nn@gmail.com>
This commit was merged in pull request #10.
This commit is contained in:
2026-01-01 10:11:53 -08:00
committed by sergeysav
parent f658b55586
commit 788dd10a91
68 changed files with 3934 additions and 1504 deletions

19
api-proc-macro/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "api-proc-macro"
edition = "2021"
version = "0.1.0"
authors = ["Sergey <me@sergeysav.com>"]
[lib]
proc-macro = true
[dependencies]
api-core = { path = "../api-core" }
proc-macro-error = { workspace = true }
quote = { workspace = true }
syn = { workspace = true }
[dev-dependencies]
api = { path = "../api" }
trybuild = { workspace = true }

View File

@@ -0,0 +1,123 @@
use proc_macro_error::abort;
use quote::{quote, quote_spanned};
use syn::spanned::Spanned;
use syn::{parse_macro_input, parse_quote, Data, DeriveInput, Fields, GenericParam, Generics};
pub fn derive_into_command_definition_impl(
item: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
let DeriveInput {
ident,
data,
generics,
..
}: DeriveInput = parse_macro_input!(item as DeriveInput);
let data = match data {
Data::Struct(data) => data,
Data::Enum(data) => abort!(
data.enum_token,
"IntoCommandDefinition not supported for enum"
),
Data::Union(data) => abort!(
data.union_token,
"IntoCommandDefinition not supported for union"
),
};
let generics = add_trait_bounds(generics);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
let num_fields = data.fields.len();
let create_param_stream = match &data.fields {
Fields::Named(fields) => {
let field_entries = fields.named.iter().map(|field| {
let name = field.ident.clone().map(|id| id.to_string());
let field_type = &field.ty;
quote_spanned! { field.span() =>
parameters.push(api::messages::command::CommandParameterDefinition {
name: #name.to_string(),
data_type: <#field_type as api::data_type::ToDataType>::DATA_TYPE,
});
}
});
quote! { #(#field_entries)* }
}
Fields::Unnamed(fields) => abort!(
fields,
"IntoCommandDefinition not supported for unnamed structs"
),
Fields::Unit => quote! {},
};
let parse_param_stream = match &data.fields {
Fields::Named(fields) => {
let field_entries = fields.named.iter().map(|field| {
let name = &field.ident;
let name_string = field.ident.clone().map(|id| id.to_string());
let field_type = &field.ty;
quote_spanned! { field.span() =>
let #name: #field_type = (*command
.parameters
.get(#name_string)
.ok_or_else(|| api::messages::command::IntoCommandDefinitionError::ParameterMissing(#name_string.to_string()))?)
.try_into()
.map_err(|_| api::messages::command::IntoCommandDefinitionError::MismatchedType {
parameter: #name_string.to_string(),
expected: <#field_type as api::data_type::ToDataType>::DATA_TYPE,
})?;
}
});
quote! { #(#field_entries)* }
}
Fields::Unnamed(_) => unreachable!("Already checked this"),
Fields::Unit => quote! {},
};
let param_name_stream = match &data.fields {
Fields::Named(fields) => {
let field_entries = fields.named.iter().map(|field| {
let name = &field.ident;
quote_spanned! { field.span() => #name, }
});
quote! { #(#field_entries)* }
}
Fields::Unnamed(_) => unreachable!("Already checked this"),
Fields::Unit => quote! {},
};
let result = quote! {
impl #impl_generics api::messages::command::IntoCommandDefinition for #ident #ty_generics #where_clause {
fn create(name: std::string::String) -> api::messages::command::CommandDefinition {
let mut parameters = std::vec::Vec::with_capacity( #num_fields );
#create_param_stream
api::messages::command::CommandDefinition {
name: name,
parameters: parameters,
}
}
fn parse(command: api::messages::command::Command) -> core::result::Result<Self, api::messages::command::IntoCommandDefinitionError> {
#parse_param_stream
Ok(Self {
#param_name_stream
})
}
}
};
result.into()
}
fn add_trait_bounds(mut generics: Generics) -> Generics {
for param in &mut generics.params {
if let GenericParam::Type(ref mut type_param) = *param {
type_param
.bounds
.push(parse_quote!(api::data_type::ToDataType));
type_param.bounds.push(parse_quote!(
core::convert::TryFrom<api::data_value::DataValue>
));
}
}
generics
}

11
api-proc-macro/src/lib.rs Normal file
View File

@@ -0,0 +1,11 @@
extern crate proc_macro;
use proc_macro_error::proc_macro_error;
mod into_command_definition;
#[proc_macro_error]
#[proc_macro_derive(IntoCommandDefinition)]
pub fn derive_into_command_definition(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
into_command_definition::derive_into_command_definition_impl(item)
}

View File

@@ -0,0 +1,10 @@
use api_proc_macro::IntoCommandDefinition;
#[derive(IntoCommandDefinition)]
enum TestEnum {
Variant
}
fn main() {
}

View File

@@ -0,0 +1,5 @@
error: IntoCommandDefinition not supported for enum
--> tests/into_command_definition/enum_fails.rs:4:1
|
4 | enum TestEnum {
| ^^^^

View File

@@ -0,0 +1,12 @@
use api_proc_macro::IntoCommandDefinition;
#[derive(IntoCommandDefinition)]
#[repr(C)]
union TestUnion {
f1: u32,
f2: f32,
}
fn main() {
}

View File

@@ -0,0 +1,5 @@
error: IntoCommandDefinition not supported for union
--> tests/into_command_definition/union_fails.rs:5:1
|
5 | union TestUnion {
| ^^^^^

View File

@@ -0,0 +1,8 @@
use api_proc_macro::IntoCommandDefinition;
#[derive(IntoCommandDefinition)]
struct TestUnnamedStruct(f32, f64, bool);
fn main() {
}

View File

@@ -0,0 +1,5 @@
error: IntoCommandDefinition not supported for unnamed structs
--> tests/into_command_definition/unnamed_struct_fails.rs:4:25
|
4 | struct TestUnnamedStruct(f32, f64, bool);
| ^^^^^^^^^^^^^^^^

View File

@@ -0,0 +1,170 @@
use api_core::command::{
Command, CommandHeader, CommandParameterDefinition, IntoCommandDefinition,
};
use api_core::data_type::DataType;
use api_core::data_value::DataValue;
use api_proc_macro::IntoCommandDefinition;
use std::collections::HashMap;
#[test]
fn test_enum_fails() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/into_command_definition/enum_fails.rs");
}
#[test]
fn test_union_fails() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/into_command_definition/union_fails.rs");
}
#[test]
fn test_unnamed_struct_fails() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/into_command_definition/unnamed_struct_fails.rs");
}
#[test]
fn test_basic_command() {
#[derive(IntoCommandDefinition)]
struct TestStruct {
#[allow(unused)]
a: f32,
#[allow(unused)]
b: f64,
#[allow(unused)]
c: bool,
}
let command_definition = TestStruct::create("Test".to_string());
assert_eq!(command_definition.name, "Test");
assert_eq!(command_definition.parameters.capacity(), 3);
assert_eq!(
command_definition.parameters[0],
CommandParameterDefinition {
name: "a".to_string(),
data_type: DataType::Float32,
}
);
assert_eq!(
command_definition.parameters[1],
CommandParameterDefinition {
name: "b".to_string(),
data_type: DataType::Float64,
}
);
assert_eq!(
command_definition.parameters[2],
CommandParameterDefinition {
name: "c".to_string(),
data_type: DataType::Boolean,
}
);
let mut parameters = HashMap::new();
parameters.insert("a".to_string(), DataValue::Float32(1.0));
parameters.insert("b".to_string(), DataValue::Float64(2.0));
parameters.insert("c".to_string(), DataValue::Boolean(true));
let result = TestStruct::parse(Command {
header: CommandHeader {
timestamp: Default::default(),
},
parameters,
})
.unwrap();
assert_eq!(result.a, 1.0f32);
assert_eq!(result.b, 2.0f64);
assert_eq!(result.c, true);
}
#[test]
fn test_generic_command() {
#[derive(IntoCommandDefinition)]
struct TestStruct<T> {
#[allow(unused)]
a: T,
}
let command_definition = TestStruct::<f32>::create("Test".to_string());
assert_eq!(command_definition.name, "Test");
assert_eq!(command_definition.parameters.capacity(), 1);
assert_eq!(
command_definition.parameters[0],
CommandParameterDefinition {
name: "a".to_string(),
data_type: DataType::Float32,
}
);
let mut parameters = HashMap::new();
parameters.insert("a".to_string(), DataValue::Float32(1.0));
let result = TestStruct::<f32>::parse(Command {
header: CommandHeader {
timestamp: Default::default(),
},
parameters,
})
.unwrap();
assert_eq!(result.a, 1.0f32);
let command_definition = TestStruct::<f64>::create("Test2".to_string());
assert_eq!(command_definition.name, "Test2");
assert_eq!(command_definition.parameters.capacity(), 1);
assert_eq!(
command_definition.parameters[0],
CommandParameterDefinition {
name: "a".to_string(),
data_type: DataType::Float64,
}
);
let mut parameters = HashMap::new();
parameters.insert("a".to_string(), DataValue::Float64(2.0));
let result = TestStruct::<f64>::parse(Command {
header: CommandHeader {
timestamp: Default::default(),
},
parameters,
})
.unwrap();
assert_eq!(result.a, 2.0f64);
let command_definition = TestStruct::<bool>::create("Test3".to_string());
assert_eq!(command_definition.name, "Test3");
assert_eq!(command_definition.parameters.capacity(), 1);
assert_eq!(
command_definition.parameters[0],
CommandParameterDefinition {
name: "a".to_string(),
data_type: DataType::Boolean,
}
);
let mut parameters = HashMap::new();
parameters.insert("a".to_string(), DataValue::Boolean(true));
let result = TestStruct::<bool>::parse(Command {
header: CommandHeader {
timestamp: Default::default(),
},
parameters,
})
.unwrap();
assert_eq!(result.a, true);
}
#[test]
fn test_unit_command() {
#[derive(IntoCommandDefinition)]
struct TestStruct;
let command_definition = TestStruct::create("Test".to_string());
assert_eq!(command_definition.name, "Test");
assert_eq!(command_definition.parameters.capacity(), 0);
TestStruct::parse(Command {
header: CommandHeader {
timestamp: Default::default(),
},
parameters: HashMap::new(),
})
.unwrap();
}