Part 1
Published in · 18 min read · Jul 1, 2022
--
This is the first part of a multi-part series about writing web apps. For this series, we will be writing the web app in Rust, and I explain to you how to write it yourself.
If you’d rather not write out the code yourself, however, I have made a repository with all the code written throughout this series here. I made a commit to the repository at the end of each part of the series.
Frameworks are powerful and useful tools. They abstract away a lot of the painful details that go into creating a web application. Theoretically, this means that new developers can be helpful to development quicker, and older developers can be even more efficient, as they don’t have to dredge through tedious, lower-level details.
However, a framework’s greatest strength is also its greatest weakness. Newer developers that only learn how to use a framework, and never learn what the framework is abstracting away, may be less efficient in the long run. They may not be entirely aware of what they are even doing when using that framework. And, in the event the framework fails, that developer may not know how to fix it, because they are unaware of what the framework is doing in the first place.
Thus, I hope to fight against that problem by writing this article. By using an example project, I want to build a web app from the ground up, and slowly upgrade it into a more modern-looking app that uses frameworks. Doing this, we should be able to see why certain design choices and frameworks are so ubiquitous and should gain a greater appreciation for the frameworks that we rely on.
When doing anything big or complicated it’s important to ask a few questions. The first two are why and what. If we know the answers to these questions, our goals and the required steps to reach them should become a lot more obvious. Once we have concrete goals in mind, it’s easy to reach your greater goal step-by-step. We’ve already covered why we’re building a web app. I just stated that in the goals section, and I’m sure you can come up with many other reasons why you might build one. Generally, it’s to solve some problem.
The important question here is what a web app is. Most of us have a pretty intuitive understanding of one. It is a website. Something like Google, YouTube or Netflix. You enter a URL into a browser, it takes you to a page, and you can click buttons and do things. However, we’re hoping for a definition that helps us understand the technical side of a website, and that one is too vague to tell us anything more about web development. So, lets dive deeper.
A web app is entered by entering a URL into a browser. The computer running the browser is known as the client. This URL connects you to another computer called a server. This server serves content to the client. The client then displays the content given by the server to the user. Generally, the client also offers different forms to input new data. This data is sent to the server, which causes it to serve new content to the client. The client sending data to the server is called a request, and the server sending data back to the client is called a response. This is shown in the diagram below.
The web app that we write is running on the server. So, a web app’s job is to take incoming requests, process them, and send back adequate responses containing content for the client to use. This “content” is, of course, our frontend. The HTML, CSS and JavaScript that our users see and use in browser to send requests. Thus, when we say that we are going to “write a web app in Rust”, we are really saying that we are going to write a program in Rust that processes incoming requests and sends back appropriate responses in the form of HTML, CSS and JavaScript.
However, not all responses are in the form of HTML, CSS and JavaScript. Responses can come in all shapes and forms. A lot of responses are actually JSON. Ultimately, this response is somehow made into HTML that can be viewed in a browser, but the server isn’t always sending the HTML directly. In fact, you may already be familiar with JSON responses if you have written a web app in the past few years.
The definition of a web app has been shaken up a little bit in recent years. Frameworks like React change how a web app is structured. With a React app created in create-react-app
you have a structure more like the following.
Instead of the user connecting directly to the server, they instead connect to a computer that serves back a compiled frontend made using React. This computer, when given a request by the user, sends it to another computer that contains the server. The server sends the data back to the computer with the React code, and the React code then uses that data to send a response to the user. (However, I should mention that this only happens when using create-react-app
during development. Generally, when you deploy a React app, it goes back to a two-computer architecture, just with fancy React-compiled JavaScript being sent to the client)
In this architecture, there are two computers that the user is interfacing with. One holds the React frontend, and the other holds some backend that only communicates data. The computer with the frontend uses the data from the backend to create a response to send to the user.
This architecture comes with some advantages for developers, as writing the frontend and backend can become a lot easier. Sometimes the backend basically just saves and returns data that the frontend tells it to and does almost no processing of the data. This is why some people say that modern websites are just fancy wrappers for databases. The backend can basically just be a database and the frontend is just a really complicated wrapper around it.
Note that this definition is not particularly useful to the web app we will be writing, but since so many web apps end up being structured like this, I thought it worth mentioning.
Sadly, there is still a little bit more technical jargon to understand before we can dive into the meat of our example. As I laid out in the previous section, users send requests to the server and the server serves back responses. However, it’s important to understand how those requests and responses look a little bit.
The server and client communicate via HTTP. It is a protocol, which means it is a set of rules that determine how data is transmitted. In more familiar terms, it is a way that server and clients have agreed to communicate with each other.
Requests in HTTP look like the following:
As you can see, we send a method, a certain URL (that’s what “Path” is), the version of the protocol, some headers that convey information to a sever, and a body (depending on the method. A GET
method does not have a body, which is why none is shown here. Some APIs, like Elasticsearch, go against the standard specified definition and do ask for bodies with GET
requests, but, again, that is not standard and should usually be avoided). Generally, user-input information is found in the body, but a user might add information for the server to use in the path or the headers. The body itself can take many different forms. Two common ones are JSON and x-www-form-urlencoded. Regardless, based on the information given in a request, a server determines a proper response, which looks like this.
Instead of a method and path, we have a status code and message, which tells us what happened with the request. We still have the version of the protocol, headers, and a body. Most of the time, in a web app structured like ours, the body of the response is HTML, and the browser will display that HTML.
This is how most, if not all, browsers send and expect to receive data, and, thus, our web app will want to accommodate this.
In recent years, a query language called GraphQL has become quite popular. If you are someone who’s aware of the above section, you may be confused as to how something like GraphQL works, because you send out requests and responses that look like this:
{
me {
name
}
}
And somehow, the browser and server are still able to understand. How does this work? Well, query languages are basically fancy ways to make HTTP requests and responses. You write out a certain piece of code in something like GraphQL, and some program converts that into a series of HTTP requests and responses.
You may end up using or seeing query languages if you continue to make web apps, and the important thing to remember is they are still using the same exact tech under the hood, but, just like frameworks, it is a layer of abstraction to help make web development easier.
Finally, before we move on, I just want to recommend this article from MDN Web Docs: An overview of HTTP. It goes over all the stuff I just covered: servers, clients, requests, responses and HTTP. If you want to hear all this explained in a slightly different way, which may help you understand it better, that is a great resource.
Finally, after 1400 words of content, we have made it to actually building our app. The app is going to be simple: it will be a Todo app. In other words, we are making an app that allows a user to make a Todo list, edit tasks in that Todo list and complete tasks in the Todo list. Why are we making a Todo app as an example? Because most web apps need to be able to allow users to create, read, update and delete the data they have access to. This is so important that it even gets a cool acronym: CRUD (Create, Read, Update, Delete) operations. A Todo app is a very simple example where we can easily and intuitively see all of the CRUD operations in action.
We are going to using Rust to create this app. So, get that installed, and perhaps check out the book to get familiarized with the language. I will do my best to explain what’s going on every step of the way, but I can’t ensure that I’ll explain every small aspect of the code we write.
Our first step is to create the skeleton of the app, and we’ll use Rust’s package manager, cargo
, to do that. Open up your terminal, go to a directory you want to store your project in, and run
cargo new todo-app
That will create a Rust project within a folder called todo-app
and will place that folder in your working directory. With that, you can open up the project, and you'll find the following files.
todo-app
│ .gitignore
│ Cargo.toml
│
└───src
main.rs
.gitignore
is not important for our purposes. Cargo.toml
is where we will be specifying all the libraries we are going to use in our project, and main.rs
is where our Rust code will go.
With that out of the way, we’re going to be installing a web framework called Rocket to create our web app. Now, the entire goal of this is to make a web app from the ground up, so it seems a bit counter-intuitive to use a framework when building it “from the ground up”. However, Rocket is not going to be abstracting away any details that are particularly important to learning more about web apps. It will basically just be handling receiving and sending the HTTP requests and responses we discussed earlier. While we could try and implement the HTTP protocol ourselves, that would be messy, and we wouldn’t learn much. Further, implementing a protocol is somehow both difficult and tedious, so I’d like to avoid it at all costs.
Now that we’re all on the same page, let’s set up a basic example. First, in Cargo.toml
, add Rocket as a dependency. Once you have done that, your Cargo.toml
should look similar to this
Now, go to main.rs
, delete everything in it, and replace it with the following code
How does this code work? The first line imports all the stuff we are installing from Rocket, and the #[macro_use]
means we are explicitly importing Rocket, so its macros (pieces of code called by writing out a name) are installed globally. #[get("/")]
is a function attribute being applied to the index
function (if you know about this stuff, it is most likely an attribute macro). This attribute is imported from Rocket and means that if an HTTP GET
request is called with the path "/", index
will be called.
index
, the function, just returns the string "Hello, world!". The #[launch]
attribute applied to the rocket
function means that, when the code is run, it will start by running the rocket
function.
That launch attribute also does some code magic to set up our server, but we will ignore that for now. Finally, rocket::build().mount("/", routes![index])
is adding that index
function we created earlier to the server. Just applying the attribute to index
is not enough, we also have to mount index
.
Let’s test it out! Navigate to your project directory in a terminal, and run the following command
cargo run
You should see a bunch of packages being compiled, and, when it’s done, you should get a message along the lines of
Rocket has launched from http://127.0.0.1:8000
Go to that link, and you’ll see a page that says “Hello, world!”. Thus, with the installation of a package, we have created a basic web app. If you are wondering what that link is, 127.0.0.1
is just a name for the computer you are on. Since you are running the server on your computer, you connect to your computer to see the web app.
Another name for that string of numbers is localhost
. Deploying this web app to a computer and connecting to that computer is a bit of a tricky mess, but, basically, that other computer would have some number, and you connect to it just like you did here. Generally, we give names to those numbers so that users don't have to write out long strings of numbers. These names and their associated numbers are stored in a Domain Name System (DNS).
Regardless, if you look in the terminal output, you’ll notice something like this listed
GET / text/html:
>> Matched: (index) GET /
>> Outcome: Success
>> Response succeeded.
As you can see, our server received an HTTP GET
request (GET
is one of the HTTP methods), and successfully sent back a response. When we entered the URL into our browser, we sent a GET
request to the server, the server called the index
function, and it returned "Hello, world!".
Now, we can get onto the C of our CRUD operations: creation. What we want to do is make it so that when a POST
request is done on the /addtask
path with task data in its body, the app saves the task somewhere where it will remember.
Why use a POST
rather than GET
or one of the many other HTTP request methods? Because POST
is specifically supposed to be used for requests that result in the creation of data on the server side. Why the path /addtask
? Because the paths used to do certain operations within the web app should be self-explanatory. When creating a request, you know you are probably creating a task if the path for the request is /addtask
. Why is the data in the body of the request?
Because that's usually where data goes. Headers within the request are normally used for metadata. Parameters and other ways of entering data through the path itself should generally not be used unless you are entering small, simple data.
Obviously, once the tasks are created, we are going to want to store them in some way, so that we can retrieve them to read, update or delete them. However, we don’t want to store the data in variables, because if the server is ever turned off, either due to some error or to maintain it, then the data will be lost. Further, the program will only continue to grow in size and become slower over time. As such, we need to use a different solution for longer-term storage of these tasks.
Now, since we are building this from the ground up, like we are the people who made the original web apps and didn’t have access to certain tools, we’re going to choose a solution that would have made sense at the time. That would be to store the data in a file. A file is something that will exist on the computer even if the program stops or the computer is turned off, and every programming language has access to some form of file creation, edition and deletion.
With that in mind, let’s write our add task function. The code added for adding the task looks like this.
The attributes in front of the struct
for Task
basically say that data pulled from a HTTP JSON body will be put into this struct. The data="<task>"
in the post attribute signifies that the body data should enter the task
parameter. The Json<Task<'_>>
type that the task
parameter takes tells Rocket to parse the body as JSON and save the data into a Task
struct.
The OpenOptions
line opens or creates a file called tasks.txt
and sets it so that whenever we write to it, we are adding rather than overwriting. task_item_string
and task_item_bytes
take the data from the Task
struct and convert it into something that can be written to our file. Finally, we write it to our file and return a string signifying that we have finished the task successfully.
However, before this works, we need to modify our rocket
function to look like this:
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![index, add_task])
}
With that out of the way, we want to test this, but how do we do that? In this tutorial, we are going to use a software called Postman to send HTTP requests to our server, but there are a variety of options for sending requests out there (like curl). To use Postman, you’ll need to install it from the website, create an account and sign in. After that, you hit File>New
and choose HTTP request on the page that appears. From there, you want to make your request looks like this.
You set the method to POST, set the URL to http://127.0.0.1:8000/addtask, and set the body to raw
with type JSON, and include the following JSON
{
"item": "Turn into a dragon"
}
With that, go back to your terminal, hit Ctrl-C
to end the server you have running in it, and run cargo run
again to start the server back up. Once the server is back up, hit Send in Postman. The web app should give an output like this in the terminal:
POST /addtask application/json:
>> Matched: (add_task) POST /addtask
>> Outcome: Success
>> Response succeeded.
And the response box in Postman should say “Task added successfully”. If you go back to your project directory, you’ll notice that a file called tasks.txt
has been created and has the following content:
Turn into a dragon
Congratulations! Our webapp can now create tasks.
What’s the point in having tasks if we can’t get them from the app? That question is rhetorical. There is no point. So, let’s make our web app a bit more useful and have it return all the tasks we have. We’ll make so that when a GET
request is sent to the server with a path of /readtasks
, we'll send back all the tasks we have stored in tasks.txt
. We use a GET
method, because that is the expected method when reading from a server. So, let's skip all the boring stuff and get straight to the code!
First, we need to modify our task
struct to look like this
#[derive(Deserialize, Serialize)]
#[serde(crate = "rocket::serde")]
struct Task<'r> {
item: &'r str
}
Now, for the code that actually reads the tasks.
Next, we can use postman to send a GET
request to our server (after stopping, adding read_tasks
to our routes and rerunning), and we can see that we get a list of tasks returned
Hoo-boy! Now that we’ve done all that work to create and read tasks, we now have a problem. Editing them, as they currently are, is difficult to impossible. What’s the issue? How do we tell the computer what task we want to edit? As of right now, the only way would be to put in the entire original message, but it’s not guaranteed that someone won’t have two tasks that are written the exact same way. Thus, we will have to store tasks with additional information, and that additional information will be used to identify certain tasks. This will require us to edit both our add_task
function and our read_tasks
function, so let's make our edits
What additional information are we going to add? We’ll just put a number next to each task. We’ll call it the “id” because it will identify our task. How do we tell where the id ends, and the task begins? We’ll separate the two via a comma. Then, later, we could save this as a csv
file and open it in a software like Excel very easily. With that in mind, let's make our changes to make this happen. First off, in add_task
, we use a BufReader
to count the number of lines in the file. The number of lines is the id
for the new task. It looks like this.
For read_tasks
, we don't particularly care for the id
, so we just discard it, as seen in the code below.
Now, you may notice that I had to do quite a bit of work in the map
function on the last line to make this happen. Let's discuss that quickly.
We use the split
method to pull apart our original line into multiple pieces. However, the split method splits a String
into a bunch of string slices &str
. What that means is instead of having an iterator of a bunch of owned strings, we have an iterator with a bunch of references to the original string. When we collect that iterator into a vector, we have a vector of references (&str
) rather than a vector of owned strings (String
). Thus, in the last line, when we pull the piece we want, we use the to_string()
method to create an owned version of the string slice that we can freely give away to the array that map
is creating. We can't give away the string slice because it is a reference to a variable that will be deleted once we leave map
.
In any case, that makes all our edits for our create and read functions, now we can actually make an edit function.
This is going to be a PUT
request put on the path /edittask
. The code for modifying is a doozy, but here it is
Now, add that function to the mounts in the rocket
function, delete tasks.txt
rerun the program, use Postman to send a request, and you can now edit tasks!
Looking at the function, it works in a way that is not completely optimal but serves our purposes for illustrating using a file to store data. The problem with storing data in a file is that it can be difficult to edit in place. You can use seekers and then write at certain locations, and attempt to delete certain bits, but it generally is a pain. So, instead, we take our file, line by line, and write it to a temporary file temp.txt
. As we are writing temp.txt
, we make our modifications, as seen in the if
statement in our for...in
loop.
Once we have written temp.txt
, we delete our original tasks.txt
and rename temp.txt
to tasks.txt
. This will, of course, take longer as our file grows in size, and there is always the worry about getting everything copied properly, but I am not aware of many better solutions. With that completed, we can now move onto deleting tasks.
Now, we are basically going to use the same exact process that we did with our edit task to make deleting tasks happen. We’ll write everything to a temporary file, delete our old file and rename the temporary one. In terms of our actual API, this will make use of a DELETE
method and will have the /deletetask
path. Onto some code!
Add this to your project and give it a test using Postman, and you should notice that we end up with our task deleted. Hooray! With this, we have created all our CRUD operations.
And that will conclude the first part of this series. In this part, we learned that we can perform CRUD operations just by reading and writing to a file, even if we don’t have a proper frontend for our app yet. In the next part, we’ll be taking a look at databases and how we can use them to make our CRUD operations a lot easier to implement.
Thank you for reading this article. I hope it and its next installments will help improve your web development skills.
- Getting started — Rust Programming Language (rust-lang.org)
- The GitHub repository for this series
- How Web Apps Work — Web Application Architecture Simplified | Reinvently
- An overview of HTTP — HTTP | MDN (mozilla.org)
- Elasticsearch: The Official Distributed Search & Analytics Engine | Elastic
- GraphQL | A query language for your API
- The Rust Programming Language Book (rust-lang.org)
- Attributes — The Rust Reference (rust-lang.org)
- Procedural Macros — The Rust Reference (rust-lang.org)
- Domain Name System — Wikipedia
- Postman API Platform