Use Typestate pattern for UMessageBuilder#341
Conversation
e532bfc to
e9e5b44
Compare
Introduced the Typestate pattern for the UMessageBuilder to enforce the correct sequence of method calls when building a UMessage of a particular type. This change ensures that all required (and only appropriate) fields are set before the message can be built, improving code safety and reducing the likelihood of runtime errors.
e9e5b44 to
0157870
Compare
PLeVasseur
left a comment
There was a problem hiding this comment.
Thanks for putting this together. The typestate direction is a nice fit for the builder: it moves the request-only and response-only attributes out of runtime panics and into the type system, while still keeping the normal call sites readable.
I found two things worth tightening before merge.
- The new public
UMessageBuilder<S>shape exposes the state type in the public API, but the state marker types andBuilderStatetrait are not reachable from the public crate API. Chained constructor usage works, but external users cannot conveniently name, store, or return a typed builder from their own APIs. - The request/response-specific method docs still describe runtime panics for using the method on the wrong message type. With this PR those methods are simply not available for the wrong state, so the docs should be updated to describe the compile-time restriction instead.
| /// | ||
| /// For each type of message there is a dedicated builder which ensures that only attributes | ||
| /// relevant to that message type are set. | ||
| pub struct UMessageBuilder<S: BuilderState> { |
There was a problem hiding this comment.
This makes the builder's state type part of the public API, but only UMessageBuilder itself is re-exported from the crate root. The state marker types and BuilderState trait are not publicly reachable.
That may be the right choice if we want to support only immediate fluent construction:
UMessageBuilder::publish(topic).build()?;Could we make that choice explicit, though? If downstream crates are expected to compose with partially configured builders, then the unexported state types make a few reasonable patterns hard to express.
For example, a wrapper crate may want to centralize project defaults but still let the caller decide the payload:
fn telemetry_event(topic: UUri) -> UMessageBuilder<PublishBuilderState> {
let mut builder = UMessageBuilder::publish(topic);
builder.with_priority(UPriority::CS2);
builder.with_traceparent(current_traceparent());
builder
}Or middleware/test utilities may want to apply common metadata to any builder state, similar to apply_common_options in this crate:
fn apply_observability<S: BuilderState>(builder: &mut UMessageBuilder<S>) {
builder.with_traceparent(current_traceparent());
}If those patterns are intentionally out of scope, it would be good to document that UMessageBuilder is intended for direct fluent use and not as a type callers should name in their own APIs. If they are in scope, could we expose that intentionally, perhaps via public aliases such as PublishMessageBuilder, RequestMessageBuilder, and ResponseMessageBuilder instead of requiring users to depend directly on the marker type names?
There was a problem hiding this comment.
Great finding. IMHO we should not constrain ourselves and users to fluent invocation only. I have several cases in the code base where I also need to invoke builder functions in a non-fluent way. I will export the BuilderState traits at the lib.rs level to mitigate this problem. Or do you see an issue with doing that apart from the fact that users might need to pull these traits into scope in order to use the builder efficiently? Maybe we should start thinking about creating a prelude?
| /// | ||
| /// The builder. | ||
| /// | ||
| /// # Panics |
There was a problem hiding this comment.
These docs still describe the old runtime behavior: with_token, with_permission_level, and with_comm_status say they panic when used with the wrong message type.
with_permission_level also says it panics if the value is greater than i32::MAX, but the method takes u32 and validation now reports inconsistent attributes as a UMessageError from build().
With the typestate API, those methods are no longer available on the wrong builder state, so misuse fails at compile time instead of panicking. Could we update these sections to describe that compile-time restriction and update or remove the stale panic bullets? That will make the docs match the main value of this PR.
There was a problem hiding this comment.
Absolutely, I seem to have forgotten to remove these
There was a problem hiding this comment.
I have removed the corresponding sections from the Rust doc comments.
9ae5c67 to
7b4086d
Compare
7b4086d to
4a6be69
Compare
|
@PLeVasseur I have pushed corresponding changes. Would you mind taking another look? |
Introduced the Typestate pattern for the UMessageBuilder to enforce the correct sequence of method calls when building a UMessage of a particular type.
This change ensures that all required (and only appropriate) fields are set before the message can be built, improving code safety and reducing the likelihood of runtime errors.