3 releases (breaking)
0.3.0 | Jul 17, 2023 |
---|---|
0.2.0 | Jul 17, 2023 |
0.1.1 | Mar 16, 2023 |
0.1.0 |
|
#20 in #serde-default
133 downloads per month
Used in 3 crates
(via libtaskrs)
26KB
424 lines
boilermates
– A boilerplate generator for similar struct
s
It's like "boilerplates", but they're m... mates... G... Get it??
What this is not
It's not an attempt at inheritance for Rust.
Ok, what is it then?
It's a proc_macro for generating:
- Similar structs that have some of the same fields
- Implementations for easily converting between them (with
From
/Into
where possible, and other methods where not) - Traits to identify types with common fields so that it's possible to implement functions that require certain fields once for all types who have them.
Why?
It's a story as old as time. You're implementing an API, you have your input type, your output type, and your internal type that presumably goes to a DB. They're mostly the same, with an id
, a checksum
, or some private data sprinkled here and there.
And so you end up with either one struct
that has many Option
s, or multiple struct
s with many conversion implmentations between them. And if they have common functionality between them, with the same implementation, because it uses the same fields, you need to copy-paste it around.
Yes, it's not that bad, but the code ends up messy, just because you don't the object's ID when the user sends it in to be created, etc.
How it works
Take for example the following code:
use boilermates::boilermates;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// This is for illustration purposes
const UNIT_PRICE: u64 = 100;
const SHIPPING_PRICE: u64 = 50;
#[boilermates("OrderRequest", "OrderResponse")]
#[boilermates(attr_for("OrderRequest", "#[derive(Clone, Debug, Deserialize)]"))]
#[boilermates(attr_for("OrderResponse", "#[derive(Clone, Debug, Serialize)]"))]
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Order {
user_id: u64,
amount: u64,
address: String,
#[serde(default)]
comments: Option<String>,
shipping_required: bool,
#[boilermates(not_in("OrderRequest"))]
id: Uuid,
#[boilermates(only_in("OrderRequest"))]
jwt_token: String,
#[boilermates(only_in("Order", "OrderResponse"))]
#[boilermates(default)]
status: OrderStatus,
#[boilermates(only_in_self)]
#[boilermates(default)]
assigned_employee_id: Option<u64>,
#[boilermates(only_in("OrderResponse"))]
#[boilermates(default)]
response_code: ResponseCode,
}
// This is for illustration purposes
#[derive(Clone, Debug, Deserialize, Serialize)]
enum OrderStatus {
Received,
Packaging,
Shipped,
}
impl Default for OrderStatus {
fn default() -> Self {
Self::Received
}
}
// This is for illustration purposes
#[derive(Clone, Debug, Deserialize, Serialize)]
enum ResponseCode {
Ok,
BadRequest,
ServerError,
}
impl Default for ResponseCode {
fn default() -> Self {
Self::Ok
}
}
Struct generation
This will create 3 structs.
First, Orders
, will have all of the fields, except for jwt_token
, which only exists in OrderRequest
since #[boilermates(only_in("OrderRequest"))]
, and response_code
which only exists in OrderResponse
because of #[boilermates(only_in("OrderResponse"))]
.
struct Order {
user_id: u64,
amount: u64,
address: String,
#[serde(default)]
comments: Option<String>,
shipping_required: bool,
status: OrderStatus,
id: Uuid,
assigned_employee_id: Option<u64>,
}
Then, OrderRequest
, which won't have id
since it's marked #[boilermates(not_in("OrderRequest"))]
, status
since it's not mentioned in #[boilermates(only_in("Order", "OrderResponse"))]
, and assigned_employee_id
because it's marked #[boilermates(only_in_self)]
, which is synonymous with #[boilermates(only_in("Order"))]
. It will however, have jwt_token
:
struct OrderRequest {
user_id: u64,
amount: u64,
address: String,
#[serde(default)]
comments: Option<String>,
shipping_required: bool,
jwt_token: String,
}
And finally, OrderResponse
, which will have everything Order
has plus the response_code
field, but not assigned_employee_id
, which is frankly none of the customer's business:
struct OrderResponse {
user_id: u64,
amount: u64,
address: String,
#[serde(default)]
comments: Option<String>,
shipping_required: bool,
id: Uuid,
status: OrderStatus,
response_code: ResponseCode,
}
Conversion
Now for the fun stuff. Let's say we've received a new order through the API, and we have an OrderRequest
in the request
variable. We can easily convert it to an Order
, only filling in the missing data. We can do it in two ways. First, use the into_order
method, which takes the arguments missing in Order
in the order in which they're written in the original struct
declaration. Its signature is pub fn into_order(self, id: Uuid, status: OrderStatus assigned_employee_id: Option<u64> ) -> Order
, so we can do this:
let order = request.into_order(Uuid::new_v4(), OrderStatus::Received, None);
But, status
and assigned_employee_id
are marked as #[boilermates(default)]
, so if we want to use the default values when converting to a type that has these fields, we can use:
let order = request.into_order_defaults(Uuid::new_v4);
Next, after we've successfully saved the order in the DB, we can convert it OrderResponse
like so:
let response = order.into_order_response(ResponseCode::Ok);
But, since ResponseCode
has a Default
implementation, return_code
is marked #[boilermates(default)]
, and all other fields from Order
are present in OrderResponse
, we can do:
let response = OrderResponse::from(order); // or `let response: OrderResponse = order.into()`
The From
/Into
conversion is implemented in all cases when conversion is possible without additional arguments.
Blanket implementations
Each field triggers the generation of a Has{Field}
trait with a getter method fn {field}(&self) -> &{field_type}
, and a setter method fn set_{field}(&mut self, value: &{field_type})
, with an implementation for each struct that has field.
Since the 3 structs share the much of the same data, they can implement some of the same functionality. For instance, if we'd like to find out what's the order total (remember UNIT_PRICE
and SHIPPING_PRICE
in the beginning of the example?), we can create a blanket implementation using the HasAmount
and HasShippingRequired
traits, which are implemented for all types that have the amount
and shipping_required
fields. It allows us to use the amount()
and shipping_required()
getter methods like so:
// These work out of the box:
request.set_amount(10);
order.set_amount(10);
response.set_amount(10);
trait GetTotal: HasAmount + HasShippingRequired {
fn total(&self) -> u64 {
self.amount() * UNIT_PRICE
+ if *self.shipping_required() {
SHIPPING_PRICE
} else {
0
}
}
}
impl<T: HasAmount + HasShippingRequired> GetTotal for T {}
// Now all of these work:
let total = request.total()
let total = order.total()
let total = response.total()
In a similar fashion, a 'HasNo{Field}' trait is generated for each struct that does not contain a specific field.
Dependencies
~1.5MB
~36K SLoC