You might have heard of Rust, the shiny, fancy, safe, and fast language everybody is talking about nowadays. If not, you might want to check how Rust gives you a better developer experience
Being a fairly recent language, it needs more high-quality libraries to enable
more people to use it for different use cases. At Infobip, we have just created
the infobip-api-rust-sdk
, which gives
you access to communication channels, like SMS, WhatsApp, and Email. If you
ever wanted to create a crate, as they are called in Rust, you might have
wondered how to create one and what should go into a library so that it’s
great.
The library fulfills its purpose
The crucial feature that makes a library good is when it serves its main purpose, helping developers save time. How to achieve it? Your library should be great in the following aspects:
- Easy to use
- Well organized
- Idiomatic
- Complete
- Fast
- Easy to develop and maintain
- Well documented
- Compliant with established conventions
- Hosted in a healthy repository
Now, I will elaborate on how we achieved these items using our Infobip SDK an example.
It saves time
The main aspect that makes a library useful is that it removes the hassle of having to do things by yourself. In the case of communicating with an HTTP API with pure Rust, you would have to do many things like finding a good HTTP client and creating abstractions for endpoints, payloads, and responses. Additionally, you would have to find a good structure for this boilerplate code in your project and be in charge of updates. We, at Infobip, wanted to make this much easier by providing a simple package that includes all these things, and more!
It’s easy to use
To make it easy to use, we included abstractions to configure the client library and shortened names for the endpoint functions. We also tried to be short, semantically correct, and consistent with payload and response naming.
The following is an example of how to send an SMS message with the SDK:
let sms_client = SmsClient::with_configuration(Configuration::from_env_api_key()?);
let mut message = Message::new(vec[Destination::new("555555555555".to_string())]);
message.text = Some("Hello Rustacean!".to_string());
let request_body = SendRequestBody::new(vec![message]);
let response = sms_client.send(request_body).await?;
It’s only a few lines. The first one is to create an SMS client instance with a
configuration loaded from environment variables that include a specific URL and
API Key. The second line creates a message instance that includes a list of
destination numbers, which in this case, contains one. Then, we set the desired
text of the message. Next, We create a request body that contains the previously
created message. Finally, we call the sms_client.send()
function and receive
a response. Our message is sent!
It’s neatly organized
A library that has a logical organization, makes it easier to find things inside it. In Rust, that is also beneficial because you can use a feature of Cargo called Features, which allows you to reduce the amount of code that needs to be compiled. This is especially useful in Rust because the compiler does more work than in other languages, and it takes longer to compile.
To only compile the features that you need (may it be one or more), use a line like the following in your Cargo.toml file:
infobip_sdk = { version = "0.3", features = [ "sms" ] }
It’s fast
To enable you to write performant applications, we made the library asynchronous. This means that you can perform several IO operations in parallel, and even do other things at the same time. That might save huge time when you have a lot of calls to a network or storage device.
It’s well documented
To make a library successful, instructions on how to use it should be readily available. For this, we used the standard way of documenting an SDK library, which is the Cargo documentation system, and Docs.rs, the online documentation platform where Rust crates are usually uploaded.
For every endpoint, you can find a comprehensive description. It also includes the signature of the function, that is, what parameters it has and what it returns. Additionally, we provided code examples for every endpoint. This is especially cool in Rust, because those examples in documentation can be actually compiled and run, so we make sure that when we introduce more changes to the library, the code still works release after release.
It’s easy to develop
To keep a library improving and updating, it must be as simple and easy to
develop as possible. Automation helps a lot in achieving this. We are using the
power of the OpenAPI specification and OpenAPI Generator. We generated code and
from that, we picked what was most useful. That saved a lot of time with
documentation generation and modelling of the API objects. Once created, models
have been improved for better naming conventions and additional features. One
example is the name of the endpoint for sending SMS, which comes generated as
send_fully_feauterd_textual_message
, and becomes sms_client.send()
. After
generating models we implemented custom logic for endpoints. We improved error
handling, and redesigned the module structure. All of the above has created a
solid code base that is fast to grow and enhance. Even tools like GitHub
copilot play well with the code base, removing the need to type a lot of the
boilerplate that comes in repetitive parts of the library.
The easier the library is to develop, the most likely it will grow in features, so that it covers all kinds of use cases, which makes for the completeness aspect of a library. Our current SDK supports main Infobip channels, but it’s growing in features!
It helps you
Taking advantage of the generated, and then customized models, we’ve been able
to provide extra help by validating field content prior to sending a request
over the network. This helps you find errors early, which in turn, helps you
detect vital omissions, e.g. which fields are mandatory or what kind of domain
a field has. The models are also easy to serialize and deserialize thanks to
the use of serde
deriving.
Models are convenient to use because you can create a new instance in several different ways:
- By calling a
new()
constructor, which enforces required fields - By parsing from JSON or other serialized formats, convenient if you already have that
- By calling the true constructors in which you set all fields in one sentence, which helps with immutability
It’s idiomatic
Code that follows the specific style and constructs that are common, natural,
and beneficial to use in a particular language, is called idiomatic. In the
library, we’ve used things like new_()
, with_()
type of constructors that
make it convenient to create instances. Also, we’ve used Result<T, E>
enums
for error handling, which is the norm in Rust. For every endpoint function in
the crate, we have a signature similar to this:
pub async fn send(
&self,
request_body: SendRequestBody
) -> Result<SdkResponse<SendResponseBody>, SdkError>
These functions return a Result
that wraps the case of success or failure. This is the idiomatic way of handling errors in Rust, with an Error
type that contains details of what went wrong. These errors can then be handled or propagated by the ?
operator. This is the definition of the Error
enum for our endpoints:
#[derive(Error, Debug)]
pub enum SdkError {
#[error("request body has field errors")]
Validation(#[from] validator::ValidationErrors),
#[error("HTTP client error")]
Reqwest(#[from] reqwest::Error),
#[error("serialization error")]
Serde(#[from] serde_json::Error),
#[error("API request error")]
ApiRequestError(#[from] ApiError),
#[error("IO error")]
Io(#[from] std::io::Error),
}
It has different types to handle everything that can go wrong, for example, HTTP errors, serialization errors, etc. For 400/500 server errors, we have the ApiError.
It follows established standards
The Rust community has a great checklist of what aspects to consider for a new crate, found in the API Guidelines Checklist.
Additionally, your library version numbers should make sense, and for that, there is a guide on Semantic versioning especially tailored for Rust, which can be found in the SemVer compatibility section of the Cargo manual.
Once your code is in a good shape, you are able to do your first publishing on Crates.io! For that, there is a section on what you should do in order to make your crate available in the Publishing on crates.io guide of the Cargo reference.
Come visit our repository!
If you are interested in learning more about Rust, and how a library is created, you can visit our GitHub Repository, where you are welcome to have a look, provide feedback, and even submit a PR!
Thanks for reading!