DEV Community

Cover image for Understanding Rust and Building a Simple Calculator API from Scratch
Peymaan Abedinpour
Peymaan Abedinpour

Posted on

Understanding Rust and Building a Simple Calculator API from Scratch

Introduction to Rust

Before diving into the code, let’s start with a brief introduction to Rust. Rust is a modern programming language designed to provide safety and performance. It’s known for its powerful features, such as memory safety without a garbage collector, concurrency support, and zero-cost abstractions. This means that Rust allows developers to write high-performance code while ensuring that the code is safe from common bugs like null pointer dereferencing or buffer overflows.

Why Rust?

Rust has been gaining popularity for several reasons:

  1. Memory Safety: Unlike languages like C or C++, Rust ensures memory safety by default. This means that programs written in Rust are protected against many common bugs and security vulnerabilities.

  2. Concurrency: Rust makes it easier to write concurrent programs (programs that do many things at once) safely. With its unique ownership model, Rust prevents data races at compile time, which are common issues in concurrent programming.

  3. Performance: Rust is designed to have zero-cost abstractions. This means that you can write high-level code that is as fast as low-level code. Rust’s performance is often comparable to that of C and C++, making it an excellent choice for systems programming, game development, and other performance-critical applications.

  4. Growing Ecosystem: Rust’s ecosystem is rapidly growing, with a rich set of libraries and frameworks that make development easier and more productive.

  5. Strong Community Support: The Rust community is known for being friendly and welcoming to newcomers. This makes Rust a great choice for beginners who are looking to learn a new language with a supportive community.

Getting Started with Rust

If you've never coded before, don't worry! We'll start from the basics and work our way up to creating a simple API (Application Programming Interface) that can perform basic arithmetic operations like addition, subtraction, multiplication, and division. An API is a way for different software applications to communicate with each other. In our case, we will create a simple server that listens for requests from users and responds with the results of arithmetic calculations.

Setting Up Rust

  1. Install Rust: To get started with Rust, you need to install it on your computer. Rust provides an installer called rustup that makes it easy to get started. You can download the installer from rust-lang.org.

  2. Set Up Your Environment: Once Rust is installed, you can check that it’s working by opening a terminal (Command Prompt on Windows, Terminal on macOS or Linux) and typing:

    rustc --version
    

    This command should display the version of Rust that you have installed.

  3. Create a New Project: Rust uses a tool called Cargo to manage projects. Cargo helps you build, run, and manage dependencies for your Rust projects. To create a new project, run:

    cargo new calculator_api
    cd calculator_api
    

    This command creates a new directory named calculator_api with some files and directories already set up for you. It also changes the directory to your new project folder.

Understanding the Code

Now, let's look at the code step by step. Our goal is to create a simple API that performs basic arithmetic operations. We'll break down the code into small pieces and explain each part in detail.

Setting Up the Server

First, let’s start by understanding the very beginning of our program:

use std::io::{Read, Write};
use std::net::TcpListener;
use std::str;
Enter fullscreen mode Exit fullscreen mode

What is this code doing?

  • use std::io::{Read, Write};: This line tells Rust to use certain modules from its standard library. The io module provides input and output functionality, and Read and Write are traits that allow us to read from and write to streams (like files, network connections, etc.).

  • use std::net::TcpListener;: This line tells Rust to use the TcpListener struct from the net module, which provides networking functionality. TcpListener allows us to listen for incoming network connections.

  • use std::str;: This line tells Rust to use the str module, which provides utilities for handling strings.

Think of these use statements as telling Rust which tools you want to use from its toolbox. Just like when you bake a cake, you might start by gathering your ingredients and tools, in Rust, you start by telling the compiler which libraries and modules you’ll need for your program.

Starting the Main Function

In Rust, the main function is the entry point of every program. Here’s the next part of the code:

fn main() {
    // Bind the TCP listener to the address and port
    let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
    println!("Server running on http://127.0.0.1:8080");
Enter fullscreen mode Exit fullscreen mode

Breaking it down:

  • fn main() { begins the definition of the main function. This is the function that gets called when you run your Rust program.

  • let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); creates a new TCP listener that listens for incoming network connections on the address 127.0.0.1 (which is a special IP address that means "localhost" or "this computer") and port 8080. Ports are like channels through which data is sent and received. Here, 8080 is a commonly used port for web servers. The unwrap() function is used to handle errors in a simple way. If binding the listener fails (perhaps because the port is already in use), the program will crash with an error message.

  • println!("Server running on http://127.0.0.1:8080"); prints a message to the terminal indicating that the server is running and ready to accept connections. This is useful feedback for the user to know that the server has started successfully.

Handling Incoming Connections

After setting up the listener, we need to handle incoming connections. The server will keep running, waiting for clients (like a web browser or another program) to connect to it.

    // Loop over incoming TCP connections
    for stream in listener.incoming() {
        let mut stream = stream.unwrap();

        // Buffer to read data from the stream
        let mut buffer = [0; 1024];
        stream.read(&mut buffer).unwrap();
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • for stream in listener.incoming() { is a loop that iterates over each incoming connection. Every time a new client connects to our server, this loop runs once for that connection.

  • let mut stream = stream.unwrap(); takes the stream (a representation of the connection) and handles any potential errors. Again, unwrap() is used to stop the program if something goes wrong, such as a connection failing unexpectedly.

  • let mut buffer = [0; 1024]; creates a buffer, which is an array of 1024 bytes. Think of this buffer as a container that will temporarily hold data from the connection. When a client sends data to the server (like a request for a webpage), that data will be read into this buffer.

  • stream.read(&mut buffer).unwrap(); reads data from the stream (the connection) into the buffer. The &mut buffer means that the buffer is passed as a mutable reference, allowing the function to modify the buffer's contents.

Processing the Request

Once the server receives a request, it needs to process it and send back a response:

        // Convert buffer to string to interpret the HTTP request
        let request = String::from_utf8_lossy(&buffer[..]);

        // Check if the request is a GET request to the /calculate endpoint
        if request.starts_with("GET /calculate") {
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • let request = String::from_utf8_lossy(&buffer[..]); converts the raw bytes in the buffer into a readable string. HTTP requests are just text, so converting the buffer to a string allows us to interpret the request.

  • if request.starts_with("GET /calculate") { checks if the request is a GET request to the /calculate endpoint. HTTP requests typically start with a method (like GET or POST), followed by a path (like /calculate). Here, we’re checking if the path requested is /calculate.

Parsing the Query Parameters

If the request is to the correct endpoint, we need to extract the numbers from the URL. For example, if the user requests /calculate?num1=10&num2=5, we want to extract 10 and 5.

            // Parse query parameters from the URL
            let (num1, num2) = parse_query_params(&request);
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • let (num1, num2) = parse_query_params(&request); calls a helper function parse_query_params to extract the two numbers from the request. This function takes the request string as input and returns a tuple (a pair of values) containing num1 and num2.

Writing the Helper Function

Now, let’s look at the helper function parse_query_params:

// Helper function to parse query parameters from the request
fn parse_query_params(request: &str) -> (f64, f64) {
    let query_string = request.split_whitespace().nth(1).unwrap_or("");
    let query_string = query_string.split('?').nth(1).unwrap_or("");

    let mut num1 = 0.0;
    let mut num2 = 0.0;

    for param in query_string.split('&') {
        let mut key_value = param.split('=');
        let key = key_value.next().unwrap_or("");
        let value = key_value.next().unwrap_or("");

        match key {
            "num1" => num1 = value.parse().unwrap_or(0.0),
            "num2" => num2 = value.parse().unwrap_or(0.0),
            _ => (),
        }
    }

    (num1, num2)
}
Enter fullscreen mode Exit fullscreen mode

Explanation of the Helper Function:

  1. Extracting the Query String:
* `let query_string = request.split_whitespace().nth(1).unwrap_or("");` splits the request into whitespace-separated words and grabs the second word (the URL path). If there is no second word, it returns an empty string.

* `let query_string = query_string.split('?').nth(1).unwrap_or("");` splits the path on the `?` character, which separates the path from the query string in a URL. It then grabs the part after the `?`. If there’s no `?`, it returns an empty string.
Enter fullscreen mode Exit fullscreen mode
  1. Parsing Parameters:
* `let mut num1 = 0.0;` and `let mut num2 = 0.0;` initialize variables to store the numbers. They start at `0.0` (floating-point zero).

* `for param in query_string.split('&') {` loops over each key-value pair in the query string. These pairs are separated by `&` in URLs (e.g., `num1=10&num2=5`).

* Inside the loop, `let mut key_value = param.split('=');` splits each pair on the `=` character. `key` gets the name (`num1` or `num2`), and `value` gets the value (`10` or `5`).
Enter fullscreen mode Exit fullscreen mode
  1. Handling the Parameters:
* `match key { "num1" => num1 = value.parse().unwrap_or(0.0), "num2" => num2 = value.parse().unwrap_or(0.0), _ => (), }`: This match statement checks if the key is `"num1"` or `"num2"`. If it is, it parses the value as a floating-point number and assigns it to `num1` or `num2`. If parsing fails (maybe because the value isn't a number), it defaults to `0.0`.
Enter fullscreen mode Exit fullscreen mode
  1. Returning the Result:
* `(num1, num2)` returns the two numbers as a tuple. This allows the calling function to use these numbers for calculations.
Enter fullscreen mode Exit fullscreen mode

Performing Calculations

Now that we have the numbers, we can perform the four basic arithmetic operations:

            // Perform calculations
            let add = num1 + num2;
            let sub = num1 - num2;
            let mul = num1 * num2;
            let div = if num2 != 0.0 { Some(num1 / num2) } else { None };
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • let add = num1 + num2; calculates the sum of num1 and num2.

  • let sub = num1 - num2; calculates the difference between num1 and num2.

  • let mul = num1 * num2; calculates the product of num1 and num2.

  • let div = if num2 != 0.0 { Some(num1 / num2) } else { None }; calculates the division of num1 by num2. If num2 is not zero, it returns the result wrapped in a Some variant. If num2 is zero (which would cause a division by zero error), it returns None.

Building the Response

After performing the calculations, we need to create a response to send back to the client:

            // Build the response
            let response_body = format!(
                "{{ \"addition\": {}, \"subtraction\": {}, \"multiplication\": {}, \"division\": {} }}",
                add,
                sub,
                mul,
                match div {
                    Some(result) => result.to_string(),
                    None => "undefined (division by zero)".to_string(),
                }
            );

            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{}",
                response_body
            );
Enter fullscreen mode Exit fullscreen mode

Explanation:

  1. Formatting the Response Body:
* `let response_body = format!(...)` constructs a JSON string containing the results of the calculations. JSON (JavaScript Object Notation) is a popular data format used for exchanging data between a server and a client.

* `format!` is a macro in Rust that works similarly to `println!`, but instead of printing to the console, it returns a formatted string. Here, it creates a JSON object with four fields: `"addition"`, `"subtraction"`, `"multiplication"`, and `"division"`.

* For the `"division"` field, a `match` statement checks if `div` is `Some(result)` or `None`. If it's `Some(result)`, it converts the result to a string. If it's `None`, it uses the string `"undefined (division by zero)"`.
Enter fullscreen mode Exit fullscreen mode
  1. Constructing the Full HTTP Response:
* `let response = format!(...)` creates the full HTTP response. HTTP responses consist of a status line (e.g., `HTTP/1.1 200 OK`), headers (e.g., `Content-Type: application/json`), and a body (the actual data, in this case, the JSON string).
Enter fullscreen mode Exit fullscreen mode

Sending the Response

Finally, the server sends the response back to the client:

            stream.write(response.as_bytes()).unwrap();
            stream.flush().unwrap();
        } else {
            // Handle other requests
            let response = "HTTP/1.1 404 NOT FOUND\r\nContent-Type: text/plain\r\n\r\nNot Found";
            stream.write(response.as_bytes()).unwrap();
            stream.flush().unwrap();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • stream.write(response.as_bytes()).unwrap(); sends the HTTP response to the client. as_bytes() converts the response string to bytes, which is the format required for sending over a network.

  • stream.flush().unwrap(); ensures that all data is sent to the client immediately. Flushing clears any buffered data, forcing it to be written out.

  • If the request is not to /calculate, the server sends a 404 Not Found response. This response includes a status line (HTTP/1.1 404 NOT FOUND), a header (Content-Type: text/plain), and a simple body (Not Found).

Full Code

use std::io::{Read, Write};
use std::net::TcpListener;
use std::str;

fn main() {
    // Bind the TCP listener to the address and port
    let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
    println!("Server running on http://127.0.0.1:8080");

    // Loop over incoming TCP connections
    for stream in listener.incoming() {
        let mut stream = stream.unwrap();

        // Buffer to read data from the stream
        let mut buffer = [0; 1024];
        stream.read(&mut buffer).unwrap();

        // Convert buffer to string to interpret the HTTP request
        let request = String::from_utf8_lossy(&buffer[..]);

        // Check if the request is a GET request to the /calculate endpoint
        if request.starts_with("GET /calculate") {
            // Parse query parameters from the URL
            let (num1, num2) = parse_query_params(&request);

            // Perform calculations
            let add = num1 + num2;
            let sub = num1 - num2;
            let mul = num1 * num2;
            let div = if num2 != 0.0 { Some(num1 / num2) } else { None };

            // Build the response
            let response_body = format!(
                "{{ \"addition\": {}, \"subtraction\": {}, \"multiplication\": {}, \"division\": {} }}",
                add,
                sub,
                mul,
                match div {
                    Some(result) => result.to_string(),
                    None => "undefined (division by zero)".to_string(),
                }
            );

            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{}",
                response_body
            );

            stream.write(response.as_bytes()).unwrap();
            stream.flush().unwrap();
        } else {
            // Handle other requests
            let response = "HTTP/1.1 404 NOT FOUND\r\nContent-Type: text/plain\r\n\r\nNot Found";
            stream.write(response.as_bytes()).unwrap();
            stream.flush().unwrap();
        }
    }
}

// Helper function to parse query parameters from the request
fn parse_query_params(request: &str) -> (f64, f64) {
    let query_string = request.split_whitespace().nth(1).unwrap_or("");
    let query_string = query_string.split('?').nth(1).unwrap_or("");

    let mut num1 = 0.0;
    let mut num2 = 0.0;

    for param in query_string.split('&') {
        let mut key_value = param.split('=');
        let key = key_value.next().unwrap_or("");
        let value = key_value.next().unwrap_or("");

        match key {
            "num1" => num1 = value.parse().unwrap_or(0.0),
            "num2" => num2 = value.parse().unwrap_or(0.0),
            _ => (),
        }
    }

    (num1, num2)
}

Enter fullscreen mode Exit fullscreen mode

Step-by-Step to Use the API

  1. Keep the Server Running: Leave the current terminal window open where the server is running. It should display:

    Server running on http://127.0.0.1:8080
    
  2. Open a New Terminal Window or Tab: Open another terminal window or tab. This allows you to interact with the server using curl.

  3. Run the curl Command in the New Terminal: In the new terminal, run the following curl command:

    curl "http://127.0.0.1:8080/calculate?num1=10&num2=5"
    

    After running this command, you should see the JSON response from your API in the new terminal:

    { "addition": 15, "subtraction": 5, "multiplication": 50, "division": 2 }
    

Troubleshooting Tips

  • Make Sure the Server is Still Running: The server must be continuously running in the original terminal for the API to respond to requests. Do not close that terminal.

  • Correct Usage of curl: Ensure that you’re typing the curl command correctly in a new terminal window, not where the server is running.

  • Check for Errors: If you don’t get a response or encounter an error, check both terminals for any error messages.

If you follow these steps, you should be able to interact with your Rust API successfully using curl from the terminal.

Conclusion

Congratulations! You've just built a simple web server in Rust that accepts HTTP requests, performs basic arithmetic operations, and sends responses back to the client. This project introduces many fundamental concepts of Rust and web development:

  • Setting Up a Server: Using Rust’s std::net module, you learned how to set up a TCP server that listens for incoming connections.

  • Reading and Parsing Requests: You saw how to read data from a network stream, convert it to a string, and parse query parameters from a URL.

  • Performing Calculations: We covered basic arithmetic operations and handling special cases, like division by zero.

  • Building and Sending Responses: You learned how to construct an HTTP response and send it back to the client.

Next Steps

If you're interested in learning more, here are a few suggestions for next steps:

  1. Learn More About Rust: Rust has a lot of features that we didn’t cover here. You can learn more about Rust’s ownership model, error handling, concurrency, and more from the official Rust Book.

  2. Expand the API: Add more functionality to your API. You could add endpoints for different mathematical operations, like square roots or exponentiation.

  3. Use a Framework: For more complex projects, consider using a web framework like actix-web or Rocket. These frameworks provide additional functionality and make it easier to build more sophisticated web applications.

  4. Deploy Your Server: Learn how to deploy your server to a cloud provider like AWS or DigitalOcean so that others can use your API.

  5. Explore Rust’s Ecosystem: Rust has a growing ecosystem of libraries and tools. Explore crates.io, Rust’s package registry, to find libraries that can help you build your projects.

By following these steps, you'll continue to build your Rust skills and be well on your way to becoming a proficient Rust programmer!

By Peymaan Abedinpour پیمان عابدین پور

Top comments (0)