Build a CRUD API with rust

Victor Teo
9 min readFeb 28, 2023

--

Title with Rust crab

Hello, I’m Victor. Today, I am going to show you how to create a deadly simple REST API in Rust.

For that, we are going to use Teo framework. Teo is the first web framework which features its own integrated ORM for rust like Ruby on Rails for Ruby and Django for Python. This article covers all the things mentioned below:

  • Start a web server and listen on a port
  • Connect to a database and setup migrations
  • Handle URL routes
  • Perform CRUD manipulations
  • Generate a type-safe query client

Installing Teo

If you don’t have rust installed, install it here from the official website. Once rust is installed, let’s install the Teo command line tool.

We install Teo with cargo, rust’s package manager tool.

cargo install teo

After Teo is installed, type cargo teo --help to validate the installation.

Create a project

Let’s create an empty project with mkdir and create our first schema file in it.

mkdir blog-example
cd blog-example
touch schema.teo

Declare a connector

A connector specifies what kind of database it is using and to which database it’s connected. One Teo project can only have one connector. Let’s declare it in the schema.

connector {
provider .sqlite
url "sqlite::memory:"
}

This connector connects to a SQLite database in memory. This is great for demo purpose. Teo supports MySQL, PostgreSQL and MongoDB. In production, you may want to choose from one of them. Just modify the connector and you don’t even need to change your models if you are not using MongoDB.

Server configuration

Teo has a great schema language and everything’s declared inside of it. And so does server configuration. Append these lines to the end of schema.teo.

server {
bind ("0.0.0.0", 5500)
jwtSecret "someTopSecret"
}

Our server will be listening on port 5500.

Query client

Having a frontend developer to copy each data transfer objects and interfaces is not that elegant. Since these works are that duplicated. Teo generates type-safe query clients for frontend developers. In this tutorial, let’s declare a TypeScript client.

client ts {
provider .typeScript
dest "../blog-example-client/"
package true
host "http://127.0.0.1:5500"
gitCommit true
}

Note the host item, the query client will send requests just to our running server at port 5500. We will generate our query client after declaring models.

Models

Declare models in Teo is deadly simple. HTTP route handlers are automatically generated according to the models.

model User {
@id @readonly @autoIncrement
id: Int
@unique @onSet($isEmail)
email: String
name: String
@relation(fields: .id, references: .userId)
posts: Post[]
}

model Post {
@id @readonly @autoIncrement
id: Int
title: String
content: String?
@default(false)
published: Bool
@foreignKey
userId: Int
@relation(fields: .userId, references: .id)
user: User
}

We’ve declared two models: User and Post. User’s email is unique. When an email value is set, validation is performed. A user has many posts while a post has a user. A post has an optional content. published has a default value false.

Generate query client and send requests

Let’s generate the query client with the following command and start the server:

cargo teo generate client
cargo teo serve

Navigate to the directory of generated query client and install ts-node there.

npm install ts-node -D

Let’s write some scripts to send requests to our server. Create a file named try.ts with the following content inside the generated query client directory:

import { teo } from "./src"

async function main() {
const results = await teo.user.create({
create: {
email: "peter@teocloud.io",
name: "Peter",
posts: {
create: [
{
title: "First post",
content: "First post has a content",
},
{
title: "Second post",
content: "Second post is published",
published: true,
},
],
},
},
include: {
posts: true,
},
})
console.log(JSON.stringify(results, null, 2))
}

main()

Let’s run this file and you will see the following outputs:

npx ts-node try.ts
{
"data": {
"id": 1,
"email": "peter@teocloud.io",
"name": "Peter",
"posts": [
{
"id": 1,
"title": "First post",
"content": "First post has a content",
"published": false,
"userId": 1
},
{
"id": 2,
"title": "Second post",
"content": "Second post is published",
"published": true,
"userId": 1
}
]
}
}

Here, we created a user with two posts. On the server side, you will see a line of output like this:

2023-02-28 14:23:11.163375 +08:00 create on User - 200 5ms

This request takes 5ms on my Mac. It’s very fast.

User session

An API is not complete if user cannot sign in. Let’s add user session by altering the User model declaration with the following:

@identity
model User {
@id @readonly @autoIncrement
id: Int
@unique @onSet($isEmail) @identity
email: String
name: String
@writeonly @onSet($hasLength(8...16).isSecurePassword.bcryptSalt)
@identityChecker($bcryptVerify($self.get(.password)))
password: String
@relation(fields: .id, references: .userId)
posts: Post[]
}

We added a decorator @identity to User model, this makes User model authenticatable. We’ve also added this decorator to the email field. It means users sign in with email. We added a new field which is password. It’s writeonly and frontend cannot read. We save encrypted password and we check user’s password on signing in.

Let’s shut down the server, regenerate the client and restart the server.

cargo teo generate client
cargo teo serve

Now let’s run the following script:

import { teo } from "./src"

async function main() {
const _peter = (await teo.user.create({
create: {
email: "peter@teocloud.io",
name: "Peter",
password: "Peter$12345"
},
})).data
const peterSignIn = await teo.user.signIn({
credentials: {
email: "peter@teocloud.io",
password: "Peter$12345"
}
})
console.log(JSON.stringify(peterSignIn, null, 2))
}

main()

You will get the user’s info along with a JWT token like this.

{
"meta": {
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6eyJpZCI6MX0sIm1vZGVsIjoiVXNlciIsImV4cCI6MTcwOTEwMTk4Nn0.Twv7-nAmqyKfgM2iU9QBSNSqb02lm0HUQy80bTyaR_k"
},
"data": {
"id": 1,
"email": "peter@teocloud.io",
"name": "Peter"
}
}

API Guards

Now we have user sessions, let’s protect our API with guards. Guards prevent arbitrary people to access protected API resources. This makes our API safe.

Replace the two model declarations with the following:

@canMutate(
$when(.update, $identity($is($self)))
.when(.delete, $identity($is($self)))
)
@identity
model User {
@id @readonly @autoIncrement
id: Int
@unique @onSet($isEmail) @identity
email: String
name: String
@writeonly @onSet($hasLength(8...16).isSecurePassword.bcryptSalt)
@identityChecker($bcryptVerify($self.get(.password)))
password: String
@relation(fields: .id, references: .userId)
posts: Post[]
}

@canMutate(
$when(.update, $identity($is($self, .user)))
.when(.delete, $identity($is($self, .user)))
.when(.create, $identity($isA(User)))
)
model Post {
@id @readonly @autoIncrement
id: Int
title: String
content: String?
@default(false)
published: Bool
@foreignKey
userId: Int
@relation(fields: .userId, references: .id)
user: User
}

We added a @canMutate decorator to each of the models. For user, any one can create a user aka sign up. But only the user him/herself can update or delete him/herself. Only a post’s owner can update or delete a post. Only valid platform users can create posts.

Again, let’s shut down the server, regenerate client and restart server.

cargo teo generate client
cargo teo serve

Run this script to verify our guards this time.

import { teo } from "./src"

async function main() {
// peter
const peter = (await teo.user.create({
create: {
email: "peter@teocloud.io",
name: "Peter",
password: "Peter$12345"
},
})).data
const peterToken = (await teo.user.signIn({
credentials: {
email: "peter@teocloud.io",
password: "Peter$12345"
}
})).meta.token

// ada
const _ada = (await teo.user.create({
create: {
email: "ada@teocloud.io",
name: "Ada",
password: "Ada$12345"
},
})).data
const adaToken = (await teo.user.signIn({
credentials: {
email: "ada@teocloud.io",
password: "Ada$12345"
}
})).meta.token
const peterPost = (await teo.$withToken(peterToken).post.create({
create: {
title: "Post 1",
content: null,
user: {
connect: {
id: peter.id
}
}
},
})).data
console.log("peter created:", peterPost)
const peterUpdatedPost = await teo.$withToken(peterToken).post.update({
where: {
id: peterPost.id,
},
update: {
content: "Peter wrote some content."
}
})
console.log("peter updated:", peterUpdatedPost)
try {
const _adaUpdatePeterPost = await teo.$withToken(adaToken).post.update({
where: {
id: peterPost.id,
},
update: {
content: "Ada changed some content."
}
})
} catch(err) {
console.log(err)
}
}

main()

You will see results like this:

peter created: { id: 1, title: 'Post 1', published: false, userId: 1 }
peter updated: {
data: {
id: 1,
title: 'Post 1',
content: 'Peter wrote some content.',
published: false,
userId: 1
}
}
TeoError: Permission denied.
at request (/Users/victor/Developer/blog-example-client/src/index.js:88:13)
at processTicksAndRejections (node:internal/process/task_queues:95:5)
at async main (/Users/victor/Developer/blog-example-client/try3.ts:55:33) {
type: 'PermissionError',
errors: { update: 'permission denied' }
}

Peter can create and update his posts, while if Ada trys to update Peter’s post, the query client throws an error which is “Permission denied”. Guards make API safe and writing guards in Teo is not as hard as in other frameworks.

Shorten outputs when finding many

Since our app is a blog system, when listing posts of a user, the results would be quite large. Let’s shorten it with Teo’s $when pipeline item. $when test for conditions and do things conditionally.

Alter the Post model’s content field with this:

@onOutput($when(.many, $if($exists, then: $ellipsis("...", 5))))
content: String?

When finding a single post, the content is still full. When listed as a list of results, it shows at most 5 content characters.

Let’s again regenerate the client and restart the server:

cargo teo generate client
cargo teo serve

Run this script:

import { teo } from "./src"

async function main() {
const results = await teo.user.create({
create: {
email: "peter@teocloud.io",
name: "Peter",
password: "Peter$12345",
posts: {
create: [
{
title: "First post",
content: null,
},
{
title: "Second post",
content: "Second post is published",
published: true,
},
],
},
},
include: {
posts: true,
},
})
console.log("with user:", JSON.stringify(results, null, 2))
const post1 = await teo.post.findUnique({
where: {
id: 1
}
});
console.log("post 1:", JSON.stringify(post1, null, 2))
const post2 = await teo.post.findUnique({
where: {
id: 2
}
});
console.log("post 2:", JSON.stringify(post2, null, 2))
}

main()

You will see the following outputs:

with user: {
"data": {
"id": 1,
"email": "peter@teocloud.io",
"name": "Peter",
"posts": [
{
"id": 1,
"title": "First post",
"published": false,
"userId": 1
},
{
"id": 2,
"title": "Second post",
"content": "Secon...",
"published": true,
"userId": 1
}
]
}
}
post 1: {
"data": {
"id": 1,
"title": "First post",
"published": false,
"userId": 1
}
}
post 2: {
"data": {
"id": 2,
"title": "Second post",
"content": "Second post is published",
"published": true,
"userId": 1
}
}

When the second post’s content is displayed in the list result, it shows 5 chars only. When it’s fetched solely, the content is full.

Extending with model entities

Teo’s experience is interesting. Till now, we are not actually writing a line of Rust. However, Teo schema language cannot provide every features a developer need. Teo allows generating model entities. Using model entities is just like using a model object in any other ORMs.

Let’s upgrade our project into a “real” Rust project. Run this command right in the directory where schema.teo is located.

cargo init --bin

Add these two lines to the [dependencies] section of Cargo.toml.

teo = { version = "0.0.50" }
tokio = { version = "1.25.0", features = ["macros"] }

Let’s declare a model entity block inside the schema file.

entity rs {
provider .rust
dest "./src/entities"
}

Then we generate the entity files with this generator command:

cargo teo generate entity

For the sake of simple, let’s print out a user when it is saved. Replace src/main.rs’s content with this:

mod entities;

use teo::prelude::{main, AppBuilder};
use self::entities::user::User;

#[main]
async fn main() -> std::io::Result<()> {
let mut app_builder = AppBuilder::new();
app_builder.callback("printUser", |user: User| async move {
println!("{}", user);
});
let app = app_builder.build().await;
app.run().await
}

We created a callback named “printUser”, let’s hook this into the schema. Put this decorator on top of the User model block.

@beforeSave($self.callback("printUser"))

So that the whole User block becomes this:

@beforeSave($self.callback("printUser"))
@canMutate(
$when(.update, $identity($is($self)))
.when(.delete, $identity($is($self)))
)
@identity
model User {
@id @readonly @autoIncrement
id: Int
@unique @onSet($isEmail) @identity
email: String
name: String
@writeonly @onSet($hasLength(8...16).isSecurePassword.bcryptSalt)
@identityChecker($bcryptVerify($self.get(.password)))
password: String
@relation(fields: .id, references: .userId)
posts: Post[]
}

Now instead of run cargo teo serve, we run cargo run serve. This time, we build our own executable instead of using the default Teo’s CLI. This executable’s usage is the same with Teo CLI. The difference is it read user’s custom program code and compile them together with Teo’s base functionality.

Run this script from the query client:

import { teo } from "./src"

async function main() {
const results = await teo.user.create({
create: {
email: "peter@teocloud.io",
name: "Peter",
password: "Peter$12345",
},
})
console.log(JSON.stringify(results, null, 2))
}

main()

In the server’s console, you’ll see this user is printed out.

User { 
id: Null,
email: String("peter@teocloud.io"),
name: String("Peter"),
password: String("$2b$12$4DdMfA1DQN8J0w10LBulT.eKHOpOgkrnFAGouohqjWHfqSTZR4mF.")
}

Conclusion

Teo is really interesting. As the author who investigated most of the time on it, I really recommend you to touch and try. You will crush on it and never come back. I’m always here to support. Give me ideas on how to decide the next steps of it to suit your needs, and make it the best web framework at all time. Feel free to give a star on GitHub.

--

--